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