diff --git a/package.json b/package.json
index ea6b9ea..7416437 100644
--- a/package.json
+++ b/package.json
@@ -81,6 +81,7 @@
"test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.test.mts",
"test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts src/components/player/nowPlayingDismiss.test.mts src/playback/playbackTargetPresentation.test.mts",
"test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs",
+ "test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts",
"test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts",
"test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts",
"test:session": "node --experimental-strip-types --test src/session/sessionState.test.mts src/session/playbackMaterialization.test.mts",
diff --git a/scripts/release/android-release.test.mjs b/scripts/release/android-release.test.mjs
index 46d6fbe..f031d3b 100644
--- a/scripts/release/android-release.test.mjs
+++ b/scripts/release/android-release.test.mjs
@@ -1,18 +1,52 @@
import assert from 'node:assert/strict';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
import test from 'node:test';
+import { fileURLToPath } from 'node:url';
import { getReleaseIdentity } from './android-release.mjs';
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
+const readJson = (relativePath) =>
+ JSON.parse(readFileSync(path.join(ROOT, relativePath), 'utf8'));
+
+// Read the identity from the same tracked sources the implementation uses rather
+// than freezing a literal. A hardcoded version turns every release bump into a
+// spurious failure — and because run-release-tests.mjs exits on the first
+// failure, this suite running first meant one stale string hid every later suite.
+const { version: versionName } = readJson('package.json');
+const { androidVersionCode: versionCode } = readJson('release.json');
+
test('builds stable artifact names from the tracked release identity', () => {
assert.deepEqual(getReleaseIdentity('github'), {
- artifactFileName: 'Astra-0.1.0-1-GitHub-arm-universal.apk',
+ artifactFileName: `Astra-${versionName}-${versionCode}-GitHub-arm-universal.apk`,
distribution: 'github',
distributionLabel: 'GitHub',
packageId: 'io.github.boof2015.astra',
- versionCode: 1,
- versionName: '0.1.0',
+ versionCode,
+ versionName,
});
- assert.equal(getReleaseIdentity('google-play').artifactFileName, 'Astra-0.1.0-1-GooglePlay.aab');
+ assert.equal(
+ getReleaseIdentity('google-play').artifactFileName,
+ `Astra-${versionName}-${versionCode}-GooglePlay.aab`
+ );
+});
+
+test('artifact names carry the real version and code, not a placeholder', () => {
+ // Guards the composition itself: deriving both sides above would still pass if
+ // the template dropped a field, so assert the values actually appear.
+ const { artifactFileName } = getReleaseIdentity('github');
+ assert.match(artifactFileName, /^Astra-\d+\.\d+\.\d+/u);
+ assert.ok(
+ artifactFileName.includes(`-${versionName}-${versionCode}-`),
+ `expected ${artifactFileName} to carry version ${versionName} and code ${versionCode}`
+ );
+});
+
+test('distribution channels differ in package format', () => {
+ assert.ok(getReleaseIdentity('github').artifactFileName.endsWith('.apk'));
+ assert.ok(getReleaseIdentity('google-play').artifactFileName.endsWith('.aab'));
+ assert.equal(getReleaseIdentity('google-play').distributionLabel, 'Google Play');
});
test('rejects unknown distribution channels', () => {
diff --git a/scripts/run-release-tests.mjs b/scripts/run-release-tests.mjs
index ce6080a..1b611c0 100644
--- a/scripts/run-release-tests.mjs
+++ b/scripts/run-release-tests.mjs
@@ -19,6 +19,7 @@ const TEST_SCRIPTS = [
'test:settings-search',
'test:now-playing-layout',
'test:memory-lifecycle',
+ 'test:ui-navigation',
'test:haptics',
'test:home-greeting',
'test:session',
diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx
index a2272f2..ebefffe 100644
--- a/src/app/(tabs)/_layout.tsx
+++ b/src/app/(tabs)/_layout.tsx
@@ -5,6 +5,7 @@ import {
TAB_TRANSITION_SETTLE_MS,
TAB_TRANSITION_SPEC,
} from '@/navigation/tabTransition';
+import { popToTop } from '@/navigation/stackActions';
import { useColors } from '@/theme/themed';
export default function TabsLayout() {
@@ -46,10 +47,21 @@ export default function TabsLayout() {
target: item.key,
canPreventDefault: true,
});
- if (!item.focused && !event.defaultPrevented) {
- lastSwitchAt.current = now;
- navigation.navigate(item.name);
+ if (event.defaultPrevented) return;
+
+ if (item.focused) {
+ // Re-tapping the active tab resets its nested stack. This is the
+ // one-tap escape from a deep library chain (artist → album →
+ // another artist), which is why back itself only pops one level.
+ const nested = state.routes[state.index]?.state;
+ if (nested?.key && (nested.index ?? 0) > 0) {
+ navigation.dispatch({ ...popToTop(), target: nested.key });
+ }
+ return;
}
+
+ lastSwitchAt.current = now;
+ navigation.navigate(item.name);
};
return ;
diff --git a/src/app/(tabs)/library/_layout.tsx b/src/app/(tabs)/library/_layout.tsx
index ca79269..7e6c891 100644
--- a/src/app/(tabs)/library/_layout.tsx
+++ b/src/app/(tabs)/library/_layout.tsx
@@ -1,6 +1,16 @@
import { Stack } from 'expo-router';
import { useColors } from '@/theme/themed';
+/**
+ * Anchor the library stack at its list. Without this, navigating straight to a
+ * detail route (from Home, quick search, or the now-playing overlay) builds a
+ * stack of just `[album]` with nothing beneath it — so popping one level had
+ * nowhere to go and back escaped the tab entirely.
+ */
+export const unstable_settings = {
+ initialRouteName: 'index',
+};
+
/**
* Nested stack inside the Library tab so album/artist detail screens keep the
* tab bar + mini-player visible.
diff --git a/src/app/(tabs)/library/album/[key].tsx b/src/app/(tabs)/library/album/[key].tsx
index 5db4d92..792b233 100644
--- a/src/app/(tabs)/library/album/[key].tsx
+++ b/src/app/(tabs)/library/album/[key].tsx
@@ -40,7 +40,7 @@ export default function AlbumScreen() {
const { key } = useLocalSearchParams<{ key: string }>();
const { items: tracks, summary: album, totalCount, loadMore } = useNativeAlbumDetail(key);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
- const handleBack = useLibraryDetailBack();
+ const { goBack, backLabel } = useLibraryDetailBack();
const insets = useSafeAreaInsets();
const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } =
useDetailCollapse();
@@ -154,7 +154,8 @@ export default function AlbumScreen() {
>
}
disabled={tracks.length === 0}
- onBack={handleBack}
+ onBack={goBack}
+ backLabel={backLabel}
onPlay={() => playFrom(0)}
onShuffle={() => void playLibraryQuery({ kind: 'album', albumKey: key }, {
shuffle: true,
diff --git a/src/app/(tabs)/library/artist/[name].tsx b/src/app/(tabs)/library/artist/[name].tsx
index 0d5c9d6..cfc051c 100644
--- a/src/app/(tabs)/library/artist/[name].tsx
+++ b/src/app/(tabs)/library/artist/[name].tsx
@@ -71,7 +71,7 @@ export default function ArtistScreen() {
name: string;
credit?: string;
}>();
- const handleBack = useLibraryDetailBack();
+ const { goBack, backLabel } = useLibraryDetailBack();
const insets = useSafeAreaInsets();
const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } =
useDetailCollapse();
@@ -246,7 +246,8 @@ export default function ArtistScreen() {
}
disabled={disabled}
- onBack={handleBack}
+ onBack={goBack}
+ backLabel={backLabel}
onPlay={playArtist}
onShuffle={shuffleArtist}
scrollY={scrollY}
diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx
index 2e62ab5..26d711a 100644
--- a/src/app/(tabs)/library/index.tsx
+++ b/src/app/(tabs)/library/index.tsx
@@ -1,5 +1,5 @@
import {
- useEffect,
+ useCallback,
useMemo,
useState
} from 'react';
@@ -11,7 +11,7 @@ import {
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
-import { useRouter } from 'expo-router';
+import { useFocusEffect, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { ViewModeSwitcher } from '@/components/library/ViewModeSwitcher';
@@ -165,14 +165,20 @@ export default function LibraryScreen() {
setPlaylistPickerOpen(false);
};
- useEffect(() => {
- if (!selectMode) return;
- const sub = BackHandler.addEventListener('hardwareBackPress', () => {
- exitSelection();
- return true;
- });
- return () => sub.remove();
- }, [selectMode]);
+ // Focus-gated, not a plain effect: the tabs layout keeps this screen mounted
+ // while blurred (`detachInactiveScreens={false}` + `freezeOnBlur: false`), so
+ // an ungated handler swallowed one back press anywhere in the app — any other
+ // tab, any settings screen — whenever selection happened to be active.
+ useFocusEffect(
+ useCallback(() => {
+ if (!selectMode) return undefined;
+ const sub = BackHandler.addEventListener('hardwareBackPress', () => {
+ exitSelection();
+ return true;
+ });
+ return () => sub.remove();
+ }, [selectMode])
+ );
const selectedDbTracks = () => sortedTracks.filter((track) => selectedIds.has(track.id));
diff --git a/src/app/(tabs)/library/playlist/[id].tsx b/src/app/(tabs)/library/playlist/[id].tsx
index 58fab1f..6fc6335 100644
--- a/src/app/(tabs)/library/playlist/[id].tsx
+++ b/src/app/(tabs)/library/playlist/[id].tsx
@@ -89,7 +89,7 @@ export default function PlaylistScreen() {
const colors = useColors();
const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>();
- const handleBack = useLibraryDetailBack();
+ const { goBack, backLabel } = useLibraryDetailBack();
const isFavorites = id === 'favorites';
const playlistId = isFavorites ? null : Number(id);
@@ -219,7 +219,7 @@ export default function PlaylistScreen() {
void (async () => {
try {
await deletePlaylist(target.id);
- handleBack();
+ goBack();
} catch (err) {
Alert.alert('Delete failed', errorMessage(err));
}
@@ -340,7 +340,8 @@ export default function PlaylistScreen() {
) : null
}
disabled={playable.length === 0}
- onBack={handleBack}
+ onBack={goBack}
+ backLabel={backLabel}
onMore={() => setOptionsOpen(true)}
onPlay={() => startPlayback(0)}
onShuffle={startShuffle}
diff --git a/src/app/desktop-remote.tsx b/src/app/desktop-remote.tsx
index 408e87d..1fb25d7 100644
--- a/src/app/desktop-remote.tsx
+++ b/src/app/desktop-remote.tsx
@@ -18,6 +18,7 @@ import {
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router';
+import { useReturnToTabs } from '@/navigation/returnToTabs';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AstraLogo } from '@/components/AstraLogo';
import { Screen } from '@/components/Screen';
@@ -225,6 +226,7 @@ export default function DesktopRemoteScreen() {
const ripple = useRipple();
const colors = useColors();
const router = useRouter();
+ const returnToTabs = useReturnToTabs();
const pairingParams = useLocalSearchParams<{
pair?: string;
baseUrl?: string;
@@ -620,7 +622,7 @@ export default function DesktopRemoteScreen() {
// screen so it slides in above wherever the user came from.
usePlayerUiStore.getState().openPlayer();
if (router.canGoBack()) router.back();
- else router.replace('/');
+ else returnToTabs('/');
}}
>
diff --git a/src/app/eq/import.tsx b/src/app/eq/import.tsx
index e613d53..39141af 100644
--- a/src/app/eq/import.tsx
+++ b/src/app/eq/import.tsx
@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
-import { useLocalSearchParams, useRouter } from 'expo-router';
+import { useLocalSearchParams } from 'expo-router';
+import { useReturnToTabs } from '@/navigation/returnToTabs';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet';
@@ -17,7 +18,7 @@ export default function EQPresetImportScreen() {
const styles = useStyles();
const ripple = useRipple();
const colors = useColors();
- const router = useRouter();
+ const returnToTabs = useReturnToTabs();
const importPreset = useEQStore((state) => state.importPreset);
const { data } = useLocalSearchParams<{ data?: string }>();
@@ -30,7 +31,9 @@ export default function EQPresetImportScreen() {
}
}, [data]);
- const goToEq = () => router.replace('/eq' as never);
+ // `/eq` is a tab, and this screen is a root-stack sibling of `(tabs)`, so a
+ // bare replace here mints a second copy of the whole tab tree.
+ const goToEq = () => returnToTabs('/eq' as never);
if (!preset) {
return (
diff --git a/src/app/eq/scan.tsx b/src/app/eq/scan.tsx
index 75397ce..50ff7b4 100644
--- a/src/app/eq/scan.tsx
+++ b/src/app/eq/scan.tsx
@@ -11,6 +11,7 @@ import {
} from 'expo-camera';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
+import { useReturnToTabs } from '@/navigation/returnToTabs';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet';
@@ -30,6 +31,7 @@ export default function EQPresetScanScreen() {
const ripple = useRipple();
const colors = useColors();
const router = useRouter();
+ const returnToTabs = useReturnToTabs();
const importPreset = useEQStore((state) => state.importPreset);
const [permission, requestPermission] = useCameraPermissions();
const [pendingPreset, setPendingPreset] = useState(null);
@@ -109,7 +111,8 @@ export default function EQPresetScanScreen() {
onConfirm={() => {
importPreset(pendingPreset);
setPendingPreset(null);
- router.replace('/eq' as never);
+ // `/eq` is a tab; see useReturnToTabs.
+ returnToTabs('/eq' as never);
}}
onClose={() => setPendingPreset(null)}
/>
diff --git a/src/app/notification.click.tsx b/src/app/notification.click.tsx
index 1ba35de..50b0c46 100644
--- a/src/app/notification.click.tsx
+++ b/src/app/notification.click.tsx
@@ -1,28 +1,31 @@
import { useEffect } from 'react';
import { View } from 'react-native';
-import { useRouter } from 'expo-router';
import { resolveNotificationClick } from '@/audio/notificationIntent';
+import { useReturnToTabs } from '@/navigation/returnToTabs';
import { createThemedStyles } from '@/theme/themed';
export default function NotificationClickRoute() {
const styles = useStyles();
- const router = useRouter();
+ const returnToTabs = useReturnToTabs();
+ // This route is a root-stack sibling of `(tabs)`, and it is entered often
+ // (media-notification and widget taps). A bare replace toward a tab route
+ // therefore left a duplicate `(tabs)` behind on every single tap.
useEffect(() => {
let cancelled = false;
resolveNotificationClick()
.then((href) => {
- if (!cancelled) router.replace(href);
+ if (!cancelled) returnToTabs(href);
})
.catch(() => {
- if (!cancelled) router.replace('/');
+ if (!cancelled) returnToTabs('/');
});
return () => {
cancelled = true;
};
- }, [router]);
+ }, [returnToTabs]);
return ;
}
diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx
index 75f9588..c19667a 100644
--- a/src/components/MiniPlayer.tsx
+++ b/src/components/MiniPlayer.tsx
@@ -16,7 +16,7 @@ import Animated, {
useSharedValue,
withTiming,
} from 'react-native-reanimated';
-import { usePlayerUiStore } from '@/stores/playerUiStore';
+import { usePlayerOnScreen, usePlayerUiStore } from '@/stores/playerUiStore';
import { Text } from './Text';
import { AstraLogo } from './AstraLogo';
import { SpectrumCurve } from './SpectrumCurve';
@@ -118,7 +118,7 @@ export function MiniPlayer() {
const styles = useStyles();
const colors = useColors();
const ripple = useRipple();
- const playerOpen = usePlayerUiStore((s) => s.playerOpen);
+ const playerOpen = usePlayerOnScreen();
const selectedTarget = usePlaybackTargetStore((s) => s.target);
const track = usePlayerStore((s) => s.currentTrack);
const playbackState = usePlayerStore((s) => s.playbackState);
diff --git a/src/components/PlaybackTargetPicker.tsx b/src/components/PlaybackTargetPicker.tsx
index 5432502..f1b5a9b 100644
--- a/src/components/PlaybackTargetPicker.tsx
+++ b/src/components/PlaybackTargetPicker.tsx
@@ -14,6 +14,7 @@ import { useRipple } from '@/theme/ripple';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlaybackTargetStore, type PlaybackTarget } from '@/stores/playbackTargetStore';
import { usePlayerStore } from '@/stores/playerStore';
+import { usePlayerUiStore } from '@/stores/playerUiStore';
import {
desktopConnectionLabel,
hostFromBaseUrl,
@@ -52,6 +53,10 @@ export function PlaybackTargetPicker({ visible, onClose }: PlaybackTargetPickerP
const pairDesktop = () => {
onClose();
+ // This picker is usually open above the full-screen now-playing overlay,
+ // which would cover the pushed screen completely — the tap looked like a
+ // no-op. Close the player so the route is actually visible.
+ usePlayerUiStore.getState().closePlayer();
router.push('/desktop-remote' as never);
};
diff --git a/src/components/library/CollapsingDetail.tsx b/src/components/library/CollapsingDetail.tsx
index 3e5e202..76dcbdf 100644
--- a/src/components/library/CollapsingDetail.tsx
+++ b/src/components/library/CollapsingDetail.tsx
@@ -126,6 +126,7 @@ export function CollapsingHeader({
heroExtra,
disabled,
onBack,
+ backLabel,
onMore,
onPlay,
onShuffle,
@@ -145,6 +146,8 @@ export function CollapsingHeader({
heroExtra?: ReactNode;
disabled?: boolean;
onBack: () => void;
+ /** Names the screen `onBack` returns to; must track the real action. */
+ backLabel: string;
onMore?: () => void;
onPlay: () => void;
onShuffle: () => void;
@@ -282,8 +285,17 @@ export function CollapsingHeader({
>
-
- Library
+
+ {backLabel}
(initialStep);
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const isFavorite = usePlaylistStore((s) => s.favoritePaths.has(track.path));
const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite);
const artistName = resolveNavigationArtist(track, groupingMode);
+ const albumHref = `/library/album/${encodeURIComponent(track.album_identity_key)}`;
+ const artistHref = `/library/artist/${encodeURIComponent(artistName)}`;
const closeAndRun = (run: () => void) => {
onClose();
@@ -79,24 +86,28 @@ function TrackActionsSheetInner({
label: 'View album',
icon: 'albums-outline',
onPress: () =>
- closeAndRun(() =>
- router.push({
- pathname: '/library/album/[key]',
- params: { key: track.album_identity_key },
- })
- ),
+ closeAndRun(() => {
+ // Already here (this sheet also opens from the album screen itself):
+ // pushing would stack a duplicate of the current screen.
+ if (pathname === albumHref) return;
+ returnToTabs(
+ { pathname: '/library/album/[key]', params: { key: track.album_identity_key } },
+ 'push'
+ );
+ }),
},
{
key: 'view-artist',
label: 'View artist',
icon: 'person-outline',
onPress: () =>
- closeAndRun(() =>
- router.push({
- pathname: '/library/artist/[name]',
- params: { name: artistName },
- })
- ),
+ closeAndRun(() => {
+ if (pathname === artistHref) return;
+ returnToTabs(
+ { pathname: '/library/artist/[name]', params: { name: artistName } },
+ 'push'
+ );
+ }),
},
{
key: 'favorite',
diff --git a/src/components/player/NowPlayingHost.tsx b/src/components/player/NowPlayingHost.tsx
index 63f15ba..4415352 100644
--- a/src/components/player/NowPlayingHost.tsx
+++ b/src/components/player/NowPlayingHost.tsx
@@ -1,24 +1,20 @@
import { NowPlayingOverlay } from '@/components/player/NowPlayingOverlay';
-import { useDelayedUnmountPresence } from '@/components/delayedPresence';
-import { NOW_PLAYING_CLOSE_UNMOUNT_MS } from '@/components/renderPresenceTiming';
-import { useAppForeground } from '@/lib/useAppForeground';
-import { usePlayerUiStore } from '@/stores/playerUiStore';
+import { usePlayerMounted } from '@/stores/playerUiStore';
/**
- * Presence gate for the heavyweight now-playing tree. It stays alive just past
- * the 200 ms close animation, but never remains hidden indefinitely. Android
- * backgrounding drops it immediately so TextureViews and decoded art release.
+ * Presence gate for the heavyweight now-playing tree. The store's `closing`
+ * phase is the linger that lets the exit animation finish, so this is a plain
+ * mirror of the phase — no second timer and no foreground gate.
+ *
+ * Backgrounding deliberately does NOT unmount here any more. Dropping the whole
+ * tree tore down the shared value mid-close, which stranded the phase and made
+ * the player permanently unopenable. The overlay releases its own TextureViews
+ * and decoded art instead, by folding `useAppForeground` into the `active` /
+ * `paused` props of the scope surfaces (the same thing MiniPlayer does).
*/
export function NowPlayingHost() {
- const playerOpen = usePlayerUiStore((s) => s.playerOpen);
- const foreground = useAppForeground();
+ const mounted = usePlayerMounted();
- const renderOverlay = useDelayedUnmountPresence(
- playerOpen,
- NOW_PLAYING_CLOSE_UNMOUNT_MS,
- !foreground
- );
-
- if (!renderOverlay) return null;
+ if (!mounted) return null;
return ;
}
diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx
index bd33a59..d97991f 100644
--- a/src/components/player/NowPlayingOverlay.tsx
+++ b/src/components/player/NowPlayingOverlay.tsx
@@ -42,6 +42,11 @@ import { PlayerStateIcon } from '@/components/player/PlayerStateIcon';
import { CachedLyricPeek } from '@/components/player/CachedLyricPeek';
import { resolveNowPlayingDismissSpring } from '@/components/player/nowPlayingDismiss';
import { useDelayedUnmountPresence } from '@/components/delayedPresence';
+import {
+ NOW_PLAYING_CLOSE_COMMIT_MS,
+ NOW_PLAYING_OPEN_SETTLE_MS,
+} from '@/components/renderPresenceTiming';
+import { useAppForeground } from '@/lib/useAppForeground';
import { SleepTimerControls } from '@/components/player/SleepTimerControls';
import { AppSheet, AppSheetTitle } from '@/components/sheets/AppSheet';
import {
@@ -63,6 +68,7 @@ import {
NOW_PLAYING_WAVEFORM_TOUCH_PADDING,
NOW_PLAYING_WIDE_PANE_GAP,
} from '@/components/player/nowPlayingLayout';
+import { useReturnToTabs } from '@/navigation/returnToTabs';
import { resolveNavigationArtist, splitCollaborators } from '@/library/artistGrouping';
import { buildArtistNameTokens } from '@/shared/library/artistCredits';
import {
@@ -76,6 +82,7 @@ import { useQueueStore } from '@/stores/queueStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { usePlayerUiStore } from '@/stores/playerUiStore';
+import { isPlayerOnScreen } from '@/stores/playerPresence';
import { useSettingsStore, type ScopeMode } from '@/stores/settingsStore';
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
import type { DbTrack } from '@/types/library';
@@ -125,10 +132,19 @@ export function NowPlayingOverlay() {
const colors = useColors();
const ripple = useRipple();
const router = useRouter();
+ const returnToTabs = useReturnToTabs();
const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const reduceMotion = useReducedMotion();
- const playerOpen = usePlayerUiStore((s) => s.playerOpen);
+ const phase = usePlayerUiStore((s) => s.phase);
+ const openRequest = usePlayerUiStore((s) => s.openRequest);
+ const exitAnimated = usePlayerUiStore((s) => s.exitAnimated);
+ const playerOpen = isPlayerOnScreen(phase);
+ // Heavy scope/spectrum surfaces release their native backing in the
+ // background. The overlay itself stays mounted — unmounting it mid-close used
+ // to tear down the exit animation and strand the phase.
+ const foreground = useAppForeground();
+ const surfacesLive = playerOpen && foreground;
const [queueOpen, setQueueOpen] = useState(false);
// Stable identity: QueueTray is memo'd, so a fresh arrow here would defeat it.
const closeQueue = useCallback(() => setQueueOpen(false), []);
@@ -181,13 +197,19 @@ export function NowPlayingOverlay() {
});
const isDesktopTarget = activePresentation.target === 'desktop';
const effectiveScopeStageVisible = !isDesktopTarget && scopeStageVisible;
+ // Backgrounding drops these two subtrees, which is what actually releases the
+ // scope TextureViews and the decoded artwork. The overlay shell around them
+ // deliberately stays mounted instead — dropping the whole tree used to tear
+ // down the close animation mid-flight and strand the player unopenable.
const renderScopeSurfaces = useDelayedUnmountPresence(
effectiveScopeStageVisible,
- motion.snap.duration
+ motion.snap.duration,
+ !foreground
);
const renderArtworkFace = useDelayedUnmountPresence(
railStyle || !effectiveScopeStageVisible,
- motion.snap.duration
+ motion.snap.duration,
+ !foreground
);
const activeTrack = desktopSnapshot?.currentTrack ?? null;
const transitionTrackKey = isDesktopTarget ? activeTrack?.id ?? '' : track?.id ?? '';
@@ -295,23 +317,26 @@ export function NowPlayingOverlay() {
.getState()
.setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum');
+ // The player is an overlay, so it can be open over a root-stack sibling of
+ // `(tabs)` (e.g. desktop-remote). Going straight to a library route from there
+ // diverges at the root stack and mints a second copy of the whole tab tree.
const navigateToArtist = (targetArtist = artistName, credit = false) => {
if (!targetArtist) return;
// Slide the overlay away while the library detail loads underneath.
dismissSheet();
- router.navigate({
- pathname: '/library/artist/[name]',
- params: { name: targetArtist, ...(credit ? { credit: '1' } : {}) },
- });
+ returnToTabs(
+ {
+ pathname: '/library/artist/[name]',
+ params: { name: targetArtist, ...(credit ? { credit: '1' } : {}) },
+ },
+ 'push'
+ );
};
const navigateToAlbum = () => {
if (!albumKey) return;
dismissSheet();
- router.navigate({
- pathname: '/library/album/[key]',
- params: { key: albumKey },
- });
+ returnToTabs({ pathname: '/library/album/[key]', params: { key: albumKey } }, 'push');
};
const menuItems: NowPlayingMenuItem[] = [];
@@ -400,12 +425,20 @@ export function NowPlayingOverlay() {
useEffect(() => {
stageProgress.value = withTiming(effectiveScopeStageVisible ? 1 : 0, motion.snap);
}, [effectiveScopeStageVisible, stageProgress]);
- // Closing is a store toggle, not navigation. Reset the inner layers so a
- // reopen starts from the plain player (parity with the old per-open mount).
- const dismiss = () => {
+ const commitClosed = () => usePlayerUiStore.getState().commitClosed();
+ /**
+ * Enter the closing phase and drop the inner layers. Split out from
+ * `dismissSheet` so the pan gesture can commit the phase without handing the
+ * offset back to a fresh animation — it is already driving `translateY`.
+ * Clearing the layers here rather than at the end also unpins the menu card,
+ * which renders outside the translating content.
+ */
+ const beginDismiss = () => {
setMenuOpen(false);
setQueueOpen(false);
- usePlayerUiStore.getState().closePlayer();
+ // `true`: this path drives the sheet away itself, so the effect below must
+ // not overwrite the offset with a competing generic slide-out.
+ usePlayerUiStore.getState().closePlayer(true);
};
const finishCloseMenu = () => setMenuOpen(false);
@@ -422,7 +455,14 @@ export function NowPlayingOverlay() {
});
}
+ // Closing is a store transition, not navigation. The phase moves to `closing`
+ // BEFORE the animation starts — a spring that gets cancelled never reports
+ // completion, and depending on that callback is what used to leave the player
+ // flagged open with the sheet parked off-screen, unopenable for the rest of
+ // the session. The completion callback now only commits the release early;
+ // a fallback timer commits it otherwise.
const dismissSheet = (velocity = 0) => {
+ beginDismiss();
translateY.value = withSpring(
windowHeight,
{
@@ -432,7 +472,7 @@ export function NowPlayingOverlay() {
overshootClamping: true,
},
(finished) => {
- if (finished) runOnJS(dismiss)();
+ if (finished) runOnJS(commitClosed)();
}
);
};
@@ -450,6 +490,9 @@ export function NowPlayingOverlay() {
e.velocityY,
windowHeight - translateY.value
);
+ // Same commit-first contract as dismissSheet, with the release spring's
+ // velocity-matched shaping preserved.
+ runOnJS(beginDismiss)();
translateY.value = withSpring(
windowHeight,
{
@@ -460,7 +503,7 @@ export function NowPlayingOverlay() {
energyThreshold: 1e-4,
},
(finished) => {
- if (finished) runOnJS(dismiss)();
+ if (finished) runOnJS(commitClosed)();
}
);
} else {
@@ -468,29 +511,74 @@ export function NowPlayingOverlay() {
}
});
- // Open animation (close normally animates via dismissSheet's spring first;
- // the else branch covers direct closePlayer calls and keeps the resting
- // offset pinned to the current window height across rotation). NOTE: this
- // effect must stay BELOW every direct `translateY.value` write — the react
- // compiler forbids mutations after an effect that depends on the value.
+ // Enter animation. Keyed on `openRequest` as well as the phase, so asking for
+ // a player that already believes it is open still re-runs the slide-in — that
+ // is the recovery path for a sheet stranded off-screen by an interrupted
+ // close. `windowHeight` is deliberately NOT a dependency: a dimension change
+ // (rotation, or an RN Modal like the output picker) would re-run this effect
+ // and cancel an in-flight exit spring. NOTE: this effect must stay BELOW
+ // every direct `translateY.value` write — the react compiler forbids
+ // mutations after an effect that depends on the value.
useEffect(() => {
- if (playerOpen) {
- translateY.value = withTiming(0, { duration: 240 });
- } else {
- translateY.value = withTiming(windowHeight, { duration: 200 });
+ if (phase === 'closing') {
+ // The gesture and button paths own the exit animation, including its
+ // velocity-matched spring shaping. Only animate here when `closing`
+ // arrived from a direct closePlayer() with no animation attached.
+ if (!exitAnimated) {
+ translateY.value = withTiming(windowHeight, { duration: 200 });
+ }
+ return;
}
- }, [playerOpen, windowHeight, translateY]);
+ translateY.value = withTiming(0, { duration: 240 });
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- windowHeight excluded on purpose (see above)
+ }, [phase, openRequest, exitAnimated, translateY]);
+
+ // `closing` → `closed`, and `opening` → `open`. Both are timers rather than
+ // animation callbacks, so a cancelled animation can never strand the phase.
+ // The store guards each transition, and this cleanup cancels a pending commit
+ // if the user reopens mid-close. The store actions are read through getState
+ // so this effect's identity never changes between playback ticks — a restarted
+ // timer would mean the commit never lands.
+ useEffect(() => {
+ if (phase === 'closing') {
+ const timer = setTimeout(
+ () => usePlayerUiStore.getState().commitClosed(),
+ NOW_PLAYING_CLOSE_COMMIT_MS
+ );
+ return () => clearTimeout(timer);
+ }
+ if (phase === 'opening') {
+ const timer = setTimeout(
+ () => usePlayerUiStore.getState().settleOpen(),
+ NOW_PLAYING_OPEN_SETTLE_MS
+ );
+ return () => clearTimeout(timer);
+ }
+ return undefined;
+ }, [phase, openRequest]);
// Hardware back, innermost layer first: menu → queue tray → player. Registered
// only while open, so it sits above the focused screen's own handlers (LIFO)
// — e.g. the library-detail back interceptor underneath.
useEffect(() => {
- if (!playerOpen) return;
+ if (!playerOpen) return undefined;
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
if (menuOpen) {
closeMenu();
return true;
}
+ if (targetPickerOpen) {
+ setTargetPickerOpen(false);
+ return true;
+ }
+ if (sleepTimerOpen) {
+ setSleepTimerOpen(false);
+ return true;
+ }
+ if (playlistActionTrack) {
+ setPlaylistActionTrack(null);
+ return true;
+ }
if (queueOpen) {
setQueueOpen(false);
return true;
@@ -499,7 +587,16 @@ export function NowPlayingOverlay() {
return true;
});
return () => sub.remove();
- });
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- closeMenu/dismissSheet are re-created every render; re-subscribing on each would thrash the LIFO chain. windowHeight is listed because dismissSheet captures it.
+ }, [
+ playerOpen,
+ menuOpen,
+ targetPickerOpen,
+ sleepTimerOpen,
+ playlistActionTrack,
+ queueOpen,
+ windowHeight,
+ ]);
const contentStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
@@ -636,7 +733,7 @@ export function NowPlayingOverlay() {
{lyricsMode && track ? (
)}
@@ -1017,7 +1114,7 @@ export function NowPlayingOverlay() {
width={layout.scopeWidth}
height={layout.scopeHeight}
mode={scopeMode}
- paused={!playerOpen || queueOpen || !effectiveScopeStageVisible}
+ paused={!surfacesLive || queueOpen || !effectiveScopeStageVisible}
revealed={effectiveScopeStageVisible}
onSwap={swapScopeMode}
/>
@@ -1039,7 +1136,7 @@ export function NowPlayingOverlay() {
width={layout.scopeWidth}
height={layout.scopeHeight}
mode={scopeMode}
- paused={!playerOpen || queueOpen || !effectiveScopeStageVisible}
+ paused={!surfacesLive || queueOpen || !effectiveScopeStageVisible}
revealed={effectiveScopeStageVisible}
onSwap={swapScopeMode}
/>
@@ -1059,7 +1156,7 @@ export function NowPlayingOverlay() {
{lyricPeekEnabled ? (
diff --git a/src/components/renderPresenceTiming.ts b/src/components/renderPresenceTiming.ts
index 01c375c..0922e9b 100644
--- a/src/components/renderPresenceTiming.ts
+++ b/src/components/renderPresenceTiming.ts
@@ -3,5 +3,17 @@ import { TAB_TRANSITION_SETTLE_MS } from '../navigation/tabTransition.ts';
/** Slightly longer than the overlay's 200 ms direct-close animation. */
export const NOW_PLAYING_CLOSE_UNMOUNT_MS = 220;
+/**
+ * Backstop for committing the now-playing close. The exit spring normally
+ * commits it from its own completion callback, but a cancelled spring never
+ * reports completion — and before this existed, that stranded the player's
+ * phase and made it impossible to reopen. Long enough that the animation
+ * ordinarily wins the race.
+ */
+export const NOW_PLAYING_CLOSE_COMMIT_MS = 450;
+
+/** Matches the overlay's 240 ms enter animation, then settles `opening` → `open`. */
+export const NOW_PLAYING_OPEN_SETTLE_MS = 260;
+
/** Keep the EQ surface through the native tab spring's settling window. */
export const EQ_GRAPH_UNMOUNT_DELAY_MS = TAB_TRANSITION_SETTLE_MS + 30;
diff --git a/src/components/search/QuickSearchOverlay.tsx b/src/components/search/QuickSearchOverlay.tsx
index db524bd..aae9705 100644
--- a/src/components/search/QuickSearchOverlay.tsx
+++ b/src/components/search/QuickSearchOverlay.tsx
@@ -17,7 +17,7 @@ import {
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list';
-import { useRouter } from 'expo-router';
+import { useReturnToTabs } from '@/navigation/returnToTabs';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo';
@@ -569,7 +569,7 @@ function QuickSearchPanel({
const styles = useStyles();
const ripple = useRipple();
const colors = useColors();
- const router = useRouter();
+ const returnToTabs = useReturnToTabs();
const insets = useSafeAreaInsets();
const { height } = useWindowDimensions();
const inputRef = useRef(null);
@@ -956,8 +956,11 @@ function QuickSearchPanel({
onClose();
};
+ // The quick-search overlay renders above the navigator, so it can fire while a
+ // root-stack sibling of `(tabs)` is focused — a bare push there mints a second
+ // copy of the whole tab tree and back-navigation dead-ends on the stale one.
const navigateTo = (href: RouteHref) => {
- router.push(href as never);
+ returnToTabs(href as never, 'push');
};
const executeResult = (result: SearchResult) => {
@@ -988,26 +991,26 @@ function QuickSearchPanel({
}
if (result.kind === 'album') {
- router.push({
- pathname: '/library/album/[key]',
- params: { key: result.album.identity_key },
- });
+ returnToTabs(
+ { pathname: '/library/album/[key]', params: { key: result.album.identity_key } },
+ 'push'
+ );
return;
}
if (result.kind === 'artist') {
- router.push({
- pathname: '/library/artist/[name]',
- params: { name: result.artist.artist },
- });
+ returnToTabs(
+ { pathname: '/library/artist/[name]', params: { name: result.artist.artist } },
+ 'push'
+ );
return;
}
if (result.kind === 'playlist') {
- router.push({
- pathname: '/library/playlist/[id]',
- params: { id: result.playlist.id },
- });
+ returnToTabs(
+ { pathname: '/library/playlist/[id]', params: { id: result.playlist.id } },
+ 'push'
+ );
return;
}
diff --git a/src/lib/useAppForeground.ts b/src/lib/useAppForeground.ts
index a6dba7f..cf026be 100644
--- a/src/lib/useAppForeground.ts
+++ b/src/lib/useAppForeground.ts
@@ -5,6 +5,17 @@ export function isForegroundAppState(state: AppStateStatus | null): boolean {
return state === 'active';
}
+/**
+ * Seed value for the hook. `AppState.currentState` can still be null/unknown
+ * while the activity is coming up, and the `change` listener only fires on a
+ * transition — so trusting a null seed leaves consumers stuck in the background
+ * branch for the whole session. A mounting React tree means the UI is up, so an
+ * unknown initial state is treated as foreground.
+ */
+export function initialForegroundAppState(state: AppStateStatus | null): boolean {
+ return state == null || state === 'unknown' ? true : isForegroundAppState(state);
+}
+
/**
* Explicit foreground signal for render loops and native-backed surfaces.
* React Native normally suspends animation frames in the background, but
@@ -12,7 +23,7 @@ export function isForegroundAppState(state: AppStateStatus | null): boolean {
*/
export function useAppForeground(): boolean {
const [foreground, setForeground] = useState(() =>
- isForegroundAppState(AppState.currentState)
+ initialForegroundAppState(AppState.currentState)
);
useEffect(() => {
diff --git a/src/navigation/libraryDetailBack.test.mts b/src/navigation/libraryDetailBack.test.mts
new file mode 100644
index 0000000..450813c
--- /dev/null
+++ b/src/navigation/libraryDetailBack.test.mts
@@ -0,0 +1,79 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ canPopWithinLibrary,
+ libraryParentLabel,
+ parentRoute,
+} from './libraryDetailBack.ts';
+
+const libraryRoot = { name: 'index' };
+const artist = { name: 'artist/[name]', params: { name: 'Radiohead' } };
+const album = { name: 'album/[key]', params: { key: 'abc123' } };
+
+test('back from an album opened on an artist page returns to that artist', () => {
+ // The bug this fixes: the old hook forced dismissTo('/library') here, so the
+ // artist page was skipped entirely and you landed at the library root.
+ const state = { index: 2, routes: [libraryRoot, artist, album] };
+ assert.equal(canPopWithinLibrary(state), true);
+ assert.deepEqual(parentRoute(state), artist);
+ assert.equal(libraryParentLabel(parentRoute(state)), 'Radiohead');
+});
+
+test('back from an album opened off the library list returns to the library', () => {
+ const state = { index: 1, routes: [libraryRoot, album] };
+ assert.equal(canPopWithinLibrary(state), true);
+ assert.equal(libraryParentLabel(parentRoute(state)), 'Library');
+});
+
+test('the library list itself has nothing to pop to', () => {
+ const state = { index: 0, routes: [libraryRoot] };
+ assert.equal(canPopWithinLibrary(state), false);
+ assert.equal(parentRoute(state), undefined);
+ assert.equal(libraryParentLabel(undefined), 'Library');
+});
+
+test('a stack entered straight at a detail screen reports no parent', () => {
+ // The library layout anchors an `index` route to make this rare, but the hook
+ // still needs the dismissTo fallback for it.
+ const state = { index: 0, routes: [album] };
+ assert.equal(canPopWithinLibrary(state), false);
+ assert.equal(parentRoute(state), undefined);
+});
+
+test('artist sub-sections label with the artist, not a generic word', () => {
+ // Library › artist › all albums › album — the parent is the sub-section route,
+ // which still carries the artist name in its params.
+ const albumsSection = { name: 'artist/[name]/albums', params: { name: 'Boards of Canada' } };
+ const state = { index: 3, routes: [libraryRoot, artist, albumsSection, album] };
+ assert.equal(libraryParentLabel(parentRoute(state)), 'Boards of Canada');
+});
+
+test('parents whose title needs a lookup fall back to their kind', () => {
+ // Album and playlist titles are resolved asynchronously by key/id, so they are
+ // not available from route params — label the kind rather than fetch for a label.
+ assert.equal(libraryParentLabel(album), 'Album');
+ assert.equal(libraryParentLabel({ name: 'playlist/[id]', params: { id: '7' } }), 'Playlist');
+});
+
+test('an artist route with no usable name degrades gracefully', () => {
+ assert.equal(libraryParentLabel({ name: 'artist/[name]' }), 'Artist');
+ assert.equal(libraryParentLabel({ name: 'artist/[name]', params: { name: ' ' } }), 'Artist');
+ assert.equal(libraryParentLabel({ name: 'artist/[name]', params: { name: 42 } }), 'Artist');
+});
+
+test('walking artist → album → artist keeps each step honest', () => {
+ // The chain the user flagged: every level names the screen it returns to, and
+ // each back press moves exactly one step.
+ const other = { name: 'artist/[name]', params: { name: 'Autechre' } };
+ const chain = { index: 3, routes: [libraryRoot, artist, album, other] };
+ assert.equal(libraryParentLabel(parentRoute(chain)), 'Album');
+ assert.equal(libraryParentLabel(parentRoute({ index: 2, routes: chain.routes })), 'Radiohead');
+ assert.equal(libraryParentLabel(parentRoute({ index: 1, routes: chain.routes })), 'Library');
+});
+
+test('a state without an explicit index falls back to the topmost route', () => {
+ assert.equal(libraryParentLabel(parentRoute({ routes: [libraryRoot, artist, album] })), 'Radiohead');
+ assert.equal(canPopWithinLibrary({ routes: [libraryRoot] }), false);
+ assert.equal(canPopWithinLibrary({ routes: [] }), false);
+ assert.equal(parentRoute(undefined), undefined);
+});
diff --git a/src/navigation/libraryDetailBack.ts b/src/navigation/libraryDetailBack.ts
new file mode 100644
index 0000000..a11e2c0
--- /dev/null
+++ b/src/navigation/libraryDetailBack.ts
@@ -0,0 +1,66 @@
+/**
+ * Back-affordance logic for the library detail screens (album / artist /
+ * playlist).
+ *
+ * These screens used to force `dismissTo('/library')` on every back press and
+ * swallow the hardware button, which flattened real history: opening an album
+ * from an artist page and pressing back skipped the artist entirely and dropped
+ * you at the library root. Back now pops one level like any stack, and the label
+ * beside the chevron names whatever it will actually return to — a fixed
+ * "Library" string was the reason the old behaviour had to stay unconditional.
+ *
+ * Kept free of imports so it stays unit-testable without the router.
+ */
+
+export interface StackRouteLike {
+ name: string;
+ params?: Record;
+}
+
+export interface StackStateLike {
+ index?: number;
+ routes: StackRouteLike[];
+}
+
+/** Generic fallbacks for parents whose real title needs an async lookup. */
+const LIBRARY_ROOT_LABEL = 'Library';
+
+function focusedIndex(state: StackStateLike): number {
+ return state.index ?? state.routes.length - 1;
+}
+
+/** The route beneath the focused one, or undefined at the bottom of the stack. */
+export function parentRoute(state: StackStateLike | undefined): StackRouteLike | undefined {
+ if (!state || state.routes.length === 0) return undefined;
+ return state.routes[focusedIndex(state) - 1];
+}
+
+/**
+ * Whether back can pop inside the library stack. False when the stack was
+ * entered directly at a detail screen — the library layout anchors an `index`
+ * route to make that rare, but a caller should still have a fallback.
+ */
+export function canPopWithinLibrary(state: StackStateLike | undefined): boolean {
+ if (!state) return false;
+ return focusedIndex(state) > 0;
+}
+
+/**
+ * Label for the back chevron, naming the parent where it is free to do so.
+ *
+ * Artist routes carry the display name in their params, so the common
+ * artist → album flow reads "‹ Radiohead". Album and playlist titles are not in
+ * their params (the detail screens resolve them asynchronously by key/id), so
+ * those fall back to the kind rather than triggering a lookup just for a label.
+ */
+export function libraryParentLabel(route: StackRouteLike | undefined): string {
+ if (!route || route.name === 'index') return LIBRARY_ROOT_LABEL;
+ if (route.name.startsWith('artist/[name]')) {
+ const name = route.params?.name;
+ if (typeof name === 'string' && name.trim() !== '') return name;
+ return 'Artist';
+ }
+ if (route.name.startsWith('album/')) return 'Album';
+ if (route.name.startsWith('playlist/')) return 'Playlist';
+ return LIBRARY_ROOT_LABEL;
+}
diff --git a/src/navigation/returnToTabs.ts b/src/navigation/returnToTabs.ts
new file mode 100644
index 0000000..f4513c5
--- /dev/null
+++ b/src/navigation/returnToTabs.ts
@@ -0,0 +1,43 @@
+import { useCallback } from 'react';
+import { useNavigationContainerRef, useRouter, type Href } from 'expo-router';
+import { needsTabsCollapse, type RootStateLike } from '@/navigation/tabsAnchor';
+
+export { needsTabsCollapse, TABS_ROUTE_NAME } from '@/navigation/tabsAnchor';
+
+/**
+ * Navigate to a route that lives inside `(tabs)`.
+ *
+ * Use this instead of a bare `push`/`replace` anywhere the caller might not be
+ * inside the tab tree — root-stack screens, and the overlays that render above
+ * the navigator (now playing, quick search, action sheets). See
+ * `needsTabsCollapse` for why that mints duplicate tab trees. `dismissAll()`
+ * pops the root stack back to its anchor without disturbing the selected tab or
+ * the library stack, and it reuses the existing route rather than minting one.
+ *
+ * The collapse runs first, which puts `(tabs)` back in focus and means the
+ * follow-up operation diverges *inside* the tab tree. Pick it to match intent:
+ *
+ * - `'navigate'` (default) for a tab root. Reuses the existing screen.
+ * - `'push'` for a library detail. Necessary because `navigate` matches on
+ * route *name*, so `/library/album/[key]` → a different key would swap params
+ * on the current screen instead of stacking — which would break walking
+ * artist → album → another artist.
+ */
+export function useReturnToTabs(): (href: Href, mode?: 'navigate' | 'push') => void {
+ const router = useRouter();
+ const rootNavigation = useNavigationContainerRef();
+
+ return useCallback(
+ (href: Href, mode: 'navigate' | 'push' = 'navigate') => {
+ const rootState = rootNavigation.isReady()
+ ? (rootNavigation.getRootState() as RootStateLike | undefined)
+ : undefined;
+ if (needsTabsCollapse(rootState) && router.canDismiss()) {
+ router.dismissAll();
+ }
+ if (mode === 'push') router.push(href);
+ else router.navigate(href);
+ },
+ [rootNavigation, router]
+ );
+}
diff --git a/src/navigation/stackActions.ts b/src/navigation/stackActions.ts
new file mode 100644
index 0000000..6eaec63
--- /dev/null
+++ b/src/navigation/stackActions.ts
@@ -0,0 +1,14 @@
+/**
+ * Minimal stack action creators.
+ *
+ * Expo Router 56 vendors React Navigation (`expo-router/build/react-navigation`)
+ * and `@react-navigation/native` is not an installed package, so `StackActions`
+ * cannot be imported. These action objects are the same shape the vendored
+ * `StackRouter` matches on, and dispatching them with a `target` routes the
+ * action to a specific (here: nested) navigator.
+ */
+
+/** Pops a stack back to its first route. */
+export function popToTop(): { type: 'POP_TO_TOP' } {
+ return { type: 'POP_TO_TOP' };
+}
diff --git a/src/navigation/tabsAnchor.test.mts b/src/navigation/tabsAnchor.test.mts
new file mode 100644
index 0000000..40ea390
--- /dev/null
+++ b/src/navigation/tabsAnchor.test.mts
@@ -0,0 +1,53 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { needsTabsCollapse, TABS_ROUTE_NAME } from './tabsAnchor.ts';
+
+test('no collapse needed while the tab tree is the focused root route', () => {
+ const rootState = {
+ index: 0,
+ routes: [{ name: TABS_ROUTE_NAME }],
+ };
+ assert.equal(needsTabsCollapse(rootState), false);
+});
+
+test('a root sibling above the anchor must be collapsed first', () => {
+ // This is the shape that dead-ends back-navigation: navigating to a route
+ // inside `(tabs)` from here diverges at the ROOT stack, and Expo Router's
+ // StackRouter mints a second `(tabs)` without de-duplicating by name.
+ const rootState = {
+ index: 1,
+ routes: [{ name: TABS_ROUTE_NAME }, { name: 'eq/scan' }],
+ };
+ assert.equal(needsTabsCollapse(rootState), true);
+});
+
+test('an already-duplicated stack still reports a needed collapse', () => {
+ const rootState = {
+ index: 2,
+ routes: [{ name: TABS_ROUTE_NAME }, { name: TABS_ROUTE_NAME }, { name: 'notification.click' }],
+ };
+ assert.equal(needsTabsCollapse(rootState), true);
+});
+
+test('a focused duplicate of the anchor needs no collapse', () => {
+ // Nothing to pop toward: the focused route already is a `(tabs)` instance, so
+ // navigation diverges inside the tab tree rather than at the root stack.
+ const rootState = {
+ index: 1,
+ routes: [{ name: TABS_ROUTE_NAME }, { name: TABS_ROUTE_NAME }],
+ };
+ assert.equal(needsTabsCollapse(rootState), false);
+});
+
+test('a missing or unready root state never triggers navigation surgery', () => {
+ assert.equal(needsTabsCollapse(undefined), false);
+ assert.equal(needsTabsCollapse({ index: 0, routes: [] }), false);
+});
+
+test('a root state without an index falls back to the topmost route', () => {
+ assert.equal(
+ needsTabsCollapse({ routes: [{ name: TABS_ROUTE_NAME }, { name: 'sources/edit' }] }),
+ true
+ );
+ assert.equal(needsTabsCollapse({ routes: [{ name: TABS_ROUTE_NAME }] }), false);
+});
diff --git a/src/navigation/tabsAnchor.ts b/src/navigation/tabsAnchor.ts
new file mode 100644
index 0000000..a7daa53
--- /dev/null
+++ b/src/navigation/tabsAnchor.ts
@@ -0,0 +1,32 @@
+/** The root stack's anchor, declared by `unstable_settings` in `src/app/_layout.tsx`. */
+export const TABS_ROUTE_NAME = '(tabs)';
+
+export interface RootStateLike {
+ index?: number;
+ routes: { name: string }[];
+}
+
+/**
+ * Whether the root stack currently has a screen sitting above the `(tabs)`
+ * anchor.
+ *
+ * This is the condition that made back-navigation dead-end. Expo Router
+ * resolves a navigation by walking down from the root and dispatching at the
+ * first navigator where the target diverges from what is focused. When a root
+ * sibling (`/eq/scan`, `/notification.click`, `/desktop-remote`, …) is focused
+ * and the target lives inside `(tabs)`, the divergence is the ROOT stack — and
+ * `StackRouter`'s PUSH/REPLACE cases create a brand new route without
+ * de-duplicating by name. The root stack becomes `[(tabs)#A, (tabs)#B]`, so back
+ * pops `#B` and lands on a stale second copy of the whole tab tree instead of
+ * exiting. Every repeat of the flow stacks another copy.
+ *
+ * Collapsing to the anchor first keeps exactly one `(tabs)` alive.
+ *
+ * Kept free of imports so it stays unit-testable without the router.
+ */
+export function needsTabsCollapse(rootState: RootStateLike | undefined): boolean {
+ if (!rootState) return false;
+ const focused = rootState.routes[rootState.index ?? rootState.routes.length - 1];
+ if (!focused) return false;
+ return focused.name !== TABS_ROUTE_NAME;
+}
diff --git a/src/navigation/useLibraryDetailBack.ts b/src/navigation/useLibraryDetailBack.ts
index 512cee4..35488ee 100644
--- a/src/navigation/useLibraryDetailBack.ts
+++ b/src/navigation/useLibraryDetailBack.ts
@@ -1,24 +1,47 @@
-import { useCallback } from 'react';
-import { BackHandler } from 'react-native';
-import { useFocusEffect, useRouter } from 'expo-router';
+import { useCallback, useMemo } from 'react';
+import { useNavigation, useRouter } from 'expo-router';
+import {
+ canPopWithinLibrary,
+ libraryParentLabel,
+ parentRoute,
+ type StackStateLike,
+} from '@/navigation/libraryDetailBack';
-export function useLibraryDetailBack() {
- const router = useRouter();
-
- const handleBack = useCallback(() => {
- router.dismissTo('/library');
- }, [router]);
-
- useFocusEffect(
- useCallback(() => {
- const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
- handleBack();
- return true;
- });
-
- return () => subscription.remove();
- }, [handleBack])
- );
-
- return handleBack;
+export interface LibraryDetailBack {
+ /** Pops one level, so artist → album → back returns to the artist. */
+ goBack: () => void;
+ /** Names what `goBack` will actually return to (see libraryParentLabel). */
+ backLabel: string;
+}
+
+/**
+ * Back behaviour for the library detail screens.
+ *
+ * No hardware-back interception any more: the old handler always returned true
+ * and redirected to the library root, which meant the Android button could never
+ * pop normally. Letting the stack handle it natively is both simpler and what
+ * users expect. A deep chain is escaped in one tap by re-pressing the Library
+ * tab (see `src/app/(tabs)/_layout.tsx`), not by flattening every back press.
+ */
+export function useLibraryDetailBack(): LibraryDetailBack {
+ const router = useRouter();
+ const navigation = useNavigation();
+
+ // The route beneath this one cannot change while the screen stays mounted, so
+ // a one-shot read is enough — no reactive subscription needed.
+ const { parent, canPop } = useMemo(() => {
+ const state = navigation.getState() as StackStateLike | undefined;
+ return { parent: parentRoute(state), canPop: canPopWithinLibrary(state) };
+ }, [navigation]);
+
+ const goBack = useCallback(() => {
+ if (canPop) {
+ router.back();
+ return;
+ }
+ // Nothing beneath us: the stack was entered straight at a detail screen.
+ router.dismissTo('/library');
+ }, [canPop, router]);
+
+ return { goBack, backLabel: libraryParentLabel(parent) };
}
diff --git a/src/session/SessionLifecycle.tsx b/src/session/SessionLifecycle.tsx
index f3cb8ff..d385eb2 100644
--- a/src/session/SessionLifecycle.tsx
+++ b/src/session/SessionLifecycle.tsx
@@ -4,9 +4,9 @@ import {
useGlobalSearchParams,
usePathname,
useRootNavigationState,
- useRouter,
useSegments,
} from 'expo-router';
+import { useReturnToTabs } from '@/navigation/returnToTabs';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { useSettingsStore } from '@/stores/settingsStore';
@@ -81,7 +81,7 @@ async function validateSavedHref(href: string): Promise {
/** Restores once, then owns stable-route tracking and session autosave. */
export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
- const router = useRouter();
+ const returnToTabs = useReturnToTabs();
const pathname = usePathname();
const segments = useSegments();
const params = useGlobalSearchParams<{
@@ -124,8 +124,12 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
if (cancelled) return;
// Every relaunch begins at rest even when a React activity was rebuilt
- // inside a still-live JS process.
- usePlayerUiStore.setState({ playerOpen: false });
+ // inside a still-live JS process — unless something already asked for the
+ // player during startup (a notification or widget tap resolves before
+ // these awaits finish, and used to be silently overridden here).
+ if (usePlayerUiStore.getState().openRequest === 0) {
+ usePlayerUiStore.setState({ phase: 'closed' });
+ }
useSearchStore.getState().closeQuickSearch();
const liveNativeSession = await hasActiveNativePlaybackSession();
@@ -150,7 +154,11 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
const stableHref = await validateSavedHref(snapshot?.lastStableHref ?? '/');
setInitialStableHref(stableHref);
if (shouldRestoreSavedRoute(initialPathname.current, initialUrl) && stableHref !== '/') {
- router.replace(stableHref as never);
+ // Never `replace` here. A root-level saved route (`/settings/audio`,
+ // `/sources`, …) would overwrite `(tabs)` at index 0 and destroy the
+ // anchor the root stack is built around, so back would exit the app;
+ // an in-tab saved route would mint a second `(tabs)` instead.
+ returnToTabs(stableHref as never);
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
}
@@ -185,7 +193,7 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
uninstallPersistence.current?.();
uninstallPersistence.current = null;
};
- }, [navigationKey, onReady, router]);
+ }, [navigationKey, onReady, returnToTabs]);
useEffect(() => {
if (!hydrated) return;
diff --git a/src/stores/playerPresence.test.mts b/src/stores/playerPresence.test.mts
new file mode 100644
index 0000000..303c3be
--- /dev/null
+++ b/src/stores/playerPresence.test.mts
@@ -0,0 +1,95 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import {
+ commitPlayerClosed,
+ initialPlayerPresence,
+ isPlayerMounted,
+ isPlayerOnScreen,
+ requestPlayerClose,
+ requestPlayerOpen,
+ settlePlayerOpen,
+ type PlayerPresenceState,
+} from './playerPresence.ts';
+
+test('a normal open/close round trip settles at both ends', () => {
+ let state = initialPlayerPresence;
+ assert.equal(isPlayerMounted(state.phase), false);
+
+ state = requestPlayerOpen(state);
+ assert.equal(state.phase, 'opening');
+ assert.equal(isPlayerMounted(state.phase), true, 'overlay mounts as soon as it is requested');
+ assert.equal(isPlayerOnScreen(state.phase), true);
+
+ state = settlePlayerOpen(state);
+ assert.equal(state.phase, 'open');
+
+ state = requestPlayerClose(state);
+ assert.equal(state.phase, 'closing');
+ assert.equal(isPlayerMounted(state.phase), true, 'stays mounted for the exit animation');
+ assert.equal(isPlayerOnScreen(state.phase), false);
+
+ state = commitPlayerClosed(state);
+ assert.equal(state.phase, 'closed');
+ assert.equal(isPlayerMounted(state.phase), false);
+});
+
+test('reopening an already-open player still registers as a request', () => {
+ // The regression this guards: `openPlayer` used to be `set({ open: true })`,
+ // so tapping the mini-player while the flag was already true produced no
+ // state change, no re-render, and no enter animation. If the sheet had been
+ // stranded off-screen by an interrupted close, it could never be reopened.
+ const open: PlayerPresenceState = { phase: 'open', openRequest: 4, exitAnimated: false };
+ const reopened = requestPlayerOpen(open);
+ assert.equal(reopened.phase, 'opening');
+ assert.notEqual(reopened.openRequest, open.openRequest, 'must be a real state change');
+});
+
+test('the close request records whether an exit animation is already running', () => {
+ const open = settlePlayerOpen(requestPlayerOpen(initialPlayerPresence));
+
+ // Gesture / chevron: the caller drives the sheet with its own velocity-matched
+ // spring, so the overlay must not start a competing slide-out.
+ assert.equal(requestPlayerClose(open, true).exitAnimated, true);
+
+ // Anything else (e.g. the output picker pushing a route underneath) needs the
+ // overlay to animate the sheet away itself.
+ assert.equal(requestPlayerClose(open).exitAnimated, false);
+
+ // A reopen clears it so the next close starts from a known state.
+ assert.equal(requestPlayerOpen(requestPlayerClose(open, true)).exitAnimated, false);
+});
+
+test('closing does not depend on the exit animation completing', () => {
+ // A cancelled spring never reports completion, so the fallback timer commits
+ // the release instead. Both routes end in the same place.
+ let state = requestPlayerOpen(initialPlayerPresence);
+ state = requestPlayerClose(state);
+ assert.equal(state.phase, 'closing');
+ state = commitPlayerClosed(state);
+ assert.equal(state.phase, 'closed', 'released with no animation callback involved');
+});
+
+test('a stale close commit cannot yank back a player the user reopened', () => {
+ // Reopening mid-close leaves the fallback timer from the previous close in
+ // flight. Committing must be a no-op unless still closing.
+ let state = requestPlayerOpen(initialPlayerPresence);
+ state = requestPlayerClose(state);
+ state = requestPlayerOpen(state);
+ assert.equal(state.phase, 'opening');
+
+ const afterStaleCommit = commitPlayerClosed(state);
+ assert.equal(afterStaleCommit.phase, 'opening', 'late commit is ignored');
+ assert.equal(isPlayerMounted(afterStaleCommit.phase), true);
+});
+
+test('a stale open settle cannot resurrect a closing player', () => {
+ let state = requestPlayerOpen(initialPlayerPresence);
+ state = requestPlayerClose(state);
+ assert.equal(settlePlayerOpen(state).phase, 'closing');
+});
+
+test('closing an already-closed player is inert', () => {
+ const closed = requestPlayerClose(initialPlayerPresence);
+ assert.equal(closed.phase, 'closed');
+ assert.equal(closed, initialPlayerPresence, 'no new state object, so no re-render');
+});
diff --git a/src/stores/playerPresence.ts b/src/stores/playerPresence.ts
new file mode 100644
index 0000000..fcb86ee
--- /dev/null
+++ b/src/stores/playerPresence.ts
@@ -0,0 +1,80 @@
+/**
+ * Now-playing presentation phases.
+ *
+ * The player is an overlay above the navigator, not a route, so nothing but this
+ * phase decides whether it is on screen. It used to be a bare `playerOpen`
+ * boolean paired with a separate mount gate and a Reanimated offset, and those
+ * three could disagree: a cancelled close animation left the flag `true` with
+ * the sheet parked off-screen, and because reopening was `set({ open: true })`
+ * it produced no state change and therefore no re-render — the player could
+ * never be reopened again for the rest of the session.
+ *
+ * Two invariants keep that from coming back:
+ * - every open request bumps `openRequest`, so a repeat request is always a
+ * real state change and always re-runs the enter animation (the repair path);
+ * - phase transitions never depend on an animation completing. `closing` is
+ * entered before the exit animation starts, and `closed` is committed by
+ * whichever lands first, the animation callback or a fallback timer.
+ */
+export type PlayerPhase = 'closed' | 'opening' | 'open' | 'closing';
+
+export interface PlayerPresenceState {
+ phase: PlayerPhase;
+ openRequest: number;
+ /**
+ * Whether the current close already has an exit animation attached. The
+ * gesture and button paths drive the sheet themselves (with velocity-matched
+ * spring shaping), so the overlay must not start a competing one; a close
+ * requested from anywhere else gets a plain slide-out instead.
+ */
+ exitAnimated: boolean;
+}
+
+export const initialPlayerPresence: PlayerPresenceState = {
+ phase: 'closed',
+ openRequest: 0,
+ exitAnimated: false,
+};
+
+/**
+ * Ask for the player. Unconditional on purpose: requesting an open while the
+ * phase already says `open` still bumps `openRequest`, which is what lets a tap
+ * recover a sheet that was stranded off-screen by an interrupted close.
+ */
+export function requestPlayerOpen(state: PlayerPresenceState): PlayerPresenceState {
+ return { phase: 'opening', openRequest: state.openRequest + 1, exitAnimated: false };
+}
+
+/** Begin the exit. Safe to call repeatedly; a closed player stays closed. */
+export function requestPlayerClose(
+ state: PlayerPresenceState,
+ exitAnimated = false
+): PlayerPresenceState {
+ if (state.phase === 'closed') return state;
+ return { ...state, phase: 'closing', exitAnimated };
+}
+
+/**
+ * Release the overlay. Ignored unless still closing, so a fallback timer that
+ * fires after the user reopened the player cannot yank it back off screen.
+ */
+export function commitPlayerClosed(state: PlayerPresenceState): PlayerPresenceState {
+ if (state.phase !== 'closing') return state;
+ return { ...state, phase: 'closed' };
+}
+
+/** Settle the enter animation. Cosmetic only — nothing gates on it. */
+export function settlePlayerOpen(state: PlayerPresenceState): PlayerPresenceState {
+ if (state.phase !== 'opening') return state;
+ return { ...state, phase: 'open' };
+}
+
+/** Whether the heavyweight overlay tree should be mounted. */
+export function isPlayerMounted(phase: PlayerPhase): boolean {
+ return phase !== 'closed';
+}
+
+/** Whether the sheet should be resting on screen (as opposed to sliding away). */
+export function isPlayerOnScreen(phase: PlayerPhase): boolean {
+ return phase === 'opening' || phase === 'open';
+}
diff --git a/src/stores/playerUiStore.ts b/src/stores/playerUiStore.ts
index 486e5b9..9d29e62 100644
--- a/src/stores/playerUiStore.ts
+++ b/src/stores/playerUiStore.ts
@@ -1,18 +1,47 @@
import { create } from 'zustand';
+import {
+ commitPlayerClosed,
+ initialPlayerPresence,
+ isPlayerMounted,
+ isPlayerOnScreen,
+ requestPlayerClose,
+ requestPlayerOpen,
+ settlePlayerOpen,
+ type PlayerPresenceState,
+} from '@/stores/playerPresence';
/**
- * Now-playing overlay gate. The player is an overlay above the navigator (not
- * a route); its host retains it only long enough to finish the close animation.
- * Session state only — never persisted.
+ * Now-playing overlay gate. The player is an overlay above the navigator (not a
+ * route), and this phase is the only thing that decides whether it is mounted
+ * and on screen. See `playerPresence.ts` for the invariants. Session state only
+ * — never persisted.
*/
-interface PlayerUiStore {
- playerOpen: boolean;
+interface PlayerUiStore extends PlayerPresenceState {
openPlayer: () => void;
- closePlayer: () => void;
+ /**
+ * @param exitAnimated pass true when the caller is already animating the sheet
+ * away, so the overlay does not start a competing slide-out.
+ */
+ closePlayer: (exitAnimated?: boolean) => void;
+ commitClosed: () => void;
+ settleOpen: () => void;
}
export const usePlayerUiStore = create((set) => ({
- playerOpen: false,
- openPlayer: () => set({ playerOpen: true }),
- closePlayer: () => set({ playerOpen: false }),
+ ...initialPlayerPresence,
+ openPlayer: () => set(requestPlayerOpen),
+ closePlayer: (exitAnimated = false) =>
+ set((state) => requestPlayerClose(state, exitAnimated)),
+ commitClosed: () => set(commitPlayerClosed),
+ settleOpen: () => set(settlePlayerOpen),
}));
+
+/** Whether the overlay tree should be rendered at all. */
+export function usePlayerMounted(): boolean {
+ return usePlayerUiStore((s) => isPlayerMounted(s.phase));
+}
+
+/** Whether the sheet should be resting on screen rather than sliding away. */
+export function usePlayerOnScreen(): boolean {
+ return usePlayerUiStore((s) => isPlayerOnScreen(s.phase));
+}