diff --git a/src/audio/playbackController.ts b/src/audio/playbackController.ts index 46ba16b..a60c5d9 100644 --- a/src/audio/playbackController.ts +++ b/src/audio/playbackController.ts @@ -8,6 +8,7 @@ import type { DbTrack } from '@/types/library'; import { usePlayerStore, type RepeatMode as RepeatModeStr } from '@/stores/playerStore'; import { useQueueStore } from '@/stores/queueStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; +import { markNowPlayingTrackTransitionDirection } from '@/stores/nowPlayingTrackTransitionStore'; import type { PlaybackSessionSnapshotV1, ResolvedPlaybackSession, @@ -805,6 +806,7 @@ export async function togglePlay(): Promise { } export async function skipToNext(): Promise { + markNowPlayingTrackTransitionDirection('next', 'phone'); await ensurePlayerReady(); const [nativeQueue, nativeIndex] = await Promise.all([ TrackPlayer.getQueue(), @@ -848,6 +850,7 @@ export async function skipToPrevious(): Promise { return; } + markNowPlayingTrackTransitionDirection('previous', 'phone'); const [nativeQueue, nativeIndex] = await Promise.all([ TrackPlayer.getQueue(), TrackPlayer.getActiveTrackIndex(), diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index 02e906e..806d3f0 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -31,6 +31,7 @@ import { usePlayerStore } from '@/stores/playerStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { skipToNext, skipToPrevious, togglePlay } from '@/audio/playbackController'; +import { markNowPlayingTrackTransitionDirection } from '@/stores/nowPlayingTrackTransitionStore'; import { useScopeActive } from '@/scope/scopeStore'; import { artworkThumbFromSource } from '@/library/artwork'; import { useAnimatedPlaybackProgress } from '@/audio/useAnimatedPlaybackProgress'; @@ -236,6 +237,12 @@ export function MiniPlayer() { const dispatchPendingSwipe = useCallback((id: number) => { const pending = pendingSwipeRef.current; if (!pending || pending.id !== id) return; + if (pending.target === 'desktop') { + markNowPlayingTrackTransitionDirection( + pending.direction, + pending.target, + ); + } const command = pending.target === 'desktop' ? sendDesktopControl(pending.direction === 'next' ? 'next' : 'previous') : pending.direction === 'next' @@ -435,6 +442,7 @@ export function MiniPlayer() { }; const onSkipNext = () => { if (isDesktop) { + markNowPlayingTrackTransitionDirection('next', 'desktop'); void sendDesktopControl('next'); return; } diff --git a/src/components/NowPlayingWash.tsx b/src/components/NowPlayingWash.tsx index 01ac5bc..6dda190 100644 --- a/src/components/NowPlayingWash.tsx +++ b/src/components/NowPlayingWash.tsx @@ -54,7 +54,7 @@ export function NowPlayingWash({ style={[StyleSheet.absoluteFill, { opacity: ART_OPACITY }]} contentFit="cover" blurRadius={BLUR_RADIUS} - transition={reduceMotion ? null : 200} + transition={reduceMotion ? null : 220} /> diff --git a/src/components/lyrics/LyricsView.tsx b/src/components/lyrics/LyricsView.tsx index 6b80c49..249e523 100644 --- a/src/components/lyrics/LyricsView.tsx +++ b/src/components/lyrics/LyricsView.tsx @@ -6,13 +6,17 @@ // art view; swipe-down still closes the player. import { Image } from 'expo-image'; -import { Pressable, View } from 'react-native'; +import { Pressable, StyleSheet, View } from 'react-native'; import { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'; import { Text } from '@/components/Text'; import { MarqueeText } from '@/components/MarqueeText'; import { AstraLogo } from '@/components/AstraLogo'; import { SeekBar } from '@/components/SeekBar'; import { TactilePressable } from '@/components/player/TactilePressable'; +import { + getNowPlayingTrackTransitionKey, + NowPlayingTrackFadeThrough, +} from '@/components/player/nowPlayingTrackTransition'; import { LyricsBand } from './LyricsBand'; import { spacing, radius } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; @@ -62,6 +66,7 @@ export function LyricsView({ const duration = usePlayerStore((s) => s.duration); const result = useLyricsStore((s) => s.byPath[track.path]?.result ?? null); const sourceLabel = result?.status === 'hit' ? getLyricsPayloadSourceLabel(result.lyrics) : null; + const transitionTrackKey = getNowPlayingTrackTransitionKey('phone', track.path); return ( @@ -70,39 +75,52 @@ export function LyricsView({ - - {track.artworkData ? ( - - ) : ( - - )} - + + + + {track.artworkData ? ( + + ) : ( + + )} + - - - {track.title} - - - - {track.artist} - - {sourceLabel ? ( - - {sourceLabel} - - ) : null} - + + + {track.title} + + + + {track.artist} + + {sourceLabel ? ( + + {sourceLabel} + + ) : null} + + + - - + + + @@ -167,6 +185,21 @@ const useStyles = createThemedStyles((colors) => ({ alignItems: 'center', justifyContent: 'center', }, + stripTrackFrame: { + flex: 1, + height: 40, + position: 'relative', + }, + stripTrack: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, thumb: { width: 40, height: 40, @@ -184,6 +217,11 @@ const useStyles = createThemedStyles((colors) => ({ flex: 1, minWidth: 0, }, + lyricsFrame: { + flex: 1, + minHeight: 0, + position: 'relative', + }, stripTitle: { color: colors.textPrimary, }, diff --git a/src/components/player/CachedLyricPeek.tsx b/src/components/player/CachedLyricPeek.tsx index 541ff8c..2ee89e9 100644 --- a/src/components/player/CachedLyricPeek.tsx +++ b/src/components/player/CachedLyricPeek.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { View } from 'react-native'; import Animated, { Keyframe, ReduceMotion } from 'react-native-reanimated'; +import { Text } from '@/components/Text'; import { TactilePressable } from '@/components/player/TactilePressable'; import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime'; import { peekCachedLyricsForTrack } from '@/lyrics/lyrics'; @@ -38,7 +39,7 @@ interface CachedLyricPeekProps { } /** - * One-line synced lyric display. Online lookup opt-in uses the shared lyrics + * Two-line synced lyric display. Online lookup opt-in uses the shared lyrics * resolver; otherwise the passive preview remains a cache-only SQLite read. */ export function CachedLyricPeek({ @@ -117,16 +118,21 @@ export function CachedLyricPeek({ accessibilityLabel={text ? `Open lyrics: ${text}` : undefined} > {text && lineKey ? ( - - {text} - + + {text} + + ) : null} @@ -135,19 +141,26 @@ export function CachedLyricPeek({ const useStyles = createThemedStyles((colors) => ({ wrap: { - height: 28, - marginBottom: spacing.sm, + // 48px fits two 22px lines plus breathing room. Pulling 12px from the + // existing media gap keeps the external footprint at its old 36px, so the + // metadata and every control below stay on the same anchors. + height: 48, + marginTop: -spacing.md, overflow: 'hidden', }, pressable: { flex: 1, - justifyContent: 'center', overflow: 'hidden', }, - line: { + lineFrame: { position: 'absolute', + top: 0, + bottom: 0, left: 0, right: 0, + justifyContent: 'center', + }, + line: { color: colors.textSecondary, fontFamily: fonts.sans.medium, fontSize: 16, diff --git a/src/components/player/NowPlayingCompanionPane.tsx b/src/components/player/NowPlayingCompanionPane.tsx index 99a1e66..7e1591a 100644 --- a/src/components/player/NowPlayingCompanionPane.tsx +++ b/src/components/player/NowPlayingCompanionPane.tsx @@ -1,4 +1,4 @@ -import { View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { SegmentedControl } from '@/components/SegmentedControl'; import { LyricsBand } from '@/components/lyrics/LyricsBand'; import { QueueTray } from '@/components/queue/QueueTray'; @@ -6,6 +6,10 @@ import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet'; import { seekTo } from '@/audio/playbackController'; import { spacing } from '@/theme'; import { createThemedStyles } from '@/theme/themed'; +import { + getNowPlayingTrackTransitionKey, + NowPlayingTrackFadeThrough, +} from './nowPlayingTrackTransition'; import { usePlayerStore } from '@/stores/playerStore'; import { useSettingsStore } from '@/stores/settingsStore'; import type { NowPlayingCompanion } from './nowPlayingPreferences'; @@ -38,6 +42,10 @@ export function NowPlayingCompanionPane({ const isPlaying = usePlayerStore( (s) => active && !desktopTarget && s.playbackState === 'playing' ); + const transitionTrackKey = getNowPlayingTrackTransitionKey( + 'phone', + track?.path ?? null + ); const selectCompanion = (next: string) => { const value: NowPlayingCompanion = next === 'lyrics' ? 'lyrics' : 'queue'; @@ -62,13 +70,19 @@ export function NowPlayingCompanionPane({ {companion === 'queue' ? ( ) : track ? ( - void seekTo(seconds)} - /> + + void seekTo(seconds)} + /> + ) : null} @@ -94,4 +108,9 @@ const useStyles = createThemedStyles((colors) => ({ flex: 1, minHeight: 0, }, + lyricsFrame: { + flex: 1, + minHeight: 0, + position: 'relative', + }, })); diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx index 22d29a3..98f8d6d 100644 --- a/src/components/player/NowPlayingOverlay.tsx +++ b/src/components/player/NowPlayingOverlay.tsx @@ -16,7 +16,6 @@ import Animated, { cancelAnimation, runOnJS, useAnimatedStyle, - useReducedMotion, useSharedValue, withDelay, withSequence, @@ -42,6 +41,10 @@ import { ScopeRack } from '@/components/player/ScopeRack'; import { NowPlayingCompanionPane } from '@/components/player/NowPlayingCompanionPane'; import { PlayerStateIcon } from '@/components/player/PlayerStateIcon'; import { CachedLyricPeek } from '@/components/player/CachedLyricPeek'; +import { + getNowPlayingTrackTransitionKey, + NowPlayingTrackFadeThrough, +} from '@/components/player/nowPlayingTrackTransition'; import { resolveNowPlayingPanRelease, resolveNowPlayingDismissSpring, @@ -92,6 +95,7 @@ import { useQueueStore } from '@/stores/queueStore'; import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { usePlayerUiStore } from '@/stores/playerUiStore'; +import { markNowPlayingTrackTransitionDirection } from '@/stores/nowPlayingTrackTransitionStore'; import { isPlayerOnScreen } from '@/stores/playerPresence'; import { useSettingsStore, type ScopeMode } from '@/stores/settingsStore'; import { useSleepTimerStore } from '@/stores/sleepTimerStore'; @@ -143,7 +147,6 @@ export function NowPlayingOverlay() { const returnToTabs = useReturnToTabs(); const insets = useSafeAreaInsets(); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); - const reduceMotion = useReducedMotion(); const phase = usePlayerUiStore((s) => s.phase); const openRequest = usePlayerUiStore((s) => s.openRequest); const exitAnimated = usePlayerUiStore((s) => s.exitAnimated); @@ -219,7 +222,10 @@ export function NowPlayingOverlay() { !foreground ); const activeTrack = desktopSnapshot?.currentTrack ?? null; - const transitionTrackKey = isDesktopTarget ? activeTrack?.id ?? '' : track?.id ?? ''; + const transitionTrackKey = getNowPlayingTrackTransitionKey( + activePresentation.target, + activePresentation.trackKey + ); const isPlaying = activePresentation.playbackState === 'playing'; const isLoading = activePresentation.playbackState === 'loading'; // Wash off a low-res thumbnail (like the album/artist detail headers do) so the @@ -304,7 +310,6 @@ export function NowPlayingOverlay() { const screenHeight = useSharedValue(windowHeight); const companionTouchStartX = useSharedValue(companionStartX); const menuProgress = useSharedValue(0); - const trackProgress = useSharedValue(1); // ∿ engagement, shared by both scope styles: rail = art shrink + strip fade, // rack = art face crossfading to the instrument rack. The presence gates keep // both faces for the 220 ms transition, then release the invisible surface. @@ -486,12 +491,6 @@ export function NowPlayingOverlay() { companionTouchStartX.value = companionStartX; }, [companionStartX, companionTouchStartX]); - useEffect(() => { - if (!transitionTrackKey) return; - trackProgress.value = 0; - trackProgress.value = withTiming(1, { ...motion.snap, duration: 200 }); - }, [trackProgress, transitionTrackKey]); - useEffect(() => { stageProgress.value = withTiming(effectiveScopeStageVisible ? 1 : 0, motion.snap); }, [effectiveScopeStageVisible, stageProgress]); @@ -770,16 +769,6 @@ export function NowPlayingOverlay() { transform: [{ translateY: MENU_ENTER_OFFSET_Y * (1 - menuProgress.value) }], })); - const artworkTransitionStyle = useAnimatedStyle(() => ({ - opacity: 0.75 + trackProgress.value * 0.25, - transform: [{ scale: 0.985 + trackProgress.value * 0.015 }], - })); - - const metadataTransitionStyle = useAnimatedStyle(() => ({ - opacity: trackProgress.value, - transform: [{ translateY: 4 * (1 - trackProgress.value) }], - })); - // Rail choreography (standard presentation only): the art box is laid out at // its scope-off size and driven to the scope-on size/position by // stageProgress, so the ∿ toggle animates as one move instead of snapping @@ -795,13 +784,10 @@ export function NowPlayingOverlay() { ? layout.artSizeScopeOn / 2 - layout.mediaStackHeight / 2 : 0; const artStageTransitionStyle = useAnimatedStyle(() => ({ - opacity: 0.75 + trackProgress.value * 0.25, transform: [ { translateY: stageProgress.value * railArtShift }, { - scale: - (0.985 + trackProgress.value * 0.015) * - (1 + stageProgress.value * (railArtScale - 1)), + scale: 1 + stageProgress.value * (railArtScale - 1), }, ], })); @@ -921,27 +907,27 @@ export function NowPlayingOverlay() { }, ]} > - {activePresentation.artworkUri ? ( ) : ( )} - + - @@ -1000,7 +987,7 @@ export function NowPlayingOverlay() { } /> - + void sendDesktopControl('previous')} + onPress={() => { + markNowPlayingTrackTransitionDirection('previous', 'desktop'); + void sendDesktopControl('previous'); + }} haptic="action" hitSlop={12} style={styles.transportMainBtn} android_ripple={ripple.icon(26)} @@ -1062,7 +1052,10 @@ export function NowPlayingOverlay() { /> void sendDesktopControl('next')} + onPress={() => { + markNowPlayingTrackTransitionDirection('next', 'desktop'); + void sendDesktopControl('next'); + }} haptic="action" hitSlop={12} style={styles.transportMainBtn} android_ripple={ripple.icon(26)} @@ -1227,18 +1220,23 @@ export function NowPlayingOverlay() { ]} > {renderArtworkFace ? ( - track.artworkData ? ( - - ) : ( - - ) + + {track.artworkData ? ( + + ) : ( + + )} + ) : null} {!railStyle && renderScopeSurfaces && ( @@ -1249,6 +1247,7 @@ export function NowPlayingOverlay() { ) : null} - @@ -1390,7 +1390,7 @@ export function NowPlayingOverlay() { } /> - + ({ width: '100%', height: '100%', }, + trackVisualLayer: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + alignItems: 'center', + justifyContent: 'center', + }, + trackInfoFrame: { + alignSelf: 'stretch', + }, trackInfo: { alignSelf: 'stretch', flexDirection: 'row', diff --git a/src/components/player/ScopeRack.tsx b/src/components/player/ScopeRack.tsx index 0af8216..e6112ab 100644 --- a/src/components/player/ScopeRack.tsx +++ b/src/components/player/ScopeRack.tsx @@ -12,6 +12,7 @@ import { import { OscilloscopeWave } from '@/components/OscilloscopeWave'; import { SpectrumCurve } from '@/components/SpectrumCurve'; import { getScopeHeight } from '@/components/player/nowPlayingLayout'; +import { NowPlayingTrackFadeThrough } from '@/components/player/nowPlayingTrackTransition'; import { useScopeActive } from '@/scope/scopeStore'; import { spacing } from '@/theme'; import { createThemedStyles } from '@/theme/themed'; @@ -35,12 +36,62 @@ interface ScopeRackProps { size: number; /** Strip span (the rail's width): wider than the card, overflowing it. */ stripWidth: number; + /** Playback target + track identity; avoids keying presence by a potentially large data URI. */ + transitionKey: string; artworkUri: string | null; spectrumSmoothing?: number; /** Freeze the scopes without unmounting (overlay closed / queue open). */ paused?: boolean; } +function ScopeRackBackdrop({ + artworkUri, + backdropSize, + maskInset, + maskBlur, +}: { + artworkUri: string | null; + backdropSize: number; + maskInset: number; + maskBlur: number; +}) { + const artwork = useImage(artworkUri); + if (!artwork) return null; + + return ( + + + + + } + > + + + + + + + + ); +} + /** * Rack-style scope face: both instruments stacked at their natural wide aspect * (oscilloscope above, spectrum grounded below). The blurred, dimmed artwork @@ -50,12 +101,12 @@ interface ScopeRackProps { export function ScopeRack({ size, stripWidth, + transitionKey, artworkUri, spectrumSmoothing, paused = false, }: ScopeRackProps) { const styles = useStyles(); - const artwork = useImage(artworkUri); const active = useScopeActive() && !paused; const width = Math.max(0, stripWidth); const stripHeight = getScopeHeight(width); @@ -76,38 +127,18 @@ export function ScopeRack({ }, ]} > - {artwork ? ( - - - - - } - > - - - - - - - - ) : null} + + + ; + contentStyle?: StyleProp; +} + +/** + * Track keys can be shared across playback targets, so target identity is part + * of the transition key. Progress and metadata updates keep the same key. + */ +export function getNowPlayingTrackTransitionKey( + target: PlaybackPresentation['target'], + trackKey: string | null +): string { + return `${target}:${trackKey ?? 'none'}`; +} + +function getTargetFromTransitionKey(transitionKey: string): PlaybackTarget { + return transitionKey.startsWith('desktop:') ? 'desktop' : 'phone'; +} + +function outgoingOffset(direction: NowPlayingTrackTransitionDirection): number { + return direction === 'next' ? -SLIDE_DISTANCE : SLIDE_DISTANCE; +} + +function incomingOffset(direction: NowPlayingTrackTransitionDirection): number { + return -outgoingOffset(direction); +} + +/** + * Single-layer directional fade-through. Next fades left and enters from the + * right; previous mirrors it. Content swaps only while fully transparent, so + * two tracks are never mounted together and rapid skipping cannot stack or + * alternate outgoing metadata layers. + */ +export function NowPlayingTrackFadeThrough({ + transitionKey, + children, + style, + contentStyle, +}: NowPlayingTrackFadeThroughProps) { + const directionHint = useNowPlayingTrackTransitionStore((state) => state.hint); + const requestedDirection = resolveNowPlayingTrackTransitionDirection( + directionHint, + getTargetFromTransitionKey(transitionKey), + ); + const [display, setDisplay] = useState({ + key: transitionKey, + generation: 0, + }); + const committedLayer = useRef({ + key: transitionKey, + children, + direction: requestedDirection, + }); + const latestRequest = useRef({ + key: transitionKey, + children, + direction: requestedDirection, + }); + const phase = useRef('idle'); + const exitDirection = useRef(null); + const animationToken = useRef(0); + const visibility = useSharedValue(1); + const translateX = useSharedValue(0); + + const finishEntrance = useCallback((token: number) => { + if (animationToken.current !== token) return; + phase.current = 'idle'; + exitDirection.current = null; + }, []); + + const commitLatestRequest = useCallback((token: number) => { + if (animationToken.current !== token) return; + const next = latestRequest.current; + phase.current = 'switching'; + committedLayer.current = next; + setDisplay((current) => ({ + key: next.key, + generation: current.generation + 1, + })); + }, []); + + // Capture the latest props every commit, but preserve the visible layer while + // a different track is fading out. + useLayoutEffect(() => { + latestRequest.current = { + key: transitionKey, + children, + direction: requestedDirection, + }; + if (display.key === transitionKey) { + committedLayer.current = latestRequest.current; + } + }, [children, display.key, requestedDirection, transitionKey]); + + // Once the outgoing layer is hidden, mount only the newest requested track + // and fade that single layer in. + useLayoutEffect(() => { + if (display.generation === 0) return undefined; + + const latest = latestRequest.current; + if (latest.key !== display.key) { + phase.current = 'switching'; + committedLayer.current = latest; + setDisplay((current) => ({ + key: latest.key, + generation: current.generation + 1, + })); + return undefined; + } + + const token = animationToken.current; + const direction = committedLayer.current.direction; + phase.current = 'entering'; + cancelAnimation(visibility); + cancelAnimation(translateX); + visibility.value = 0; + translateX.value = incomingOffset(direction); + visibility.value = withTiming(1, ENTER, (finished) => { + if (finished) runOnJS(finishEntrance)(token); + }); + translateX.value = withTiming(0, ENTER); + return undefined; + }, [ + display.generation, + display.key, + finishEntrance, + translateX, + visibility, + ]); + + // Start the fade-out for a new key. Requests arriving during that exit only + // update latestRequest; the midpoint commits the newest one. If the request + // returns to the visible key, reverse cleanly instead of swapping away/back. + useLayoutEffect(() => { + if (display.key === transitionKey) { + if (phase.current !== 'exiting') return undefined; + const token = ++animationToken.current; + phase.current = 'entering'; + exitDirection.current = null; + cancelAnimation(visibility); + cancelAnimation(translateX); + visibility.value = withTiming(1, ENTER, (finished) => { + if (finished) runOnJS(finishEntrance)(token); + }); + translateX.value = withTiming(0, ENTER); + return undefined; + } + + if (phase.current === 'exiting') { + const direction = latestRequest.current.direction; + if (exitDirection.current !== direction) { + exitDirection.current = direction; + cancelAnimation(translateX); + translateX.value = withTiming(outgoingOffset(direction), EXIT); + } + return undefined; + } + + if (phase.current === 'switching') { + return undefined; + } + + const token = ++animationToken.current; + const direction = latestRequest.current.direction; + phase.current = 'exiting'; + exitDirection.current = direction; + cancelAnimation(visibility); + cancelAnimation(translateX); + visibility.value = withTiming(0, EXIT, (finished) => { + if (finished) runOnJS(commitLatestRequest)(token); + }); + translateX.value = withTiming(outgoingOffset(direction), EXIT); + return undefined; + }, [ + commitLatestRequest, + display.key, + finishEntrance, + requestedDirection, + transitionKey, + translateX, + visibility, + ]); + + useEffect(() => () => { + animationToken.current += 1; + cancelAnimation(visibility); + cancelAnimation(translateX); + }, [translateX, visibility]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: visibility.value, + transform: [{ translateX: translateX.value }], + })); + const renderedChildren = + display.key === transitionKey ? children : committedLayer.current.children; + + return ( + + + {renderedChildren} + + + ); +} diff --git a/src/stores/nowPlayingTrackTransitionStore.ts b/src/stores/nowPlayingTrackTransitionStore.ts new file mode 100644 index 0000000..c600005 --- /dev/null +++ b/src/stores/nowPlayingTrackTransitionStore.ts @@ -0,0 +1,59 @@ +import { create } from 'zustand'; +import type { PlaybackTarget } from '@/stores/playbackTargetStore'; + +export type NowPlayingTrackTransitionDirection = 'next' | 'previous'; + +interface NowPlayingTrackTransitionHint { + direction: NowPlayingTrackTransitionDirection; + target: PlaybackTarget; + issuedAt: number; +} + +interface NowPlayingTrackTransitionStore { + hint: NowPlayingTrackTransitionHint | null; + markDirection: ( + direction: NowPlayingTrackTransitionDirection, + target: PlaybackTarget, + ) => void; +} + +const DIRECTION_HINT_LIFETIME_MS = 5_000; + +/** + * Short-lived transport intent shared by the independently animated Now + * Playing surfaces. It is session-only and never persisted. + */ +export const useNowPlayingTrackTransitionStore = + create((set) => ({ + hint: null, + markDirection: (direction, target) => + set({ + hint: { + direction, + target, + issuedAt: Date.now(), + }, + }), + })); + +export function markNowPlayingTrackTransitionDirection( + direction: NowPlayingTrackTransitionDirection, + target: PlaybackTarget, +): void { + useNowPlayingTrackTransitionStore.getState().markDirection(direction, target); +} + +export function resolveNowPlayingTrackTransitionDirection( + hint: NowPlayingTrackTransitionHint | null, + target: PlaybackTarget, + now = Date.now(), +): NowPlayingTrackTransitionDirection { + if ( + hint?.target === target && + now - hint.issuedAt >= 0 && + now - hint.issuedAt <= DIRECTION_HINT_LIFETIME_MS + ) { + return hint.direction; + } + return 'next'; +}