mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-19 20:20:18 +02:00
track change qol
This commit is contained in:
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user