From 34e9cde5bcce6e8a122a91933f8ec90f9a49fd74 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:53:40 -0400 Subject: [PATCH] update now playing screen --- package.json | 1 + src/app/(tabs)/library/artist/[name].tsx | 12 +- .../(tabs)/library/artist/[name]/albums.tsx | 10 +- .../library/artist/[name]/appearances.tsx | 10 +- .../(tabs)/library/artist/[name]/songs.tsx | 10 +- src/components/NowPlayingWash.tsx | 4 +- src/components/SeekBar.tsx | 2 + src/components/WaveformSeekBar.tsx | 2 + src/components/lyrics/LyricsBand.tsx | 4 +- src/components/lyrics/LyricsView.tsx | 24 +- src/components/player/CachedLyricPeek.tsx | 139 +++ .../player/NowPlayingCompanionPane.tsx | 99 ++ src/components/player/NowPlayingOverlay.tsx | 910 ++++++++++-------- src/components/player/PlayerStateIcon.tsx | 45 + src/components/player/TactilePressable.tsx | 86 ++ .../player/nowPlayingLayout.test.mts | 102 ++ src/components/player/nowPlayingLayout.ts | 259 +++++ .../player/nowPlayingPreferences.test.mts | 26 + .../player/nowPlayingPreferences.ts | 5 + src/components/queue/QueueTray.tsx | 74 +- src/components/queue/RemoteQueueSheet.tsx | 41 +- src/lyrics/lyrics.ts | Bin 8534 -> 9186 bytes src/lyrics/presentation.test.mts | 10 + src/lyrics/presentation.ts | 14 + src/stores/settingsStore.ts | 26 +- src/theme/motion.ts | 14 +- 26 files changed, 1482 insertions(+), 447 deletions(-) create mode 100644 src/components/player/CachedLyricPeek.tsx create mode 100644 src/components/player/NowPlayingCompanionPane.tsx create mode 100644 src/components/player/PlayerStateIcon.tsx create mode 100644 src/components/player/TactilePressable.tsx create mode 100644 src/components/player/nowPlayingLayout.test.mts create mode 100644 src/components/player/nowPlayingLayout.ts create mode 100644 src/components/player/nowPlayingPreferences.test.mts create mode 100644 src/components/player/nowPlayingPreferences.ts diff --git a/package.json b/package.json index a6e4a9c..e42fcf2 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts", "test:eq-math": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eq.test.mts", "test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts", + "test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts", "typecheck": "tsc --noEmit", "postinstall": "patch-package" }, diff --git a/src/app/(tabs)/library/artist/[name].tsx b/src/app/(tabs)/library/artist/[name].tsx index 6163656..4a5c318 100644 --- a/src/app/(tabs)/library/artist/[name].tsx +++ b/src/app/(tabs)/library/artist/[name].tsx @@ -65,19 +65,23 @@ export default function ArtistScreen() { const styles = useStyles(); const colors = useColors(); const router = useRouter(); - const { name = 'Artist' } = useLocalSearchParams<{ name: string }>(); + const { name = 'Artist', credit } = useLocalSearchParams<{ + name: string; + credit?: string; + }>(); const handleBack = useLibraryDetailBack(); const insets = useSafeAreaInsets(); const { scrollY, heroFaded, collapsed, onScroll, scrollEventThrottle, expandedHeight, onHeroBlockLayout } = useDetailCollapse(); const allTracks = useLibraryStore((s) => s.tracks); const groupingMode = useSettingsStore((s) => s.artistGroupingMode); + const detailGroupingMode = credit === '1' ? 'astra' : groupingMode; const currentPath = usePlayerStore((s) => s.currentTrack?.path); const [actionTrack, setActionTrack] = useState(null); const detail = useMemo( - () => buildArtistDetail(allTracks, name, groupingMode), - [allTracks, name, groupingMode] + () => buildArtistDetail(allTracks, name, detailGroupingMode), + [allTracks, name, detailGroupingMode] ); const listItems = useMemo(() => buildListItems(detail), [detail]); @@ -96,7 +100,7 @@ export default function ArtistScreen() { const openSection = (target: ArtistSectionTarget) => { router.push({ pathname: `/library/artist/[name]/${target}`, - params: { name }, + params: { name, ...(credit === '1' ? { credit: '1' } : {}) }, }); }; diff --git a/src/app/(tabs)/library/artist/[name]/albums.tsx b/src/app/(tabs)/library/artist/[name]/albums.tsx index 86e0444..2f53942 100644 --- a/src/app/(tabs)/library/artist/[name]/albums.tsx +++ b/src/app/(tabs)/library/artist/[name]/albums.tsx @@ -21,13 +21,17 @@ export default function ArtistAlbumsScreen() { const colors = useColors(); const ripple = useRipple(); const router = useRouter(); - const { name = 'Artist' } = useLocalSearchParams<{ name: string }>(); + const { name = 'Artist', credit } = useLocalSearchParams<{ + name: string; + credit?: string; + }>(); const allTracks = useLibraryStore((s) => s.tracks); const groupingMode = useSettingsStore((s) => s.artistGroupingMode); + const detailGroupingMode = credit === '1' ? 'astra' : groupingMode; const detail = useMemo( - () => buildArtistDetail(allTracks, name, groupingMode), - [allTracks, name, groupingMode] + () => buildArtistDetail(allTracks, name, detailGroupingMode), + [allTracks, name, detailGroupingMode] ); return ( diff --git a/src/app/(tabs)/library/artist/[name]/appearances.tsx b/src/app/(tabs)/library/artist/[name]/appearances.tsx index ba2f702..72a09f6 100644 --- a/src/app/(tabs)/library/artist/[name]/appearances.tsx +++ b/src/app/(tabs)/library/artist/[name]/appearances.tsx @@ -26,15 +26,19 @@ export default function ArtistAppearancesScreen() { const colors = useColors(); const ripple = useRipple(); const router = useRouter(); - const { name = 'Artist' } = useLocalSearchParams<{ name: string }>(); + const { name = 'Artist', credit } = useLocalSearchParams<{ + name: string; + credit?: string; + }>(); const allTracks = useLibraryStore((s) => s.tracks); const groupingMode = useSettingsStore((s) => s.artistGroupingMode); + const detailGroupingMode = credit === '1' ? 'astra' : groupingMode; const currentPath = usePlayerStore((s) => s.currentTrack?.path); const [actionTrack, setActionTrack] = useState(null); const detail = useMemo( - () => buildArtistDetail(allTracks, name, groupingMode), - [allTracks, name, groupingMode] + () => buildArtistDetail(allTracks, name, detailGroupingMode), + [allTracks, name, detailGroupingMode] ); const tracks = detail.appearanceTracks; diff --git a/src/app/(tabs)/library/artist/[name]/songs.tsx b/src/app/(tabs)/library/artist/[name]/songs.tsx index 7d987b6..1f5d3d4 100644 --- a/src/app/(tabs)/library/artist/[name]/songs.tsx +++ b/src/app/(tabs)/library/artist/[name]/songs.tsx @@ -26,15 +26,19 @@ export default function ArtistSongsScreen() { const colors = useColors(); const ripple = useRipple(); const router = useRouter(); - const { name = 'Artist' } = useLocalSearchParams<{ name: string }>(); + const { name = 'Artist', credit } = useLocalSearchParams<{ + name: string; + credit?: string; + }>(); const allTracks = useLibraryStore((s) => s.tracks); const groupingMode = useSettingsStore((s) => s.artistGroupingMode); + const detailGroupingMode = credit === '1' ? 'astra' : groupingMode; const currentPath = usePlayerStore((s) => s.currentTrack?.path); const [actionTrack, setActionTrack] = useState(null); const detail = useMemo( - () => buildArtistDetail(allTracks, name, groupingMode), - [allTracks, name, groupingMode] + () => buildArtistDetail(allTracks, name, detailGroupingMode), + [allTracks, name, detailGroupingMode] ); const tracks = detail.songTracks; diff --git a/src/components/NowPlayingWash.tsx b/src/components/NowPlayingWash.tsx index 06f3699..8c4f205 100644 --- a/src/components/NowPlayingWash.tsx +++ b/src/components/NowPlayingWash.tsx @@ -7,6 +7,7 @@ import { vec } from '@shopify/react-native-skia'; import { useColors } from '@/theme/themed'; +import { useReducedMotion } from 'react-native-reanimated'; // A faint wash of the current cover art bleeding from the very top of the // now-playing sheet, fading back to the background before it reaches the @@ -31,6 +32,7 @@ export function NowPlayingWash({ offset: { top: number; left: number; right: number }; }) { const colors = useColors(); + const reduceMotion = useReducedMotion(); const { width, height } = useWindowDimensions(); if (!artworkUri) return null; const bandH = Math.round(height * REACH); @@ -48,7 +50,7 @@ export function NowPlayingWash({ style={[StyleSheet.absoluteFill, { opacity: ART_OPACITY }]} contentFit="cover" blurRadius={BLUR_RADIUS} - transition={null} + transition={reduceMotion ? null : 200} /> diff --git a/src/components/SeekBar.tsx b/src/components/SeekBar.tsx index 2c8c89c..ec79d2f 100644 --- a/src/components/SeekBar.tsx +++ b/src/components/SeekBar.tsx @@ -11,6 +11,7 @@ import { } from '@/theme'; import { createThemedStyles } from '@/theme/themed'; import { formatDuration } from '@/lib/format'; +import { tickHaptic } from '@/lib/haptics'; const THUMB_SIZE = 12; @@ -58,6 +59,7 @@ export function SeekBar({ currentTime, duration, onSeek, trackKey }: SeekBarProp const fraction = clamp(event.nativeEvent.locationX / Math.max(1, widthRef.current)); grantRef.current = { fraction, pageX: event.nativeEvent.pageX }; setScrub(fraction); + tickHaptic(); }; const handleMove = (event: GestureResponderEvent) => { diff --git a/src/components/WaveformSeekBar.tsx b/src/components/WaveformSeekBar.tsx index 824c7ff..7339659 100644 --- a/src/components/WaveformSeekBar.tsx +++ b/src/components/WaveformSeekBar.tsx @@ -23,6 +23,7 @@ import { formatDuration } from '@/lib/format'; import { downsampleWaveform, getWaveform } from '@/scope/waveform'; import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime'; import { usePlayerStore } from '@/stores/playerStore'; +import { tickHaptic } from '@/lib/haptics'; const CANVAS_HEIGHT = 58; const BAR_WIDTH = 3; @@ -124,6 +125,7 @@ export function WaveformSeekBar({ const fraction = clamp(event.nativeEvent.locationX / Math.max(1, widthRef.current)); grantRef.current = { fraction, pageX: event.nativeEvent.pageX }; setScrub(fraction); + tickHaptic(); }; const handleMove = (event: GestureResponderEvent) => { diff --git a/src/components/lyrics/LyricsBand.tsx b/src/components/lyrics/LyricsBand.tsx index e23197a..7a23395 100644 --- a/src/components/lyrics/LyricsBand.tsx +++ b/src/components/lyrics/LyricsBand.tsx @@ -18,6 +18,7 @@ import { getSyncedLyricsDisplayLines, getSyncedLyricsGapProgress, hasRenderableSyncedLines, + LYRICS_DISPLAY_LEAD_MS, resolveSyncedLyricsTiming, } from '@/lyrics/presentation'; import { LyricsLine, type LyricsLineTier } from './LyricsLine'; @@ -29,7 +30,6 @@ const H_PADDING = 22; // The displayed active line lags the audio by a fixed pipeline delay (RNTP // position reporting + poll/smoothing) that the desktop doesn't have, so advance // the lyrics clock by this much. Tune to taste — bigger = earlier highlight. -const LYRICS_LEAD_MS = 350; interface LyricsBandProps { track: Track; @@ -61,7 +61,7 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }: const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying); // Lead the audio to counter display-pipeline lag (see LYRICS_LEAD_MS). - const lyricsTime = smoothTime + LYRICS_LEAD_MS / 1000; + const lyricsTime = smoothTime + LYRICS_DISPLAY_LEAD_MS / 1000; const result = entry?.result ?? null; const isLoading = entry?.loading ?? !entry; diff --git a/src/components/lyrics/LyricsView.tsx b/src/components/lyrics/LyricsView.tsx index 05cf4bb..1a76654 100644 --- a/src/components/lyrics/LyricsView.tsx +++ b/src/components/lyrics/LyricsView.tsx @@ -12,6 +12,7 @@ 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 { LyricsBand } from './LyricsBand'; import { spacing, radius } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; @@ -93,8 +94,10 @@ export function LyricsView({ - - + - - + - + - - + + - - + + - + diff --git a/src/components/player/CachedLyricPeek.tsx b/src/components/player/CachedLyricPeek.tsx new file mode 100644 index 0000000..3a6a3b3 --- /dev/null +++ b/src/components/player/CachedLyricPeek.tsx @@ -0,0 +1,139 @@ +import { useEffect, useMemo, useState } from 'react'; +import { View } from 'react-native'; +import Animated, { Keyframe, ReduceMotion } from 'react-native-reanimated'; +import { TactilePressable } from '@/components/player/TactilePressable'; +import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime'; +import { peekCachedLyricsForTrack } from '@/lyrics/lyrics'; +import { + getActiveSyncedLyricsLine, + LYRICS_DISPLAY_LEAD_MS, +} from '@/lyrics/presentation'; +import { fonts, spacing } from '@/theme'; +import { createThemedStyles } from '@/theme/themed'; +import { useLyricsStore } from '@/stores/lyricsStore'; +import { usePlayerStore } from '@/stores/playerStore'; +import type { LyricsLookupResult } from '@/lyrics/types'; +import type { Track } from '@/types/audio'; + +const ENTERING = new Keyframe({ + 0: { opacity: 0, transform: [{ translateY: 6 }] }, + 100: { opacity: 1, transform: [{ translateY: 0 }] }, +}) + .duration(190) + .reduceMotion(ReduceMotion.System); + +const EXITING = new Keyframe({ + 0: { opacity: 1, transform: [{ translateY: 0 }] }, + 100: { opacity: 0, transform: [{ translateY: -6 }] }, +}) + .duration(160) + .reduceMotion(ReduceMotion.System); + +interface CachedLyricPeekProps { + track: Track; + active: boolean; + hidden?: boolean; + onOpenLyrics: () => void; +} + +/** + * One-line synced lyric display. It consumes an existing in-memory result or a + * cache-only SQLite read; it never initiates media scanning or provider work. + */ +export function CachedLyricPeek({ + track, + active, + hidden = false, + onOpenLyrics, +}: CachedLyricPeekProps) { + const styles = useStyles(); + const memoryResult = useLyricsStore((s) => s.byPath[track.path]?.result ?? null); + const [cached, setCached] = useState<{ + path: string; + result: LyricsLookupResult | null; + } | null>(null); + const currentTime = usePlayerStore((s) => (active ? s.currentTime : 0)); + const duration = usePlayerStore((s) => s.duration); + const isPlaying = usePlayerStore( + (s) => active && s.playbackState === 'playing' + ); + const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying); + + useEffect(() => { + if (!active || memoryResult) return; + let cancelled = false; + void peekCachedLyricsForTrack(track) + .then((result) => { + if (!cancelled) setCached({ path: track.path, result }); + }) + .catch(() => { + if (!cancelled) setCached({ path: track.path, result: null }); + }); + return () => { + cancelled = true; + }; + }, [active, memoryResult, track]); + + const storedResult = cached?.path === track.path ? cached.result : null; + const result = memoryResult?.status === 'hit' ? memoryResult : storedResult; + const activeLine = useMemo(() => { + if (hidden || result?.status !== 'hit') return null; + return getActiveSyncedLyricsLine( + result.lyrics.syncedLines, + smoothTime + LYRICS_DISPLAY_LEAD_MS / 1000, + { durationSeconds: duration } + ); + }, [duration, hidden, result, smoothTime]); + const text = activeLine?.text.trim() || null; + const lineKey = text + ? `${track.path}:${activeLine?.timestampMs ?? -1}:${text}` + : null; + + return ( + + + {text && lineKey ? ( + + {text} + + ) : null} + + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + wrap: { + height: 28, + marginBottom: spacing.sm, + overflow: 'hidden', + }, + pressable: { + flex: 1, + justifyContent: 'center', + overflow: 'hidden', + }, + line: { + position: 'absolute', + left: 0, + right: 0, + color: colors.textSecondary, + fontFamily: fonts.sans.medium, + fontSize: 16, + lineHeight: 22, + }, +})); diff --git a/src/components/player/NowPlayingCompanionPane.tsx b/src/components/player/NowPlayingCompanionPane.tsx new file mode 100644 index 0000000..9258105 --- /dev/null +++ b/src/components/player/NowPlayingCompanionPane.tsx @@ -0,0 +1,99 @@ +import { View } from 'react-native'; +import { SegmentedControl } from '@/components/SegmentedControl'; +import { LyricsBand } from '@/components/lyrics/LyricsBand'; +import { QueueTray } from '@/components/queue/QueueTray'; +import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet'; +import { seekTo } from '@/audio/playbackController'; +import { tickHaptic } from '@/lib/haptics'; +import { spacing } from '@/theme'; +import { createThemedStyles } from '@/theme/themed'; +import { usePlayerStore } from '@/stores/playerStore'; +import { useSettingsStore } from '@/stores/settingsStore'; +import type { NowPlayingCompanion } from './nowPlayingPreferences'; +import type { Track } from '@/types/audio'; + +const COMPANION_SEGMENTS = [ + { key: 'queue', label: 'Queue' }, + { key: 'lyrics', label: 'Lyrics' }, +]; + +const noop = () => {}; + +interface NowPlayingCompanionPaneProps { + active: boolean; + desktopTarget: boolean; + track: Track | null; +} + +/** Roomy-tablet companion rail. Phone sheets/takeovers remain separate. */ +export function NowPlayingCompanionPane({ + active, + desktopTarget, + track, +}: NowPlayingCompanionPaneProps) { + const styles = useStyles(); + const companion = useSettingsStore((s) => s.nowPlayingCompanion); + const setCompanion = useSettingsStore((s) => s.setNowPlayingCompanion); + const currentTime = usePlayerStore((s) => (active && !desktopTarget ? s.currentTime : 0)); + const duration = usePlayerStore((s) => (desktopTarget ? 0 : s.duration)); + const isPlaying = usePlayerStore( + (s) => active && !desktopTarget && s.playbackState === 'playing' + ); + + const selectCompanion = (next: string) => { + const value: NowPlayingCompanion = next === 'lyrics' ? 'lyrics' : 'queue'; + if (value === companion) return; + tickHaptic(); + void setCompanion(value); + }; + + return ( + + {desktopTarget ? ( + + ) : ( + <> + + + + + {companion === 'queue' ? ( + + ) : track ? ( + void seekTo(seconds)} + /> + ) : null} + + + )} + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + root: { + flex: 1, + minWidth: 0, + borderLeftColor: colors.glassBorder, + borderLeftWidth: 1, + paddingLeft: spacing.lg, + overflow: 'hidden', + }, + switcher: { + paddingHorizontal: spacing.sm, + paddingBottom: spacing.lg, + }, + content: { + flex: 1, + minHeight: 0, + }, +})); diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx index 30a25d3..2479f71 100644 --- a/src/components/player/NowPlayingOverlay.tsx +++ b/src/components/player/NowPlayingOverlay.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'; import { BackHandler, View, @@ -14,6 +14,7 @@ import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, + useReducedMotion, useSharedValue, withSpring, withTiming @@ -32,15 +33,30 @@ import { TrackActionsSheet } from '@/components/library/TrackActionsSheet'; import { PlaybackTargetPicker } from '@/components/PlaybackTargetPicker'; import { QueueTray } from '@/components/queue/QueueTray'; import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet'; +import { TactilePressable } from '@/components/player/TactilePressable'; +import { NowPlayingCompanionPane } from '@/components/player/NowPlayingCompanionPane'; +import { PlayerStateIcon } from '@/components/player/PlayerStateIcon'; +import { CachedLyricPeek } from '@/components/player/CachedLyricPeek'; import { radius, spacing, } from '@/theme'; import { createThemedStyles, useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; -import { WIDE_MIN_WIDTH, isWideWindow } from '@/theme/adaptive'; import { motion } from '@/theme/motion'; -import { resolveNavigationArtist } from '@/library/artistGrouping'; +import { + getNowPlayingLayout, + getTabletCompanionLayout, + NOW_PLAYING_CONTENT_BOTTOM_PADDING, + NOW_PLAYING_CONTENT_TOP_PADDING, + NOW_PLAYING_HEADER_HEIGHT, + NOW_PLAYING_PLAY_BUTTON_SIZE, + NOW_PLAYING_SUB_BUTTON_SIZE, + NOW_PLAYING_WAVEFORM_TOUCH_PADDING, + NOW_PLAYING_WIDE_PANE_GAP, +} from '@/components/player/nowPlayingLayout'; +import { resolveNavigationArtist, splitCollaborators } from '@/library/artistGrouping'; +import { buildArtistNameTokens } from '@/shared/library/artistCredits'; import { artworkThumbFromSource } from '@/library/artwork'; import { useLibraryStore } from '@/stores/libraryStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; @@ -69,68 +85,19 @@ import { const DISMISS_DISTANCE = 140; const DISMISS_VELOCITY = 1000; -const MAX_CONTENT_WIDTH = 408; -const CONTENT_SIDE_PADDING = spacing.lg; -const NARROW_CONTENT_SIDE_PADDING = spacing.md; -const MEDIA_AREA_MIN = 220; -// Tablet-portrait tier: tall windows >= WIDE_MIN_WIDTH keep the single column but grow it. -const TABLET_MAX_CONTENT_WIDTH = 520; -const TABLET_ART_SIZE_MAX = 440; -// Wide (landscape/desktop) tier: two panes, art left, controls right. -const WIDE_MAX_CONTENT_WIDTH = 960; -const WIDE_PANE_GAP = spacing.xxl; -const WIDE_RIGHT_PANE_MIN = 300; -const WIDE_RIGHT_PANE_MAX = MAX_CONTENT_WIDTH; -const WIDE_ART_SIZE_MAX = 400; -const WIDE_ART_SIZE_MIN = 160; -const WIDE_COMPACT_HEIGHT = 480; -const VISUALIZER_WIDTH_MAX = 448; -const VISUALIZER_SIDE_PADDING = spacing.md; -const VISUALIZER_TOP_GAP = spacing.lg; -const VISUALIZER_BOTTOM_GAP = spacing.sm; -const VISUALIZER_HEIGHT_MIN = 84; -const VISUALIZER_HEIGHT_MAX = 108; -const VISUALIZER_HEIGHT_RATIO = 0.28; -const HEADER_HEIGHT = 32; -const CONTENT_TOP_PADDING = spacing.sm; -const CONTENT_BOTTOM_PADDING = spacing.lg; -const MEDIA_TOP_MARGIN = spacing.lg; -const MEDIA_BOTTOM_GAP = spacing.xl; -const TRACK_INFO_ESTIMATE = 96; -const WAVEFORM_HEIGHT = 58; -const WAVEFORM_TOUCH_PADDING = spacing.md; -const WAVEFORM_BLOCK_ESTIMATE = WAVEFORM_HEIGHT + WAVEFORM_TOUCH_PADDING * 2 + 24; -const PLAY_BUTTON_SIZE = 68; +const HEADER_HEIGHT = NOW_PLAYING_HEADER_HEIGHT; +const CONTENT_TOP_PADDING = NOW_PLAYING_CONTENT_TOP_PADDING; +const CONTENT_BOTTOM_PADDING = NOW_PLAYING_CONTENT_BOTTOM_PADDING; +const WAVEFORM_TOUCH_PADDING = NOW_PLAYING_WAVEFORM_TOUCH_PADDING; +const PLAY_BUTTON_SIZE = NOW_PLAYING_PLAY_BUTTON_SIZE; const SKIP_ICON_SIZE = 32; const PLAY_ICON_SIZE = 34; -const TRANSPORT_TOP_MARGIN = spacing.lg; -const SUB_BUTTON_SIZE = 40; +const SUB_BUTTON_SIZE = NOW_PLAYING_SUB_BUTTON_SIZE; const SUB_ICON_SIZE = 20; -const SUB_TOP_MARGIN = spacing.lg; -const MIN_FLOATING_SPACE = spacing.sm; const MENU_ANIMATION_IN_MS = 130; const MENU_ANIMATION_OUT_MS = 100; const MENU_ENTER_OFFSET_Y = -8; -interface NowPlayingLayout { - isWide: boolean; - contentPadding: number; - contentWidth: number; - leftPaneWidth: number; - rightPaneWidth: number; - controlsGap: number; - trackInfoGap: number; - waveformHeight: number; - mediaStackHeight: number; - artSize: number; - scopeWidth: number; - scopeHeight: number; - visualizerTopGap: number; - visualizerBottomGap: number; - mediaTopMargin: number; - mediaBottomGap: number; -} - interface NowPlayingMenuItem { key: string; label: string; @@ -138,136 +105,6 @@ interface NowPlayingMenuItem { onPress: () => void; } -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -function getScopeHeight(scopeWidth: number): number { - return Math.round( - clamp(scopeWidth * VISUALIZER_HEIGHT_RATIO, VISUALIZER_HEIGHT_MIN, VISUALIZER_HEIGHT_MAX) - ); -} - -function getNowPlayingLayout( - availableWidth: number, - availableHeight: number, - showVisualizer: boolean -): NowPlayingLayout { - const isWide = isWideWindow(availableWidth, availableHeight); - - if (isWide) { - const contentPadding = CONTENT_SIDE_PADDING; - const contentWidth = Math.max( - 0, - Math.min(availableWidth - contentPadding * 2, WIDE_MAX_CONTENT_WIDTH) - ); - const rightPaneWidth = Math.round( - clamp(contentWidth * 0.46, WIDE_RIGHT_PANE_MIN, WIDE_RIGHT_PANE_MAX) - ); - const leftPaneWidth = Math.max(0, contentWidth - WIDE_PANE_GAP - rightPaneWidth); - const scopeWidth = Math.min(leftPaneWidth, VISUALIZER_WIDTH_MAX); - const scopeHeight = getScopeHeight(scopeWidth); - const visualizerTopGap = showVisualizer ? VISUALIZER_TOP_GAP : 0; - const verticalBudget = - availableHeight - CONTENT_TOP_PADDING - CONTENT_BOTTOM_PADDING - HEADER_HEIGHT - spacing.md; - const artHeightBudget = verticalBudget - (showVisualizer ? scopeHeight + visualizerTopGap : 0); - const artSize = Math.round( - clamp(Math.min(leftPaneWidth, artHeightBudget), WIDE_ART_SIZE_MIN, WIDE_ART_SIZE_MAX) - ); - const controlsGap = availableHeight < WIDE_COMPACT_HEIGHT ? spacing.sm : spacing.lg; - return { - isWide: true, - contentPadding, - contentWidth, - leftPaneWidth, - rightPaneWidth, - controlsGap, - trackInfoGap: spacing.md, - waveformHeight: WAVEFORM_HEIGHT, - mediaStackHeight: showVisualizer - ? artSize + visualizerTopGap + scopeHeight - : artSize, - artSize, - scopeWidth, - scopeHeight, - visualizerTopGap, - visualizerBottomGap: 0, - mediaTopMargin: 0, - mediaBottomGap: 0, - }; - } - - // Tall windows: single column. Tablet-width ones get a larger column and art cap. - const isTabletColumn = availableWidth >= WIDE_MIN_WIDTH; - const contentPadding = - availableWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING; - const maxContentWidth = isTabletColumn ? TABLET_MAX_CONTENT_WIDTH : MAX_CONTENT_WIDTH; - const contentWidth = Math.max(0, Math.min(availableWidth - contentPadding * 2, maxContentWidth)); - const scopeWidth = Math.max( - 0, - Math.min(availableWidth - VISUALIZER_SIDE_PADDING * 2, VISUALIZER_WIDTH_MAX) - ); - const scopeHeight = getScopeHeight(scopeWidth); - // Art may grow to the full column width when the height budget allows it. - const mediaMax = Math.min(contentWidth, isTabletColumn ? TABLET_ART_SIZE_MAX : contentWidth); - const mediaMin = Math.min(mediaMax, MEDIA_AREA_MIN); - const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN; - const defaultMediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP; - const mediaBottomGap = defaultMediaBottomGap; - const fixedHeightBase = - CONTENT_TOP_PADDING + - CONTENT_BOTTOM_PADDING + - HEADER_HEIGHT + - mediaTopMargin + - TRACK_INFO_ESTIMATE + - WAVEFORM_BLOCK_ESTIMATE + - TRANSPORT_TOP_MARGIN + - PLAY_BUTTON_SIZE + - SUB_TOP_MARGIN + - SUB_BUTTON_SIZE + - MIN_FLOATING_SPACE; - // The Math.max(96, ...) floor lets art shrink below MEDIA_AREA_MIN in squat - // windows (split-screen halves) instead of pushing the controls off-screen. - const bound = availableHeight - fixedHeightBase - mediaBottomGap; - const scopeOffArt = Math.round( - clamp(bound, Math.min(mediaMin, Math.max(96, bound)), mediaMax) - ); - // Roomy screens get a taller waveform; the rest of the spare space is - // distributed between the control rows by flex (space-between), so no - // height estimate error can pool as one gap above the controls. - const offSurplus = Math.max(0, bound - scopeOffArt); - const stretchUnit = Math.min(Math.floor(offSurplus / 5), spacing.md); - const waveformHeight = WAVEFORM_HEIGHT + stretchUnit * 2; - // The media stack keeps one locked height in both scope states — the scope - // steals its space from the art alone, so toggling it moves nothing else. - const scopeBlockHeight = VISUALIZER_TOP_GAP + scopeHeight + VISUALIZER_BOTTOM_GAP; - const mediaStackHeight = Math.max(scopeOffArt, 96 + scopeBlockHeight); - const scopeOnArt = mediaStackHeight - scopeBlockHeight; - const artSize = showVisualizer ? scopeOnArt : scopeOffArt; - - const visualizerTopGap = showVisualizer ? VISUALIZER_TOP_GAP : 0; - const visualizerBottomGap = showVisualizer ? VISUALIZER_BOTTOM_GAP : 0; - - return { - isWide: false, - contentPadding, - contentWidth, - leftPaneWidth: contentWidth, - rightPaneWidth: contentWidth, - controlsGap: TRANSPORT_TOP_MARGIN, - trackInfoGap: spacing.md, - waveformHeight, - mediaStackHeight, - artSize, - scopeWidth, - scopeHeight, - visualizerTopGap, - visualizerBottomGap, - mediaTopMargin, - mediaBottomGap, - }; -} - export function NowPlayingOverlay() { const styles = useStyles(); const colors = useColors(); @@ -275,6 +112,7 @@ export function NowPlayingOverlay() { const router = useRouter(); const insets = useSafeAreaInsets(); const { width: windowWidth, height: windowHeight } = useWindowDimensions(); + const reduceMotion = useReducedMotion(); const playerOpen = usePlayerUiStore((s) => s.playerOpen); const [queueOpen, setQueueOpen] = useState(false); // Stable identity: QueueTray is memo'd, so a fresh arrow here would defeat it. @@ -288,6 +126,8 @@ export function NowPlayingOverlay() { const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible); const lyricsVisible = useSettingsStore((s) => s.lyricsVisible); const setLyricsVisible = useSettingsStore((s) => s.setLyricsVisible); + const nowPlayingCompanion = useSettingsStore((s) => s.nowPlayingCompanion); + const setNowPlayingCompanion = useSettingsStore((s) => s.setNowPlayingCompanion); const artistGroupingMode = useSettingsStore((s) => s.artistGroupingMode); const libraryTracks = useLibraryStore((s) => s.tracks); const track = usePlayerStore((s) => s.currentTrack); @@ -319,11 +159,9 @@ export function NowPlayingOverlay() { }); const isDesktopTarget = activePresentation.target === 'desktop'; const activeTrack = desktopSnapshot?.currentTrack ?? null; + const transitionTrackKey = isDesktopTarget ? activeTrack?.id ?? '' : track?.id ?? ''; const isPlaying = activePresentation.playbackState === 'playing'; const isLoading = activePresentation.playbackState === 'loading'; - // Lyrics mode takes over the whole phone-playback body (its own header + minimal - // controls); only for local playback, never the desktop-remote target. - const lyricsMode = !isDesktopTarget && !!track && lyricsVisible; // Wash off a low-res thumbnail (like the album/artist detail headers do) so the // blur reads as pure colors — full-res art keeps its detail at any blur radius. // currentTrack only carries the full-size artworkData, so derive the thumb from it. @@ -332,16 +170,30 @@ export function NowPlayingOverlay() { ); const availableHeight = windowHeight - insets.top - insets.bottom; const effectiveWidth = windowWidth - insets.left - insets.right; - const layout = getNowPlayingLayout( + const standardLayout = getNowPlayingLayout( effectiveWidth, availableHeight, isDesktopTarget ? false : scopeStageVisible ); + const tabletCompanionLayout = getTabletCompanionLayout( + effectiveWidth, + availableHeight, + isDesktopTarget ? false : scopeStageVisible + ); + const hasTabletCompanion = tabletCompanionLayout !== null; + const lyricPeekEnabled = !isDesktopTarget && availableHeight >= 720; + const layout = tabletCompanionLayout?.playerLayout ?? standardLayout; + const contentPadding = tabletCompanionLayout ? spacing.lg : layout.contentPadding; + const shellWidth = tabletCompanionLayout?.shellWidth ?? layout.contentWidth; + // Lyrics takes over only on the phone. Roomy tablets keep the player visible + // and render lyrics in the companion rail. + const lyricsMode = + !hasTabletCompanion && !isDesktopTarget && !!track && lyricsVisible; const source = activePresentation.sourceLabel; const shellRight = insets.right + - layout.contentPadding + - Math.max(0, (effectiveWidth - layout.contentPadding * 2 - layout.contentWidth) / 2); + contentPadding + + Math.max(0, (effectiveWidth - contentPadding * 2 - shellWidth) / 2); const menuTop = insets.top + CONTENT_TOP_PADDING + HEADER_HEIGHT + spacing.xs; const libraryTrack = useMemo( () => (track ? libraryTracks.find((entry) => entry.path === track.path) ?? null : null), @@ -353,15 +205,60 @@ export function NowPlayingOverlay() { artistGroupingMode ) : ''; + const artistCreditTokens = useMemo(() => { + if (!track) return []; + const collaborators = splitCollaborators(track.artist); + return buildArtistNameTokens( + collaborators.length > 0 ? collaborators : [track.artist] + ); + }, [track]); const albumKey = track?.albumIdentityKey ?? libraryTrack?.album_identity_key; - const navigateToArtist = () => { - if (!artistName) return; + useEffect(() => { + if (!hasTabletCompanion || isDesktopTarget) return; + if (queueOpen) { + const frame = requestAnimationFrame(() => { + setQueueOpen(false); + void setNowPlayingCompanion('queue'); + }); + return () => cancelAnimationFrame(frame); + } + if (lyricsVisible) void setNowPlayingCompanion('lyrics'); + return undefined; + }, [ + isDesktopTarget, + lyricsVisible, + queueOpen, + setNowPlayingCompanion, + hasTabletCompanion, + ]); + + const showLyrics = () => { + if (hasTabletCompanion) { + void setNowPlayingCompanion('lyrics'); + return; + } + void setLyricsVisible(!lyricsVisible); + }; + + const showQueue = () => { + if (hasTabletCompanion) { + if (!isDesktopTarget) { + void setLyricsVisible(false); + void setNowPlayingCompanion('queue'); + } + return; + } + setQueueOpen(true); + }; + + const navigateToArtist = (targetArtist = artistName, credit = false) => { + if (!targetArtist) return; // Slide the overlay away while the library detail loads underneath. dismissSheet(); router.navigate({ pathname: '/library/artist/[name]', - params: { name: artistName }, + params: { name: targetArtist, ...(credit ? { credit: '1' } : {}) }, }); }; @@ -422,6 +319,13 @@ export function NowPlayingOverlay() { // sheet on the UI thread. Starts off-screen so a pre-warmed mount never flashes. const translateY = useSharedValue(windowHeight); const menuProgress = useSharedValue(0); + const trackProgress = useSharedValue(1); + + useEffect(() => { + if (!transitionTrackKey) return; + trackProgress.value = 0; + trackProgress.value = withTiming(1, { ...motion.snap, duration: 200 }); + }, [trackProgress, transitionTrackKey]); // Closing is a store toggle, not navigation. Reset the inner layers so a // reopen starts from the plain player (parity with the old per-open mount). const dismiss = () => { @@ -530,6 +434,16 @@ 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) }], + })); + return ( @@ -538,8 +452,8 @@ export function NowPlayingOverlay() { styles.content, contentStyle, { - paddingLeft: insets.left + layout.contentPadding, - paddingRight: insets.right + layout.contentPadding, + paddingLeft: insets.left + contentPadding, + paddingRight: insets.right + contentPadding, paddingTop: insets.top + CONTENT_TOP_PADDING, paddingBottom: insets.bottom + CONTENT_BOTTOM_PADDING, }, @@ -549,11 +463,11 @@ export function NowPlayingOverlay() { artworkUri={washArtworkUri} offset={{ top: -(insets.top + CONTENT_TOP_PADDING), - left: -(insets.left + layout.contentPadding), - right: -(insets.right + layout.contentPadding), + left: -(insets.left + contentPadding), + right: -(insets.right + contentPadding), }} /> - + {!lyricsMode && ( @@ -571,19 +485,46 @@ export function NowPlayingOverlay() { {!isDesktopTarget && track ? ( - void setLyricsVisible(!lyricsVisible)} + haptic="selection" + onPress={showLyrics} hitSlop={12} - accessibilityLabel={lyricsVisible ? 'Hide lyrics' : 'Show lyrics'} - accessibilityState={{ selected: lyricsVisible }} + accessibilityLabel={ + hasTabletCompanion + ? 'Show lyrics in companion' + : lyricsVisible + ? 'Hide lyrics' + : 'Show lyrics' + } + accessibilityState={{ + selected: hasTabletCompanion + ? nowPlayingCompanion === 'lyrics' + : lyricsVisible, + }} > - + } + active={ + + } /> - + ) : null} )} + + + {lyricsMode && track ? ( - {activePresentation.artworkUri ? ( ) : ( )} - + - + ]} + > + + - void sendDesktopControl('toggle-favorite')} accessibilityLabel={activeTrack.isFavorite ? 'Remove from favorites' : 'Add to favorites'} accessibilityState={{ selected: activeTrack.isFavorite }} > - + } + active={ + + } /> - - + + - void sendDesktopControl('toggle-shuffle')} accessibilityLabel="Shuffle" accessibilityState={{ selected: Boolean(desktopSnapshot?.shuffle) }} > - + } + active={ + + } /> - - + void sendDesktopControl('previous')} + haptic="light" hitSlop={12} style={styles.transportMainBtn} android_ripple={ripple.icon(26)} accessibilityLabel="Previous" @@ -722,9 +706,11 @@ export function NowPlayingOverlay() { size={SKIP_ICON_SIZE} color={colors.textPrimary} /> - - + void sendDesktopControl(isPlaying ? 'pause' : 'play')} + haptic="light" + pressedScale={0.97} hitSlop={12} style={styles.playButton} android_ripple={ripple.onAccent()} accessibilityLabel={isPlaying ? 'Pause desktop' : 'Play desktop'} @@ -734,9 +720,10 @@ export function NowPlayingOverlay() { size={PLAY_ICON_SIZE} color={colors.bgPrimary} /> - - + void sendDesktopControl('next')} + haptic="light" hitSlop={12} style={styles.transportMainBtn} android_ripple={ripple.icon(26)} accessibilityLabel="Next" @@ -746,8 +733,8 @@ export function NowPlayingOverlay() { size={SKIP_ICON_SIZE} color={colors.textPrimary} /> - - + void sendDesktopControl('toggle-repeat')} accessibilityLabel="Repeat" accessibilityState={{ selected: desktopSnapshot?.repeat !== 'none' }} > - {desktopSnapshot?.repeat === 'one' ? ( - - ) : ( - - )} - + + } + active={desktopSnapshot?.repeat === 'one' ? ( + + ) : ( + + )} + /> + + - + - void reconnectDesktop()} accessibilityLabel="Reconnect to desktop" > - + {desktopQueue ? ( - setQueueOpen(true)} + haptic="selection" + onPress={showQueue} accessibilityLabel="Desktop queue" > - + ) : null} @@ -865,9 +865,10 @@ export function NowPlayingOverlay() { }, ]} > - {track.artworkData ? ( ) : ( )} - + {scopeStageVisible && ( - useSettingsStore .getState() .setScopeMode(scopeMode === 'spectrum' ? 'scope' : 'spectrum') } + haptic="selection" hitSlop={12} style={styles.scopeSwap} android_ripple={ripple.icon(24)} accessibilityRole="button" @@ -934,7 +936,7 @@ export function NowPlayingOverlay() { {scopeMode === 'spectrum' ? 'SPECTRUM' : 'SCOPE'} - + )} @@ -947,141 +949,228 @@ export function NowPlayingOverlay() { : styles.playerControlsFill, ]} > - - - - {track.title} - - - + {lyricPeekEnabled ? ( + + {track.title} + + + {artistCreditTokens.map(({ artist, separator }) => ( + + navigateToArtist(artist, true)} + hitSlop={4} + style={styles.artistCreditButton} + android_ripple={ripple.bounded} + accessibilityRole="link" + accessibilityLabel={`View artist ${artist}`} + > + + {artist} + + + {separator ? ( + + {separator} + + ) : null} + + ))} + - - void toggleFavorite(track)} - accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'} - accessibilityState={{ selected: isFavorite }} - > - - - - - void seekTo(seconds)} - /> - - - void toggleShuffle()} - accessibilityLabel="Shuffle" - accessibilityState={{ selected: shuffle }} - > - - - - - - - - - - - - void cycleRepeat()} - accessibilityLabel="Repeat" - accessibilityState={{ selected: repeat !== 'none' }} - > - {repeat === 'one' ? ( - void toggleFavorite(track)} + accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'} + accessibilityState={{ selected: isFavorite }} + > + + } + active={ + + } /> - ) : ( + + + + void seekTo(seconds)} + /> + + + void toggleShuffle()} + accessibilityLabel="Shuffle" + accessibilityState={{ selected: shuffle }} + > + + } + active={ + + } + /> + + - )} - + + + + + + + + void cycleRepeat()} + accessibilityLabel="Repeat" + accessibilityState={{ selected: repeat !== 'none' }} + > + + } + active={repeat === 'one' ? ( + + ) : ( + + )} + /> + + - + - + - void setScopeStageVisible(!scopeStageVisible)} accessibilityLabel={scopeStageVisible ? 'Hide visualizer' : 'Show visualizer'} accessibilityState={{ selected: scopeStageVisible }} > - + } + active={ + + } /> - - + setQueueOpen(true)} + haptic="selection" + onPress={showQueue} accessibilityLabel="Queue" > - + @@ -1102,6 +1191,23 @@ export function NowPlayingOverlay() { )} + + + {tabletCompanionLayout ? ( + + + + ) : null} + @@ -1141,7 +1247,7 @@ export function NowPlayingOverlay() { initialStep="pickPlaylist" onClose={() => setPlaylistActionTrack(null)} /> - {queueOpen && ( + {queueOpen && !hasTabletCompanion && ( isDesktopTarget ? ( ) : ( @@ -1170,6 +1276,39 @@ const useStyles = createThemedStyles((colors) => ({ shell: { flex: 1, }, + playerBody: { + flex: 1, + minHeight: 0, + }, + playerBodyTablet: { + position: 'relative', + }, + playerRegion: { + flex: 1, + minWidth: 0, + alignItems: 'center', + }, + playerRegionTablet: { + position: 'absolute', + top: 0, + bottom: 0, + left: 0, + flexGrow: 0, + flexShrink: 0, + }, + playerCanvas: { + flex: 1, + minHeight: 0, + }, + companionRegion: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + flexGrow: 0, + flexShrink: 0, + minHeight: 0, + }, header: { height: HEADER_HEIGHT, flexDirection: 'row', @@ -1248,7 +1387,7 @@ const useStyles = createThemedStyles((colors) => ({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - columnGap: WIDE_PANE_GAP, + columnGap: NOW_PLAYING_WIDE_PANE_GAP, }, middleStack: { width: '100%', @@ -1313,14 +1452,15 @@ const useStyles = createThemedStyles((colors) => ({ trackMetaRow: { alignSelf: 'stretch', flexDirection: 'row', - flexWrap: 'nowrap', + flexWrap: 'wrap', alignItems: 'center', - gap: spacing.sm, marginTop: spacing.xs, }, - artistButton: { - flex: 1, - minWidth: 0, + artistCreditButton: { + alignSelf: 'flex-start', + }, + artistSeparator: { + color: colors.textTertiary, }, centered: { textAlign: 'center', @@ -1333,7 +1473,13 @@ const useStyles = createThemedStyles((colors) => ({ }, playerControlsFill: { flex: 1, - justifyContent: 'space-between', + }, + primaryControls: { + width: '100%', + }, + utilityFooter: { + marginTop: 'auto', + paddingTop: spacing.lg, }, transport: { flexDirection: 'row', diff --git a/src/components/player/PlayerStateIcon.tsx b/src/components/player/PlayerStateIcon.tsx new file mode 100644 index 0000000..2278959 --- /dev/null +++ b/src/components/player/PlayerStateIcon.tsx @@ -0,0 +1,45 @@ +import { useEffect, type ReactNode } from 'react'; +import { StyleSheet, View } from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { motion } from '@/theme/motion'; + +interface PlayerStateIconProps { + selected: boolean; + size: number; + inactive: ReactNode; + active: ReactNode; +} + +/** Cross-fades transport/utility state without animating icon-font colour. */ +export function PlayerStateIcon({ + selected, + size, + inactive, + active, +}: PlayerStateIconProps) { + const progress = useSharedValue(selected ? 1 : 0); + + useEffect(() => { + progress.value = withTiming(selected ? 1 : 0, motion.quick); + }, [progress, selected]); + + const inactiveStyle = useAnimatedStyle(() => ({ + opacity: 1 - progress.value, + })); + const activeStyle = useAnimatedStyle(() => ({ + opacity: progress.value, + })); + + return ( + + {inactive} + + {active} + + + ); +} diff --git a/src/components/player/TactilePressable.tsx b/src/components/player/TactilePressable.tsx new file mode 100644 index 0000000..766d084 --- /dev/null +++ b/src/components/player/TactilePressable.tsx @@ -0,0 +1,86 @@ +/* eslint-disable react-hooks/immutability -- Reanimated shared values are mutable press state. */ +import type { ReactNode } from 'react'; +import { + Pressable, + type PressableProps, + type StyleProp, + type ViewStyle, +} from 'react-native'; +import Animated, { + useAnimatedStyle, + useSharedValue, + withSequence, + withTiming, +} from 'react-native-reanimated'; +import { commitHaptic, tickHaptic } from '@/lib/haptics'; +import { motion } from '@/theme/motion'; + +const AnimatedPressable = Animated.createAnimatedComponent(Pressable); + +type HapticFeedback = 'selection' | 'light' | 'none'; + +interface TactilePressableProps + extends Omit { + children: ReactNode; + style?: StyleProp; + pressedScale?: number; + confirmationScale?: number; + haptic?: HapticFeedback; +} + +/** + * Now Playing press surface: restrained UI-thread compression plus one + * best-effort haptic only after a press successfully commits. + */ +export function TactilePressable({ + children, + style, + pressedScale = 0.94, + confirmationScale, + haptic = 'none', + disabled, + onPress, + onPressIn, + onPressOut, + ...rest +}: TactilePressableProps) { + const scale = useSharedValue(1); + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + const handlePressIn: NonNullable = (event) => { + scale.value = withTiming(pressedScale, motion.quick); + onPressIn?.(event); + }; + + const handlePressOut: NonNullable = (event) => { + scale.value = withTiming(1, motion.quick); + onPressOut?.(event); + }; + + const handlePress: NonNullable = (event) => { + if (haptic === 'selection') tickHaptic(); + else if (haptic === 'light') commitHaptic(); + if (confirmationScale) { + scale.value = withSequence( + withTiming(confirmationScale, motion.quick), + withTiming(1, motion.quick) + ); + } + onPress?.(event); + }; + + return ( + + {children} + + ); +} diff --git a/src/components/player/nowPlayingLayout.test.mts b/src/components/player/nowPlayingLayout.test.mts new file mode 100644 index 0000000..7692765 --- /dev/null +++ b/src/components/player/nowPlayingLayout.test.mts @@ -0,0 +1,102 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + getNowPlayingLayout, + getTabletCompanionLayout, +} from './nowPlayingLayout.ts'; + +const BASELINES = [ + [320, 568, false, 296, 134, 204, 58], + [320, 568, true, 296, 96, 204, 58], + [360, 640, false, 328, 206, 214, 58], + [360, 640, true, 328, 96, 214, 58], + [393, 852, false, 361, 361, 361, 76], + [393, 852, true, 361, 234, 361, 76], + [412, 915, false, 380, 380, 380, 82], + [412, 915, true, 380, 248, 380, 82], + [600, 840, false, 520, 394, 394, 58], + [600, 840, true, 520, 262, 394, 58], + [800, 600, false, 768, 383, 383, 58], + [800, 600, true, 768, 383, 506, 58], +] as const; + +test('preserves existing non-companion media geometry', () => { + for (const [width, height, visualizer, contentWidth, artSize, mediaHeight, waveform] of BASELINES) { + const layout = getNowPlayingLayout(width, height, visualizer); + assert.deepEqual( + [layout.contentWidth, layout.artSize, layout.mediaStackHeight, layout.waveformHeight], + [contentWidth, artSize, mediaHeight, waveform], + `${width}x${height}, visualizer=${visualizer}` + ); + } +}); + +test('keeps the lower-content anchor stable when the analyzer toggles', () => { + for (const [width, height] of [ + [320, 568], + [360, 640], + [393, 852], + [412, 915], + [600, 840], + ]) { + const hidden = getNowPlayingLayout(width, height, false); + const visible = getNowPlayingLayout(width, height, true); + assert.equal(visible.mediaStackHeight, hidden.mediaStackHeight); + assert.equal(visible.mediaTopMargin, hidden.mediaTopMargin); + assert.equal(visible.mediaBottomGap, hidden.mediaBottomGap); + } +}); + +test('adds the companion only to roomy tablet canvases', () => { + for (const [width, height] of [ + [320, 568], + [360, 640], + [393, 852], + [412, 915], + [600, 840], + [800, 600], + ]) { + assert.equal(getTabletCompanionLayout(width, height, true), null); + } + + for (const [width, height] of [ + [768, 1024], + [1024, 600], + [1024, 768], + [1366, 1024], + ]) { + const layout = getTabletCompanionLayout(width, height, true); + assert.ok(layout, `${width}x${height} should qualify`); + assert.ok(layout.companionWidth >= 320 && layout.companionWidth <= 400); + assert.ok(layout.playerRegionWidth > 0); + assert.ok(layout.shellWidth <= 1200); + assert.equal( + layout.playerRegionWidth + layout.gap + layout.companionWidth, + layout.shellWidth + ); + } +}); + +test('keeps calculated dimensions finite and non-negative', () => { + for (const [width, height] of [ + [320, 568], + [360, 640], + [393, 852], + [412, 915], + [600, 840], + [800, 600], + [768, 1024], + [1024, 600], + [1024, 768], + [1366, 1024], + ]) { + for (const visualizer of [false, true]) { + const layout = getNowPlayingLayout(width, height, visualizer); + for (const value of Object.values(layout)) { + if (typeof value !== 'number') continue; + assert.ok(Number.isFinite(value)); + assert.ok(value >= 0); + } + } + } +}); diff --git a/src/components/player/nowPlayingLayout.ts b/src/components/player/nowPlayingLayout.ts new file mode 100644 index 0000000..9ea5933 --- /dev/null +++ b/src/components/player/nowPlayingLayout.ts @@ -0,0 +1,259 @@ +import { spacing } from '../../theme/spacing.ts'; +import { WIDE_MIN_WIDTH, isWideWindow } from '../../theme/adaptive.ts'; + +const MAX_CONTENT_WIDTH = 408; +const CONTENT_SIDE_PADDING = spacing.lg; +const NARROW_CONTENT_SIDE_PADDING = spacing.md; +const MEDIA_AREA_MIN = 220; +const TABLET_MAX_CONTENT_WIDTH = 520; +const TABLET_ART_SIZE_MAX = 440; +const WIDE_MAX_CONTENT_WIDTH = 960; +export const NOW_PLAYING_WIDE_PANE_GAP = spacing.xxl; +const WIDE_RIGHT_PANE_MIN = 300; +const WIDE_RIGHT_PANE_MAX = MAX_CONTENT_WIDTH; +const WIDE_ART_SIZE_MAX = 400; +const WIDE_ART_SIZE_MIN = 160; +const WIDE_COMPACT_HEIGHT = 480; +const VISUALIZER_WIDTH_MAX = 448; +const VISUALIZER_SIDE_PADDING = spacing.md; +const VISUALIZER_TOP_GAP = spacing.lg; +const VISUALIZER_BOTTOM_GAP = spacing.sm; +const VISUALIZER_HEIGHT_MIN = 84; +const VISUALIZER_HEIGHT_MAX = 108; +const VISUALIZER_HEIGHT_RATIO = 0.28; +export const NOW_PLAYING_HEADER_HEIGHT = 32; +export const NOW_PLAYING_CONTENT_TOP_PADDING = spacing.sm; +export const NOW_PLAYING_CONTENT_BOTTOM_PADDING = spacing.lg; +const MEDIA_TOP_MARGIN = spacing.lg; +const MEDIA_BOTTOM_GAP = spacing.xl; +const TRACK_INFO_ESTIMATE = 96; +export const NOW_PLAYING_WAVEFORM_HEIGHT = 58; +export const NOW_PLAYING_WAVEFORM_TOUCH_PADDING = spacing.md; +const WAVEFORM_BLOCK_ESTIMATE = + NOW_PLAYING_WAVEFORM_HEIGHT + NOW_PLAYING_WAVEFORM_TOUCH_PADDING * 2 + 24; +export const NOW_PLAYING_PLAY_BUTTON_SIZE = 68; +const TRANSPORT_TOP_MARGIN = spacing.lg; +export const NOW_PLAYING_SUB_BUTTON_SIZE = 40; +const SUB_TOP_MARGIN = spacing.lg; +const MIN_FLOATING_SPACE = spacing.sm; + +const TABLET_SHELL_MIN_WIDTH = 720; +const TABLET_SHELL_MAX_WIDTH = 1200; +const TABLET_COMPANION_GAP = spacing.xl; +const TABLET_COMPANION_MIN_WIDTH = 320; +const TABLET_COMPANION_MAX_WIDTH = 400; +const TABLET_STACKED_MIN_HEIGHT = 760; +const TABLET_WIDE_PLAYER_MIN_WIDTH = 600; +const TABLET_WIDE_MIN_HEIGHT = 520; + +export type NowPlayingPresentation = 'standard' | 'wide'; + +export interface NowPlayingLayout { + presentation: NowPlayingPresentation; + isWide: boolean; + contentPadding: number; + contentWidth: number; + leftPaneWidth: number; + rightPaneWidth: number; + controlsGap: number; + trackInfoGap: number; + waveformHeight: number; + mediaStackHeight: number; + artSize: number; + scopeWidth: number; + scopeHeight: number; + visualizerTopGap: number; + visualizerBottomGap: number; + mediaTopMargin: number; + mediaBottomGap: number; +} + +export interface TabletCompanionLayout { + presentation: 'tablet-companion'; + shellWidth: number; + playerRegionWidth: number; + companionWidth: number; + gap: number; + playerLayout: NowPlayingLayout; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function getScopeHeight(scopeWidth: number): number { + return Math.round( + clamp(scopeWidth * VISUALIZER_HEIGHT_RATIO, VISUALIZER_HEIGHT_MIN, VISUALIZER_HEIGHT_MAX) + ); +} + +/** + * Existing Now Playing layout calculator. Keep the numeric outputs stable for + * phone, split-screen, foldable, and short-landscape windows. + */ +export function getNowPlayingLayout( + availableWidth: number, + availableHeight: number, + showVisualizer: boolean, + forceWide = false +): NowPlayingLayout { + const isWide = forceWide || isWideWindow(availableWidth, availableHeight); + + if (isWide) { + const contentPadding = CONTENT_SIDE_PADDING; + const contentWidth = Math.max( + 0, + Math.min(availableWidth - contentPadding * 2, WIDE_MAX_CONTENT_WIDTH) + ); + const rightPaneWidth = Math.round( + clamp(contentWidth * 0.46, WIDE_RIGHT_PANE_MIN, WIDE_RIGHT_PANE_MAX) + ); + const leftPaneWidth = Math.max( + 0, + contentWidth - NOW_PLAYING_WIDE_PANE_GAP - rightPaneWidth + ); + const scopeWidth = Math.min(leftPaneWidth, VISUALIZER_WIDTH_MAX); + const scopeHeight = getScopeHeight(scopeWidth); + const visualizerTopGap = showVisualizer ? VISUALIZER_TOP_GAP : 0; + const verticalBudget = + availableHeight - + NOW_PLAYING_CONTENT_TOP_PADDING - + NOW_PLAYING_CONTENT_BOTTOM_PADDING - + NOW_PLAYING_HEADER_HEIGHT - + spacing.md; + const artHeightBudget = + verticalBudget - (showVisualizer ? scopeHeight + visualizerTopGap : 0); + const artSize = Math.round( + clamp(Math.min(leftPaneWidth, artHeightBudget), WIDE_ART_SIZE_MIN, WIDE_ART_SIZE_MAX) + ); + const controlsGap = availableHeight < WIDE_COMPACT_HEIGHT ? spacing.sm : spacing.lg; + return { + presentation: 'wide', + isWide: true, + contentPadding, + contentWidth, + leftPaneWidth, + rightPaneWidth, + controlsGap, + trackInfoGap: spacing.md, + waveformHeight: NOW_PLAYING_WAVEFORM_HEIGHT, + mediaStackHeight: showVisualizer + ? artSize + visualizerTopGap + scopeHeight + : artSize, + artSize, + scopeWidth, + scopeHeight, + visualizerTopGap, + visualizerBottomGap: 0, + mediaTopMargin: 0, + mediaBottomGap: 0, + }; + } + + const isTabletColumn = availableWidth >= WIDE_MIN_WIDTH; + const contentPadding = + availableWidth < 360 ? NARROW_CONTENT_SIDE_PADDING : CONTENT_SIDE_PADDING; + const maxContentWidth = isTabletColumn ? TABLET_MAX_CONTENT_WIDTH : MAX_CONTENT_WIDTH; + const contentWidth = Math.max( + 0, + Math.min(availableWidth - contentPadding * 2, maxContentWidth) + ); + const scopeWidth = Math.max( + 0, + Math.min(availableWidth - VISUALIZER_SIDE_PADDING * 2, VISUALIZER_WIDTH_MAX) + ); + const scopeHeight = getScopeHeight(scopeWidth); + const mediaMax = Math.min( + contentWidth, + isTabletColumn ? TABLET_ART_SIZE_MAX : contentWidth + ); + const mediaMin = Math.min(mediaMax, MEDIA_AREA_MIN); + const mediaTopMargin = availableHeight < 680 ? spacing.md : MEDIA_TOP_MARGIN; + const mediaBottomGap = availableHeight < 680 ? spacing.lg : MEDIA_BOTTOM_GAP; + const fixedHeightBase = + NOW_PLAYING_CONTENT_TOP_PADDING + + NOW_PLAYING_CONTENT_BOTTOM_PADDING + + NOW_PLAYING_HEADER_HEIGHT + + mediaTopMargin + + TRACK_INFO_ESTIMATE + + WAVEFORM_BLOCK_ESTIMATE + + TRANSPORT_TOP_MARGIN + + NOW_PLAYING_PLAY_BUTTON_SIZE + + SUB_TOP_MARGIN + + NOW_PLAYING_SUB_BUTTON_SIZE + + MIN_FLOATING_SPACE; + const bound = availableHeight - fixedHeightBase - mediaBottomGap; + const scopeOffArt = Math.round( + clamp(bound, Math.min(mediaMin, Math.max(96, bound)), mediaMax) + ); + const offSurplus = Math.max(0, bound - scopeOffArt); + const stretchUnit = Math.min(Math.floor(offSurplus / 5), spacing.md); + const waveformHeight = NOW_PLAYING_WAVEFORM_HEIGHT + stretchUnit * 2; + const scopeBlockHeight = VISUALIZER_TOP_GAP + scopeHeight + VISUALIZER_BOTTOM_GAP; + const mediaStackHeight = Math.max(scopeOffArt, 96 + scopeBlockHeight); + const scopeOnArt = mediaStackHeight - scopeBlockHeight; + const artSize = showVisualizer ? scopeOnArt : scopeOffArt; + const visualizerTopGap = showVisualizer ? VISUALIZER_TOP_GAP : 0; + const visualizerBottomGap = showVisualizer ? VISUALIZER_BOTTOM_GAP : 0; + + return { + presentation: 'standard', + isWide: false, + contentPadding, + contentWidth, + leftPaneWidth: contentWidth, + rightPaneWidth: contentWidth, + controlsGap: TRANSPORT_TOP_MARGIN, + trackInfoGap: spacing.md, + waveformHeight, + mediaStackHeight, + artSize, + scopeWidth, + scopeHeight, + visualizerTopGap, + visualizerBottomGap, + mediaTopMargin, + mediaBottomGap, + }; +} + +/** + * Additive tablet tier. Returning null means the caller must use the existing + * single/wide layout unchanged. + */ +export function getTabletCompanionLayout( + availableWidth: number, + availableHeight: number, + showVisualizer: boolean +): TabletCompanionLayout | null { + const shellWidth = Math.min( + Math.max(0, availableWidth - CONTENT_SIDE_PADDING * 2), + TABLET_SHELL_MAX_WIDTH + ); + if (shellWidth < TABLET_SHELL_MIN_WIDTH) return null; + + const companionWidth = Math.round( + clamp(shellWidth * 0.34, TABLET_COMPANION_MIN_WIDTH, TABLET_COMPANION_MAX_WIDTH) + ); + const playerRegionWidth = shellWidth - TABLET_COMPANION_GAP - companionWidth; + const canStack = availableHeight >= TABLET_STACKED_MIN_HEIGHT; + const canUseWidePlayer = + playerRegionWidth >= TABLET_WIDE_PLAYER_MIN_WIDTH && + availableHeight >= TABLET_WIDE_MIN_HEIGHT; + if (!canStack && !canUseWidePlayer) return null; + + const forceWide = canUseWidePlayer && availableWidth > availableHeight; + return { + presentation: 'tablet-companion', + shellWidth, + playerRegionWidth, + companionWidth, + gap: TABLET_COMPANION_GAP, + playerLayout: getNowPlayingLayout( + playerRegionWidth, + availableHeight, + showVisualizer, + forceWide + ), + }; +} diff --git a/src/components/player/nowPlayingPreferences.test.mts b/src/components/player/nowPlayingPreferences.test.mts new file mode 100644 index 0000000..55a861d --- /dev/null +++ b/src/components/player/nowPlayingPreferences.test.mts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseNowPlayingCompanion } from './nowPlayingPreferences.ts'; +import { splitCollaborators } from '../../shared/library/albumGrouping.ts'; +import { buildArtistNameTokens } from '../../shared/library/artistCredits.ts'; + +test('defaults missing and invalid companion preferences to queue', () => { + assert.equal(parseNowPlayingCompanion(null), 'queue'); + assert.equal(parseNowPlayingCompanion(''), 'queue'); + assert.equal(parseNowPlayingCompanion('spectrum'), 'queue'); +}); + +test('restores persisted queue and lyrics companion preferences', () => { + assert.equal(parseNowPlayingCompanion('queue'), 'queue'); + assert.equal(parseNowPlayingCompanion('lyrics'), 'lyrics'); +}); + +test('builds separate clickable credits for collaborative track artists', () => { + const artists = splitCollaborators('Dazbee feat. 9Lana & ValkyR'); + assert.deepEqual(artists, ['Dazbee', '9Lana', 'ValkyR']); + assert.deepEqual(buildArtistNameTokens(artists), [ + { artist: 'Dazbee', separator: ', ' }, + { artist: '9Lana', separator: ' & ' }, + { artist: 'ValkyR', separator: null }, + ]); +}); diff --git a/src/components/player/nowPlayingPreferences.ts b/src/components/player/nowPlayingPreferences.ts new file mode 100644 index 0000000..ac9e427 --- /dev/null +++ b/src/components/player/nowPlayingPreferences.ts @@ -0,0 +1,5 @@ +export type NowPlayingCompanion = 'queue' | 'lyrics'; + +export function parseNowPlayingCompanion(value: string | null): NowPlayingCompanion { + return value === 'lyrics' ? 'lyrics' : 'queue'; +} diff --git a/src/components/queue/QueueTray.tsx b/src/components/queue/QueueTray.tsx index bf8b470..d16dbdf 100644 --- a/src/components/queue/QueueTray.tsx +++ b/src/components/queue/QueueTray.tsx @@ -149,11 +149,12 @@ function reconcileQueueEntries( interface QueueTrayProps { onClose: () => void; + embedded?: boolean; } // memo: the parent now-playing screen re-renders on store changes; the tray's // ~15-hook body shouldn't re-execute unless its own inputs change. -export const QueueTray = memo(function QueueTray({ onClose }: QueueTrayProps) { +export const QueueTray = memo(function QueueTray({ onClose, embedded = false }: QueueTrayProps) { const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); @@ -168,11 +169,15 @@ export const QueueTray = memo(function QueueTray({ onClose }: QueueTrayProps) { // freeze. Clamping the list container to the window height caps the viewport // no matter what the sheet reports; both snap points stay unaffected. const listClampStyle = useMemo(() => ({ maxHeight: windowHeight }), [windowHeight]); + const embeddedListStyle = useMemo( + () => ({ maxHeight: windowHeight, flex: 1 }), + [windowHeight] + ); // Same bug, milder symptom: a viewport measured during the open animation can // stick at the clamp height (taller than the sheet's real content area), which // silently shortens the scroll range — the last few rows become unreachable. // Mounting the list only after the sheet settles removes the bad window. - const [listReady, setListReady] = useState(false); + const [listReady, setListReady] = useState(embedded); const onSheetChange = useCallback((index: number) => { if (index >= 0) setListReady(true); }, []); @@ -573,20 +578,8 @@ export const QueueTray = memo(function QueueTray({ onClose }: QueueTrayProps) { const selectedCount = visibleSelectedKeys.size; const canEdit = queueReady && entries.length > 0; - return ( - + const body = ( + <> @@ -638,23 +631,30 @@ export const QueueTray = memo(function QueueTray({ onClose }: QueueTrayProps) { item.key} drawDistance={QUEUE_ROW_HEIGHT * 12} maintainVisibleContentPosition={{ disabled: true }} - renderScrollComponent={renderFlashListScrollComponent} + renderScrollComponent={embedded ? undefined : renderFlashListScrollComponent} renderItem={renderItem} extraData={listExtraData} - contentContainerStyle={ - editMode && selectedCount > 0 ? listContentEditStyle : listContentStyle - } + contentContainerStyle={embedded + ? styles.embeddedListContent + : editMode && selectedCount > 0 + ? listContentEditStyle + : listContentStyle} showsVerticalScrollIndicator={false} ListEmptyComponent={renderEmpty} /> ) : null} {editMode && selectedCount > 0 ? ( - + ) : null} + + ); + + if (embedded) { + return {body}; + } + + return ( + + {body} ); }); @@ -957,6 +979,14 @@ const useStyles = createThemedStyles((colors) => ({ backgroundColor: colors.glassBorder, width: 38, }, + embeddedRoot: { + flex: 1, + overflow: 'hidden', + }, + embeddedListContent: { + flexGrow: 1, + paddingBottom: spacing.lg, + }, headerRow: { flexDirection: 'row', alignItems: 'center', diff --git a/src/components/queue/RemoteQueueSheet.tsx b/src/components/queue/RemoteQueueSheet.tsx index f02f1d2..2245661 100644 --- a/src/components/queue/RemoteQueueSheet.tsx +++ b/src/components/queue/RemoteQueueSheet.tsx @@ -24,9 +24,10 @@ import type { DesktopRemoteQueueItem } from '@/types/desktopRemote'; interface RemoteQueueSheetProps { onClose: () => void; + embedded?: boolean; } -export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) { +export function RemoteQueueSheet({ onClose, embedded = false }: RemoteQueueSheetProps) { const styles = useStyles(); const colors = useColors(); const ripple = useRipple(); @@ -101,17 +102,8 @@ export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) { [playItem, colors, styles, ripple] ); - return ( - + const body = ( + <> Desktop queue @@ -121,7 +113,7 @@ export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) { item.queueId} - renderScrollComponent={renderFlashListScrollComponent} + renderScrollComponent={embedded ? undefined : renderFlashListScrollComponent} renderItem={renderItem} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false} @@ -133,6 +125,25 @@ export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) { } /> + + ); + + if (embedded) { + return {body}; + } + + return ( + + {body} ); } @@ -145,6 +156,10 @@ const useStyles = createThemedStyles((colors) => ({ handle: { backgroundColor: colors.textTertiary, }, + embeddedRoot: { + flex: 1, + overflow: 'hidden', + }, headerRow: { flexDirection: 'row', alignItems: 'baseline', diff --git a/src/lyrics/lyrics.ts b/src/lyrics/lyrics.ts index 9d02f7e12c3fe60cc305c13339f3add8023c2369..58d48029b41d1671a2da0250de962649dc0dd048 100644 GIT binary patch delta 465 zcmYLF%}PQ+6sBEVg%BkpNRBIiK;5(|OeiRfA_#kcapstV*O}Wnb1h2{dWE~~A%fne zb+u^OE7aU;(e^v%`+Yt9IC=ieFFVa<08O|=p%|5s34q^mJCuz505N? z07pVj+AtVv28E#>A_<3{$rBxMs>WKO6x6}#5S8*OEQK-JT!SzQaE3I8i0n+OB@(cO zNHFUe!d2fp86cbrZ89b2?VZ=YN3t!0?$**IQ^|lSDlJ<5&PCflcLKbNEIAN7Hxfp% zkxpCV%i!cF&9Ba~GRKw%X@z@ZfQ24Li7r)1=+xJ1B27 z_C2geP_Jfaa-J(Er-e#x^_lIHJZ;sJ$5N&7|GV04u~-e*W+z { + assert.equal(getActiveSyncedLyricsLine(lines(), 1.2)?.text, 'B'); + assert.equal( + getActiveSyncedLyricsLine([{ timestampMs: 5000, text: 'later' }], 1), + null + ); }); test('translation selection honors the language priority list', () => { diff --git a/src/lyrics/presentation.ts b/src/lyrics/presentation.ts index d97c844..fd8fb7a 100644 --- a/src/lyrics/presentation.ts +++ b/src/lyrics/presentation.ts @@ -29,6 +29,8 @@ export function getLyricsPayloadSourceLabel(payload: LyricsPayload): string { export const LYRICS_INFERRED_GAP_THRESHOLD_MS = 10_000; export const LYRICS_POST_LINE_HOLD_MS = 4_000; +/** Shared display compensation for the full lyrics view and compact lyric peek. */ +export const LYRICS_DISPLAY_LEAD_MS = 350; export interface RenderableSyncedLine { line: LyricsLine; @@ -406,6 +408,18 @@ export function findActiveSyncedLineIndex( return resolveSyncedLyricsTiming(lines, currentTimeSeconds, options).activeLineIndex; } +/** The raw active lyric cue, or null while playback is in a neutral gap. */ +export function getActiveSyncedLyricsLine( + lines: LyricsLine[], + currentTimeSeconds: number, + options: SyncedLyricsTimingOptions = {} +): LyricsLine | null { + const timing = resolveSyncedLyricsTiming(lines, currentTimeSeconds, options); + if (timing.isNeutral || timing.activeCueIndex < 0) return null; + const line = lines[timing.activeCueIndex] ?? null; + return line && isRenderableSyncedLine(line) ? line : null; +} + /** * The "MANUAL XLRC • SYNCED" style status chip. Trimmed from the desktop * variant so it takes only the pieces the mobile band has, not the full Track. diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 1e5f013..0d01221 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -2,6 +2,10 @@ import { create } from 'zustand'; import { openLibraryDb } from '@/db/database'; import { getSetting, setSetting } from '@/db/queries'; import type { ArtistGroupingMode } from '@/library/artistGrouping'; +import { + parseNowPlayingCompanion, + type NowPlayingCompanion, +} from '@/components/player/nowPlayingPreferences'; /** * Persisted app preferences. SQLite (settings table) is the source of truth — this @@ -13,6 +17,7 @@ const INCLUDE_SINGLES_KEY = 'album_include_singles'; const SCOPE_MODE_KEY = 'scope_mode'; const SCOPE_STAGE_VISIBLE_KEY = 'scope_stage_visible'; const LYRICS_VISIBLE_KEY = 'lyrics_visible'; +const NOW_PLAYING_COMPANION_KEY = 'now_playing_companion'; /** Which visualizer the now-playing scope stage shows. */ export type ScopeMode = 'spectrum' | 'scope'; @@ -37,6 +42,7 @@ interface SettingsStore { scopeStageVisible: boolean; /** Whether the now-playing top half shows lyrics instead of art/scope. */ lyricsVisible: boolean; + nowPlayingCompanion: NowPlayingCompanion; loaded: boolean; load: () => Promise; setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise; @@ -44,6 +50,7 @@ interface SettingsStore { setScopeMode: (mode: ScopeMode) => Promise; setScopeStageVisible: (visible: boolean) => Promise; setLyricsVisible: (visible: boolean) => Promise; + setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise; } export const useSettingsStore = create((set, get) => ({ @@ -52,17 +59,26 @@ export const useSettingsStore = create((set, get) => ({ scopeMode: 'spectrum', scopeStageVisible: false, lyricsVisible: false, + nowPlayingCompanion: 'queue', loaded: false, load: async () => { if (get().loaded) return; const db = await openLibraryDb(); - const [grouping, includeSingles, scope, scopeStageVisible, lyricsVisible] = await Promise.all([ + const [ + grouping, + includeSingles, + scope, + scopeStageVisible, + lyricsVisible, + nowPlayingCompanion, + ] = await Promise.all([ getSetting(db, ARTIST_GROUPING_KEY), getSetting(db, INCLUDE_SINGLES_KEY), getSetting(db, SCOPE_MODE_KEY), getSetting(db, SCOPE_STAGE_VISIBLE_KEY), getSetting(db, LYRICS_VISIBLE_KEY), + getSetting(db, NOW_PLAYING_COMPANION_KEY), ]); set({ artistGroupingMode: parseGroupingMode(grouping), @@ -70,6 +86,7 @@ export const useSettingsStore = create((set, get) => ({ scopeMode: parseScopeMode(scope), scopeStageVisible: parseBoolean(scopeStageVisible), lyricsVisible: parseBoolean(lyricsVisible), + nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion), loaded: true, }); }, @@ -108,4 +125,11 @@ export const useSettingsStore = create((set, get) => ({ const db = await openLibraryDb(); await setSetting(db, LYRICS_VISIBLE_KEY, visible ? 'true' : 'false'); }, + + setNowPlayingCompanion: async (companion) => { + if (get().nowPlayingCompanion === companion) return; + set({ nowPlayingCompanion: companion }); + const db = await openLibraryDb(); + await setSetting(db, NOW_PLAYING_COMPANION_KEY, companion); + }, })); diff --git a/src/theme/motion.ts b/src/theme/motion.ts index 24f402a..49e9f68 100644 --- a/src/theme/motion.ts +++ b/src/theme/motion.ts @@ -1,4 +1,4 @@ -import { Easing } from 'react-native-reanimated'; +import { Easing, ReduceMotion } from 'react-native-reanimated'; /** * Shared motion curves. Deliberately spring-free: plain ease-out timing so @@ -7,7 +7,15 @@ import { Easing } from 'react-native-reanimated'; */ export const motion = { /** Small, fast settle — swipe spring-back, row snaps. */ - quick: { duration: 160, easing: Easing.out(Easing.cubic) }, + quick: { + duration: 160, + easing: Easing.out(Easing.cubic), + reduceMotion: ReduceMotion.System, + }, /** Sheet / snap-point transitions. */ - snap: { duration: 220, easing: Easing.out(Easing.cubic) }, + snap: { + duration: 220, + easing: Easing.out(Easing.cubic), + reduceMotion: ReduceMotion.System, + }, } as const;