mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-18 19:54:26 +02:00
fix internal routing, and remove softlocking
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
TAB_TRANSITION_SETTLE_MS,
|
||||
TAB_TRANSITION_SPEC,
|
||||
} from '@/navigation/tabTransition';
|
||||
import { popToTop } from '@/navigation/stackActions';
|
||||
import { useColors } from '@/theme/themed';
|
||||
|
||||
export default function TabsLayout() {
|
||||
@@ -46,10 +47,21 @@ export default function TabsLayout() {
|
||||
target: item.key,
|
||||
canPreventDefault: true,
|
||||
});
|
||||
if (!item.focused && !event.defaultPrevented) {
|
||||
lastSwitchAt.current = now;
|
||||
navigation.navigate(item.name);
|
||||
if (event.defaultPrevented) return;
|
||||
|
||||
if (item.focused) {
|
||||
// Re-tapping the active tab resets its nested stack. This is the
|
||||
// one-tap escape from a deep library chain (artist → album →
|
||||
// another artist), which is why back itself only pops one level.
|
||||
const nested = state.routes[state.index]?.state;
|
||||
if (nested?.key && (nested.index ?? 0) > 0) {
|
||||
navigation.dispatch({ ...popToTop(), target: nested.key });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
lastSwitchAt.current = now;
|
||||
navigation.navigate(item.name);
|
||||
};
|
||||
|
||||
return <TabBar items={items} onPress={handlePress} />;
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { Stack } from 'expo-router';
|
||||
import { useColors } from '@/theme/themed';
|
||||
|
||||
/**
|
||||
* Anchor the library stack at its list. Without this, navigating straight to a
|
||||
* detail route (from Home, quick search, or the now-playing overlay) builds a
|
||||
* stack of just `[album]` with nothing beneath it — so popping one level had
|
||||
* nowhere to go and back escaped the tab entirely.
|
||||
*/
|
||||
export const unstable_settings = {
|
||||
initialRouteName: 'index',
|
||||
};
|
||||
|
||||
/**
|
||||
* Nested stack inside the Library tab so album/artist detail screens keep the
|
||||
* tab bar + mini-player visible.
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function AlbumScreen() {
|
||||
const { key } = useLocalSearchParams<{ key: string }>();
|
||||
const { items: tracks, summary: album, totalCount, loadMore } = useNativeAlbumDetail(key);
|
||||
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
|
||||
const handleBack = useLibraryDetailBack();
|
||||
const { goBack, backLabel } = useLibraryDetailBack();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } =
|
||||
useDetailCollapse();
|
||||
@@ -154,7 +154,8 @@ export default function AlbumScreen() {
|
||||
</>
|
||||
}
|
||||
disabled={tracks.length === 0}
|
||||
onBack={handleBack}
|
||||
onBack={goBack}
|
||||
backLabel={backLabel}
|
||||
onPlay={() => playFrom(0)}
|
||||
onShuffle={() => void playLibraryQuery({ kind: 'album', albumKey: key }, {
|
||||
shuffle: true,
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function ArtistScreen() {
|
||||
name: string;
|
||||
credit?: string;
|
||||
}>();
|
||||
const handleBack = useLibraryDetailBack();
|
||||
const { goBack, backLabel } = useLibraryDetailBack();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } =
|
||||
useDetailCollapse();
|
||||
@@ -246,7 +246,8 @@ export default function ArtistScreen() {
|
||||
</View>
|
||||
}
|
||||
disabled={disabled}
|
||||
onBack={handleBack}
|
||||
onBack={goBack}
|
||||
backLabel={backLabel}
|
||||
onPlay={playArtist}
|
||||
onShuffle={shuffleArtist}
|
||||
scrollY={scrollY}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState
|
||||
} from 'react';
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useFocusEffect, useRouter } from 'expo-router';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Text } from '@/components/Text';
|
||||
import { ViewModeSwitcher } from '@/components/library/ViewModeSwitcher';
|
||||
@@ -165,14 +165,20 @@ export default function LibraryScreen() {
|
||||
setPlaylistPickerOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectMode) return;
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
exitSelection();
|
||||
return true;
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [selectMode]);
|
||||
// Focus-gated, not a plain effect: the tabs layout keeps this screen mounted
|
||||
// while blurred (`detachInactiveScreens={false}` + `freezeOnBlur: false`), so
|
||||
// an ungated handler swallowed one back press anywhere in the app — any other
|
||||
// tab, any settings screen — whenever selection happened to be active.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
if (!selectMode) return undefined;
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
exitSelection();
|
||||
return true;
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [selectMode])
|
||||
);
|
||||
|
||||
const selectedDbTracks = () => sortedTracks.filter((track) => selectedIds.has(track.id));
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function PlaylistScreen() {
|
||||
const colors = useColors();
|
||||
const router = useRouter();
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const handleBack = useLibraryDetailBack();
|
||||
const { goBack, backLabel } = useLibraryDetailBack();
|
||||
const isFavorites = id === 'favorites';
|
||||
const playlistId = isFavorites ? null : Number(id);
|
||||
|
||||
@@ -219,7 +219,7 @@ export default function PlaylistScreen() {
|
||||
void (async () => {
|
||||
try {
|
||||
await deletePlaylist(target.id);
|
||||
handleBack();
|
||||
goBack();
|
||||
} catch (err) {
|
||||
Alert.alert('Delete failed', errorMessage(err));
|
||||
}
|
||||
@@ -340,7 +340,8 @@ export default function PlaylistScreen() {
|
||||
) : null
|
||||
}
|
||||
disabled={playable.length === 0}
|
||||
onBack={handleBack}
|
||||
onBack={goBack}
|
||||
backLabel={backLabel}
|
||||
onMore={() => setOptionsOpen(true)}
|
||||
onPlay={() => startPlayback(0)}
|
||||
onShuffle={startShuffle}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useReturnToTabs } from '@/navigation/returnToTabs';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { AstraLogo } from '@/components/AstraLogo';
|
||||
import { Screen } from '@/components/Screen';
|
||||
@@ -225,6 +226,7 @@ export default function DesktopRemoteScreen() {
|
||||
const ripple = useRipple();
|
||||
const colors = useColors();
|
||||
const router = useRouter();
|
||||
const returnToTabs = useReturnToTabs();
|
||||
const pairingParams = useLocalSearchParams<{
|
||||
pair?: string;
|
||||
baseUrl?: string;
|
||||
@@ -620,7 +622,7 @@ export default function DesktopRemoteScreen() {
|
||||
// screen so it slides in above wherever the user came from.
|
||||
usePlayerUiStore.getState().openPlayer();
|
||||
if (router.canGoBack()) router.back();
|
||||
else router.replace('/');
|
||||
else returnToTabs('/');
|
||||
}}
|
||||
>
|
||||
<Ionicons name="musical-notes-outline" size={18} color={colors.accentTextStrong} />
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Pressable, StyleSheet, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { useReturnToTabs } from '@/navigation/returnToTabs';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Text } from '@/components/Text';
|
||||
import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet';
|
||||
@@ -17,7 +18,7 @@ export default function EQPresetImportScreen() {
|
||||
const styles = useStyles();
|
||||
const ripple = useRipple();
|
||||
const colors = useColors();
|
||||
const router = useRouter();
|
||||
const returnToTabs = useReturnToTabs();
|
||||
const importPreset = useEQStore((state) => state.importPreset);
|
||||
const { data } = useLocalSearchParams<{ data?: string }>();
|
||||
|
||||
@@ -30,7 +31,9 @@ export default function EQPresetImportScreen() {
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const goToEq = () => router.replace('/eq' as never);
|
||||
// `/eq` is a tab, and this screen is a root-stack sibling of `(tabs)`, so a
|
||||
// bare replace here mints a second copy of the whole tab tree.
|
||||
const goToEq = () => returnToTabs('/eq' as never);
|
||||
|
||||
if (!preset) {
|
||||
return (
|
||||
|
||||
+4
-1
@@ -11,6 +11,7 @@ import {
|
||||
} from 'expo-camera';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useReturnToTabs } from '@/navigation/returnToTabs';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { Text } from '@/components/Text';
|
||||
import { EQPresetPreviewSheet } from '@/components/eq/EQPresetPreviewSheet';
|
||||
@@ -30,6 +31,7 @@ export default function EQPresetScanScreen() {
|
||||
const ripple = useRipple();
|
||||
const colors = useColors();
|
||||
const router = useRouter();
|
||||
const returnToTabs = useReturnToTabs();
|
||||
const importPreset = useEQStore((state) => state.importPreset);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [pendingPreset, setPendingPreset] = useState<EQPreset | null>(null);
|
||||
@@ -109,7 +111,8 @@ export default function EQPresetScanScreen() {
|
||||
onConfirm={() => {
|
||||
importPreset(pendingPreset);
|
||||
setPendingPreset(null);
|
||||
router.replace('/eq' as never);
|
||||
// `/eq` is a tab; see useReturnToTabs.
|
||||
returnToTabs('/eq' as never);
|
||||
}}
|
||||
onClose={() => setPendingPreset(null)}
|
||||
/>
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
import { useEffect } from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { resolveNotificationClick } from '@/audio/notificationIntent';
|
||||
import { useReturnToTabs } from '@/navigation/returnToTabs';
|
||||
import { createThemedStyles } from '@/theme/themed';
|
||||
|
||||
export default function NotificationClickRoute() {
|
||||
const styles = useStyles();
|
||||
const router = useRouter();
|
||||
const returnToTabs = useReturnToTabs();
|
||||
|
||||
// This route is a root-stack sibling of `(tabs)`, and it is entered often
|
||||
// (media-notification and widget taps). A bare replace toward a tab route
|
||||
// therefore left a duplicate `(tabs)` behind on every single tap.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
resolveNotificationClick()
|
||||
.then((href) => {
|
||||
if (!cancelled) router.replace(href);
|
||||
if (!cancelled) returnToTabs(href);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) router.replace('/');
|
||||
if (!cancelled) returnToTabs('/');
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [router]);
|
||||
}, [returnToTabs]);
|
||||
|
||||
return <View style={styles.root} />;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import Animated, {
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { usePlayerUiStore } from '@/stores/playerUiStore';
|
||||
import { usePlayerOnScreen, usePlayerUiStore } from '@/stores/playerUiStore';
|
||||
import { Text } from './Text';
|
||||
import { AstraLogo } from './AstraLogo';
|
||||
import { SpectrumCurve } from './SpectrumCurve';
|
||||
@@ -118,7 +118,7 @@ export function MiniPlayer() {
|
||||
const styles = useStyles();
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
const playerOpen = usePlayerUiStore((s) => s.playerOpen);
|
||||
const playerOpen = usePlayerOnScreen();
|
||||
const selectedTarget = usePlaybackTargetStore((s) => s.target);
|
||||
const track = usePlayerStore((s) => s.currentTrack);
|
||||
const playbackState = usePlayerStore((s) => s.playbackState);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useRipple } from '@/theme/ripple';
|
||||
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
||||
import { usePlaybackTargetStore, type PlaybackTarget } from '@/stores/playbackTargetStore';
|
||||
import { usePlayerStore } from '@/stores/playerStore';
|
||||
import { usePlayerUiStore } from '@/stores/playerUiStore';
|
||||
import {
|
||||
desktopConnectionLabel,
|
||||
hostFromBaseUrl,
|
||||
@@ -52,6 +53,10 @@ export function PlaybackTargetPicker({ visible, onClose }: PlaybackTargetPickerP
|
||||
|
||||
const pairDesktop = () => {
|
||||
onClose();
|
||||
// This picker is usually open above the full-screen now-playing overlay,
|
||||
// which would cover the pushed screen completely — the tap looked like a
|
||||
// no-op. Close the player so the route is actually visible.
|
||||
usePlayerUiStore.getState().closePlayer();
|
||||
router.push('/desktop-remote' as never);
|
||||
};
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ export function CollapsingHeader({
|
||||
heroExtra,
|
||||
disabled,
|
||||
onBack,
|
||||
backLabel,
|
||||
onMore,
|
||||
onPlay,
|
||||
onShuffle,
|
||||
@@ -145,6 +146,8 @@ export function CollapsingHeader({
|
||||
heroExtra?: ReactNode;
|
||||
disabled?: boolean;
|
||||
onBack: () => void;
|
||||
/** Names the screen `onBack` returns to; must track the real action. */
|
||||
backLabel: string;
|
||||
onMore?: () => void;
|
||||
onPlay: () => void;
|
||||
onShuffle: () => void;
|
||||
@@ -282,8 +285,17 @@ export function CollapsingHeader({
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={colors.textPrimary} />
|
||||
</Pressable>
|
||||
<Animated.Text style={[styles.label, { top: barCenterY - 10, left: spacing.md + 26 }, labelStyle]}>
|
||||
Library
|
||||
<Animated.Text
|
||||
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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { usePathname } from 'expo-router';
|
||||
import { useReturnToTabs } from '@/navigation/returnToTabs';
|
||||
import {
|
||||
AppSheet,
|
||||
AppSheetItem,
|
||||
@@ -42,13 +43,19 @@ function TrackActionsSheetInner({
|
||||
initialStep = 'menu',
|
||||
extraItems = [],
|
||||
}: 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 groupingMode = useSettingsStore((s) => s.artistGroupingMode);
|
||||
const isFavorite = usePlaylistStore((s) => s.favoritePaths.has(track.path));
|
||||
const toggleFavorite = usePlaylistStore((s) => s.toggleFavorite);
|
||||
|
||||
const artistName = resolveNavigationArtist(track, groupingMode);
|
||||
const albumHref = `/library/album/${encodeURIComponent(track.album_identity_key)}`;
|
||||
const artistHref = `/library/artist/${encodeURIComponent(artistName)}`;
|
||||
|
||||
const closeAndRun = (run: () => void) => {
|
||||
onClose();
|
||||
@@ -79,24 +86,28 @@ function TrackActionsSheetInner({
|
||||
label: 'View album',
|
||||
icon: 'albums-outline',
|
||||
onPress: () =>
|
||||
closeAndRun(() =>
|
||||
router.push({
|
||||
pathname: '/library/album/[key]',
|
||||
params: { key: track.album_identity_key },
|
||||
})
|
||||
),
|
||||
closeAndRun(() => {
|
||||
// Already here (this sheet also opens from the album screen itself):
|
||||
// pushing would stack a duplicate of the current screen.
|
||||
if (pathname === albumHref) return;
|
||||
returnToTabs(
|
||||
{ pathname: '/library/album/[key]', params: { key: track.album_identity_key } },
|
||||
'push'
|
||||
);
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'view-artist',
|
||||
label: 'View artist',
|
||||
icon: 'person-outline',
|
||||
onPress: () =>
|
||||
closeAndRun(() =>
|
||||
router.push({
|
||||
pathname: '/library/artist/[name]',
|
||||
params: { name: artistName },
|
||||
})
|
||||
),
|
||||
closeAndRun(() => {
|
||||
if (pathname === artistHref) return;
|
||||
returnToTabs(
|
||||
{ pathname: '/library/artist/[name]', params: { name: artistName } },
|
||||
'push'
|
||||
);
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'favorite',
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { NowPlayingOverlay } from '@/components/player/NowPlayingOverlay';
|
||||
import { useDelayedUnmountPresence } from '@/components/delayedPresence';
|
||||
import { NOW_PLAYING_CLOSE_UNMOUNT_MS } from '@/components/renderPresenceTiming';
|
||||
import { useAppForeground } from '@/lib/useAppForeground';
|
||||
import { usePlayerUiStore } from '@/stores/playerUiStore';
|
||||
import { usePlayerMounted } from '@/stores/playerUiStore';
|
||||
|
||||
/**
|
||||
* Presence gate for the heavyweight now-playing tree. It stays alive just past
|
||||
* the 200 ms close animation, but never remains hidden indefinitely. Android
|
||||
* backgrounding drops it immediately so TextureViews and decoded art release.
|
||||
* Presence gate for the heavyweight now-playing tree. The store's `closing`
|
||||
* phase is the linger that lets the exit animation finish, so this is a plain
|
||||
* mirror of the phase — no second timer and no foreground gate.
|
||||
*
|
||||
* Backgrounding deliberately does NOT unmount here any more. Dropping the whole
|
||||
* tree tore down the shared value mid-close, which stranded the phase and made
|
||||
* the player permanently unopenable. The overlay releases its own TextureViews
|
||||
* and decoded art instead, by folding `useAppForeground` into the `active` /
|
||||
* `paused` props of the scope surfaces (the same thing MiniPlayer does).
|
||||
*/
|
||||
export function NowPlayingHost() {
|
||||
const playerOpen = usePlayerUiStore((s) => s.playerOpen);
|
||||
const foreground = useAppForeground();
|
||||
const mounted = usePlayerMounted();
|
||||
|
||||
const renderOverlay = useDelayedUnmountPresence(
|
||||
playerOpen,
|
||||
NOW_PLAYING_CLOSE_UNMOUNT_MS,
|
||||
!foreground
|
||||
);
|
||||
|
||||
if (!renderOverlay) return null;
|
||||
if (!mounted) return null;
|
||||
return <NowPlayingOverlay />;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,11 @@ import { PlayerStateIcon } from '@/components/player/PlayerStateIcon';
|
||||
import { CachedLyricPeek } from '@/components/player/CachedLyricPeek';
|
||||
import { resolveNowPlayingDismissSpring } from '@/components/player/nowPlayingDismiss';
|
||||
import { useDelayedUnmountPresence } from '@/components/delayedPresence';
|
||||
import {
|
||||
NOW_PLAYING_CLOSE_COMMIT_MS,
|
||||
NOW_PLAYING_OPEN_SETTLE_MS,
|
||||
} from '@/components/renderPresenceTiming';
|
||||
import { useAppForeground } from '@/lib/useAppForeground';
|
||||
import { SleepTimerControls } from '@/components/player/SleepTimerControls';
|
||||
import { AppSheet, AppSheetTitle } from '@/components/sheets/AppSheet';
|
||||
import {
|
||||
@@ -63,6 +68,7 @@ import {
|
||||
NOW_PLAYING_WAVEFORM_TOUCH_PADDING,
|
||||
NOW_PLAYING_WIDE_PANE_GAP,
|
||||
} from '@/components/player/nowPlayingLayout';
|
||||
import { useReturnToTabs } from '@/navigation/returnToTabs';
|
||||
import { resolveNavigationArtist, splitCollaborators } from '@/library/artistGrouping';
|
||||
import { buildArtistNameTokens } from '@/shared/library/artistCredits';
|
||||
import {
|
||||
@@ -76,6 +82,7 @@ import { useQueueStore } from '@/stores/queueStore';
|
||||
import { usePlaylistStore } from '@/stores/playlistStore';
|
||||
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
||||
import { usePlayerUiStore } from '@/stores/playerUiStore';
|
||||
import { isPlayerOnScreen } from '@/stores/playerPresence';
|
||||
import { useSettingsStore, type ScopeMode } from '@/stores/settingsStore';
|
||||
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
|
||||
import type { DbTrack } from '@/types/library';
|
||||
@@ -125,10 +132,19 @@ export function NowPlayingOverlay() {
|
||||
const colors = useColors();
|
||||
const ripple = useRipple();
|
||||
const router = useRouter();
|
||||
const returnToTabs = useReturnToTabs();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
const reduceMotion = useReducedMotion();
|
||||
const playerOpen = usePlayerUiStore((s) => s.playerOpen);
|
||||
const phase = usePlayerUiStore((s) => s.phase);
|
||||
const openRequest = usePlayerUiStore((s) => s.openRequest);
|
||||
const exitAnimated = usePlayerUiStore((s) => s.exitAnimated);
|
||||
const playerOpen = isPlayerOnScreen(phase);
|
||||
// Heavy scope/spectrum surfaces release their native backing in the
|
||||
// background. The overlay itself stays mounted — unmounting it mid-close used
|
||||
// to tear down the exit animation and strand the phase.
|
||||
const foreground = useAppForeground();
|
||||
const surfacesLive = playerOpen && foreground;
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
// Stable identity: QueueTray is memo'd, so a fresh arrow here would defeat it.
|
||||
const closeQueue = useCallback(() => setQueueOpen(false), []);
|
||||
@@ -181,13 +197,19 @@ export function NowPlayingOverlay() {
|
||||
});
|
||||
const isDesktopTarget = activePresentation.target === 'desktop';
|
||||
const effectiveScopeStageVisible = !isDesktopTarget && scopeStageVisible;
|
||||
// Backgrounding drops these two subtrees, which is what actually releases the
|
||||
// scope TextureViews and the decoded artwork. The overlay shell around them
|
||||
// deliberately stays mounted instead — dropping the whole tree used to tear
|
||||
// down the close animation mid-flight and strand the player unopenable.
|
||||
const renderScopeSurfaces = useDelayedUnmountPresence(
|
||||
effectiveScopeStageVisible,
|
||||
motion.snap.duration
|
||||
motion.snap.duration,
|
||||
!foreground
|
||||
);
|
||||
const renderArtworkFace = useDelayedUnmountPresence(
|
||||
railStyle || !effectiveScopeStageVisible,
|
||||
motion.snap.duration
|
||||
motion.snap.duration,
|
||||
!foreground
|
||||
);
|
||||
const activeTrack = desktopSnapshot?.currentTrack ?? null;
|
||||
const transitionTrackKey = isDesktopTarget ? activeTrack?.id ?? '' : track?.id ?? '';
|
||||
@@ -295,23 +317,26 @@ export function NowPlayingOverlay() {
|
||||
.getState()
|
||||
.setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum');
|
||||
|
||||
// The player is an overlay, so it can be open over a root-stack sibling of
|
||||
// `(tabs)` (e.g. desktop-remote). Going straight to a library route from there
|
||||
// diverges at the root stack and mints a second copy of the whole tab tree.
|
||||
const navigateToArtist = (targetArtist = artistName, credit = false) => {
|
||||
if (!targetArtist) return;
|
||||
// Slide the overlay away while the library detail loads underneath.
|
||||
dismissSheet();
|
||||
router.navigate({
|
||||
pathname: '/library/artist/[name]',
|
||||
params: { name: targetArtist, ...(credit ? { credit: '1' } : {}) },
|
||||
});
|
||||
returnToTabs(
|
||||
{
|
||||
pathname: '/library/artist/[name]',
|
||||
params: { name: targetArtist, ...(credit ? { credit: '1' } : {}) },
|
||||
},
|
||||
'push'
|
||||
);
|
||||
};
|
||||
|
||||
const navigateToAlbum = () => {
|
||||
if (!albumKey) return;
|
||||
dismissSheet();
|
||||
router.navigate({
|
||||
pathname: '/library/album/[key]',
|
||||
params: { key: albumKey },
|
||||
});
|
||||
returnToTabs({ pathname: '/library/album/[key]', params: { key: albumKey } }, 'push');
|
||||
};
|
||||
|
||||
const menuItems: NowPlayingMenuItem[] = [];
|
||||
@@ -400,12 +425,20 @@ export function NowPlayingOverlay() {
|
||||
useEffect(() => {
|
||||
stageProgress.value = withTiming(effectiveScopeStageVisible ? 1 : 0, motion.snap);
|
||||
}, [effectiveScopeStageVisible, stageProgress]);
|
||||
// Closing is a store toggle, not navigation. Reset the inner layers so a
|
||||
// reopen starts from the plain player (parity with the old per-open mount).
|
||||
const dismiss = () => {
|
||||
const commitClosed = () => usePlayerUiStore.getState().commitClosed();
|
||||
/**
|
||||
* Enter the closing phase and drop the inner layers. Split out from
|
||||
* `dismissSheet` so the pan gesture can commit the phase without handing the
|
||||
* offset back to a fresh animation — it is already driving `translateY`.
|
||||
* Clearing the layers here rather than at the end also unpins the menu card,
|
||||
* which renders outside the translating content.
|
||||
*/
|
||||
const beginDismiss = () => {
|
||||
setMenuOpen(false);
|
||||
setQueueOpen(false);
|
||||
usePlayerUiStore.getState().closePlayer();
|
||||
// `true`: this path drives the sheet away itself, so the effect below must
|
||||
// not overwrite the offset with a competing generic slide-out.
|
||||
usePlayerUiStore.getState().closePlayer(true);
|
||||
};
|
||||
const finishCloseMenu = () => setMenuOpen(false);
|
||||
|
||||
@@ -422,7 +455,14 @@ export function NowPlayingOverlay() {
|
||||
});
|
||||
}
|
||||
|
||||
// Closing is a store transition, not navigation. The phase moves to `closing`
|
||||
// BEFORE the animation starts — a spring that gets cancelled never reports
|
||||
// completion, and depending on that callback is what used to leave the player
|
||||
// flagged open with the sheet parked off-screen, unopenable for the rest of
|
||||
// the session. The completion callback now only commits the release early;
|
||||
// a fallback timer commits it otherwise.
|
||||
const dismissSheet = (velocity = 0) => {
|
||||
beginDismiss();
|
||||
translateY.value = withSpring(
|
||||
windowHeight,
|
||||
{
|
||||
@@ -432,7 +472,7 @@ export function NowPlayingOverlay() {
|
||||
overshootClamping: true,
|
||||
},
|
||||
(finished) => {
|
||||
if (finished) runOnJS(dismiss)();
|
||||
if (finished) runOnJS(commitClosed)();
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -450,6 +490,9 @@ export function NowPlayingOverlay() {
|
||||
e.velocityY,
|
||||
windowHeight - translateY.value
|
||||
);
|
||||
// Same commit-first contract as dismissSheet, with the release spring's
|
||||
// velocity-matched shaping preserved.
|
||||
runOnJS(beginDismiss)();
|
||||
translateY.value = withSpring(
|
||||
windowHeight,
|
||||
{
|
||||
@@ -460,7 +503,7 @@ export function NowPlayingOverlay() {
|
||||
energyThreshold: 1e-4,
|
||||
},
|
||||
(finished) => {
|
||||
if (finished) runOnJS(dismiss)();
|
||||
if (finished) runOnJS(commitClosed)();
|
||||
}
|
||||
);
|
||||
} else {
|
||||
@@ -468,29 +511,74 @@ export function NowPlayingOverlay() {
|
||||
}
|
||||
});
|
||||
|
||||
// Open animation (close normally animates via dismissSheet's spring first;
|
||||
// the else branch covers direct closePlayer calls and keeps the resting
|
||||
// offset pinned to the current window height across rotation). NOTE: this
|
||||
// effect must stay BELOW every direct `translateY.value` write — the react
|
||||
// compiler forbids mutations after an effect that depends on the value.
|
||||
// Enter animation. Keyed on `openRequest` as well as the phase, so asking for
|
||||
// a player that already believes it is open still re-runs the slide-in — that
|
||||
// is the recovery path for a sheet stranded off-screen by an interrupted
|
||||
// close. `windowHeight` is deliberately NOT a dependency: a dimension change
|
||||
// (rotation, or an RN Modal like the output picker) would re-run this effect
|
||||
// and cancel an in-flight exit spring. NOTE: this effect must stay BELOW
|
||||
// every direct `translateY.value` write — the react compiler forbids
|
||||
// mutations after an effect that depends on the value.
|
||||
useEffect(() => {
|
||||
if (playerOpen) {
|
||||
translateY.value = withTiming(0, { duration: 240 });
|
||||
} else {
|
||||
translateY.value = withTiming(windowHeight, { duration: 200 });
|
||||
if (phase === 'closing') {
|
||||
// The gesture and button paths own the exit animation, including its
|
||||
// velocity-matched spring shaping. Only animate here when `closing`
|
||||
// arrived from a direct closePlayer() with no animation attached.
|
||||
if (!exitAnimated) {
|
||||
translateY.value = withTiming(windowHeight, { duration: 200 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}, [playerOpen, windowHeight, translateY]);
|
||||
translateY.value = withTiming(0, { duration: 240 });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- windowHeight excluded on purpose (see above)
|
||||
}, [phase, openRequest, exitAnimated, translateY]);
|
||||
|
||||
// `closing` → `closed`, and `opening` → `open`. Both are timers rather than
|
||||
// animation callbacks, so a cancelled animation can never strand the phase.
|
||||
// The store guards each transition, and this cleanup cancels a pending commit
|
||||
// if the user reopens mid-close. The store actions are read through getState
|
||||
// so this effect's identity never changes between playback ticks — a restarted
|
||||
// timer would mean the commit never lands.
|
||||
useEffect(() => {
|
||||
if (phase === 'closing') {
|
||||
const timer = setTimeout(
|
||||
() => usePlayerUiStore.getState().commitClosed(),
|
||||
NOW_PLAYING_CLOSE_COMMIT_MS
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
if (phase === 'opening') {
|
||||
const timer = setTimeout(
|
||||
() => usePlayerUiStore.getState().settleOpen(),
|
||||
NOW_PLAYING_OPEN_SETTLE_MS
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [phase, openRequest]);
|
||||
|
||||
// Hardware back, innermost layer first: menu → queue tray → player. Registered
|
||||
// only while open, so it sits above the focused screen's own handlers (LIFO)
|
||||
// — e.g. the library-detail back interceptor underneath.
|
||||
useEffect(() => {
|
||||
if (!playerOpen) return;
|
||||
if (!playerOpen) return undefined;
|
||||
const sub = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
if (menuOpen) {
|
||||
closeMenu();
|
||||
return true;
|
||||
}
|
||||
if (targetPickerOpen) {
|
||||
setTargetPickerOpen(false);
|
||||
return true;
|
||||
}
|
||||
if (sleepTimerOpen) {
|
||||
setSleepTimerOpen(false);
|
||||
return true;
|
||||
}
|
||||
if (playlistActionTrack) {
|
||||
setPlaylistActionTrack(null);
|
||||
return true;
|
||||
}
|
||||
if (queueOpen) {
|
||||
setQueueOpen(false);
|
||||
return true;
|
||||
@@ -499,7 +587,16 @@ export function NowPlayingOverlay() {
|
||||
return true;
|
||||
});
|
||||
return () => sub.remove();
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- closeMenu/dismissSheet are re-created every render; re-subscribing on each would thrash the LIFO chain. windowHeight is listed because dismissSheet captures it.
|
||||
}, [
|
||||
playerOpen,
|
||||
menuOpen,
|
||||
targetPickerOpen,
|
||||
sleepTimerOpen,
|
||||
playlistActionTrack,
|
||||
queueOpen,
|
||||
windowHeight,
|
||||
]);
|
||||
|
||||
const contentStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
@@ -636,7 +733,7 @@ export function NowPlayingOverlay() {
|
||||
{lyricsMode && track ? (
|
||||
<LyricsView
|
||||
track={track}
|
||||
active={playerOpen}
|
||||
active={surfacesLive}
|
||||
isPlaying={isPlaying}
|
||||
isLoading={isLoading}
|
||||
isFavorite={isFavorite}
|
||||
@@ -994,7 +1091,7 @@ export function NowPlayingOverlay() {
|
||||
stripWidth={layout.scopeWidth}
|
||||
artworkUri={backdropArtworkUri}
|
||||
spectrumSmoothing={NOW_PLAYING_SPECTRUM_SMOOTHING}
|
||||
paused={!playerOpen || queueOpen || !effectiveScopeStageVisible}
|
||||
paused={!surfacesLive || queueOpen || !effectiveScopeStageVisible}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
@@ -1017,7 +1114,7 @@ export function NowPlayingOverlay() {
|
||||
width={layout.scopeWidth}
|
||||
height={layout.scopeHeight}
|
||||
mode={scopeMode}
|
||||
paused={!playerOpen || queueOpen || !effectiveScopeStageVisible}
|
||||
paused={!surfacesLive || queueOpen || !effectiveScopeStageVisible}
|
||||
revealed={effectiveScopeStageVisible}
|
||||
onSwap={swapScopeMode}
|
||||
/>
|
||||
@@ -1039,7 +1136,7 @@ export function NowPlayingOverlay() {
|
||||
width={layout.scopeWidth}
|
||||
height={layout.scopeHeight}
|
||||
mode={scopeMode}
|
||||
paused={!playerOpen || queueOpen || !effectiveScopeStageVisible}
|
||||
paused={!surfacesLive || queueOpen || !effectiveScopeStageVisible}
|
||||
revealed={effectiveScopeStageVisible}
|
||||
onSwap={swapScopeMode}
|
||||
/>
|
||||
@@ -1059,7 +1156,7 @@ export function NowPlayingOverlay() {
|
||||
{lyricPeekEnabled ? (
|
||||
<CachedLyricPeek
|
||||
track={track}
|
||||
active={playerOpen && !queueOpen}
|
||||
active={surfacesLive && !queueOpen}
|
||||
hidden={
|
||||
hasTabletCompanion && nowPlayingCompanion === 'lyrics'
|
||||
}
|
||||
@@ -1136,7 +1233,7 @@ export function NowPlayingOverlay() {
|
||||
</Animated.View>
|
||||
|
||||
<WaveformSeekBar
|
||||
active={playerOpen}
|
||||
active={surfacesLive}
|
||||
height={layout.waveformHeight}
|
||||
touchPadding={WAVEFORM_TOUCH_PADDING}
|
||||
trackPath={track.path}
|
||||
@@ -1347,7 +1444,7 @@ export function NowPlayingOverlay() {
|
||||
]}
|
||||
>
|
||||
<NowPlayingCompanionPane
|
||||
active={playerOpen}
|
||||
active={surfacesLive}
|
||||
desktopTarget={isDesktopTarget}
|
||||
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. */
|
||||
export const NOW_PLAYING_CLOSE_UNMOUNT_MS = 220;
|
||||
|
||||
/**
|
||||
* Backstop for committing the now-playing close. The exit spring normally
|
||||
* commits it from its own completion callback, but a cancelled spring never
|
||||
* reports completion — and before this existed, that stranded the player's
|
||||
* phase and made it impossible to reopen. Long enough that the animation
|
||||
* ordinarily wins the race.
|
||||
*/
|
||||
export const NOW_PLAYING_CLOSE_COMMIT_MS = 450;
|
||||
|
||||
/** Matches the overlay's 240 ms enter animation, then settles `opening` → `open`. */
|
||||
export const NOW_PLAYING_OPEN_SETTLE_MS = 260;
|
||||
|
||||
/** Keep the EQ surface through the native tab spring's settling window. */
|
||||
export const EQ_GRAPH_UNMOUNT_DELAY_MS = TAB_TRANSITION_SETTLE_MS + 30;
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useReturnToTabs } from '@/navigation/returnToTabs';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Text } from '@/components/Text';
|
||||
import { AstraLogo } from '@/components/AstraLogo';
|
||||
@@ -569,7 +569,7 @@ function QuickSearchPanel({
|
||||
const styles = useStyles();
|
||||
const ripple = useRipple();
|
||||
const colors = useColors();
|
||||
const router = useRouter();
|
||||
const returnToTabs = useReturnToTabs();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { height } = useWindowDimensions();
|
||||
const inputRef = useRef<TextInput | null>(null);
|
||||
@@ -956,8 +956,11 @@ function QuickSearchPanel({
|
||||
onClose();
|
||||
};
|
||||
|
||||
// The quick-search overlay renders above the navigator, so it can fire while a
|
||||
// root-stack sibling of `(tabs)` is focused — a bare push there mints a second
|
||||
// copy of the whole tab tree and back-navigation dead-ends on the stale one.
|
||||
const navigateTo = (href: RouteHref) => {
|
||||
router.push(href as never);
|
||||
returnToTabs(href as never, 'push');
|
||||
};
|
||||
|
||||
const executeResult = (result: SearchResult) => {
|
||||
@@ -988,26 +991,26 @@ function QuickSearchPanel({
|
||||
}
|
||||
|
||||
if (result.kind === 'album') {
|
||||
router.push({
|
||||
pathname: '/library/album/[key]',
|
||||
params: { key: result.album.identity_key },
|
||||
});
|
||||
returnToTabs(
|
||||
{ pathname: '/library/album/[key]', params: { key: result.album.identity_key } },
|
||||
'push'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.kind === 'artist') {
|
||||
router.push({
|
||||
pathname: '/library/artist/[name]',
|
||||
params: { name: result.artist.artist },
|
||||
});
|
||||
returnToTabs(
|
||||
{ pathname: '/library/artist/[name]', params: { name: result.artist.artist } },
|
||||
'push'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.kind === 'playlist') {
|
||||
router.push({
|
||||
pathname: '/library/playlist/[id]',
|
||||
params: { id: result.playlist.id },
|
||||
});
|
||||
returnToTabs(
|
||||
{ pathname: '/library/playlist/[id]', params: { id: result.playlist.id } },
|
||||
'push'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,17 @@ export function isForegroundAppState(state: AppStateStatus | null): boolean {
|
||||
return state === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed value for the hook. `AppState.currentState` can still be null/unknown
|
||||
* while the activity is coming up, and the `change` listener only fires on a
|
||||
* transition — so trusting a null seed leaves consumers stuck in the background
|
||||
* branch for the whole session. A mounting React tree means the UI is up, so an
|
||||
* unknown initial state is treated as foreground.
|
||||
*/
|
||||
export function initialForegroundAppState(state: AppStateStatus | null): boolean {
|
||||
return state == null || state === 'unknown' ? true : isForegroundAppState(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit foreground signal for render loops and native-backed surfaces.
|
||||
* React Native normally suspends animation frames in the background, but
|
||||
@@ -12,7 +23,7 @@ export function isForegroundAppState(state: AppStateStatus | null): boolean {
|
||||
*/
|
||||
export function useAppForeground(): boolean {
|
||||
const [foreground, setForeground] = useState(() =>
|
||||
isForegroundAppState(AppState.currentState)
|
||||
initialForegroundAppState(AppState.currentState)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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 { BackHandler } from 'react-native';
|
||||
import { useFocusEffect, useRouter } from 'expo-router';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useNavigation, useRouter } from 'expo-router';
|
||||
import {
|
||||
canPopWithinLibrary,
|
||||
libraryParentLabel,
|
||||
parentRoute,
|
||||
type StackStateLike,
|
||||
} from '@/navigation/libraryDetailBack';
|
||||
|
||||
export function useLibraryDetailBack() {
|
||||
const router = useRouter();
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
router.dismissTo('/library');
|
||||
}, [router]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
|
||||
handleBack();
|
||||
return true;
|
||||
});
|
||||
|
||||
return () => subscription.remove();
|
||||
}, [handleBack])
|
||||
);
|
||||
|
||||
return handleBack;
|
||||
export interface LibraryDetailBack {
|
||||
/** Pops one level, so artist → album → back returns to the artist. */
|
||||
goBack: () => void;
|
||||
/** Names what `goBack` will actually return to (see libraryParentLabel). */
|
||||
backLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Back behaviour for the library detail screens.
|
||||
*
|
||||
* No hardware-back interception any more: the old handler always returned true
|
||||
* and redirected to the library root, which meant the Android button could never
|
||||
* pop normally. Letting the stack handle it natively is both simpler and what
|
||||
* users expect. A deep chain is escaped in one tap by re-pressing the Library
|
||||
* tab (see `src/app/(tabs)/_layout.tsx`), not by flattening every back press.
|
||||
*/
|
||||
export function useLibraryDetailBack(): LibraryDetailBack {
|
||||
const router = useRouter();
|
||||
const navigation = useNavigation();
|
||||
|
||||
// The route beneath this one cannot change while the screen stays mounted, so
|
||||
// a one-shot read is enough — no reactive subscription needed.
|
||||
const { parent, canPop } = useMemo(() => {
|
||||
const state = navigation.getState() as StackStateLike | undefined;
|
||||
return { parent: parentRoute(state), canPop: canPopWithinLibrary(state) };
|
||||
}, [navigation]);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (canPop) {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
// Nothing beneath us: the stack was entered straight at a detail screen.
|
||||
router.dismissTo('/library');
|
||||
}, [canPop, router]);
|
||||
|
||||
return { goBack, backLabel: libraryParentLabel(parent) };
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
useGlobalSearchParams,
|
||||
usePathname,
|
||||
useRootNavigationState,
|
||||
useRouter,
|
||||
useSegments,
|
||||
} from 'expo-router';
|
||||
import { useReturnToTabs } from '@/navigation/returnToTabs';
|
||||
import { useLibraryStore } from '@/stores/libraryStore';
|
||||
import { usePlaylistStore } from '@/stores/playlistStore';
|
||||
import { useSettingsStore } from '@/stores/settingsStore';
|
||||
@@ -81,7 +81,7 @@ async function validateSavedHref(href: string): Promise<string> {
|
||||
|
||||
/** Restores once, then owns stable-route tracking and session autosave. */
|
||||
export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
|
||||
const router = useRouter();
|
||||
const returnToTabs = useReturnToTabs();
|
||||
const pathname = usePathname();
|
||||
const segments = useSegments();
|
||||
const params = useGlobalSearchParams<{
|
||||
@@ -124,8 +124,12 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
|
||||
if (cancelled) return;
|
||||
|
||||
// Every relaunch begins at rest even when a React activity was rebuilt
|
||||
// inside a still-live JS process.
|
||||
usePlayerUiStore.setState({ playerOpen: false });
|
||||
// inside a still-live JS process — unless something already asked for the
|
||||
// player during startup (a notification or widget tap resolves before
|
||||
// these awaits finish, and used to be silently overridden here).
|
||||
if (usePlayerUiStore.getState().openRequest === 0) {
|
||||
usePlayerUiStore.setState({ phase: 'closed' });
|
||||
}
|
||||
useSearchStore.getState().closeQuickSearch();
|
||||
|
||||
const liveNativeSession = await hasActiveNativePlaybackSession();
|
||||
@@ -150,7 +154,11 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
|
||||
const stableHref = await validateSavedHref(snapshot?.lastStableHref ?? '/');
|
||||
setInitialStableHref(stableHref);
|
||||
if (shouldRestoreSavedRoute(initialPathname.current, initialUrl) && stableHref !== '/') {
|
||||
router.replace(stableHref as never);
|
||||
// Never `replace` here. A root-level saved route (`/settings/audio`,
|
||||
// `/sources`, …) would overwrite `(tabs)` at index 0 and destroy the
|
||||
// anchor the root stack is built around, so back would exit the app;
|
||||
// an in-tab saved route would mint a second `(tabs)` instead.
|
||||
returnToTabs(stableHref as never);
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
@@ -185,7 +193,7 @@ export function SessionLifecycle({ onReady }: SessionLifecycleProps) {
|
||||
uninstallPersistence.current?.();
|
||||
uninstallPersistence.current = null;
|
||||
};
|
||||
}, [navigationKey, onReady, router]);
|
||||
}, [navigationKey, onReady, returnToTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated) return;
|
||||
|
||||
@@ -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 {
|
||||
commitPlayerClosed,
|
||||
initialPlayerPresence,
|
||||
isPlayerMounted,
|
||||
isPlayerOnScreen,
|
||||
requestPlayerClose,
|
||||
requestPlayerOpen,
|
||||
settlePlayerOpen,
|
||||
type PlayerPresenceState,
|
||||
} from '@/stores/playerPresence';
|
||||
|
||||
/**
|
||||
* Now-playing overlay gate. The player is an overlay above the navigator (not
|
||||
* a route); its host retains it only long enough to finish the close animation.
|
||||
* Session state only — never persisted.
|
||||
* Now-playing overlay gate. The player is an overlay above the navigator (not a
|
||||
* route), and this phase is the only thing that decides whether it is mounted
|
||||
* and on screen. See `playerPresence.ts` for the invariants. Session state only
|
||||
* — never persisted.
|
||||
*/
|
||||
interface PlayerUiStore {
|
||||
playerOpen: boolean;
|
||||
interface PlayerUiStore extends PlayerPresenceState {
|
||||
openPlayer: () => void;
|
||||
closePlayer: () => void;
|
||||
/**
|
||||
* @param exitAnimated pass true when the caller is already animating the sheet
|
||||
* away, so the overlay does not start a competing slide-out.
|
||||
*/
|
||||
closePlayer: (exitAnimated?: boolean) => void;
|
||||
commitClosed: () => void;
|
||||
settleOpen: () => void;
|
||||
}
|
||||
|
||||
export const usePlayerUiStore = create<PlayerUiStore>((set) => ({
|
||||
playerOpen: false,
|
||||
openPlayer: () => set({ playerOpen: true }),
|
||||
closePlayer: () => set({ playerOpen: false }),
|
||||
...initialPlayerPresence,
|
||||
openPlayer: () => set(requestPlayerOpen),
|
||||
closePlayer: (exitAnimated = false) =>
|
||||
set((state) => requestPlayerClose(state, exitAnimated)),
|
||||
commitClosed: () => set(commitPlayerClosed),
|
||||
settleOpen: () => set(settlePlayerOpen),
|
||||
}));
|
||||
|
||||
/** Whether the overlay tree should be rendered at all. */
|
||||
export function usePlayerMounted(): boolean {
|
||||
return usePlayerUiStore((s) => isPlayerMounted(s.phase));
|
||||
}
|
||||
|
||||
/** Whether the sheet should be resting on screen rather than sliding away. */
|
||||
export function usePlayerOnScreen(): boolean {
|
||||
return usePlayerUiStore((s) => isPlayerOnScreen(s.phase));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user