fix internal routing, and remove softlocking

This commit is contained in:
Boof2015
2026-07-25 11:12:02 -04:00
parent 098992639d
commit 0f34ca2e8d
33 changed files with 904 additions and 157 deletions
+1
View File
@@ -81,6 +81,7 @@
"test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.test.mts", "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: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: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: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: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", "test:session": "node --experimental-strip-types --test src/session/sessionState.test.mts src/session/playbackMaterialization.test.mts",
+38 -4
View File
@@ -1,18 +1,52 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { getReleaseIdentity } from './android-release.mjs'; 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', () => { test('builds stable artifact names from the tracked release identity', () => {
assert.deepEqual(getReleaseIdentity('github'), { assert.deepEqual(getReleaseIdentity('github'), {
artifactFileName: 'Astra-0.1.0-1-GitHub-arm-universal.apk', artifactFileName: `Astra-${versionName}-${versionCode}-GitHub-arm-universal.apk`,
distribution: 'github', distribution: 'github',
distributionLabel: 'GitHub', distributionLabel: 'GitHub',
packageId: 'io.github.boof2015.astra', packageId: 'io.github.boof2015.astra',
versionCode: 1, versionCode,
versionName: '0.1.0', 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', () => { test('rejects unknown distribution channels', () => {
+1
View File
@@ -19,6 +19,7 @@ const TEST_SCRIPTS = [
'test:settings-search', 'test:settings-search',
'test:now-playing-layout', 'test:now-playing-layout',
'test:memory-lifecycle', 'test:memory-lifecycle',
'test:ui-navigation',
'test:haptics', 'test:haptics',
'test:home-greeting', 'test:home-greeting',
'test:session', 'test:session',
+15 -3
View File
@@ -5,6 +5,7 @@ import {
TAB_TRANSITION_SETTLE_MS, TAB_TRANSITION_SETTLE_MS,
TAB_TRANSITION_SPEC, TAB_TRANSITION_SPEC,
} from '@/navigation/tabTransition'; } from '@/navigation/tabTransition';
import { popToTop } from '@/navigation/stackActions';
import { useColors } from '@/theme/themed'; import { useColors } from '@/theme/themed';
export default function TabsLayout() { export default function TabsLayout() {
@@ -46,10 +47,21 @@ export default function TabsLayout() {
target: item.key, target: item.key,
canPreventDefault: true, canPreventDefault: true,
}); });
if (!item.focused && !event.defaultPrevented) { if (event.defaultPrevented) return;
lastSwitchAt.current = now;
navigation.navigate(item.name); 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 <TabBar items={items} onPress={handlePress} />; return <TabBar items={items} onPress={handlePress} />;
+10
View File
@@ -1,6 +1,16 @@
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { useColors } from '@/theme/themed'; 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 * Nested stack inside the Library tab so album/artist detail screens keep the
* tab bar + mini-player visible. * tab bar + mini-player visible.
+3 -2
View File
@@ -40,7 +40,7 @@ export default function AlbumScreen() {
const { key } = useLocalSearchParams<{ key: string }>(); const { key } = useLocalSearchParams<{ key: string }>();
const { items: tracks, summary: album, totalCount, loadMore } = useNativeAlbumDetail(key); const { items: tracks, summary: album, totalCount, loadMore } = useNativeAlbumDetail(key);
const currentPath = usePlayerStore((s) => s.currentTrack?.path); const currentPath = usePlayerStore((s) => s.currentTrack?.path);
const handleBack = useLibraryDetailBack(); const { goBack, backLabel } = useLibraryDetailBack();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } = const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } =
useDetailCollapse(); useDetailCollapse();
@@ -154,7 +154,8 @@ export default function AlbumScreen() {
</> </>
} }
disabled={tracks.length === 0} disabled={tracks.length === 0}
onBack={handleBack} onBack={goBack}
backLabel={backLabel}
onPlay={() => playFrom(0)} onPlay={() => playFrom(0)}
onShuffle={() => void playLibraryQuery({ kind: 'album', albumKey: key }, { onShuffle={() => void playLibraryQuery({ kind: 'album', albumKey: key }, {
shuffle: true, shuffle: true,
+3 -2
View File
@@ -71,7 +71,7 @@ export default function ArtistScreen() {
name: string; name: string;
credit?: string; credit?: string;
}>(); }>();
const handleBack = useLibraryDetailBack(); const { goBack, backLabel } = useLibraryDetailBack();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } = const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } =
useDetailCollapse(); useDetailCollapse();
@@ -246,7 +246,8 @@ export default function ArtistScreen() {
</View> </View>
} }
disabled={disabled} disabled={disabled}
onBack={handleBack} onBack={goBack}
backLabel={backLabel}
onPlay={playArtist} onPlay={playArtist}
onShuffle={shuffleArtist} onShuffle={shuffleArtist}
scrollY={scrollY} scrollY={scrollY}
+16 -10
View File
@@ -1,5 +1,5 @@
import { import {
useEffect, useCallback,
useMemo, useMemo,
useState useState
} from 'react'; } from 'react';
@@ -11,7 +11,7 @@ import {
} from 'react-native'; } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list'; import { FlashList } from '@shopify/flash-list';
import { useRouter } from 'expo-router'; import { useFocusEffect, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen'; import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { ViewModeSwitcher } from '@/components/library/ViewModeSwitcher'; import { ViewModeSwitcher } from '@/components/library/ViewModeSwitcher';
@@ -165,14 +165,20 @@ export default function LibraryScreen() {
setPlaylistPickerOpen(false); setPlaylistPickerOpen(false);
}; };
useEffect(() => { // Focus-gated, not a plain effect: the tabs layout keeps this screen mounted
if (!selectMode) return; // while blurred (`detachInactiveScreens={false}` + `freezeOnBlur: false`), so
const sub = BackHandler.addEventListener('hardwareBackPress', () => { // an ungated handler swallowed one back press anywhere in the app — any other
exitSelection(); // tab, any settings screen — whenever selection happened to be active.
return true; useFocusEffect(
}); useCallback(() => {
return () => sub.remove(); if (!selectMode) return undefined;
}, [selectMode]); const sub = BackHandler.addEventListener('hardwareBackPress', () => {
exitSelection();
return true;
});
return () => sub.remove();
}, [selectMode])
);
const selectedDbTracks = () => sortedTracks.filter((track) => selectedIds.has(track.id)); const selectedDbTracks = () => sortedTracks.filter((track) => selectedIds.has(track.id));
+4 -3
View File
@@ -89,7 +89,7 @@ export default function PlaylistScreen() {
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
const { id } = useLocalSearchParams<{ id: string }>(); const { id } = useLocalSearchParams<{ id: string }>();
const handleBack = useLibraryDetailBack(); const { goBack, backLabel } = useLibraryDetailBack();
const isFavorites = id === 'favorites'; const isFavorites = id === 'favorites';
const playlistId = isFavorites ? null : Number(id); const playlistId = isFavorites ? null : Number(id);
@@ -219,7 +219,7 @@ export default function PlaylistScreen() {
void (async () => { void (async () => {
try { try {
await deletePlaylist(target.id); await deletePlaylist(target.id);
handleBack(); goBack();
} catch (err) { } catch (err) {
Alert.alert('Delete failed', errorMessage(err)); Alert.alert('Delete failed', errorMessage(err));
} }
@@ -340,7 +340,8 @@ export default function PlaylistScreen() {
) : null ) : null
} }
disabled={playable.length === 0} disabled={playable.length === 0}
onBack={handleBack} onBack={goBack}
backLabel={backLabel}
onMore={() => setOptionsOpen(true)} onMore={() => setOptionsOpen(true)}
onPlay={() => startPlayback(0)} onPlay={() => startPlayback(0)}
onShuffle={startShuffle} onShuffle={startShuffle}
+3 -1
View File
@@ -18,6 +18,7 @@ import {
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import { useReturnToTabs } from '@/navigation/returnToTabs';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { AstraLogo } from '@/components/AstraLogo'; import { AstraLogo } from '@/components/AstraLogo';
import { Screen } from '@/components/Screen'; import { Screen } from '@/components/Screen';
@@ -225,6 +226,7 @@ export default function DesktopRemoteScreen() {
const ripple = useRipple(); const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
const returnToTabs = useReturnToTabs();
const pairingParams = useLocalSearchParams<{ const pairingParams = useLocalSearchParams<{
pair?: string; pair?: string;
baseUrl?: string; baseUrl?: string;
@@ -620,7 +622,7 @@ export default function DesktopRemoteScreen() {
// screen so it slides in above wherever the user came from. // screen so it slides in above wherever the user came from.
usePlayerUiStore.getState().openPlayer(); usePlayerUiStore.getState().openPlayer();
if (router.canGoBack()) router.back(); if (router.canGoBack()) router.back();
else router.replace('/'); else returnToTabs('/');
}} }}
> >
<Ionicons name="musical-notes-outline" size={18} color={colors.accentTextStrong} /> <Ionicons name="musical-notes-outline" size={18} color={colors.accentTextStrong} />
+6 -3
View File
@@ -1,7 +1,8 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Pressable, StyleSheet, View } from 'react-native'; import { Pressable, StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; 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 { Screen } from '@/components/Screen';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet'; import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet';
@@ -17,7 +18,7 @@ export default function EQPresetImportScreen() {
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple(); const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const returnToTabs = useReturnToTabs();
const importPreset = useEQStore((state) => state.importPreset); const importPreset = useEQStore((state) => state.importPreset);
const { data } = useLocalSearchParams<{ data?: string }>(); const { data } = useLocalSearchParams<{ data?: string }>();
@@ -30,7 +31,9 @@ export default function EQPresetImportScreen() {
} }
}, [data]); }, [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) { if (!preset) {
return ( return (
+4 -1
View File
@@ -11,6 +11,7 @@ import {
} from 'expo-camera'; } from 'expo-camera';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useReturnToTabs } from '@/navigation/returnToTabs';
import { Screen } from '@/components/Screen'; import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet'; import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet';
@@ -30,6 +31,7 @@ export default function EQPresetScanScreen() {
const ripple = useRipple(); const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const router = useRouter();
const returnToTabs = useReturnToTabs();
const importPreset = useEQStore((state) => state.importPreset); const importPreset = useEQStore((state) => state.importPreset);
const [permission, requestPermission] = useCameraPermissions(); const [permission, requestPermission] = useCameraPermissions();
const [pendingPreset, setPendingPreset] = useState<EQPreset | null>(null); const [pendingPreset, setPendingPreset] = useState<EQPreset | null>(null);
@@ -109,7 +111,8 @@ export default function EQPresetScanScreen() {
onConfirm={() => { onConfirm={() => {
importPreset(pendingPreset); importPreset(pendingPreset);
setPendingPreset(null); setPendingPreset(null);
router.replace('/eq' as never); // `/eq` is a tab; see useReturnToTabs.
returnToTabs('/eq' as never);
}} }}
onClose={() => setPendingPreset(null)} onClose={() => setPendingPreset(null)}
/> />
+8 -5
View File
@@ -1,28 +1,31 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { View } from 'react-native'; import { View } from 'react-native';
import { useRouter } from 'expo-router';
import { resolveNotificationClick } from '@/audio/notificationIntent'; import { resolveNotificationClick } from '@/audio/notificationIntent';
import { useReturnToTabs } from '@/navigation/returnToTabs';
import { createThemedStyles } from '@/theme/themed'; import { createThemedStyles } from '@/theme/themed';
export default function NotificationClickRoute() { export default function NotificationClickRoute() {
const styles = useStyles(); 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
resolveNotificationClick() resolveNotificationClick()
.then((href) => { .then((href) => {
if (!cancelled) router.replace(href); if (!cancelled) returnToTabs(href);
}) })
.catch(() => { .catch(() => {
if (!cancelled) router.replace('/'); if (!cancelled) returnToTabs('/');
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [router]); }, [returnToTabs]);
return <View style={styles.root} />; return <View style={styles.root} />;
} }
+2 -2
View File
@@ -16,7 +16,7 @@ import Animated, {
useSharedValue, useSharedValue,
withTiming, withTiming,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { usePlayerUiStore } from '@/stores/playerUiStore'; import { usePlayerOnScreen, usePlayerUiStore } from '@/stores/playerUiStore';
import { Text } from './Text'; import { Text } from './Text';
import { AstraLogo } from './AstraLogo'; import { AstraLogo } from './AstraLogo';
import { SpectrumCurve } from './SpectrumCurve'; import { SpectrumCurve } from './SpectrumCurve';
@@ -118,7 +118,7 @@ export function MiniPlayer() {
const styles = useStyles(); const styles = useStyles();
const colors = useColors(); const colors = useColors();
const ripple = useRipple(); const ripple = useRipple();
const playerOpen = usePlayerUiStore((s) => s.playerOpen); const playerOpen = usePlayerOnScreen();
const selectedTarget = usePlaybackTargetStore((s) => s.target); const selectedTarget = usePlaybackTargetStore((s) => s.target);
const track = usePlayerStore((s) => s.currentTrack); const track = usePlayerStore((s) => s.currentTrack);
const playbackState = usePlayerStore((s) => s.playbackState); const playbackState = usePlayerStore((s) => s.playbackState);
+5
View File
@@ -14,6 +14,7 @@ import { useRipple } from '@/theme/ripple';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlaybackTargetStore, type PlaybackTarget } from '@/stores/playbackTargetStore'; import { usePlaybackTargetStore, type PlaybackTarget } from '@/stores/playbackTargetStore';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { usePlayerUiStore } from '@/stores/playerUiStore';
import { import {
desktopConnectionLabel, desktopConnectionLabel,
hostFromBaseUrl, hostFromBaseUrl,
@@ -52,6 +53,10 @@ export function PlaybackTargetPicker({ visible, onClose }: PlaybackTargetPickerP
const pairDesktop = () => { const pairDesktop = () => {
onClose(); 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); router.push('/desktop-remote' as never);
}; };
+14 -2
View File
@@ -126,6 +126,7 @@ export function CollapsingHeader({
heroExtra, heroExtra,
disabled, disabled,
onBack, onBack,
backLabel,
onMore, onMore,
onPlay, onPlay,
onShuffle, onShuffle,
@@ -145,6 +146,8 @@ export function CollapsingHeader({
heroExtra?: ReactNode; heroExtra?: ReactNode;
disabled?: boolean; disabled?: boolean;
onBack: () => void; onBack: () => void;
/** Names the screen `onBack` returns to; must track the real action. */
backLabel: string;
onMore?: () => void; onMore?: () => void;
onPlay: () => void; onPlay: () => void;
onShuffle: () => void; onShuffle: () => void;
@@ -282,8 +285,17 @@ export function CollapsingHeader({
> >
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} /> <Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
</Pressable> </Pressable>
<Animated.Text style={[styles.label, { top: barCenterY - 10, left: spacing.md + 26 }, labelStyle]}> <Animated.Text
Library numberOfLines={1}
style={[
styles.label,
// Bounded so a long artist name ellipsizes instead of running off the
// edge; clears the overflow button when the screen has one.
{ top: barCenterY - 10, left: spacing.md + 26, right: onMore ? 56 : spacing.md },
labelStyle,
]}
>
{backLabel}
</Animated.Text> </Animated.Text>
<Animated.Text <Animated.Text
+25 -14
View File
@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'expo-router'; import { usePathname } from 'expo-router';
import { useReturnToTabs } from '@/navigation/returnToTabs';
import { import {
AppSheet, AppSheet,
AppSheetItem, AppSheetItem,
@@ -42,13 +43,19 @@ function TrackActionsSheetInner({
initialStep = 'menu', initialStep = 'menu',
extraItems = [], extraItems = [],
}: TrackActionsSheetProps & { track: DbTrack }) { }: TrackActionsSheetProps & { track: DbTrack }) {
const router = useRouter(); // This sheet also renders from `recently-played` and from inside the
// now-playing overlay, i.e. while a root-stack sibling of `(tabs)` is focused,
// where a bare push would mint a second copy of the whole tab tree.
const returnToTabs = useReturnToTabs();
const pathname = usePathname();
const [step, setStep] = useState<'menu' | 'pickPlaylist'>(initialStep); const [step, setStep] = useState<'menu' | 'pickPlaylist'>(initialStep);
const groupingMode = useSettingsStore((s) => s.artistGroupingMode); const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const isFavorite = usePlaylistStore((s) => s.favoritePaths.has(track.path)); const isFavorite = usePlaylistStore((s) => s.favoritePaths.has(track.path));
const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite); const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite);
const artistName = resolveNavigationArtist(track, groupingMode); const artistName = resolveNavigationArtist(track, groupingMode);
const albumHref = `/library/album/${encodeURIComponent(track.album_identity_key)}`;
const artistHref = `/library/artist/${encodeURIComponent(artistName)}`;
const closeAndRun = (run: () => void) => { const closeAndRun = (run: () => void) => {
onClose(); onClose();
@@ -79,24 +86,28 @@ function TrackActionsSheetInner({
label: 'View album', label: 'View album',
icon: 'albums-outline', icon: 'albums-outline',
onPress: () => onPress: () =>
closeAndRun(() => closeAndRun(() => {
router.push({ // Already here (this sheet also opens from the album screen itself):
pathname: '/library/album/[key]', // pushing would stack a duplicate of the current screen.
params: { key: track.album_identity_key }, if (pathname === albumHref) return;
}) returnToTabs(
), { pathname: '/library/album/[key]', params: { key: track.album_identity_key } },
'push'
);
}),
}, },
{ {
key: 'view-artist', key: 'view-artist',
label: 'View artist', label: 'View artist',
icon: 'person-outline', icon: 'person-outline',
onPress: () => onPress: () =>
closeAndRun(() => closeAndRun(() => {
router.push({ if (pathname === artistHref) return;
pathname: '/library/artist/[name]', returnToTabs(
params: { name: artistName }, { pathname: '/library/artist/[name]', params: { name: artistName } },
}) 'push'
), );
}),
}, },
{ {
key: 'favorite', key: 'favorite',
+12 -16
View File
@@ -1,24 +1,20 @@
import { NowPlayingOverlay } from '@/components/player/NowPlayingOverlay'; import { NowPlayingOverlay } from '@/components/player/NowPlayingOverlay';
import { useDelayedUnmountPresence } from '@/components/delayedPresence'; import { usePlayerMounted } from '@/stores/playerUiStore';
import { NOW_PLAYING_CLOSE_UNMOUNT_MS } from '@/components/renderPresenceTiming';
import { useAppForeground } from '@/lib/useAppForeground';
import { usePlayerUiStore } from '@/stores/playerUiStore';
/** /**
* Presence gate for the heavyweight now-playing tree. It stays alive just past * Presence gate for the heavyweight now-playing tree. The store's `closing`
* the 200 ms close animation, but never remains hidden indefinitely. Android * phase is the linger that lets the exit animation finish, so this is a plain
* backgrounding drops it immediately so TextureViews and decoded art release. * 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() { export function NowPlayingHost() {
const playerOpen = usePlayerUiStore((s) => s.playerOpen); const mounted = usePlayerMounted();
const foreground = useAppForeground();
const renderOverlay = useDelayedUnmountPresence( if (!mounted) return null;
playerOpen,
NOW_PLAYING_CLOSE_UNMOUNT_MS,
!foreground
);
if (!renderOverlay) return null;
return <NowPlayingOverlay />; return <NowPlayingOverlay />;
} }
+133 -36
View File
@@ -42,6 +42,11 @@ import { PlayerStateIcon } from '@/components/player/PlayerStateIcon';
import { CachedLyricPeek } from '@/components/player/CachedLyricPeek'; import { CachedLyricPeek } from '@/components/player/CachedLyricPeek';
import { resolveNowPlayingDismissSpring } from '@/components/player/nowPlayingDismiss'; import { resolveNowPlayingDismissSpring } from '@/components/player/nowPlayingDismiss';
import { useDelayedUnmountPresence } from '@/components/delayedPresence'; 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 { SleepTimerControls } from '@/components/player/SleepTimerControls';
import { AppSheet, AppSheetTitle } from '@/components/sheets/AppSheet'; import { AppSheet, AppSheetTitle } from '@/components/sheets/AppSheet';
import { import {
@@ -63,6 +68,7 @@ import {
NOW_PLAYING_WAVEFORM_TOUCH_PADDING, NOW_PLAYING_WAVEFORM_TOUCH_PADDING,
NOW_PLAYING_WIDE_PANE_GAP, NOW_PLAYING_WIDE_PANE_GAP,
} from '@/components/player/nowPlayingLayout'; } from '@/components/player/nowPlayingLayout';
import { useReturnToTabs } from '@/navigation/returnToTabs';
import { resolveNavigationArtist, splitCollaborators } from '@/library/artistGrouping'; import { resolveNavigationArtist, splitCollaborators } from '@/library/artistGrouping';
import { buildArtistNameTokens } from '@/shared/library/artistCredits'; import { buildArtistNameTokens } from '@/shared/library/artistCredits';
import { import {
@@ -76,6 +82,7 @@ import { useQueueStore } from '@/stores/queueStore';
import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { usePlayerUiStore } from '@/stores/playerUiStore'; import { usePlayerUiStore } from '@/stores/playerUiStore';
import { isPlayerOnScreen } from '@/stores/playerPresence';
import { useSettingsStore, type ScopeMode } from '@/stores/settingsStore'; import { useSettingsStore, type ScopeMode } from '@/stores/settingsStore';
import { useSleepTimerStore } from '@/stores/sleepTimerStore'; import { useSleepTimerStore } from '@/stores/sleepTimerStore';
import type { DbTrack } from '@/types/library'; import type { DbTrack } from '@/types/library';
@@ -125,10 +132,19 @@ export function NowPlayingOverlay() {
const colors = useColors(); const colors = useColors();
const ripple = useRipple(); const ripple = useRipple();
const router = useRouter(); const router = useRouter();
const returnToTabs = useReturnToTabs();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const reduceMotion = useReducedMotion(); 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); const [queueOpen, setQueueOpen] = useState(false);
// Stable identity: QueueTray is memo'd, so a fresh arrow here would defeat it. // Stable identity: QueueTray is memo'd, so a fresh arrow here would defeat it.
const closeQueue = useCallback(() => setQueueOpen(false), []); const closeQueue = useCallback(() => setQueueOpen(false), []);
@@ -181,13 +197,19 @@ export function NowPlayingOverlay() {
}); });
const isDesktopTarget = activePresentation.target === 'desktop'; const isDesktopTarget = activePresentation.target === 'desktop';
const effectiveScopeStageVisible = !isDesktopTarget && scopeStageVisible; 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( const renderScopeSurfaces = useDelayedUnmountPresence(
effectiveScopeStageVisible, effectiveScopeStageVisible,
motion.snap.duration motion.snap.duration,
!foreground
); );
const renderArtworkFace = useDelayedUnmountPresence( const renderArtworkFace = useDelayedUnmountPresence(
railStyle || !effectiveScopeStageVisible, railStyle || !effectiveScopeStageVisible,
motion.snap.duration motion.snap.duration,
!foreground
); );
const activeTrack = desktopSnapshot?.currentTrack ?? null; const activeTrack = desktopSnapshot?.currentTrack ?? null;
const transitionTrackKey = isDesktopTarget ? activeTrack?.id ?? '' : track?.id ?? ''; const transitionTrackKey = isDesktopTarget ? activeTrack?.id ?? '' : track?.id ?? '';
@@ -295,23 +317,26 @@ export function NowPlayingOverlay() {
.getState() .getState()
.setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum'); .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) => { const navigateToArtist = (targetArtist = artistName, credit = false) => {
if (!targetArtist) return; if (!targetArtist) return;
// Slide the overlay away while the library detail loads underneath. // Slide the overlay away while the library detail loads underneath.
dismissSheet(); dismissSheet();
router.navigate({ returnToTabs(
pathname: '/library/artist/[name]', {
params: { name: targetArtist, ...(credit ? { credit: '1' } : {}) }, pathname: '/library/artist/[name]',
}); params: { name: targetArtist, ...(credit ? { credit: '1' } : {}) },
},
'push'
);
}; };
const navigateToAlbum = () => { const navigateToAlbum = () => {
if (!albumKey) return; if (!albumKey) return;
dismissSheet(); dismissSheet();
router.navigate({ returnToTabs({ pathname: '/library/album/[key]', params: { key: albumKey } }, 'push');
pathname: '/library/album/[key]',
params: { key: albumKey },
});
}; };
const menuItems: NowPlayingMenuItem[] = []; const menuItems: NowPlayingMenuItem[] = [];
@@ -400,12 +425,20 @@ export function NowPlayingOverlay() {
useEffect(() => { useEffect(() => {
stageProgress.value = withTiming(effectiveScopeStageVisible ? 1 : 0, motion.snap); stageProgress.value = withTiming(effectiveScopeStageVisible ? 1 : 0, motion.snap);
}, [effectiveScopeStageVisible, stageProgress]); }, [effectiveScopeStageVisible, stageProgress]);
// Closing is a store toggle, not navigation. Reset the inner layers so a const commitClosed = () => usePlayerUiStore.getState().commitClosed();
// reopen starts from the plain player (parity with the old per-open mount). /**
const dismiss = () => { * 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); setMenuOpen(false);
setQueueOpen(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); 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) => { const dismissSheet = (velocity = 0) => {
beginDismiss();
translateY.value = withSpring( translateY.value = withSpring(
windowHeight, windowHeight,
{ {
@@ -432,7 +472,7 @@ export function NowPlayingOverlay() {
overshootClamping: true, overshootClamping: true,
}, },
(finished) => { (finished) => {
if (finished) runOnJS(dismiss)(); if (finished) runOnJS(commitClosed)();
} }
); );
}; };
@@ -450,6 +490,9 @@ export function NowPlayingOverlay() {
e.velocityY, e.velocityY,
windowHeight - translateY.value windowHeight - translateY.value
); );
// Same commit-first contract as dismissSheet, with the release spring's
// velocity-matched shaping preserved.
runOnJS(beginDismiss)();
translateY.value = withSpring( translateY.value = withSpring(
windowHeight, windowHeight,
{ {
@@ -460,7 +503,7 @@ export function NowPlayingOverlay() {
energyThreshold: 1e-4, energyThreshold: 1e-4,
}, },
(finished) => { (finished) => {
if (finished) runOnJS(dismiss)(); if (finished) runOnJS(commitClosed)();
} }
); );
} else { } else {
@@ -468,29 +511,74 @@ export function NowPlayingOverlay() {
} }
}); });
// Open animation (close normally animates via dismissSheet's spring first; // Enter animation. Keyed on `openRequest` as well as the phase, so asking for
// the else branch covers direct closePlayer calls and keeps the resting // a player that already believes it is open still re-runs the slide-in — that
// offset pinned to the current window height across rotation). NOTE: this // is the recovery path for a sheet stranded off-screen by an interrupted
// effect must stay BELOW every direct `translateY.value` write — the react // close. `windowHeight` is deliberately NOT a dependency: a dimension change
// compiler forbids mutations after an effect that depends on the value. // (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(() => { useEffect(() => {
if (playerOpen) { if (phase === 'closing') {
translateY.value = withTiming(0, { duration: 240 }); // The gesture and button paths own the exit animation, including its
} else { // velocity-matched spring shaping. Only animate here when `closing`
translateY.value = withTiming(windowHeight, { duration: 200 }); // 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 // Hardware back, innermost layer first: menu → queue tray → player. Registered
// only while open, so it sits above the focused screen's own handlers (LIFO) // only while open, so it sits above the focused screen's own handlers (LIFO)
// — e.g. the library-detail back interceptor underneath. // — e.g. the library-detail back interceptor underneath.
useEffect(() => { useEffect(() => {
if (!playerOpen) return; if (!playerOpen) return undefined;
const sub = BackHandler.addEventListener('hardwareBackPress', () => { const sub = BackHandler.addEventListener('hardwareBackPress', () => {
if (menuOpen) { if (menuOpen) {
closeMenu(); closeMenu();
return true; return true;
} }
if (targetPickerOpen) {
setTargetPickerOpen(false);
return true;
}
if (sleepTimerOpen) {
setSleepTimerOpen(false);
return true;
}
if (playlistActionTrack) {
setPlaylistActionTrack(null);
return true;
}
if (queueOpen) { if (queueOpen) {
setQueueOpen(false); setQueueOpen(false);
return true; return true;
@@ -499,7 +587,16 @@ export function NowPlayingOverlay() {
return true; return true;
}); });
return () => sub.remove(); 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(() => ({ const contentStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }], transform: [{ translateY: translateY.value }],
@@ -636,7 +733,7 @@ export function NowPlayingOverlay() {
{lyricsMode && track ? ( {lyricsMode && track ? (
<LyricsView <LyricsView
track={track} track={track}
active={playerOpen} active={surfacesLive}
isPlaying={isPlaying} isPlaying={isPlaying}
isLoading={isLoading} isLoading={isLoading}
isFavorite={isFavorite} isFavorite={isFavorite}
@@ -994,7 +1091,7 @@ export function NowPlayingOverlay() {
stripWidth={layout.scopeWidth} stripWidth={layout.scopeWidth}
artworkUri={backdropArtworkUri} artworkUri={backdropArtworkUri}
spectrumSmoothing={NOW_PLAYING_SPECTRUM_SMOOTHING} spectrumSmoothing={NOW_PLAYING_SPECTRUM_SMOOTHING}
paused={!playerOpen || queueOpen || !effectiveScopeStageVisible} paused={!surfacesLive || queueOpen || !effectiveScopeStageVisible}
/> />
</Animated.View> </Animated.View>
)} )}
@@ -1017,7 +1114,7 @@ export function NowPlayingOverlay() {
width={layout.scopeWidth} width={layout.scopeWidth}
height={layout.scopeHeight} height={layout.scopeHeight}
mode={scopeMode} mode={scopeMode}
paused={!playerOpen || queueOpen || !effectiveScopeStageVisible} paused={!surfacesLive || queueOpen || !effectiveScopeStageVisible}
revealed={effectiveScopeStageVisible} revealed={effectiveScopeStageVisible}
onSwap={swapScopeMode} onSwap={swapScopeMode}
/> />
@@ -1039,7 +1136,7 @@ export function NowPlayingOverlay() {
width={layout.scopeWidth} width={layout.scopeWidth}
height={layout.scopeHeight} height={layout.scopeHeight}
mode={scopeMode} mode={scopeMode}
paused={!playerOpen || queueOpen || !effectiveScopeStageVisible} paused={!surfacesLive || queueOpen || !effectiveScopeStageVisible}
revealed={effectiveScopeStageVisible} revealed={effectiveScopeStageVisible}
onSwap={swapScopeMode} onSwap={swapScopeMode}
/> />
@@ -1059,7 +1156,7 @@ export function NowPlayingOverlay() {
{lyricPeekEnabled ? ( {lyricPeekEnabled ? (
<CachedLyricPeek <CachedLyricPeek
track={track} track={track}
active={playerOpen && !queueOpen} active={surfacesLive && !queueOpen}
hidden={ hidden={
hasTabletCompanion && nowPlayingCompanion === 'lyrics' hasTabletCompanion && nowPlayingCompanion === 'lyrics'
} }
@@ -1136,7 +1233,7 @@ export function NowPlayingOverlay() {
</Animated.View> </Animated.View>
<WaveformSeekBar <WaveformSeekBar
active={playerOpen} active={surfacesLive}
height={layout.waveformHeight} height={layout.waveformHeight}
touchPadding={WAVEFORM_TOUCH_PADDING} touchPadding={WAVEFORM_TOUCH_PADDING}
trackPath={track.path} trackPath={track.path}
@@ -1347,7 +1444,7 @@ export function NowPlayingOverlay() {
]} ]}
> >
<NowPlayingCompanionPane <NowPlayingCompanionPane
active={playerOpen} active={surfacesLive}
desktopTarget={isDesktopTarget} desktopTarget={isDesktopTarget}
track={track} track={track}
/> />
+12
View File
@@ -3,5 +3,17 @@ import { TAB_TRANSITION_SETTLE_MS } from '../navigation/tabTransition.ts';
/** Slightly longer than the overlay's 200 ms direct-close animation. */ /** Slightly longer than the overlay's 200 ms direct-close animation. */
export const NOW_PLAYING_CLOSE_UNMOUNT_MS = 220; 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. */ /** Keep the EQ surface through the native tab spring's settling window. */
export const EQ_GRAPH_UNMOUNT_DELAY_MS = TAB_TRANSITION_SETTLE_MS + 30; export const EQ_GRAPH_UNMOUNT_DELAY_MS = TAB_TRANSITION_SETTLE_MS + 30;
+18 -15
View File
@@ -17,7 +17,7 @@ import {
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { FlashList } from '@shopify/flash-list'; 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 { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { AstraLogo } from '@/components/AstraLogo'; import { AstraLogo } from '@/components/AstraLogo';
@@ -569,7 +569,7 @@ function QuickSearchPanel({
const styles = useStyles(); const styles = useStyles();
const ripple = useRipple(); const ripple = useRipple();
const colors = useColors(); const colors = useColors();
const router = useRouter(); const returnToTabs = useReturnToTabs();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { height } = useWindowDimensions(); const { height } = useWindowDimensions();
const inputRef = useRef<TextInput | null>(null); const inputRef = useRef<TextInput | null>(null);
@@ -956,8 +956,11 @@ function QuickSearchPanel({
onClose(); 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) => { const navigateTo = (href: RouteHref) => {
router.push(href as never); returnToTabs(href as never, 'push');
}; };
const executeResult = (result: SearchResult) => { const executeResult = (result: SearchResult) => {
@@ -988,26 +991,26 @@ function QuickSearchPanel({
} }
if (result.kind === 'album') { if (result.kind === 'album') {
router.push({ returnToTabs(
pathname: '/library/album/[key]', { pathname: '/library/album/[key]', params: { key: result.album.identity_key } },
params: { key: result.album.identity_key }, 'push'
}); );
return; return;
} }
if (result.kind === 'artist') { if (result.kind === 'artist') {
router.push({ returnToTabs(
pathname: '/library/artist/[name]', { pathname: '/library/artist/[name]', params: { name: result.artist.artist } },
params: { name: result.artist.artist }, 'push'
}); );
return; return;
} }
if (result.kind === 'playlist') { if (result.kind === 'playlist') {
router.push({ returnToTabs(
pathname: '/library/playlist/[id]', { pathname: '/library/playlist/[id]', params: { id: result.playlist.id } },
params: { id: result.playlist.id }, 'push'
}); );
return; return;
} }
+12 -1
View File
@@ -5,6 +5,17 @@ export function isForegroundAppState(state: AppStateStatus | null): boolean {
return state === 'active'; 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. * Explicit foreground signal for render loops and native-backed surfaces.
* React Native normally suspends animation frames in the background, but * 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 { export function useAppForeground(): boolean {
const [foreground, setForeground] = useState(() => const [foreground, setForeground] = useState(() =>
isForegroundAppState(AppState.currentState) initialForegroundAppState(AppState.currentState)
); );
useEffect(() => { useEffect(() => {
+79
View File
@@ -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);
});
+66
View File
@@ -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<string, unknown>;
}
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;
}
+43
View File
@@ -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]
);
}
+14
View File
@@ -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' };
}
+53
View File
@@ -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);
});
+32
View File
@@ -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;
}
+45 -22
View File
@@ -1,24 +1,47 @@
import { useCallback } from 'react'; import { useCallback, useMemo } from 'react';
import { BackHandler } from 'react-native'; import { useNavigation, useRouter } from 'expo-router';
import { useFocusEffect, useRouter } from 'expo-router'; import {
canPopWithinLibrary,
libraryParentLabel,
parentRoute,
type StackStateLike,
} from '@/navigation/libraryDetailBack';
export function useLibraryDetailBack() { export interface LibraryDetailBack {
const router = useRouter(); /** Pops one level, so artist → album → back returns to the artist. */
goBack: () => void;
const handleBack = useCallback(() => { /** Names what `goBack` will actually return to (see libraryParentLabel). */
router.dismissTo('/library'); backLabel: string;
}, [router]); }
useFocusEffect( /**
useCallback(() => { * Back behaviour for the library detail screens.
const subscription = BackHandler.addEventListener('hardwareBackPress', () => { *
handleBack(); * No hardware-back interception any more: the old handler always returned true
return 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
return () => subscription.remove(); * tab (see `src/app/(tabs)/_layout.tsx`), not by flattening every back press.
}, [handleBack]) */
); export function useLibraryDetailBack(): LibraryDetailBack {
const router = useRouter();
return handleBack; 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) };
} }
+14 -6
View File
@@ -4,9 +4,9 @@ import {
useGlobalSearchParams, useGlobalSearchParams,
usePathname, usePathname,
useRootNavigationState, useRootNavigationState,
useRouter,
useSegments, useSegments,
} from 'expo-router'; } from 'expo-router';
import { useReturnToTabs } from '@/navigation/returnToTabs';
import { useLibraryStore } from '@/stores/libraryStore'; import { useLibraryStore } from '@/stores/libraryStore';
import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlaylistStore } from '@/stores/playlistStore';
import { useSettingsStore } from '@/stores/settingsStore'; import { useSettingsStore } from '@/stores/settingsStore';
@@ -81,7 +81,7 @@ async function validateSavedHref(href: string): Promise<string> {
/** Restores once, then owns stable-route tracking and session autosave. */ /** Restores once, then owns stable-route tracking and session autosave. */
export function SessionLifecycle({ onReady }: SessionLifecycleProps) { export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
const router = useRouter(); const returnToTabs = useReturnToTabs();
const pathname = usePathname(); const pathname = usePathname();
const segments = useSegments(); const segments = useSegments();
const params = useGlobalSearchParams<{ const params = useGlobalSearchParams<{
@@ -124,8 +124,12 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
if (cancelled) return; if (cancelled) return;
// Every relaunch begins at rest even when a React activity was rebuilt // Every relaunch begins at rest even when a React activity was rebuilt
// inside a still-live JS process. // inside a still-live JS process — unless something already asked for the
usePlayerUiStore.setState({ playerOpen: false }); // 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(); useSearchStore.getState().closeQuickSearch();
const liveNativeSession = await hasActiveNativePlaybackSession(); const liveNativeSession = await hasActiveNativePlaybackSession();
@@ -150,7 +154,11 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
const stableHref = await validateSavedHref(snapshot?.lastStableHref ?? '/'); const stableHref = await validateSavedHref(snapshot?.lastStableHref ?? '/');
setInitialStableHref(stableHref); setInitialStableHref(stableHref);
if (shouldRestoreSavedRoute(initialPathname.current, initialUrl) && 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<void>((resolve) => requestAnimationFrame(() => resolve())); await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
} }
@@ -185,7 +193,7 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
uninstallPersistence.current?.(); uninstallPersistence.current?.();
uninstallPersistence.current = null; uninstallPersistence.current = null;
}; };
}, [navigationKey, onReady, router]); }, [navigationKey, onReady, returnToTabs]);
useEffect(() => { useEffect(() => {
if (!hydrated) return; if (!hydrated) return;
+95
View File
@@ -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');
});
+80
View File
@@ -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';
}
+38 -9
View File
@@ -1,18 +1,47 @@
import { create } from 'zustand'; 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 * Now-playing overlay gate. The player is an overlay above the navigator (not a
* a route); its host retains it only long enough to finish the close animation. * route), and this phase is the only thing that decides whether it is mounted
* Session state only never persisted. * and on screen. See `playerPresence.ts` for the invariants. Session state only
* never persisted.
*/ */
interface PlayerUiStore { interface PlayerUiStore extends PlayerPresenceState {
playerOpen: boolean;
openPlayer: () => void; 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<PlayerUiStore>((set) => ({ export const usePlayerUiStore = create<PlayerUiStore>((set) => ({
playerOpen: false, ...initialPlayerPresence,
openPlayer: () => set({ playerOpen: true }), openPlayer: () => set(requestPlayerOpen),
closePlayer: () => set({ playerOpen: false }), 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));
}