fix internal routing, and remove softlocking

This commit is contained in:
Boof2015
2026-07-25 11:12:02 -04:00
parent 098992639d
commit 0f34ca2e8d
33 changed files with 904 additions and 157 deletions
+2 -2
View File
@@ -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);
+5
View File
@@ -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);
};
+14 -2
View File
@@ -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
+25 -14
View File
@@ -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',
+12 -16
View File
@@ -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 />;
}
+133 -36
View File
@@ -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}
/>
+12
View File
@@ -3,5 +3,17 @@ import { TAB_TRANSITION_SETTLE_MS } from '../navigation/tabTransition.ts';
/** Slightly longer than the overlay's 200 ms direct-close animation. */
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;
+18 -15
View File
@@ -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;
}