mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-17 19:24:22 +02:00
fix internal routing, and remove softlocking
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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} />;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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
@@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user