mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 04:06:43 +02:00
fix internal routing, and remove softlocking
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user