update now playing screen

This commit is contained in:
Boof2015
2026-07-12 16:53:40 -04:00
parent 804e47a7ca
commit 34e9cde5bc
26 changed files with 1482 additions and 447 deletions
+1
View File
@@ -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"
},
+8 -4
View File
@@ -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<DbTrack | null>(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' } : {}) },
});
};
@@ -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 (
@@ -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<DbTrack | null>(null);
const detail = useMemo(
() => buildArtistDetail(allTracks, name, groupingMode),
[allTracks, name, groupingMode]
() => buildArtistDetail(allTracks, name, detailGroupingMode),
[allTracks, name, detailGroupingMode]
);
const tracks = detail.appearanceTracks;
@@ -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<DbTrack | null>(null);
const detail = useMemo(
() => buildArtistDetail(allTracks, name, groupingMode),
[allTracks, name, groupingMode]
() => buildArtistDetail(allTracks, name, detailGroupingMode),
[allTracks, name, detailGroupingMode]
);
const tracks = detail.songTracks;
+3 -1
View File
@@ -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}
/>
<Canvas style={StyleSheet.absoluteFill}>
<Rect x={0} y={0} width={bandW} height={bandH}>
+2
View File
@@ -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) => {
+2
View File
@@ -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) => {
+2 -2
View File
@@ -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;
+14 -10
View File
@@ -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({
</View>
</View>
<Pressable android_ripple={ripple.bounded}
<TactilePressable android_ripple={ripple.bounded}
onPress={onToggleFavorite}
haptic="light"
confirmationScale={1.08}
hitSlop={10}
style={styles.stripBtn}
accessibilityLabel={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
@@ -105,17 +108,18 @@ export function LyricsView({
size={20}
color={isFavorite ? colors.accent : colors.textTertiary}
/>
</Pressable>
</TactilePressable>
<Pressable android_ripple={ripple.bounded}
<TactilePressable android_ripple={ripple.bounded}
onPress={onExitLyrics}
haptic="selection"
hitSlop={10}
style={styles.stripBtn}
accessibilityLabel="Hide lyrics"
accessibilityState={{ selected: true }}
>
<MaterialCommunityIcons name="script-text-outline" size={20} color={colors.accent} />
</Pressable>
</TactilePressable>
</View>
<LyricsBand
@@ -129,19 +133,19 @@ export function LyricsView({
<View style={styles.controls}>
<SeekBar currentTime={currentTime} duration={duration} trackKey={track.id} onSeek={onSeek} />
<View style={styles.transport}>
<Pressable android_ripple={ripple.bounded} onPress={onPrev} hitSlop={12} style={styles.transportBtn} accessibilityLabel="Previous">
<TactilePressable android_ripple={ripple.bounded} onPress={onPrev} haptic="light" hitSlop={12} style={styles.transportBtn} accessibilityLabel="Previous">
<Ionicons name="play-skip-back" size={28} color={colors.textPrimary} />
</Pressable>
<Pressable android_ripple={ripple.bounded} onPress={onPlayPause} hitSlop={12} style={styles.playButton} accessibilityLabel={isPlaying ? 'Pause' : 'Play'}>
</TactilePressable>
<TactilePressable android_ripple={ripple.bounded} onPress={onPlayPause} haptic="light" pressedScale={0.97} hitSlop={12} style={styles.playButton} accessibilityLabel={isPlaying ? 'Pause' : 'Play'}>
<Ionicons
name={isLoading ? 'ellipsis-horizontal' : isPlaying ? 'pause' : 'play'}
size={28}
color={colors.bgPrimary}
/>
</Pressable>
<Pressable android_ripple={ripple.bounded} onPress={onNext} hitSlop={12} style={styles.transportBtn} accessibilityLabel="Next">
</TactilePressable>
<TactilePressable android_ripple={ripple.bounded} onPress={onNext} haptic="light" hitSlop={12} style={styles.transportBtn} accessibilityLabel="Next">
<Ionicons name="play-skip-forward" size={28} color={colors.textPrimary} />
</Pressable>
</TactilePressable>
</View>
</View>
</View>
+139
View File
@@ -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 (
<View style={styles.wrap}>
<TactilePressable
style={styles.pressable}
disabled={!text}
haptic="selection"
onPress={onOpenLyrics}
accessibilityRole={text ? 'button' : undefined}
accessibilityLabel={text ? `Open lyrics: ${text}` : undefined}
>
{text && lineKey ? (
<Animated.Text
key={lineKey}
entering={ENTERING}
exiting={EXITING}
numberOfLines={1}
ellipsizeMode="tail"
style={styles.line}
>
{text}
</Animated.Text>
) : null}
</TactilePressable>
</View>
);
}
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,
},
}));
@@ -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 (
<View style={styles.root}>
{desktopTarget ? (
<RemoteQueueSheet embedded onClose={noop} />
) : (
<>
<View style={styles.switcher}>
<SegmentedControl
segments={COMPANION_SEGMENTS}
value={companion}
onChange={selectCompanion}
/>
</View>
<View style={styles.content}>
{companion === 'queue' ? (
<QueueTray embedded onClose={noop} />
) : track ? (
<LyricsBand
track={track}
currentTime={currentTime}
duration={duration}
isPlaying={isPlaying}
onSeek={(seconds) => void seekTo(seconds)}
/>
) : null}
</View>
</>
)}
</View>
);
}
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,
},
}));
File diff suppressed because it is too large Load Diff
+45
View File
@@ -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 (
<View style={{ width: size, height: size }}>
<Animated.View style={inactiveStyle}>{inactive}</Animated.View>
<Animated.View style={[StyleSheet.absoluteFill, activeStyle]}>
{active}
</Animated.View>
</View>
);
}
@@ -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<PressableProps, 'children' | 'style'> {
children: ReactNode;
style?: StyleProp<ViewStyle>;
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<PressableProps['onPressIn']> = (event) => {
scale.value = withTiming(pressedScale, motion.quick);
onPressIn?.(event);
};
const handlePressOut: NonNullable<PressableProps['onPressOut']> = (event) => {
scale.value = withTiming(1, motion.quick);
onPressOut?.(event);
};
const handlePress: NonNullable<PressableProps['onPress']> = (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 (
<AnimatedPressable
{...rest}
disabled={disabled}
style={[style, animatedStyle]}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={handlePress}
>
{children}
</AnimatedPressable>
);
}
@@ -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);
}
}
}
});
+259
View File
@@ -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
),
};
}
@@ -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 },
]);
});
@@ -0,0 +1,5 @@
export type NowPlayingCompanion = 'queue' | 'lyrics';
export function parseNowPlayingCompanion(value: string | null): NowPlayingCompanion {
return value === 'lyrics' ? 'lyrics' : 'queue';
}
+52 -22
View File
@@ -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 (
<BottomSheet
index={0}
snapPoints={snapPoints}
enableDynamicSizing={false}
enablePanDownToClose
enableContentPanningGesture={!editMode}
enableHandlePanningGesture
onChange={onSheetChange}
onClose={onClose}
backdropComponent={renderBackdrop}
backgroundStyle={styles.sheetBg}
handleIndicatorStyle={styles.handle}
>
const body = (
<>
<View style={styles.headerRow}>
<View style={styles.headerText}>
<Text variant="heading" style={styles.headerTitle}>
@@ -638,23 +631,30 @@ export const QueueTray = memo(function QueueTray({ onClose }: QueueTrayProps) {
<FlashList
data={entries}
scrollEnabled={!editMode}
style={listClampStyle}
style={embedded ? embeddedListStyle : listClampStyle}
keyExtractor={(item) => 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 ? (
<View style={[styles.actionBar, { paddingBottom: insets.bottom + spacing.sm }]}>
<View
style={[
styles.actionBar,
{ paddingBottom: (embedded ? 0 : insets.bottom) + spacing.sm },
]}
>
<Pressable
android_ripple={ripple.bounded}
style={styles.actionBtn}
@@ -681,6 +681,28 @@ export const QueueTray = memo(function QueueTray({ onClose }: QueueTrayProps) {
</Pressable>
</View>
) : null}
</>
);
if (embedded) {
return <View style={styles.embeddedRoot}>{body}</View>;
}
return (
<BottomSheet
index={0}
snapPoints={snapPoints}
enableDynamicSizing={false}
enablePanDownToClose
enableContentPanningGesture={!editMode}
enableHandlePanningGesture
onChange={onSheetChange}
onClose={onClose}
backdropComponent={renderBackdrop}
backgroundStyle={styles.sheetBg}
handleIndicatorStyle={styles.handle}
>
{body}
</BottomSheet>
);
});
@@ -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',
+28 -13
View File
@@ -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 (
<BottomSheet
index={0}
snapPoints={snapPoints}
enableDynamicSizing={false}
enablePanDownToClose
onClose={onClose}
backdropComponent={renderBackdrop}
backgroundStyle={styles.sheetBg}
handleIndicatorStyle={styles.handle}
>
const body = (
<>
<View style={styles.headerRow}>
<Text variant="heading">Desktop queue</Text>
<Text variant="label" color={colors.textTertiary}>
@@ -121,7 +113,7 @@ export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) {
<FlashList
data={items}
keyExtractor={(item) => item.queueId}
renderScrollComponent={renderFlashListScrollComponent}
renderScrollComponent={embedded ? undefined : renderFlashListScrollComponent}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
showsVerticalScrollIndicator={false}
@@ -133,6 +125,25 @@ export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) {
</View>
}
/>
</>
);
if (embedded) {
return <View style={styles.embeddedRoot}>{body}</View>;
}
return (
<BottomSheet
index={0}
snapPoints={snapPoints}
enableDynamicSizing={false}
enablePanDownToClose
onClose={onClose}
backdropComponent={renderBackdrop}
backgroundStyle={styles.sheetBg}
handleIndicatorStyle={styles.handle}
>
{body}
</BottomSheet>
);
}
@@ -145,6 +156,10 @@ const useStyles = createThemedStyles((colors) => ({
handle: {
backgroundColor: colors.textTertiary,
},
embeddedRoot: {
flex: 1,
overflow: 'hidden',
},
headerRow: {
flexDirection: 'row',
alignItems: 'baseline',
Binary file not shown.
+10
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import type { LyricsLine, LyricsPayload } from './types.ts';
import {
findActiveSyncedLineIndex,
getActiveSyncedLyricsLine,
getCompensatedLyricsTime,
getLyricsLineSeekTimeSeconds,
getLyricsMetaChipText,
@@ -48,6 +49,15 @@ test('a long instrumental gap inserts a synthetic gap row and neutralizes', () =
const timing = resolveSyncedLyricsTiming(withGap, 6);
assert.equal(timing.activeLineIndex, -1);
assert.equal(timing.isNeutral, true);
assert.equal(getActiveSyncedLyricsLine(withGap, 6), null);
});
test('compact lyric peek resolves the raw active cue', () => {
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', () => {
+14
View File
@@ -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.
+25 -1
View File
@@ -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<void>;
setArtistGroupingMode: (mode: ArtistGroupingMode) => Promise<void>;
@@ -44,6 +50,7 @@ interface SettingsStore {
setScopeMode: (mode: ScopeMode) => Promise<void>;
setScopeStageVisible: (visible: boolean) => Promise<void>;
setLyricsVisible: (visible: boolean) => Promise<void>;
setNowPlayingCompanion: (companion: NowPlayingCompanion) => Promise<void>;
}
export const useSettingsStore = create<SettingsStore>((set, get) => ({
@@ -52,17 +59,26 @@ export const useSettingsStore = create<SettingsStore>((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<SettingsStore>((set, get) => ({
scopeMode: parseScopeMode(scope),
scopeStageVisible: parseBoolean(scopeStageVisible),
lyricsVisible: parseBoolean(lyricsVisible),
nowPlayingCompanion: parseNowPlayingCompanion(nowPlayingCompanion),
loaded: true,
});
},
@@ -108,4 +125,11 @@ export const useSettingsStore = create<SettingsStore>((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);
},
}));
+11 -3
View File
@@ -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;