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
+3
View File
@@ -8,6 +8,7 @@ import type { DbTrack } from '@/types/library';
import { usePlayerStore, type RepeatMode as RepeatModeStr } from '@/stores/playerStore'; import { usePlayerStore, type RepeatMode as RepeatModeStr } from '@/stores/playerStore';
import { useQueueStore } from '@/stores/queueStore'; import { useQueueStore } from '@/stores/queueStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { markNowPlayingTrackTransitionDirection } from '@/stores/nowPlayingTrackTransitionStore';
import type { import type {
PlaybackSessionSnapshotV1, PlaybackSessionSnapshotV1,
ResolvedPlaybackSession, ResolvedPlaybackSession,
@@ -805,6 +806,7 @@ export async function togglePlay(): Promise<void> {
} }
export async function skipToNext(): Promise<void> { export async function skipToNext(): Promise<void> {
markNowPlayingTrackTransitionDirection('next', 'phone');
await ensurePlayerReady(); await ensurePlayerReady();
const [nativeQueue, nativeIndex] = await Promise.all([ const [nativeQueue, nativeIndex] = await Promise.all([
TrackPlayer.getQueue(), TrackPlayer.getQueue(),
@@ -848,6 +850,7 @@ export async function skipToPrevious(): Promise<void> {
return; return;
} }
markNowPlayingTrackTransitionDirection('previous', 'phone');
const [nativeQueue, nativeIndex] = await Promise.all([ const [nativeQueue, nativeIndex] = await Promise.all([
TrackPlayer.getQueue(), TrackPlayer.getQueue(),
TrackPlayer.getActiveTrackIndex(), TrackPlayer.getActiveTrackIndex(),
+8
View File
@@ -31,6 +31,7 @@ import { usePlayerStore } from '@/stores/playerStore';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { skipToNext, skipToPrevious, togglePlay } from '@/audio/playbackController'; import { skipToNext, skipToPrevious, togglePlay } from '@/audio/playbackController';
import { markNowPlayingTrackTransitionDirection } from '@/stores/nowPlayingTrackTransitionStore';
import { useScopeActive } from '@/scope/scopeStore'; import { useScopeActive } from '@/scope/scopeStore';
import { artworkThumbFromSource } from '@/library/artwork'; import { artworkThumbFromSource } from '@/library/artwork';
import { useAnimatedPlaybackProgress } from '@/audio/useAnimatedPlaybackProgress'; import { useAnimatedPlaybackProgress } from '@/audio/useAnimatedPlaybackProgress';
@@ -236,6 +237,12 @@ export function MiniPlayer() {
const dispatchPendingSwipe = useCallback((id: number) => { const dispatchPendingSwipe = useCallback((id: number) => {
const pending = pendingSwipeRef.current; const pending = pendingSwipeRef.current;
if (!pending || pending.id !== id) return; if (!pending || pending.id !== id) return;
if (pending.target === 'desktop') {
markNowPlayingTrackTransitionDirection(
pending.direction,
pending.target,
);
}
const command = pending.target === 'desktop' const command = pending.target === 'desktop'
? sendDesktopControl(pending.direction === 'next' ? 'next' : 'previous') ? sendDesktopControl(pending.direction === 'next' ? 'next' : 'previous')
: pending.direction === 'next' : pending.direction === 'next'
@@ -435,6 +442,7 @@ export function MiniPlayer() {
}; };
const onSkipNext = () => { const onSkipNext = () => {
if (isDesktop) { if (isDesktop) {
markNowPlayingTrackTransitionDirection('next', 'desktop');
void sendDesktopControl('next'); void sendDesktopControl('next');
return; return;
} }
+1 -1
View File
@@ -54,7 +54,7 @@ export function NowPlayingWash({
style={[StyleSheet.absoluteFill, { opacity: ART_OPACITY }]} style={[StyleSheet.absoluteFill, { opacity: ART_OPACITY }]}
contentFit="cover" contentFit="cover"
blurRadius={BLUR_RADIUS} blurRadius={BLUR_RADIUS}
transition={reduceMotion ? null : 200} transition={reduceMotion ? null : 220}
/> />
<Canvas style={StyleSheet.absoluteFill}> <Canvas style={StyleSheet.absoluteFill}>
<Rect x={0} y={0} width={bandW} height={bandH}> <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. // art view; swipe-down still closes the player.
import { Image } from 'expo-image'; 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 { Ionicons, MaterialCommunityIcons } from '@expo/vector-icons';
import { Text } from '@/components/Text'; import { Text } from '@/components/Text';
import { MarqueeText } from '@/components/MarqueeText'; import { MarqueeText } from '@/components/MarqueeText';
import { AstraLogo } from '@/components/AstraLogo'; import { AstraLogo } from '@/components/AstraLogo';
import { SeekBar } from '@/components/SeekBar'; import { SeekBar } from '@/components/SeekBar';
import { TactilePressable } from '@/components/player/TactilePressable'; import { TactilePressable } from '@/components/player/TactilePressable';
import {
getNowPlayingTrackTransitionKey,
NowPlayingTrackFadeThrough,
} from '@/components/player/nowPlayingTrackTransition';
import { LyricsBand } from './LyricsBand'; import { LyricsBand } from './LyricsBand';
import { spacing, radius } from '@/theme'; import { spacing, radius } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed'; import { createThemedStyles, useColors } from '@/theme/themed';
@@ -62,6 +66,7 @@ export function LyricsView({
const duration = usePlayerStore((s) => s.duration); const duration = usePlayerStore((s) => s.duration);
const result = useLyricsStore((s) => s.byPath[track.path]?.result ?? null); const result = useLyricsStore((s) => s.byPath[track.path]?.result ?? null);
const sourceLabel = result?.status === 'hit' ? getLyricsPayloadSourceLabel(result.lyrics) : null; const sourceLabel = result?.status === 'hit' ? getLyricsPayloadSourceLabel(result.lyrics) : null;
const transitionTrackKey = getNowPlayingTrackTransitionKey('phone', track.path);
return ( return (
<View style={styles.root}> <View style={styles.root}>
@@ -70,39 +75,52 @@ export function LyricsView({
<Ionicons name="chevron-down" size={24} color={colors.textSecondary} /> <Ionicons name="chevron-down" size={24} color={colors.textSecondary} />
</Pressable> </Pressable>
<View style={styles.thumb}> <View style={styles.stripTrackFrame}>
{track.artworkData ? ( <NowPlayingTrackFadeThrough
<Image source={{ uri: track.artworkData }} style={styles.thumbImage} contentFit="cover" /> transitionKey={transitionTrackKey}
) : ( style={StyleSheet.absoluteFill}
<AstraLogo size={18} /> contentStyle={styles.stripTrack}
)} >
</View> <View style={styles.thumb}>
{track.artworkData ? (
<Image source={{ uri: track.artworkData }} style={styles.thumbImage} contentFit="cover" />
) : (
<AstraLogo size={18} />
)}
</View>
<View style={styles.stripText}> <View style={styles.stripText}>
<MarqueeText variant="label" style={styles.stripTitle}> <MarqueeText variant="label" style={styles.stripTitle}>
{track.title} {track.title}
</MarqueeText> </MarqueeText>
<View style={styles.stripSubRow}> <View style={styles.stripSubRow}>
<Text variant="caption" numberOfLines={1} color={colors.textTertiary} style={styles.stripArtist}> <Text variant="caption" numberOfLines={1} color={colors.textTertiary} style={styles.stripArtist}>
{track.artist} {track.artist}
</Text> </Text>
{sourceLabel ? ( {sourceLabel ? (
<Text variant="mono" numberOfLines={1} color={colors.textTertiary} style={styles.sourceTag}> <Text variant="mono" numberOfLines={1} color={colors.textTertiary} style={styles.sourceTag}>
{sourceLabel} {sourceLabel}
</Text> </Text>
) : null} ) : null}
</View> </View>
</View>
</NowPlayingTrackFadeThrough>
</View> </View>
</View> </View>
<LyricsBand <NowPlayingTrackFadeThrough
track={track} transitionKey={transitionTrackKey}
currentTime={currentTime} style={styles.lyricsFrame}
duration={duration} contentStyle={StyleSheet.absoluteFill}
isPlaying={isPlaying && active} >
onSeek={onSeek} <LyricsBand
/> track={track}
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying && active}
onSeek={onSeek}
/>
</NowPlayingTrackFadeThrough>
<View style={styles.controls}> <View style={styles.controls}>
<SeekBar currentTime={currentTime} duration={duration} trackKey={track.id} onSeek={onSeek} /> <SeekBar currentTime={currentTime} duration={duration} trackKey={track.id} onSeek={onSeek} />
@@ -167,6 +185,21 @@ const useStyles = createThemedStyles((colors) => ({
alignItems: 'center', alignItems: 'center',
justifyContent: '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: { thumb: {
width: 40, width: 40,
height: 40, height: 40,
@@ -184,6 +217,11 @@ const useStyles = createThemedStyles((colors) => ({
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },
lyricsFrame: {
flex: 1,
minHeight: 0,
position: 'relative',
},
stripTitle: { stripTitle: {
color: colors.textPrimary, color: colors.textPrimary,
}, },
+24 -11
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { View } from 'react-native'; import { View } from 'react-native';
import Animated, { Keyframe, ReduceMotion } from 'react-native-reanimated'; import Animated, { Keyframe, ReduceMotion } from 'react-native-reanimated';
import { Text } from '@/components/Text';
import { TactilePressable } from '@/components/player/TactilePressable'; import { TactilePressable } from '@/components/player/TactilePressable';
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime'; import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
import { peekCachedLyricsForTrack } from '@/lyrics/lyrics'; 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. * resolver; otherwise the passive preview remains a cache-only SQLite read.
*/ */
export function CachedLyricPeek({ export function CachedLyricPeek({
@@ -117,16 +118,21 @@ export function CachedLyricPeek({
accessibilityLabel={text ? `Open lyrics: ${text}` : undefined} accessibilityLabel={text ? `Open lyrics: ${text}` : undefined}
> >
{text && lineKey ? ( {text && lineKey ? (
<Animated.Text <Animated.View
key={lineKey} key={lineKey}
entering={ENTERING} entering={ENTERING}
exiting={EXITING} exiting={EXITING}
numberOfLines={1} pointerEvents="none"
ellipsizeMode="tail" style={styles.lineFrame}
style={styles.line}
> >
{text} <Text
</Animated.Text> numberOfLines={2}
ellipsizeMode="tail"
style={styles.line}
>
{text}
</Text>
</Animated.View>
) : null} ) : null}
</TactilePressable> </TactilePressable>
</View> </View>
@@ -135,19 +141,26 @@ export function CachedLyricPeek({
const useStyles = createThemedStyles((colors) => ({ const useStyles = createThemedStyles((colors) => ({
wrap: { wrap: {
height: 28, // 48px fits two 22px lines plus breathing room. Pulling 12px from the
marginBottom: spacing.sm, // 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', overflow: 'hidden',
}, },
pressable: { pressable: {
flex: 1, flex: 1,
justifyContent: 'center',
overflow: 'hidden', overflow: 'hidden',
}, },
line: { lineFrame: {
position: 'absolute', position: 'absolute',
top: 0,
bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
justifyContent: 'center',
},
line: {
color: colors.textSecondary, color: colors.textSecondary,
fontFamily: fonts.sans.medium, fontFamily: fonts.sans.medium,
fontSize: 16, fontSize: 16,
@@ -1,4 +1,4 @@
import { View } from 'react-native'; import { StyleSheet, View } from 'react-native';
import { SegmentedControl } from '@/components/SegmentedControl'; import { SegmentedControl } from '@/components/SegmentedControl';
import { LyricsBand } from '@/components/lyrics/LyricsBand'; import { LyricsBand } from '@/components/lyrics/LyricsBand';
import { QueueTray } from '@/components/queue/QueueTray'; import { QueueTray } from '@/components/queue/QueueTray';
@@ -6,6 +6,10 @@ import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
import { seekTo } from '@/audio/playbackController'; import { seekTo } from '@/audio/playbackController';
import { spacing } from '@/theme'; import { spacing } from '@/theme';
import { createThemedStyles } from '@/theme/themed'; import { createThemedStyles } from '@/theme/themed';
import {
getNowPlayingTrackTransitionKey,
NowPlayingTrackFadeThrough,
} from './nowPlayingTrackTransition';
import { usePlayerStore } from '@/stores/playerStore'; import { usePlayerStore } from '@/stores/playerStore';
import { useSettingsStore } from '@/stores/settingsStore'; import { useSettingsStore } from '@/stores/settingsStore';
import type { NowPlayingCompanion } from './nowPlayingPreferences'; import type { NowPlayingCompanion } from './nowPlayingPreferences';
@@ -38,6 +42,10 @@ export function NowPlayingCompanionPane({
const isPlaying = usePlayerStore( const isPlaying = usePlayerStore(
(s) => active && !desktopTarget && s.playbackState === 'playing' (s) => active && !desktopTarget && s.playbackState === 'playing'
); );
const transitionTrackKey = getNowPlayingTrackTransitionKey(
'phone',
track?.path ?? null
);
const selectCompanion = (next: string) => { const selectCompanion = (next: string) => {
const value: NowPlayingCompanion = next === 'lyrics' ? 'lyrics' : 'queue'; const value: NowPlayingCompanion = next === 'lyrics' ? 'lyrics' : 'queue';
@@ -62,13 +70,19 @@ export function NowPlayingCompanionPane({
{companion === 'queue' ? ( {companion === 'queue' ? (
<QueueTray embedded onClose={noop} /> <QueueTray embedded onClose={noop} />
) : track ? ( ) : track ? (
<LyricsBand <NowPlayingTrackFadeThrough
track={track} transitionKey={transitionTrackKey}
currentTime={currentTime} style={styles.lyricsFrame}
duration={duration} contentStyle={StyleSheet.absoluteFill}
isPlaying={isPlaying} >
onSeek={(seconds) => void seekTo(seconds)} <LyricsBand
/> track={track}
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying}
onSeek={(seconds) => void seekTo(seconds)}
/>
</NowPlayingTrackFadeThrough>
) : null} ) : null}
</View> </View>
</> </>
@@ -94,4 +108,9 @@ const useStyles = createThemedStyles((colors) => ({
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
}, },
lyricsFrame: {
flex: 1,
minHeight: 0,
position: 'relative',
},
})); }));
+62 -50
View File
@@ -16,7 +16,6 @@ import Animated, {
cancelAnimation, cancelAnimation,
runOnJS, runOnJS,
useAnimatedStyle, useAnimatedStyle,
useReducedMotion,
useSharedValue, useSharedValue,
withDelay, withDelay,
withSequence, withSequence,
@@ -42,6 +41,10 @@ import { ScopeRack } from '@/components/player/ScopeRack';
import { NowPlayingCompanionPane } from '@/components/player/NowPlayingCompanionPane'; import { NowPlayingCompanionPane } from '@/components/player/NowPlayingCompanionPane';
import { PlayerStateIcon } from '@/components/player/PlayerStateIcon'; import { PlayerStateIcon } from '@/components/player/PlayerStateIcon';
import { CachedLyricPeek } from '@/components/player/CachedLyricPeek'; import { CachedLyricPeek } from '@/components/player/CachedLyricPeek';
import {
getNowPlayingTrackTransitionKey,
NowPlayingTrackFadeThrough,
} from '@/components/player/nowPlayingTrackTransition';
import { import {
resolveNowPlayingPanRelease, resolveNowPlayingPanRelease,
resolveNowPlayingDismissSpring, resolveNowPlayingDismissSpring,
@@ -92,6 +95,7 @@ import { useQueueStore } from '@/stores/queueStore';
import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
import { usePlayerUiStore } from '@/stores/playerUiStore'; import { usePlayerUiStore } from '@/stores/playerUiStore';
import { markNowPlayingTrackTransitionDirection } from '@/stores/nowPlayingTrackTransitionStore';
import { isPlayerOnScreen } from '@/stores/playerPresence'; import { isPlayerOnScreen } from '@/stores/playerPresence';
import { useSettingsStore, type ScopeMode } from '@/stores/settingsStore'; import { useSettingsStore, type ScopeMode } from '@/stores/settingsStore';
import { useSleepTimerStore } from '@/stores/sleepTimerStore'; import { useSleepTimerStore } from '@/stores/sleepTimerStore';
@@ -143,7 +147,6 @@ export function NowPlayingOverlay() {
const returnToTabs = useReturnToTabs(); const returnToTabs = useReturnToTabs();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions(); const { width: windowWidth, height: windowHeight } = useWindowDimensions();
const reduceMotion = useReducedMotion();
const phase = usePlayerUiStore((s) => s.phase); const phase = usePlayerUiStore((s) => s.phase);
const openRequest = usePlayerUiStore((s) => s.openRequest); const openRequest = usePlayerUiStore((s) => s.openRequest);
const exitAnimated = usePlayerUiStore((s) => s.exitAnimated); const exitAnimated = usePlayerUiStore((s) => s.exitAnimated);
@@ -219,7 +222,10 @@ export function NowPlayingOverlay() {
!foreground !foreground
); );
const activeTrack = desktopSnapshot?.currentTrack ?? null; 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 isPlaying = activePresentation.playbackState === 'playing';
const isLoading = activePresentation.playbackState === 'loading'; const isLoading = activePresentation.playbackState === 'loading';
// Wash off a low-res thumbnail (like the album/artist detail headers do) so the // 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 screenHeight = useSharedValue(windowHeight);
const companionTouchStartX = useSharedValue(companionStartX); const companionTouchStartX = useSharedValue(companionStartX);
const menuProgress = useSharedValue(0); const menuProgress = useSharedValue(0);
const trackProgress = useSharedValue(1);
// ∿ engagement, shared by both scope styles: rail = art shrink + strip fade, // ∿ engagement, shared by both scope styles: rail = art shrink + strip fade,
// rack = art face crossfading to the instrument rack. The presence gates keep // rack = art face crossfading to the instrument rack. The presence gates keep
// both faces for the 220 ms transition, then release the invisible surface. // both faces for the 220 ms transition, then release the invisible surface.
@@ -486,12 +491,6 @@ export function NowPlayingOverlay() {
companionTouchStartX.value = companionStartX; companionTouchStartX.value = companionStartX;
}, [companionStartX, companionTouchStartX]); }, [companionStartX, companionTouchStartX]);
useEffect(() => {
if (!transitionTrackKey) return;
trackProgress.value = 0;
trackProgress.value = withTiming(1, { ...motion.snap, duration: 200 });
}, [trackProgress, transitionTrackKey]);
useEffect(() => { useEffect(() => {
stageProgress.value = withTiming(effectiveScopeStageVisible ? 1 : 0, motion.snap); stageProgress.value = withTiming(effectiveScopeStageVisible ? 1 : 0, motion.snap);
}, [effectiveScopeStageVisible, stageProgress]); }, [effectiveScopeStageVisible, stageProgress]);
@@ -770,16 +769,6 @@ export function NowPlayingOverlay() {
transform: [{ translateY: MENU_ENTER_OFFSET_Y * (1 - menuProgress.value) }], 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 // 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 // its scope-off size and driven to the scope-on size/position by
// stageProgress, so the ∿ toggle animates as one move instead of snapping // stageProgress, so the ∿ toggle animates as one move instead of snapping
@@ -795,13 +784,10 @@ export function NowPlayingOverlay() {
? layout.artSizeScopeOn / 2 - layout.mediaStackHeight / 2 ? layout.artSizeScopeOn / 2 - layout.mediaStackHeight / 2
: 0; : 0;
const artStageTransitionStyle = useAnimatedStyle(() => ({ const artStageTransitionStyle = useAnimatedStyle(() => ({
opacity: 0.75 + trackProgress.value * 0.25,
transform: [ transform: [
{ translateY: stageProgress.value * railArtShift }, { translateY: stageProgress.value * railArtShift },
{ {
scale: scale: 1 + stageProgress.value * (railArtScale - 1),
(0.985 + trackProgress.value * 0.015) *
(1 + stageProgress.value * (railArtScale - 1)),
}, },
], ],
})); }));
@@ -921,27 +907,27 @@ export function NowPlayingOverlay() {
}, },
]} ]}
> >
<Animated.View <NowPlayingTrackFadeThrough
transitionKey={transitionTrackKey}
style={[ style={[
styles.artCard, styles.artCard,
artworkTransitionStyle,
{ {
width: layout.artSize, width: layout.artSize,
height: layout.artSize, height: layout.artSize,
}, },
]} ]}
contentStyle={styles.trackVisualLayer}
> >
{activePresentation.artworkUri ? ( {activePresentation.artworkUri ? (
<Image <Image
source={{ uri: activePresentation.artworkUri }} source={{ uri: activePresentation.artworkUri }}
style={styles.artImage} style={styles.artImage}
contentFit="cover" contentFit="cover"
transition={reduceMotion ? null : 200}
/> />
) : ( ) : (
<AstraLogo size={Math.round(layout.artSize * 0.4)} /> <AstraLogo size={Math.round(layout.artSize * 0.4)} />
)} )}
</Animated.View> </NowPlayingTrackFadeThrough>
</View> </View>
<View <View
@@ -953,11 +939,12 @@ export function NowPlayingOverlay() {
]} ]}
> >
<View style={styles.primaryControls}> <View style={styles.primaryControls}>
<Animated.View <NowPlayingTrackFadeThrough
style={[ transitionKey={transitionTrackKey}
style={styles.trackInfoFrame}
contentStyle={[
styles.trackInfo, styles.trackInfo,
{ marginBottom: layout.trackInfoGap }, { marginBottom: layout.trackInfoGap },
metadataTransitionStyle,
]} ]}
> >
<View style={styles.trackTextStack}> <View style={styles.trackTextStack}>
@@ -1000,7 +987,7 @@ export function NowPlayingOverlay() {
} }
/> />
</TactilePressable> </TactilePressable>
</Animated.View> </NowPlayingTrackFadeThrough>
<SeekBar <SeekBar
currentTime={activePresentation.currentTime} currentTime={activePresentation.currentTime}
@@ -1035,7 +1022,10 @@ export function NowPlayingOverlay() {
/> />
</TactilePressable> </TactilePressable>
<TactilePressable <TactilePressable
onPress={() => void sendDesktopControl('previous')} onPress={() => {
markNowPlayingTrackTransitionDirection('previous', 'desktop');
void sendDesktopControl('previous');
}}
haptic="action" haptic="action"
hitSlop={12} hitSlop={12}
style={styles.transportMainBtn} android_ripple={ripple.icon(26)} style={styles.transportMainBtn} android_ripple={ripple.icon(26)}
@@ -1062,7 +1052,10 @@ export function NowPlayingOverlay() {
/> />
</TactilePressable> </TactilePressable>
<TactilePressable <TactilePressable
onPress={() => void sendDesktopControl('next')} onPress={() => {
markNowPlayingTrackTransitionDirection('next', 'desktop');
void sendDesktopControl('next');
}}
haptic="action" haptic="action"
hitSlop={12} hitSlop={12}
style={styles.transportMainBtn} android_ripple={ripple.icon(26)} style={styles.transportMainBtn} android_ripple={ripple.icon(26)}
@@ -1227,18 +1220,23 @@ export function NowPlayingOverlay() {
]} ]}
> >
{renderArtworkFace ? ( {renderArtworkFace ? (
track.artworkData ? ( <NowPlayingTrackFadeThrough
<Image transitionKey={transitionTrackKey}
source={{ uri: track.artworkData }} style={StyleSheet.absoluteFill}
style={styles.artImage} contentStyle={styles.trackVisualLayer}
contentFit="cover" >
cachePolicy="disk" {track.artworkData ? (
allowDownscaling <Image
transition={reduceMotion ? null : 200} source={{ uri: track.artworkData }}
/> style={styles.artImage}
) : ( contentFit="cover"
<AstraLogo size={Math.round(artBoxSize * 0.4)} /> cachePolicy="disk"
) allowDownscaling
/>
) : (
<AstraLogo size={Math.round(artBoxSize * 0.4)} />
)}
</NowPlayingTrackFadeThrough>
) : null} ) : null}
</Animated.View> </Animated.View>
{!railStyle && renderScopeSurfaces && ( {!railStyle && renderScopeSurfaces && (
@@ -1249,6 +1247,7 @@ export function NowPlayingOverlay() {
<ScopeRack <ScopeRack
size={artBoxSize} size={artBoxSize}
stripWidth={layout.scopeWidth} stripWidth={layout.scopeWidth}
transitionKey={transitionTrackKey}
artworkUri={backdropArtworkUri} artworkUri={backdropArtworkUri}
spectrumSmoothing={NOW_PLAYING_SPECTRUM_SMOOTHING} spectrumSmoothing={NOW_PLAYING_SPECTRUM_SMOOTHING}
paused={!surfacesLive || queueOpen || !effectiveScopeStageVisible} paused={!surfacesLive || queueOpen || !effectiveScopeStageVisible}
@@ -1323,11 +1322,12 @@ export function NowPlayingOverlay() {
onOpenLyrics={showLyrics} onOpenLyrics={showLyrics}
/> />
) : null} ) : null}
<Animated.View <NowPlayingTrackFadeThrough
style={[ transitionKey={transitionTrackKey}
style={styles.trackInfoFrame}
contentStyle={[
styles.trackInfo, styles.trackInfo,
{ marginBottom: layout.trackInfoGap }, { marginBottom: layout.trackInfoGap },
metadataTransitionStyle,
]} ]}
> >
<View style={styles.trackTextStack}> <View style={styles.trackTextStack}>
@@ -1390,7 +1390,7 @@ export function NowPlayingOverlay() {
} }
/> />
</TactilePressable> </TactilePressable>
</Animated.View> </NowPlayingTrackFadeThrough>
<WaveformSeekBar <WaveformSeekBar
active={surfacesLive} active={surfacesLive}
@@ -1924,6 +1924,18 @@ const useStyles = createThemedStyles((colors) => ({
width: '100%', width: '100%',
height: '100%', height: '100%',
}, },
trackVisualLayer: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
alignItems: 'center',
justifyContent: 'center',
},
trackInfoFrame: {
alignSelf: 'stretch',
},
trackInfo: { trackInfo: {
alignSelf: 'stretch', alignSelf: 'stretch',
flexDirection: 'row', flexDirection: 'row',
+64 -33
View File
@@ -12,6 +12,7 @@ import {
import { OscilloscopeWave } from '@/components/OscilloscopeWave'; import { OscilloscopeWave } from '@/components/OscilloscopeWave';
import { SpectrumCurve } from '@/components/SpectrumCurve'; import { SpectrumCurve } from '@/components/SpectrumCurve';
import { getScopeHeight } from '@/components/player/nowPlayingLayout'; import { getScopeHeight } from '@/components/player/nowPlayingLayout';
import { NowPlayingTrackFadeThrough } from '@/components/player/nowPlayingTrackTransition';
import { useScopeActive } from '@/scope/scopeStore'; import { useScopeActive } from '@/scope/scopeStore';
import { spacing } from '@/theme'; import { spacing } from '@/theme';
import { createThemedStyles } from '@/theme/themed'; import { createThemedStyles } from '@/theme/themed';
@@ -35,12 +36,62 @@ interface ScopeRackProps {
size: number; size: number;
/** Strip span (the rail's width): wider than the card, overflowing it. */ /** Strip span (the rail's width): wider than the card, overflowing it. */
stripWidth: number; stripWidth: number;
/** Playback target + track identity; avoids keying presence by a potentially large data URI. */
transitionKey: string;
artworkUri: string | null; artworkUri: string | null;
spectrumSmoothing?: number; spectrumSmoothing?: number;
/** Freeze the scopes without unmounting (overlay closed / queue open). */ /** Freeze the scopes without unmounting (overlay closed / queue open). */
paused?: boolean; 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 * Rack-style scope face: both instruments stacked at their natural wide aspect
* (oscilloscope above, spectrum grounded below). The blurred, dimmed artwork * (oscilloscope above, spectrum grounded below). The blurred, dimmed artwork
@@ -50,12 +101,12 @@ interface ScopeRackProps {
export function ScopeRack({ export function ScopeRack({
size, size,
stripWidth, stripWidth,
transitionKey,
artworkUri, artworkUri,
spectrumSmoothing, spectrumSmoothing,
paused = false, paused = false,
}: ScopeRackProps) { }: ScopeRackProps) {
const styles = useStyles(); const styles = useStyles();
const artwork = useImage(artworkUri);
const active = useScopeActive() && !paused; const active = useScopeActive() && !paused;
const width = Math.max(0, stripWidth); const width = Math.max(0, stripWidth);
const stripHeight = getScopeHeight(width); const stripHeight = getScopeHeight(width);
@@ -76,38 +127,18 @@ export function ScopeRack({
}, },
]} ]}
> >
{artwork ? ( <NowPlayingTrackFadeThrough
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}> transitionKey={transitionKey}
<Mask style={StyleSheet.absoluteFill}
mode="alpha" contentStyle={StyleSheet.absoluteFill}
mask={ >
<RoundedRect <ScopeRackBackdrop
x={maskInset} artworkUri={artworkUri}
y={maskInset} backdropSize={backdropSize}
width={backdropSize - maskInset * 2} maskInset={maskInset}
height={backdropSize - maskInset * 2} maskBlur={maskBlur}
r={maskInset * 0.6} />
color="white" </NowPlayingTrackFadeThrough>
>
<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}
</View> </View>
<View style={[styles.strips, { width, left: (size - width) / 2 }]}> <View style={[styles.strips, { width, left: (size - width) / 2 }]}>
<OscilloscopeWave <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>
);
}
@@ -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<NowPlayingTrackTransitionStore>((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';
}