track change qol

This commit is contained in:
Boof2015
2026-07-28 16:39:13 -04:00
parent d4b1a1e122
commit 304192fbb6
10 changed files with 582 additions and 133 deletions
+8
View File
@@ -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;
}
+1 -1
View File
@@ -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}
/>
<Canvas style={StyleSheet.absoluteFill}>
<Rect x={0} y={0} width={bandW} height={bandH}>
+68 -30
View File
@@ -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 (
<View style={styles.root}>
@@ -70,39 +75,52 @@ export function LyricsView({
<Ionicons name="chevron-down" size={24} color={colors.textSecondary} />
</Pressable>
<View style={styles.thumb}>
{track.artworkData ? (
<Image source={{ uri: track.artworkData }} style={styles.thumbImage} contentFit="cover" />
) : (
<AstraLogo size={18} />
)}
</View>
<View style={styles.stripTrackFrame}>
<NowPlayingTrackFadeThrough
transitionKey={transitionTrackKey}
style={StyleSheet.absoluteFill}
contentStyle={styles.stripTrack}
>
<View style={styles.thumb}>
{track.artworkData ? (
<Image source={{ uri: track.artworkData }} style={styles.thumbImage} contentFit="cover" />
) : (
<AstraLogo size={18} />
)}
</View>
<View style={styles.stripText}>
<MarqueeText variant="label" style={styles.stripTitle}>
{track.title}
</MarqueeText>
<View style={styles.stripSubRow}>
<Text variant="caption" numberOfLines={1} color={colors.textTertiary} style={styles.stripArtist}>
{track.artist}
</Text>
{sourceLabel ? (
<Text variant="mono" numberOfLines={1} color={colors.textTertiary} style={styles.sourceTag}>
{sourceLabel}
</Text>
) : null}
</View>
<View style={styles.stripText}>
<MarqueeText variant="label" style={styles.stripTitle}>
{track.title}
</MarqueeText>
<View style={styles.stripSubRow}>
<Text variant="caption" numberOfLines={1} color={colors.textTertiary} style={styles.stripArtist}>
{track.artist}
</Text>
{sourceLabel ? (
<Text variant="mono" numberOfLines={1} color={colors.textTertiary} style={styles.sourceTag}>
{sourceLabel}
</Text>
) : null}
</View>
</View>
</NowPlayingTrackFadeThrough>
</View>
</View>
<LyricsBand
track={track}
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying && active}
onSeek={onSeek}
/>
<NowPlayingTrackFadeThrough
transitionKey={transitionTrackKey}
style={styles.lyricsFrame}
contentStyle={StyleSheet.absoluteFill}
>
<LyricsBand
track={track}
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying && active}
onSeek={onSeek}
/>
</NowPlayingTrackFadeThrough>
<View style={styles.controls}>
<SeekBar currentTime={currentTime} duration={duration} trackKey={track.id} onSeek={onSeek} />
@@ -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,
},
+24 -11
View File
@@ -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 ? (
<Animated.Text
<Animated.View
key={lineKey}
entering={ENTERING}
exiting={EXITING}
numberOfLines={1}
ellipsizeMode="tail"
style={styles.line}
pointerEvents="none"
style={styles.lineFrame}
>
{text}
</Animated.Text>
<Text
numberOfLines={2}
ellipsizeMode="tail"
style={styles.line}
>
{text}
</Text>
</Animated.View>
) : null}
</TactilePressable>
</View>
@@ -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,
@@ -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' ? (
<QueueTray embedded onClose={noop} />
) : track ? (
<LyricsBand
track={track}
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying}
onSeek={(seconds) => void seekTo(seconds)}
/>
<NowPlayingTrackFadeThrough
transitionKey={transitionTrackKey}
style={styles.lyricsFrame}
contentStyle={StyleSheet.absoluteFill}
>
<LyricsBand
track={track}
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying}
onSeek={(seconds) => void seekTo(seconds)}
/>
</NowPlayingTrackFadeThrough>
) : null}
</View>
</>
@@ -94,4 +108,9 @@ const useStyles = createThemedStyles((colors) => ({
flex: 1,
minHeight: 0,
},
lyricsFrame: {
flex: 1,
minHeight: 0,
position: 'relative',
},
}));
+62 -50
View File
@@ -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() {
},
]}
>
<Animated.View
<NowPlayingTrackFadeThrough
transitionKey={transitionTrackKey}
style={[
styles.artCard,
artworkTransitionStyle,
{
width: layout.artSize,
height: layout.artSize,
},
]}
contentStyle={styles.trackVisualLayer}
>
{activePresentation.artworkUri ? (
<Image
source={{ uri: activePresentation.artworkUri }}
style={styles.artImage}
contentFit="cover"
transition={reduceMotion ? null : 200}
/>
) : (
<AstraLogo size={Math.round(layout.artSize * 0.4)} />
)}
</Animated.View>
</NowPlayingTrackFadeThrough>
</View>
<View
@@ -953,11 +939,12 @@ export function NowPlayingOverlay() {
]}
>
<View style={styles.primaryControls}>
<Animated.View
style={[
<NowPlayingTrackFadeThrough
transitionKey={transitionTrackKey}
style={styles.trackInfoFrame}
contentStyle={[
styles.trackInfo,
{ marginBottom: layout.trackInfoGap },
metadataTransitionStyle,
]}
>
<View style={styles.trackTextStack}>
@@ -1000,7 +987,7 @@ export function NowPlayingOverlay() {
}
/>
</TactilePressable>
</Animated.View>
</NowPlayingTrackFadeThrough>
<SeekBar
currentTime={activePresentation.currentTime}
@@ -1035,7 +1022,10 @@ export function NowPlayingOverlay() {
/>
</TactilePressable>
<TactilePressable
onPress={() => 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() {
/>
</TactilePressable>
<TactilePressable
onPress={() => 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 ? (
<Image
source={{ uri: track.artworkData }}
style={styles.artImage}
contentFit="cover"
cachePolicy="disk"
allowDownscaling
transition={reduceMotion ? null : 200}
/>
) : (
<AstraLogo size={Math.round(artBoxSize * 0.4)} />
)
<NowPlayingTrackFadeThrough
transitionKey={transitionTrackKey}
style={StyleSheet.absoluteFill}
contentStyle={styles.trackVisualLayer}
>
{track.artworkData ? (
<Image
source={{ uri: track.artworkData }}
style={styles.artImage}
contentFit="cover"
cachePolicy="disk"
allowDownscaling
/>
) : (
<AstraLogo size={Math.round(artBoxSize * 0.4)} />
)}
</NowPlayingTrackFadeThrough>
) : null}
</Animated.View>
{!railStyle && renderScopeSurfaces && (
@@ -1249,6 +1247,7 @@ export function NowPlayingOverlay() {
<ScopeRack
size={artBoxSize}
stripWidth={layout.scopeWidth}
transitionKey={transitionTrackKey}
artworkUri={backdropArtworkUri}
spectrumSmoothing={NOW_PLAYING_SPECTRUM_SMOOTHING}
paused={!surfacesLive || queueOpen || !effectiveScopeStageVisible}
@@ -1323,11 +1322,12 @@ export function NowPlayingOverlay() {
onOpenLyrics={showLyrics}
/>
) : null}
<Animated.View
style={[
<NowPlayingTrackFadeThrough
transitionKey={transitionTrackKey}
style={styles.trackInfoFrame}
contentStyle={[
styles.trackInfo,
{ marginBottom: layout.trackInfoGap },
metadataTransitionStyle,
]}
>
<View style={styles.trackTextStack}>
@@ -1390,7 +1390,7 @@ export function NowPlayingOverlay() {
}
/>
</TactilePressable>
</Animated.View>
</NowPlayingTrackFadeThrough>
<WaveformSeekBar
active={surfacesLive}
@@ -1924,6 +1924,18 @@ const useStyles = createThemedStyles((colors) => ({
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',
+64 -33
View File
@@ -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 (
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
<Mask
mode="alpha"
mask={
<RoundedRect
x={maskInset}
y={maskInset}
width={backdropSize - maskInset * 2}
height={backdropSize - maskInset * 2}
r={maskInset * 0.6}
color="white"
>
<BlurMask blur={maskBlur} style="normal" />
</RoundedRect>
}
>
<Group opacity={BACKDROP_IMAGE_OPACITY}>
<SkiaImage
image={artwork}
x={0}
y={0}
width={backdropSize}
height={backdropSize}
fit="cover"
>
<Blur blur={BACKDROP_BLUR_RADIUS} mode="clamp" />
</SkiaImage>
</Group>
</Mask>
</Canvas>
);
}
/**
* 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 ? (
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
<Mask
mode="alpha"
mask={
<RoundedRect
x={maskInset}
y={maskInset}
width={backdropSize - maskInset * 2}
height={backdropSize - maskInset * 2}
r={maskInset * 0.6}
color="white"
>
<BlurMask blur={maskBlur} style="normal" />
</RoundedRect>
}
>
<Group opacity={BACKDROP_IMAGE_OPACITY}>
<SkiaImage
image={artwork}
x={0}
y={0}
width={backdropSize}
height={backdropSize}
fit="cover"
>
<Blur blur={BACKDROP_BLUR_RADIUS} mode="clamp" />
</SkiaImage>
</Group>
</Mask>
</Canvas>
) : null}
<NowPlayingTrackFadeThrough
transitionKey={transitionKey}
style={StyleSheet.absoluteFill}
contentStyle={StyleSheet.absoluteFill}
>
<ScopeRackBackdrop
artworkUri={artworkUri}
backdropSize={backdropSize}
maskInset={maskInset}
maskBlur={maskBlur}
/>
</NowPlayingTrackFadeThrough>
</View>
<View style={[styles.strips, { width, left: (size - width) / 2 }]}>
<OscilloscopeWave
@@ -0,0 +1,266 @@
/* eslint-disable react-hooks/immutability, react-hooks/refs -- the fade-through keeps committed React content in refs and drives its single visual layer on the UI thread. */
import {
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from 'react';
import {
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import Animated, {
Easing,
ReduceMotion,
cancelAnimation,
runOnJS,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import type { PlaybackPresentation } from '@/playback/playbackTargetPresentation';
import type { PlaybackTarget } from '@/stores/playbackTargetStore';
import {
resolveNowPlayingTrackTransitionDirection,
useNowPlayingTrackTransitionStore,
type NowPlayingTrackTransitionDirection,
} from '@/stores/nowPlayingTrackTransitionStore';
const EXIT = {
duration: 110,
easing: Easing.in(Easing.cubic),
reduceMotion: ReduceMotion.System,
} as const;
const ENTER = {
duration: 190,
easing: Easing.out(Easing.cubic),
reduceMotion: ReduceMotion.System,
} as const;
const SLIDE_DISTANCE = 14;
interface TrackLayer {
key: string;
children: ReactNode;
direction: NowPlayingTrackTransitionDirection;
}
interface DisplayState {
key: string;
generation: number;
}
type TransitionPhase = 'idle' | 'exiting' | 'switching' | 'entering';
interface NowPlayingTrackFadeThroughProps {
transitionKey: string;
children: ReactNode;
style?: StyleProp<ViewStyle>;
contentStyle?: StyleProp<ViewStyle>;
}
/**
* 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<DisplayState>({
key: transitionKey,
generation: 0,
});
const committedLayer = useRef<TrackLayer>({
key: transitionKey,
children,
direction: requestedDirection,
});
const latestRequest = useRef<TrackLayer>({
key: transitionKey,
children,
direction: requestedDirection,
});
const phase = useRef<TransitionPhase>('idle');
const exitDirection = useRef<NowPlayingTrackTransitionDirection | null>(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 (
<View collapsable={false} style={[{ position: 'relative' }, style]}>
<Animated.View
key={display.key}
style={[contentStyle, animatedStyle]}
>
{renderedChildren}
</Animated.View>
</View>
);
}