diff --git a/package.json b/package.json index ccdba5d..4725fdf 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,10 @@ "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:audio-startup": "node --experimental-strip-types --test src/audio/dspStartupCoordinator.test.mts src/audio/dspStartupGain.test.mts", - "test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts", + "test:lyrics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lyrics/parsing.test.mts src/lyrics/presentation.test.mts src/lyrics/displaySettings.test.mts", + "test:sleep": "node --experimental-strip-types --test src/audio/sleepTimerState.test.mts", + "test:troubleshooting": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/db/libraryMaintenance.test.mts src/lib/cacheInvalidation.test.mts", + "test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.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", "test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs", "test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts", diff --git a/patches/react-native-track-player+4.1.2.patch b/patches/react-native-track-player+4.1.2.patch index 69358ce..c48672b 100644 --- a/patches/react-native-track-player+4.1.2.patch +++ b/patches/react-native-track-player+4.1.2.patch @@ -191,7 +191,7 @@ index b2409a0..4491bad 100644 if (verifyServiceBoundOrReject(callback)) return@launch musicService.stop() -@@ -431,188 +450,213 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM +@@ -431,188 +450,222 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM callback.resolve(null) } @@ -215,6 +215,15 @@ index b2409a0..4491bad 100644 musicService.pause() callback.resolve(null) } ++ } ++ ++ @ReactMethod ++ fun setPauseAtEndOfItem(enabled: Boolean, callback: Promise) { scope.launch { ++ if (verifyServiceBoundOrReject(callback)) return@launch ++ ++ musicService.setPauseAtEndOfItem(enabled) ++ callback.resolve(null) ++ } + } @ReactMethod @@ -452,7 +461,12 @@ diff --git a/node_modules/react-native-track-player/android/src/main/java/com/do index afa6b0f..c6f01d5 100644 --- a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt +++ b/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt -@@ -741,7 +741,7 @@ class MusicService : HeadlessJsTaskService() { +@@ -337,0 +338,4 @@ class MusicService : HeadlessJsTaskService() { ++ fun setPauseAtEndOfItem(enabled: Boolean) { ++ player.setPauseAtEndOfItem(enabled) ++ } ++ +@@ -741,7 +745,7 @@ class MusicService : HeadlessJsTaskService() { @MainThread private fun emit(event: String, data: Bundle? = null) { @@ -461,7 +475,7 @@ index afa6b0f..c6f01d5 100644 ?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) ?.emit(event, data?.let { Arguments.fromBundle(it) }) } -@@ -751,7 +751,7 @@ class MusicService : HeadlessJsTaskService() { +@@ -751,7 +755,7 @@ class MusicService : HeadlessJsTaskService() { val payload = Arguments.createArray() data.forEach { payload.pushMap(Arguments.fromBundle(it)) } diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx index fe8ef4e..0f9c17f 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(tabs)/settings.tsx @@ -26,6 +26,8 @@ import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore'; import { useLibraryStore } from '@/stores/libraryStore'; import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore'; import { useThemeStore } from '@/stores/themeStore'; +import { useSleepTimerStore } from '@/stores/sleepTimerStore'; +import { formatSleepTimerStatus } from '@/audio/sleepTimerState'; function formatEnabled(value: boolean): string { return value ? 'On' : 'Off'; @@ -48,6 +50,9 @@ export default function SettingsScreen() { const desktopSyncStatus = useDesktopSyncStore((s) => s.status); const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt); const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length); + const sleepTimer = useSleepTimerStore((s) => s.timer); + const sleepRemainingMs = useSleepTimerStore((s) => s.remainingMs); + void sleepRemainingMs; useEffect(() => { const task = InteractionManager.runAfterInteractions(() => { @@ -103,6 +108,12 @@ export default function SettingsScreen() { subtitle={`Normalization ${formatEnabled(normalizationEnabled)}. ReplayGain ${formatEnabled(replayGainEnabled)}.`} onPress={() => router.push('/settings/audio' as never)} /> + router.push('/settings/playback' as never)} + /> router.push('/settings/experimental' as never)} /> - ABOUT + SUPPORT + router.push('/settings/troubleshooting' as never)} + /> { + void useSleepTimerStore.getState().hydrate(); + const subscription = AppState.addEventListener('change', (state) => { + if (state === 'active') void useSleepTimerStore.getState().reconcile(); + }); + return () => subscription.remove(); + }, []); + return null; +} + /** Owns the visualizer on/off gate (foreground + playing + motion). Renders nothing. */ function ScopeLifecycle() { useScopeLifecycle(); @@ -110,7 +123,10 @@ function PlaybackTargetSync() { }, [loadTarget]); useEffect(() => { - if (target === 'desktop') void initDesktopRemote(); + if (target === 'desktop') { + if (useSleepTimerStore.getState().timer) void useSleepTimerStore.getState().cancel(); + void initDesktopRemote(); + } }, [target, initDesktopRemote]); return null; @@ -346,6 +362,10 @@ export default function RootLayout() { .getState() .load() .catch((err) => console.error('[audioSettings] load failed', err)); + useLyricsSettingsStore + .getState() + .load() + .catch((err) => console.error('[lyricsSettings] load failed', err)); // Remote sources: load server rows + hydrate the URL registry from cached // config/token (no network on launch). Runs after library init reads first. useRemoteSourcesStore @@ -373,6 +393,7 @@ export default function RootLayout() { {onboardingComplete ? ( <> + diff --git a/src/app/settings/experimental.tsx b/src/app/settings/experimental.tsx index e62437c..0b93098 100644 --- a/src/app/settings/experimental.tsx +++ b/src/app/settings/experimental.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { Alert, InteractionManager } from 'react-native'; +import { InteractionManager } from 'react-native'; import { useRouter } from 'expo-router'; import { SettingsNavRow, @@ -10,7 +10,6 @@ import { formatRelativeTime } from '@/lib/format'; import { useColors } from '@/theme/themed'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { useDesktopSyncStore } from '@/stores/desktopSyncStore'; -import { useOnboardingStore } from '@/stores/onboardingStore'; export default function ExperimentalSettingsScreen() { const colors = useColors(); @@ -32,16 +31,6 @@ export default function ExperimentalSettingsScreen() { const desktopRemoteSubtitle = desktopRemoteConnection ? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'}: ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}` : 'Pair with Astra Desktop to control playback from this phone.'; - const replayOnboarding = () => { - Alert.alert( - 'Replay onboarding?', - 'The first-run setup wizard will show again the next time you return to the home screen. Your library and settings are kept.', - [ - { text: 'Cancel', style: 'cancel' }, - { text: 'Replay', onPress: () => void useOnboardingStore.getState().reset() }, - ] - ); - }; const desktopSyncSubtitle = !desktopRemoteConnection ? 'Sync favorites and playlists with Astra Desktop.' @@ -77,12 +66,6 @@ export default function ExperimentalSettingsScreen() { subtitle="Audition semantic feedback, device primitives, and signature candidates." onPress={() => router.push('/settings/haptics-lab' as never)} /> - ); } diff --git a/src/app/settings/lyrics.tsx b/src/app/settings/lyrics.tsx new file mode 100644 index 0000000..49741c3 --- /dev/null +++ b/src/app/settings/lyrics.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from 'react'; +import { TextInput, View } from 'react-native'; +import { Text } from '@/components/Text'; +import { + SettingsCard, + SettingsSectionLabel, + SettingsSectionScreen, + SettingsToggleRow, +} from '@/components/settings/SettingsSectionScaffold'; +import { spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; +import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore'; +import { useLyricsStore } from '@/stores/lyricsStore'; +import { usePlayerStore } from '@/stores/playerStore'; + +export default function LyricsSettingsScreen() { + const styles = useStyles(); + const colors = useColors(); + const onlineLookupEnabled = useLyricsSettingsStore((s) => s.onlineLookupEnabled); + const wordTimingEnabled = useLyricsSettingsStore((s) => s.wordTimingEnabled); + const furiganaEnabled = useLyricsSettingsStore((s) => s.furiganaEnabled); + const translationsEnabled = useLyricsSettingsStore((s) => s.translationsEnabled); + const translationPriority = useLyricsSettingsStore((s) => s.translationPriority); + const voiceLabelsEnabled = useLyricsSettingsStore((s) => s.voiceLabelsEnabled); + const load = useLyricsSettingsStore((s) => s.load); + const setOnlineLookupEnabled = useLyricsSettingsStore((s) => s.setOnlineLookupEnabled); + const setWordTimingEnabled = useLyricsSettingsStore((s) => s.setWordTimingEnabled); + const setFuriganaEnabled = useLyricsSettingsStore((s) => s.setFuriganaEnabled); + const setTranslationsEnabled = useLyricsSettingsStore((s) => s.setTranslationsEnabled); + const setTranslationPriority = useLyricsSettingsStore((s) => s.setTranslationPriority); + const setVoiceLabelsEnabled = useLyricsSettingsStore((s) => s.setVoiceLabelsEnabled); + const [priorityDraft, setPriorityDraft] = useState(null); + const priorityValue = priorityDraft ?? translationPriority.join(', '); + + useEffect(() => { + void load(); + }, [load]); + + const setOnlineLookup = async (enabled: boolean) => { + await setOnlineLookupEnabled(enabled); + if (enabled) { + const entries = Object.values(useLyricsStore.getState().byPath); + if (entries.some((entry) => entry.result?.status === 'not_found' && entry.result.reason === 'online-disabled')) { + useLyricsStore.getState().invalidateAll(); + await useLyricsStore.getState().loadForTrack(usePlayerStore.getState().currentTrack); + } + } + }; + + const commitPriority = () => { + void setTranslationPriority(priorityValue).then(() => { + setPriorityDraft(null); + }); + }; + + return ( + + LOOKUP + + void setOnlineLookup(enabled)} + /> + + + XLRC DISPLAY + + void setWordTimingEnabled(enabled)} + /> + + void setFuriganaEnabled(enabled)} + /> + + void setTranslationsEnabled(enabled)} + /> + + void setVoiceLabelsEnabled(enabled)} + /> + + + TRANSLATION PRIORITY + + + Comma-separated language tags, in preferred order. + + + + Duplicates are removed. Leaving this empty restores en, ja-Latn. + + + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + stack: { + gap: spacing.lg, + }, + divider: { + height: 1, + backgroundColor: colors.glassBorder, + }, + inputCard: { + gap: spacing.sm, + }, + input: { + color: colors.textPrimary, + backgroundColor: colors.bgTertiary, + borderColor: colors.glassBorder, + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + fontSize: 16, + }, +})); diff --git a/src/app/settings/playback.tsx b/src/app/settings/playback.tsx new file mode 100644 index 0000000..6395fc4 --- /dev/null +++ b/src/app/settings/playback.tsx @@ -0,0 +1,17 @@ +import { SleepTimerControls } from '@/components/player/SleepTimerControls'; +import { + SettingsCard, + SettingsSectionLabel, + SettingsSectionScreen, +} from '@/components/settings/SettingsSectionScaffold'; + +export default function PlaybackSettingsScreen() { + return ( + + SLEEP TIMER + + + + + ); +} diff --git a/src/app/settings/services.tsx b/src/app/settings/services.tsx index d1a7e60..ac80a9c 100644 --- a/src/app/settings/services.tsx +++ b/src/app/settings/services.tsx @@ -6,12 +6,14 @@ import { SettingsSectionScreen, } from '@/components/settings/SettingsSectionScaffold'; import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore'; +import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore'; import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore'; export default function ServicesSettingsScreen() { const router = useRouter(); const remoteSources = useRemoteSourcesStore((s) => s.sources); const lastFmStatus = useLastFmSettingsStore((s) => s.status); + const onlineLookupEnabled = useLyricsSettingsStore((s) => s.onlineLookupEnabled); return ( @@ -27,6 +29,14 @@ export default function ServicesSettingsScreen() { onPress={() => router.push('/sources')} /> + LYRICS + router.push('/settings/lyrics' as never)} + /> + SCROBBLING s.isScanning); + const [runningAction, setRunningAction] = useState(null); + const [feedback, setFeedback] = useState<{ kind: 'success' | 'error'; text: string } | null>(null); + const [counts, setCounts] = useState(null); + const disabled = isScanning || runningAction !== null; + + const refreshCounts = useCallback(async () => { + const db = await openLibraryDb(); + const [lyrics, waveforms] = await Promise.all([ + getLyricsCacheCount(db), + getWaveformCacheCount(db), + ]); + setCounts({ lyrics, waveforms }); + }, []); + + useEffect(() => { + const timer = setTimeout(() => { + void refreshCounts().catch(() => setCounts({ lyrics: 0, waveforms: 0 })); + }, 0); + return () => clearTimeout(timer); + }, [refreshCounts]); + + const run = async (key: ActionKey, action: () => Promise, success: string) => { + if (disabled) return; + setRunningAction(key); + setFeedback(null); + try { + await action(); + const scanError = useLibraryStore.getState().scanError; + if ((key === 'scan' || key === 'rebuild') && scanError) throw new Error(scanError); + setFeedback({ kind: 'success', text: success }); + await refreshCounts(); + } catch (error) { + setFeedback({ + kind: 'error', + text: error instanceof Error ? error.message : 'The maintenance action failed.', + }); + } finally { + setRunningAction(null); + } + }; + + const confirmRebuild = () => { + Alert.alert( + 'Rebuild local library index?', + 'A foreground scan will re-read every local track. Folders, playlists, favorites, history, remote sources, and settings are preserved.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Rebuild', + onPress: () => void run( + 'rebuild', + () => useLibraryStore.getState().rebuildLocalIndex(), + 'Local library index rebuilt.', + ), + }, + ] + ); + }; + + const confirmOnboarding = () => { + Alert.alert( + 'Replay onboarding?', + 'The first-run setup opens immediately. Your library and settings are kept.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Replay', + onPress: () => void run('onboarding', () => useOnboardingStore.getState().reset(), 'Opening onboarding…'), + }, + ] + ); + }; + + return ( + + + + LIBRARY + void run('scan', () => useLibraryStore.getState().rescan(), 'Library scan complete.')} + /> + + + CACHES + void run('lyrics', async () => { + await clearAllLyricsCache(); + useLyricsStore.getState().invalidateAll(); + }, 'Lyrics cache cleared.')} + /> + void run('waveform', clearAllWaveformCache, 'Waveform cache cleared.')} + /> + + SETUP + + + {feedback ? ( + + + + {feedback.text} + + + ) : null} + + ); +} + +function MaintenanceRow({ + icon, + title, + description, + disabled, + running, + onPress, +}: { + icon: SettingsIconName; + title: string; + description: string; + disabled: boolean; + running: boolean; + onPress: () => void; +}) { + const styles = useStyles(); + const colors = useColors(); + const ripple = useRipple(); + return ( + + + + + + {title} + {description} + + {running ? : } + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + row: { + flexDirection: 'row', alignItems: 'center', gap: spacing.md, padding: spacing.lg, + borderRadius: radius.md, borderWidth: 1, borderColor: colors.glassBorder, + backgroundColor: colors.glassBg, + }, + icon: { + width: 36, height: 36, borderRadius: radius.sm, + alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bgTertiary, + }, + meta: { flex: 1, minWidth: 0, gap: 2 }, + description: { lineHeight: 16 }, + disabled: { opacity: 0.45 }, + feedback: { + flexDirection: 'row', alignItems: 'center', gap: spacing.sm, + padding: spacing.md, borderRadius: radius.sm, + borderWidth: 1, borderColor: colors.glassBorder, + }, + errorFeedback: { borderColor: colors.warning }, + feedbackText: { flex: 1 }, +})); diff --git a/src/audio/playbackService.ts b/src/audio/playbackService.ts index ffc0ef0..2a4318b 100644 --- a/src/audio/playbackService.ts +++ b/src/audio/playbackService.ts @@ -6,6 +6,7 @@ import { startAudioProcessingWarmup } from './audioProcessingStartup'; import { playForCar, skipToNext, skipToPrevious } from './playbackController'; import { nativeIndexToAbsolute } from './queueLoader'; import { useQueueStore } from '@/stores/queueStore'; +import { useSleepTimerStore } from '@/stores/sleepTimerStore'; /** * RNTP playback service — registered in `index.js`. Runs in a headless context @@ -13,6 +14,7 @@ import { useQueueStore } from '@/stores/queueStore'; * controls to the player. Must not depend on React or the JS UI tree. */ export async function PlaybackService(): Promise { + void useSleepTimerStore.getState().hydrate().catch(() => {}); // Begin the small fail-closed warm-up before a car/Bluetooth play command can // arrive. Full-queue registration and analysis start only after it is safe. void startAudioProcessingWarmup('playback-service-start').catch((error) => { @@ -72,6 +74,28 @@ export async function PlaybackService(): Promise { }); TrackPlayer.addEventListener(Event.PlaybackState, () => { scheduleSync(); + void useSleepTimerStore.getState().reconcile(); + }); + TrackPlayer.addEventListener(Event.PlaybackProgressUpdated, ({ position, duration }) => { + const timer = useSleepTimerStore.getState(); + void timer.reconcile(); + if (timer.timer?.mode === 'end-of-track') { + void TrackPlayer.getPlayWhenReady() + .then((playWhenReady) => timer.reconcileEndOfTrack(position, duration, playWhenReady)) + .catch(() => {}); + } + }); + TrackPlayer.addEventListener(Event.PlaybackPlayWhenReadyChanged, ({ playWhenReady }) => { + if (playWhenReady || useSleepTimerStore.getState().timer?.mode !== 'end-of-track') return; + void TrackPlayer.getProgress() + .then(({ position, duration }) => useSleepTimerStore.getState().reconcileEndOfTrack(position, duration, playWhenReady)) + .catch(() => {}); + }); + TrackPlayer.addEventListener(Event.PlaybackQueueEnded, () => { + if (useSleepTimerStore.getState().timer?.mode !== 'end-of-track') return; + void TrackPlayer.getProgress() + .then(({ position, duration }) => useSleepTimerStore.getState().reconcileEndOfTrack(position, duration, false)) + .catch(() => {}); }); TrackPlayer.addEventListener(Event.RemotePlay, () => { void playForCar() diff --git a/src/audio/sleepTimerState.test.mts b/src/audio/sleepTimerState.test.mts new file mode 100644 index 0000000..9f51a7c --- /dev/null +++ b/src/audio/sleepTimerState.test.mts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + formatSleepTimerStatus, + getSleepTimerRemainingMs, + normalizePersistedSleepTimer, + normalizeSleepTimerMinutes, + shouldCompleteEndOfTrackTimer, + transitionSleepTimer, +} from './sleepTimerState.ts'; + +test('sleep timer accepts presets and custom whole minutes from 1 through 720', () => { + for (const value of [1, 15, 30, 45, 60, 720, '90']) { + assert.equal(normalizeSleepTimerMinutes(value), Number(value)); + } + for (const value of [0, 721, 1.5, '', 'nope']) assert.equal(normalizeSleepTimerMinutes(value), null); +}); + +test('minute timers use an absolute wall-clock deadline', () => { + const timer = normalizePersistedSleepTimer({ + mode: 'minutes', startedAtMs: 1_000, expiresAtMs: 61_000, durationMinutes: 1, + }); + assert.ok(timer); + assert.equal(getSleepTimerRemainingMs(timer, 31_000), 30_000); + assert.equal(getSleepTimerRemainingMs(timer, 80_000), 0); + assert.equal(formatSleepTimerStatus(timer, 31_000), '0:30 remaining'); +}); + +test('stale or corrupt persisted timers are rejected while end-of-track is normalized', () => { + assert.equal(normalizePersistedSleepTimer({ mode: 'minutes', startedAtMs: 0, expiresAtMs: 1, durationMinutes: 0 }), null); + assert.equal(normalizePersistedSleepTimer({ mode: 'unknown' }), null); + assert.deepEqual(normalizePersistedSleepTimer({ mode: 'end-of-track', startedAtMs: 10, expiresAtMs: 50 }), { + mode: 'end-of-track', startedAtMs: 10, expiresAtMs: null, durationMinutes: null, + }); +}); + +test('starting replaces an active timer and cancellation clears it', () => { + const first = transitionSleepTimer(null, { type: 'start-minutes', minutes: 15 }, 1_000); + const replacement = transitionSleepTimer(first, { type: 'start-minutes', minutes: 45 }, 5_000); + assert.equal(replacement?.durationMinutes, 45); + assert.equal(replacement?.expiresAtMs, 2_705_000); + assert.equal(transitionSleepTimer(replacement, { type: 'cancel' }, 6_000), null); +}); + +test('end-of-track completes only after native playback pauses at the boundary', () => { + const timer = transitionSleepTimer(null, { type: 'start-end-of-track' }, 1_000); + assert.equal(shouldCompleteEndOfTrackTimer(timer, 99.8, 100, true), false); + assert.equal(shouldCompleteEndOfTrackTimer(timer, 50, 100, false), false); + assert.equal(shouldCompleteEndOfTrackTimer(timer, 99.8, 100, false), true); +}); diff --git a/src/audio/sleepTimerState.ts b/src/audio/sleepTimerState.ts new file mode 100644 index 0000000..dc996a1 --- /dev/null +++ b/src/audio/sleepTimerState.ts @@ -0,0 +1,100 @@ +export const SLEEP_TIMER_PRESETS = [15, 30, 45, 60] as const; +export const MIN_SLEEP_TIMER_MINUTES = 1; +export const MAX_SLEEP_TIMER_MINUTES = 720; + +export type SleepTimerMode = 'minutes' | 'end-of-track'; + +export interface PersistedSleepTimerState { + mode: SleepTimerMode; + startedAtMs: number; + expiresAtMs: number | null; + durationMinutes: number | null; +} + +export type SleepTimerTransition = + | { type: 'start-minutes'; minutes: number } + | { type: 'start-end-of-track' } + | { type: 'cancel' }; + +export function normalizeSleepTimerMinutes(value: unknown): number | null { + const parsed = typeof value === 'number' ? value : Number(String(value).trim()); + if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) return null; + if (parsed < MIN_SLEEP_TIMER_MINUTES || parsed > MAX_SLEEP_TIMER_MINUTES) return null; + return parsed; +} + +export function normalizePersistedSleepTimer(value: unknown): PersistedSleepTimerState | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + const candidate = value as Partial; + if (candidate.mode !== 'minutes' && candidate.mode !== 'end-of-track') return null; + if (typeof candidate.startedAtMs !== 'number' || !Number.isFinite(candidate.startedAtMs)) return null; + if (candidate.mode === 'end-of-track') { + return { + mode: 'end-of-track', + startedAtMs: candidate.startedAtMs, + expiresAtMs: null, + durationMinutes: null, + }; + } + const durationMinutes = normalizeSleepTimerMinutes(candidate.durationMinutes); + if (durationMinutes === null || typeof candidate.expiresAtMs !== 'number' || !Number.isFinite(candidate.expiresAtMs)) { + return null; + } + return { + mode: 'minutes', + startedAtMs: candidate.startedAtMs, + expiresAtMs: candidate.expiresAtMs, + durationMinutes, + }; +} + +export function transitionSleepTimer( + current: PersistedSleepTimerState | null, + transition: SleepTimerTransition, + nowMs: number +): PersistedSleepTimerState | null { + void current; + if (transition.type === 'cancel') return null; + if (transition.type === 'start-end-of-track') { + return { mode: 'end-of-track', startedAtMs: nowMs, expiresAtMs: null, durationMinutes: null }; + } + const minutes = normalizeSleepTimerMinutes(transition.minutes); + if (minutes === null) return null; + return { + mode: 'minutes', + startedAtMs: nowMs, + expiresAtMs: nowMs + minutes * 60_000, + durationMinutes: minutes, + }; +} + +export function shouldCompleteEndOfTrackTimer( + timer: PersistedSleepTimerState | null, + position: number, + duration: number, + playWhenReady: boolean +): boolean { + return timer?.mode === 'end-of-track' + && !playWhenReady + && Number.isFinite(duration) + && duration > 0 + && position >= duration - 0.75; +} + +export function getSleepTimerRemainingMs(timer: PersistedSleepTimerState | null, nowMs: number): number | null { + if (!timer || timer.mode !== 'minutes' || timer.expiresAtMs === null) return null; + return Math.max(0, timer.expiresAtMs - nowMs); +} + +export function formatSleepTimerStatus(timer: PersistedSleepTimerState | null, nowMs = Date.now()): string { + if (!timer) return 'Off'; + if (timer.mode === 'end-of-track') return 'Ends after this track'; + const remaining = getSleepTimerRemainingMs(timer, nowMs) ?? 0; + const totalSeconds = Math.max(0, Math.ceil(remaining / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + return hours > 0 + ? `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')} remaining` + : `${minutes}:${String(seconds).padStart(2, '0')} remaining`; +} diff --git a/src/audio/trackPlayerExtensions.ts b/src/audio/trackPlayerExtensions.ts new file mode 100644 index 0000000..b35f196 --- /dev/null +++ b/src/audio/trackPlayerExtensions.ts @@ -0,0 +1,19 @@ +import { NativeModules, Platform } from 'react-native'; + +interface TrackPlayerModuleExtension { + setPauseAtEndOfItem?: (enabled: boolean) => Promise; +} + +const nativeTrackPlayer = NativeModules.TrackPlayerModule as TrackPlayerModuleExtension | undefined; + +export function supportsNativePauseAtEndOfItem(): boolean { + return Platform.OS === 'android' && typeof nativeTrackPlayer?.setPauseAtEndOfItem === 'function'; +} + +export async function setPauseAtEndOfItem(enabled: boolean): Promise { + if (!supportsNativePauseAtEndOfItem()) { + if (enabled) throw new Error('End-of-track timers are unavailable on this device.'); + return; + } + await nativeTrackPlayer?.setPauseAtEndOfItem?.(enabled); +} diff --git a/src/components/lyrics/LyricsBand.tsx b/src/components/lyrics/LyricsBand.tsx index 7a23395..e99081b 100644 --- a/src/components/lyrics/LyricsBand.tsx +++ b/src/components/lyrics/LyricsBand.tsx @@ -13,6 +13,7 @@ import { useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime'; import { useLyricsStore } from '@/stores/lyricsStore'; +import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore'; import { getLyricsLineSeekTimeSeconds, getSyncedLyricsDisplayLines, @@ -24,7 +25,6 @@ import { import { LyricsLine, type LyricsLineTier } from './LyricsLine'; import type { Track } from '@/types/audio'; -const TRANSLATION_PRIORITY = ['en', 'ja-Latn']; const ANCHOR_RATIO = 0.4; const H_PADDING = 22; // The displayed active line lags the audio by a fixed pipeline delay (RNTP @@ -48,6 +48,11 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }: const ripple = useRipple(); const entry = useLyricsStore((s) => s.byPath[track.path]); const loadForTrack = useLyricsStore((s) => s.loadForTrack); + const wordTimingEnabled = useLyricsSettingsStore((s) => s.wordTimingEnabled); + const furiganaEnabled = useLyricsSettingsStore((s) => s.furiganaEnabled); + const translationsEnabled = useLyricsSettingsStore((s) => s.translationsEnabled); + const translationPriority = useLyricsSettingsStore((s) => s.translationPriority); + const voiceLabelsEnabled = useLyricsSettingsStore((s) => s.voiceLabelsEnabled); useEffect(() => { void loadForTrack(track); @@ -109,7 +114,7 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }: }, 90); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [track.path, displayLines.length]); + }, [track.path, displayLines.length, furiganaEnabled, translationsEnabled, voiceLabelsEnabled, wordTimingEnabled]); // Follow the active/focus line as playback advances. useEffect(() => { @@ -184,8 +189,8 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }: }; if (displayLine.kind === 'gap') { - const progress = getSyncedLyricsGapProgress(displayLine, lyricsTime) ?? 0; const isCurrentGap = displayLine.displayIndex === focusIndex && timing.isNeutral; + const progress = isCurrentGap ? getSyncedLyricsGapProgress(displayLine, lyricsTime) ?? 0 : 0; return ( { if (seekSeconds != null) onSeek(seekSeconds); }} diff --git a/src/components/lyrics/LyricsLine.tsx b/src/components/lyrics/LyricsLine.tsx index c3d8a25..9ea276d 100644 --- a/src/components/lyrics/LyricsLine.tsx +++ b/src/components/lyrics/LyricsLine.tsx @@ -1,28 +1,11 @@ -// One synced lyric line for the now-playing lyrics view. Left-aligned (mobile / -// Apple-Music reading style), with furigana ruby columns when present and an -// optional translation line beneath. Tapping seeks to the line. -// -// Every line uses the SAME font size, weight, and metrics, so text wrapping and -// line heights are identical across tiers — the layout never reflows as the active -// line moves, which is what caused the surrounding lines to shift/flicker. The -// active line is emphasized only by opacity, a small scale, and an accent glow — -// all paint/transform, never layout — and both animate so tier changes ease -// instead of snapping. -// -// RN has no , so ruby is a stacked column: a small reading Text over the -// base Text. Every segment is the identical box (a View reserving `readingHeight` -// of top space with the base below) so all bases land on one line; the reading is -// absolutely positioned in that top zone, base-width and shrunk to fit, so a wide -// reading never widens the column and spreads the sentence apart. - -import { memo, useEffect, useMemo } from 'react'; +import { memo, useEffect, useMemo, useState } from 'react'; import { Pressable, View, type LayoutChangeEvent } from 'react-native'; import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; import { Text } from '@/components/Text'; import { useColors } from '@/theme/themed'; import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; -import { getPreferredLyricsTranslation } from '@/lyrics/presentation'; -import type { LyricsFurigana, LyricsLine as LyricsLineData } from '@/lyrics/types'; +import { getPreferredLyricsTranslation, resolveLyricsWordTiming } from '@/lyrics/presentation'; +import type { LyricsFurigana, LyricsLine as LyricsLineData, LyricsWord } from '@/lyrics/types'; export type LyricsLineTier = 'active' | 'near' | 'far' | 'distant'; @@ -30,16 +13,19 @@ interface LyricsLineProps { line: LyricsLineData; tier: LyricsLineTier; baseSize: number; + activeTimeSeconds: number | null; + wordTimingEnabled: boolean; + furiganaEnabled: boolean; + translationsEnabled: boolean; translationPriority: string[]; + voiceLabelsEnabled: boolean; onSeek: () => void; onLayout?: (event: LayoutChangeEvent) => void; } -// scale/opacity only — never anything that reflows layout. Active scale is kept -// small enough that the grow overflows into the horizontal padding, not off-screen. const TIER: Record = { active: { scale: 1.06, opacity: 1 }, - near: { scale: 1.0, opacity: 0.52 }, + near: { scale: 1, opacity: 0.52 }, far: { scale: 0.965, opacity: 0.27 }, distant: { scale: 0.93, opacity: 0.13 }, }; @@ -64,24 +50,157 @@ function buildSegments(text: string, furigana: LyricsFurigana[] | undefined): Se cursor = entry.end; } if (cursor < text.length) segments.push({ text: text.slice(cursor) }); - return segments; + return segments.length > 0 ? segments : [{ text }]; } -function LyricsLineComponent({ line, tier, baseSize, translationPriority, onSeek, onLayout }: LyricsLineProps) { +function RubyText({ + text, + furigana, + enabled, + size, + lineHeight, + readingSize, + readingHeight, + color, + readingColor, + shadow, +}: { + text: string; + furigana?: LyricsFurigana[]; + enabled: boolean; + size: number; + lineHeight: number; + readingSize: number; + readingHeight: number; + color: string; + readingColor: string; + shadow?: object; +}) { + const segments = useMemo( + () => buildSegments(text, enabled ? furigana : undefined), + [enabled, furigana, text] + ); + if (!enabled || !furigana?.length) { + return {text}; + } + return ( + + {segments.map((segment, index) => ( + + + {segment.text} + + {segment.reading ? ( + + {segment.reading} + + ) : null} + + ))} + + ); +} + +function TimedWord({ + word, + progress, + furiganaEnabled, + size, + lineHeight, + readingSize, + readingHeight, +}: { + word: LyricsWord; + progress: number; + furiganaEnabled: boolean; + size: number; + lineHeight: number; + readingSize: number; + readingHeight: number; +}) { + const colors = useColors(); + const [width, setWidth] = useState(0); + const hasFurigana = furiganaEnabled && Boolean(word.furigana?.length); + const textTop = hasFurigana ? readingHeight : 0; + return ( + setWidth(event.nativeEvent.layout.width)} + > + + {width > 0 && progress > 0 ? ( + + + {word.text} + + + ) : null} + + ); +} + +function LyricsLineComponent({ + line, + tier, + baseSize, + activeTimeSeconds, + wordTimingEnabled, + furiganaEnabled, + translationsEnabled, + translationPriority, + voiceLabelsEnabled, + onSeek, + onLayout, +}: LyricsLineProps) { const colors = useColors(); const ripple = useRipple(); const target = TIER[tier]; - // Uniform metrics for every line (the whole point — no wrap/reflow between tiers). const size = baseSize; const lineHeight = Math.round(size * 1.2); const readingSize = Math.max(9, Math.round(size * 0.5)); const readingHeight = Math.round(readingSize * 1.25); const translation = useMemo( - () => getPreferredLyricsTranslation(line, translationPriority), - [line, translationPriority] + () => translationsEnabled ? getPreferredLyricsTranslation(line, translationPriority) : null, + [line, translationPriority, translationsEnabled] + ); + const words = useMemo( + () => wordTimingEnabled ? line.words ?? [] : [], + [line.words, wordTimingEnabled] + ); + const wordTiming = useMemo( + () => activeTimeSeconds === null ? null : resolveLyricsWordTiming(words, activeTimeSeconds), + [activeTimeSeconds, words] ); - const hasFurigana = Boolean(line.furigana && line.furigana.length > 0); - const segments = useMemo(() => buildSegments(line.text, line.furigana), [line.text, line.furigana]); const opacity = useSharedValue(target.opacity); const scale = useSharedValue(target.scale); @@ -93,71 +212,57 @@ function LyricsLineComponent({ line, tier, baseSize, translationPriority, onSeek opacity: opacity.value, transform: [{ scale: scale.value }], })); - - const textColor = colors.textPrimary; - const mainShadow = - tier === 'active' - ? { textShadowColor: colors.accentGlow, textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 16 } - : undefined; + const mainShadow = tier === 'active' + ? { textShadowColor: colors.accentGlow, textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 16 } + : undefined; return ( - {hasFurigana ? ( - - {segments.map((segment, index) => ( - - - {segment.text} - - {segment.reading ? ( - - {segment.reading} - - ) : null} - - ))} - - ) : ( - - {line.text} - - )} - + + {voiceLabelsEnabled && line.voice?.trim() ? ( + + + {line.voice.trim()} + + + ) : null} + {words.length > 0 ? ( + + {words.map((word, index) => ( + + ))} + + ) : ( + + )} + {translation ? ( {translation.text} @@ -168,4 +273,16 @@ function LyricsLineComponent({ line, tier, baseSize, translationPriority, onSeek ); } -export const LyricsLine = memo(LyricsLineComponent); +function sameLyricsLineProps(previous: LyricsLineProps, next: LyricsLineProps): boolean { + return previous.line === next.line + && previous.tier === next.tier + && previous.baseSize === next.baseSize + && previous.activeTimeSeconds === next.activeTimeSeconds + && previous.wordTimingEnabled === next.wordTimingEnabled + && previous.furiganaEnabled === next.furiganaEnabled + && previous.translationsEnabled === next.translationsEnabled + && previous.translationPriority === next.translationPriority + && previous.voiceLabelsEnabled === next.voiceLabelsEnabled; +} + +export const LyricsLine = memo(LyricsLineComponent, sameLyricsLineProps); diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx index 4a78218..52efdad 100644 --- a/src/components/player/NowPlayingOverlay.tsx +++ b/src/components/player/NowPlayingOverlay.tsx @@ -41,6 +41,8 @@ import { NowPlayingCompanionPane } from '@/components/player/NowPlayingCompanion import { PlayerStateIcon } from '@/components/player/PlayerStateIcon'; import { CachedLyricPeek } from '@/components/player/CachedLyricPeek'; import { useDelayedUnmountPresence } from '@/components/delayedPresence'; +import { SleepTimerControls } from '@/components/player/SleepTimerControls'; +import { AppSheet, AppSheetTitle } from '@/components/sheets/AppSheet'; import { radius, spacing, @@ -73,6 +75,7 @@ import { usePlaylistStore } from '@/stores/playlistStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; import { usePlayerUiStore } from '@/stores/playerUiStore'; import { useSettingsStore, type ScopeMode } from '@/stores/settingsStore'; +import { useSleepTimerStore } from '@/stores/sleepTimerStore'; import type { DbTrack } from '@/types/library'; import { cycleRepeat, @@ -89,6 +92,7 @@ import { getPhonePlaybackPresentation, hostFromBaseUrl, } from '@/playback/playbackTargetPresentation'; +import { formatSleepTimerStatus } from '@/audio/sleepTimerState'; const DISMISS_DISTANCE = 140; const DISMISS_VELOCITY = 1000; @@ -127,6 +131,7 @@ export function NowPlayingOverlay() { const closeQueue = useCallback(() => setQueueOpen(false), []); const [menuOpen, setMenuOpen] = useState(false); const [targetPickerOpen, setTargetPickerOpen] = useState(false); + const [sleepTimerOpen, setSleepTimerOpen] = useState(false); const [playlistActionTrack, setPlaylistActionTrack] = useState(null); const selectedTarget = usePlaybackTargetStore((s) => s.target); const scopeMode = useSettingsStore((s) => s.scopeMode); @@ -152,6 +157,9 @@ export function NowPlayingOverlay() { const desktopQueue = useDesktopRemoteStore((s) => s.queue); const sendDesktopControl = useDesktopRemoteStore((s) => s.sendControl); const reconnectDesktop = useDesktopRemoteStore((s) => s.reconnect); + const sleepTimer = useSleepTimerStore((s) => s.timer); + const sleepRemainingMs = useSleepTimerStore((s) => s.remainingMs); + void sleepRemainingMs; const phonePresentation = getPhonePlaybackPresentation({ track, playbackState, @@ -311,6 +319,17 @@ export function NowPlayingOverlay() { setTargetPickerOpen(true); }, }); + if (!isDesktopTarget) { + menuItems.push({ + key: 'sleep-timer', + label: sleepTimer ? `Sleep timer · ${formatSleepTimerStatus(sleepTimer)}` : 'Sleep timer', + icon: 'moon-outline', + onPress: () => { + closeMenu(); + setSleepTimerOpen(true); + }, + }); + } if (!isDesktopTarget && artistName) { menuItems.push({ key: 'artist', @@ -1351,6 +1370,12 @@ export function NowPlayingOverlay() { initialStep="pickPlaylist" onClose={() => setPlaylistActionTrack(null)} /> + {sleepTimerOpen ? ( + setSleepTimerOpen(false)}> + + + + ) : null} {queueOpen && !hasTabletCompanion && ( isDesktopTarget ? ( diff --git a/src/components/player/SleepTimerControls.tsx b/src/components/player/SleepTimerControls.tsx new file mode 100644 index 0000000..f8d6be3 --- /dev/null +++ b/src/components/player/SleepTimerControls.tsx @@ -0,0 +1,169 @@ +import { useEffect, useState } from 'react'; +import { Pressable, TextInput, View } from 'react-native'; +import { Text } from '@/components/Text'; +import { SLEEP_TIMER_PRESETS, formatSleepTimerStatus, normalizeSleepTimerMinutes } from '@/audio/sleepTimerState'; +import { supportsNativePauseAtEndOfItem } from '@/audio/trackPlayerExtensions'; +import { usePlayerStore } from '@/stores/playerStore'; +import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; +import { useSleepTimerStore } from '@/stores/sleepTimerStore'; +import { radius, spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; +import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple'; + +export function SleepTimerControls() { + const styles = useStyles(); + const colors = useColors(); + const ripple = useRipple(); + const timer = useSleepTimerStore((s) => s.timer); + const remainingMs = useSleepTimerStore((s) => s.remainingMs); + const hydrate = useSleepTimerStore((s) => s.hydrate); + const startMinutes = useSleepTimerStore((s) => s.startMinutes); + const startEndOfTrack = useSleepTimerStore((s) => s.startEndOfTrack); + const cancel = useSleepTimerStore((s) => s.cancel); + const reconcile = useSleepTimerStore((s) => s.reconcile); + const target = usePlaybackTargetStore((s) => s.target); + const track = usePlayerStore((s) => s.currentTrack); + const [customMinutes, setCustomMinutes] = useState(''); + const [feedback, setFeedback] = useState(null); + const available = target === 'phone' && Boolean(track); + void remainingMs; + + useEffect(() => { + void hydrate(); + }, [hydrate]); + + useEffect(() => { + if (!timer) return; + const interval = setInterval(() => void reconcile(), 1000); + return () => clearInterval(interval); + }, [reconcile, timer]); + + const run = async (action: () => Promise, success: string) => { + setFeedback(null); + try { + await action(); + setFeedback(success); + } catch (error) { + setFeedback(error instanceof Error ? error.message : 'Could not update the sleep timer.'); + } + }; + + const startCustom = () => { + const minutes = normalizeSleepTimerMinutes(customMinutes); + if (minutes === null) { + setFeedback('Enter a whole number from 1 to 720 minutes.'); + return; + } + void run(() => startMinutes(minutes), `Timer set for ${minutes} minutes.`); + }; + + return ( + + + {timer ? formatSleepTimerStatus(timer) : 'No sleep timer'} + + {!available + ? target === 'desktop' + ? 'Sleep timers are available for phone playback only.' + : 'Load a track on this phone to set a timer.' + : timer?.mode === 'minutes' + ? 'Wall-clock time continues while playback is paused.' + : timer?.mode === 'end-of-track' + ? 'Seeking and manual skips keep the timer armed.' + : 'Playback pauses without clearing the queue or position.'} + + + + + {SLEEP_TIMER_PRESETS.map((minutes) => ( + void run(() => startMinutes(minutes), `Timer set for ${minutes} minutes.`)} + style={({ pressed }) => [styles.preset, !available && styles.disabled, pressed && available && styles.pressed]} + accessibilityRole="button" + > + {minutes} min + + ))} + + + + + [styles.action, !available && styles.disabled, pressed && available && styles.pressed]} + > + Set custom + + + + void run(startEndOfTrack, 'Timer set for the end of the track.')} + style={({ pressed }) => [ + styles.fullAction, + (!available || !supportsNativePauseAtEndOfItem()) && styles.disabled, + pressed && available && styles.pressed, + ]} + > + End of track + + {supportsNativePauseAtEndOfItem() ? 'Pause exactly before the next track begins.' : 'Requires the Android playback engine.'} + + + + {timer ? ( + void run(cancel, 'Sleep timer canceled.')} style={styles.cancel}> + Cancel sleep timer + + ) : null} + + {feedback ? {feedback} : null} + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + container: { gap: spacing.md }, + statusBlock: { gap: 3 }, + presets: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.sm }, + preset: { + flexGrow: 1, minWidth: 66, alignItems: 'center', paddingVertical: spacing.sm, + borderRadius: radius.sm, borderWidth: 1, borderColor: colors.glassBorder, + backgroundColor: colors.bgTertiary, + }, + customRow: { flexDirection: 'row', gap: spacing.sm }, + input: { + width: 92, color: colors.textPrimary, backgroundColor: colors.bgTertiary, + borderRadius: radius.sm, borderWidth: 1, borderColor: colors.glassBorder, + paddingHorizontal: spacing.md, paddingVertical: spacing.sm, fontSize: 16, + }, + action: { + flex: 1, alignItems: 'center', justifyContent: 'center', borderRadius: radius.sm, + borderWidth: 1, borderColor: colors.accent, backgroundColor: colors.bgTertiary, + }, + fullAction: { + gap: 2, padding: spacing.md, borderRadius: radius.sm, + borderWidth: 1, borderColor: colors.glassBorder, backgroundColor: colors.bgTertiary, + }, + cancel: { alignItems: 'center', paddingVertical: spacing.sm }, + disabled: { opacity: 0.42 }, + pressed: { opacity: 0.72 }, +})); diff --git a/src/components/search/QuickSearchOverlay.tsx b/src/components/search/QuickSearchOverlay.tsx index 512b97f..24aa282 100644 --- a/src/components/search/QuickSearchOverlay.tsx +++ b/src/components/search/QuickSearchOverlay.tsx @@ -50,6 +50,7 @@ import type { DbTrack } from '@/types/library'; import type { Playlist } from '@/types/playlist'; +import { SETTINGS_SEARCH_ROUTES } from '@/components/search/settingsSearchRoutes'; type IconName = keyof typeof Ionicons.glyphMap; type RouteHref = @@ -60,8 +61,11 @@ type RouteHref = | '/settings/appearance' | '/settings/library' | '/settings/audio' + | '/settings/playback' | '/settings/services' + | '/settings/lyrics' | '/settings/experimental' + | '/settings/troubleshooting' | '/settings/info' | '/sources' | '/lastfm'; @@ -149,6 +153,14 @@ const SETTING_ENTRIES: { icon: 'volume-high', keywords: ['audio', 'normalization', 'replaygain', 'loudness', 'gain', 'target lufs'], }, + { + id: 'setting:playback', + label: 'Playback settings', + subtitle: 'Sleep timer / end of track', + href: SETTINGS_SEARCH_ROUTES.playback, + icon: 'play-circle-outline', + keywords: ['playback', 'sleep timer', 'timer', 'end of track', 'pause after track'], + }, { id: 'setting:library', label: 'Library settings', @@ -173,6 +185,14 @@ const SETTING_ENTRIES: { icon: 'server-outline', keywords: ['services', 'integrations', 'remote sources', 'scrobbling'], }, + { + id: 'setting:lyrics', + label: 'Lyrics settings', + subtitle: 'XLRC / furigana / translations', + href: SETTINGS_SEARCH_ROUTES.lyrics, + icon: 'musical-notes-outline', + keywords: ['lyrics', 'xlrc', 'word timing', 'furigana', 'translations', 'voice labels', 'lrclib', 'xlrcdb'], + }, { id: 'setting:sources', label: 'Remote sources', @@ -205,6 +225,14 @@ const SETTING_ENTRIES: { icon: 'information-circle-outline', keywords: ['info', 'about', 'version', 'license', 'attribution', 'github', 'repo', 'repository', 'discord', 'kofi', 'ko-fi', 'support', 'gpl'], }, + { + id: 'setting:troubleshooting', + label: 'Troubleshooting', + subtitle: 'Rescan / rebuild / clear caches', + href: SETTINGS_SEARCH_ROUTES.troubleshooting, + icon: 'build-outline', + keywords: ['troubleshooting', 'support', 'rescan', 'rebuild index', 'clear lyrics cache', 'clear waveform cache', 'onboarding'], + }, ]; const EMPTY_SHORTCUT_IDS = ['nav:library', 'nav:eq', 'setting:sources', 'setting:lastfm']; diff --git a/src/components/search/settingsSearchRoutes.test.mts b/src/components/search/settingsSearchRoutes.test.mts new file mode 100644 index 0000000..4faba93 --- /dev/null +++ b/src/components/search/settingsSearchRoutes.test.mts @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { SETTINGS_SEARCH_ROUTES } from './settingsSearchRoutes.ts'; + +test('Quick Search exposes the new stable settings destinations', () => { + assert.deepEqual(Object.values(SETTINGS_SEARCH_ROUTES), [ + '/settings/playback', + '/settings/lyrics', + '/settings/troubleshooting', + ]); +}); diff --git a/src/components/search/settingsSearchRoutes.ts b/src/components/search/settingsSearchRoutes.ts new file mode 100644 index 0000000..8c0337f --- /dev/null +++ b/src/components/search/settingsSearchRoutes.ts @@ -0,0 +1,5 @@ +export const SETTINGS_SEARCH_ROUTES = { + playback: '/settings/playback', + lyrics: '/settings/lyrics', + troubleshooting: '/settings/troubleshooting', +} as const; diff --git a/src/db/libraryMaintenance.test.mts b/src/db/libraryMaintenance.test.mts new file mode 100644 index 0000000..aa6d619 --- /dev/null +++ b/src/db/libraryMaintenance.test.mts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { REBUILD_LOCAL_LIBRARY_INDEX_SQL, markLocalTracksStaleForRebuild } from './libraryMaintenance.ts'; + +test('library rebuild marks only local tracks stale', async () => { + assert.match(REBUILD_LOCAL_LIBRARY_INDEX_SQL, /source_type\s*=\s*'local'/i); + assert.doesNotMatch(REBUILD_LOCAL_LIBRARY_INDEX_SQL, /DELETE|DROP/i); + let executed = ''; + const changes = await markLocalTracksStaleForRebuild({ + run: async (sql: string) => { + executed = sql; + return { changes: 12, lastInsertRowid: 0 }; + }, + } as never); + assert.equal(executed, REBUILD_LOCAL_LIBRARY_INDEX_SQL); + assert.equal(changes, 12); +}); diff --git a/src/db/libraryMaintenance.ts b/src/db/libraryMaintenance.ts new file mode 100644 index 0000000..c9376dd --- /dev/null +++ b/src/db/libraryMaintenance.ts @@ -0,0 +1,10 @@ +import type { LibraryDatabase } from './database'; + +export const REBUILD_LOCAL_LIBRARY_INDEX_SQL = + "UPDATE tracks SET mtime = -1 WHERE source_type = 'local'"; + +/** Marks only device-local rows stale so the normal scanner re-extracts them. */ +export async function markLocalTracksStaleForRebuild(db: LibraryDatabase): Promise { + const result = await db.run(REBUILD_LOCAL_LIBRARY_INDEX_SQL); + return result.changes; +} diff --git a/src/db/lyricsQueries.ts b/src/db/lyricsQueries.ts index 9f95c12..56622c4 100644 --- a/src/db/lyricsQueries.ts +++ b/src/db/lyricsQueries.ts @@ -105,3 +105,12 @@ export async function putLyricsCache(db: LibraryDatabase, entry: LyricsCacheWrit ] ); } + +export async function getLyricsCacheCount(db: LibraryDatabase): Promise { + const row = await db.get<{ count: number }>('SELECT COUNT(*) AS count FROM lyrics_cache'); + return row?.count ?? 0; +} + +export async function clearLyricsCache(db: LibraryDatabase): Promise { + await db.run('DELETE FROM lyrics_cache'); +} diff --git a/src/db/waveformQueries.ts b/src/db/waveformQueries.ts index dfdbbc0..958c603 100644 --- a/src/db/waveformQueries.ts +++ b/src/db/waveformQueries.ts @@ -36,6 +36,15 @@ export async function putWaveformPeaks( ); } +export async function getWaveformCacheCount(db: LibraryDatabase): Promise { + const row = await db.get<{ count: number }>('SELECT COUNT(*) AS count FROM waveform_peaks'); + return row?.count ?? 0; +} + +export async function clearWaveformCache(db: LibraryDatabase): Promise { + await db.run('DELETE FROM waveform_peaks'); +} + function toFloat32(blob: ArrayBuffer | ArrayBufferView): Float32Array { if (blob instanceof Float32Array) return blob; if (ArrayBuffer.isView(blob)) { diff --git a/src/lib/cacheInvalidation.test.mts b/src/lib/cacheInvalidation.test.mts new file mode 100644 index 0000000..a5d482e --- /dev/null +++ b/src/lib/cacheInvalidation.test.mts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { CacheInvalidationGate } from './cacheInvalidation.ts'; + +test('cache invalidation deletes after an already-running write', async () => { + const gate = new CacheInvalidationGate(); + const generation = gate.capture(); + const actions: string[] = []; + let releaseWrite!: () => void; + const writeBlocked = new Promise((resolve) => { releaseWrite = resolve; }); + const write = gate.enqueue(async () => { + assert.equal(gate.isCurrent(generation), true); + actions.push('write-start'); + await writeBlocked; + actions.push('write-end'); + }); + await Promise.resolve(); + const clear = gate.invalidate(async () => { actions.push('clear'); }); + releaseWrite(); + await Promise.all([write, clear]); + assert.deepEqual(actions, ['write-start', 'write-end', 'clear']); +}); + +test('work captured before a clear is stale while new writes follow the clear', async () => { + const gate = new CacheInvalidationGate(); + const staleGeneration = gate.capture(); + const actions: string[] = []; + await gate.invalidate(async () => { actions.push('clear'); }); + await gate.enqueue(async () => { + if (gate.isCurrent(staleGeneration)) actions.push('stale-write'); + }); + const currentGeneration = gate.capture(); + await gate.enqueue(async () => { + if (gate.isCurrent(currentGeneration)) actions.push('new-write'); + }); + assert.deepEqual(actions, ['clear', 'new-write']); +}); diff --git a/src/lib/cacheInvalidation.ts b/src/lib/cacheInvalidation.ts new file mode 100644 index 0000000..d662d88 --- /dev/null +++ b/src/lib/cacheInvalidation.ts @@ -0,0 +1,24 @@ +/** Serializes cache writes and gives clears a generation boundary. */ +export class CacheInvalidationGate { + private generation = 0; + private mutationQueue: Promise = Promise.resolve(); + + capture(): number { + return this.generation; + } + + isCurrent(generation: number): boolean { + return generation === this.generation; + } + + enqueue(operation: () => Promise): Promise { + const task = this.mutationQueue.then(operation, operation); + this.mutationQueue = task.catch(() => {}); + return task; + } + + invalidate(operation: () => Promise): Promise { + this.generation += 1; + return this.enqueue(operation); + } +} diff --git a/src/lyrics/displaySettings.test.mts b/src/lyrics/displaySettings.test.mts new file mode 100644 index 0000000..4f9370f --- /dev/null +++ b/src/lyrics/displaySettings.test.mts @@ -0,0 +1,33 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + DEFAULT_LYRICS_DISPLAY_SETTINGS, + normalizeLyricsDisplaySettings, + normalizeLyricsLanguagePriority, +} from './displaySettings.ts'; + +test('lyrics display settings default safely for upgraded installs', () => { + assert.deepEqual(normalizeLyricsDisplaySettings(null), DEFAULT_LYRICS_DISPLAY_SETTINGS); + assert.deepEqual(normalizeLyricsDisplaySettings({}), DEFAULT_LYRICS_DISPLAY_SETTINGS); +}); + +test('lyrics display settings preserve explicit disabled layers and voice labels', () => { + assert.deepEqual(normalizeLyricsDisplaySettings({ + wordTimingEnabled: false, + furiganaEnabled: false, + translationsEnabled: false, + translationPriority: ['fr'], + voiceLabelsEnabled: true, + }), { + wordTimingEnabled: false, + furiganaEnabled: false, + translationsEnabled: false, + translationPriority: ['fr'], + voiceLabelsEnabled: true, + }); +}); + +test('language priority trims, deduplicates case-insensitively, and restores defaults when empty', () => { + assert.deepEqual(normalizeLyricsLanguagePriority(' en, ja-Latn, EN, fr , '), ['en', 'ja-Latn', 'fr']); + assert.deepEqual(normalizeLyricsLanguagePriority(' , '), ['en', 'ja-Latn']); +}); diff --git a/src/lyrics/displaySettings.ts b/src/lyrics/displaySettings.ts new file mode 100644 index 0000000..00a9a4b --- /dev/null +++ b/src/lyrics/displaySettings.ts @@ -0,0 +1,50 @@ +export const DEFAULT_LYRICS_LANGUAGE_PRIORITY = ['en', 'ja-Latn'] as const; + +export interface LyricsDisplaySettings { + wordTimingEnabled: boolean; + furiganaEnabled: boolean; + translationsEnabled: boolean; + translationPriority: string[]; + voiceLabelsEnabled: boolean; +} + +export const DEFAULT_LYRICS_DISPLAY_SETTINGS: LyricsDisplaySettings = { + wordTimingEnabled: true, + furiganaEnabled: true, + translationsEnabled: true, + translationPriority: [...DEFAULT_LYRICS_LANGUAGE_PRIORITY], + voiceLabelsEnabled: false, +}; + +export function normalizeLyricsLanguagePriority(value: unknown): string[] { + const entries = Array.isArray(value) + ? value + : typeof value === 'string' + ? value.split(',') + : []; + const seen = new Set(); + const normalized: string[] = []; + for (const entry of entries) { + if (typeof entry !== 'string') continue; + const trimmed = entry.trim(); + if (!trimmed) continue; + const key = trimmed.toLocaleLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + normalized.push(trimmed); + } + return normalized.length > 0 ? normalized : [...DEFAULT_LYRICS_LANGUAGE_PRIORITY]; +} + +export function normalizeLyricsDisplaySettings(value: unknown): LyricsDisplaySettings { + const candidate = value && typeof value === 'object' && !Array.isArray(value) + ? value as Partial + : {}; + return { + wordTimingEnabled: candidate.wordTimingEnabled !== false, + furiganaEnabled: candidate.furiganaEnabled !== false, + translationsEnabled: candidate.translationsEnabled !== false, + translationPriority: normalizeLyricsLanguagePriority(candidate.translationPriority), + voiceLabelsEnabled: candidate.voiceLabelsEnabled === true, + }; +} diff --git a/src/lyrics/lyrics.ts b/src/lyrics/lyrics.ts index 58d4802..462e2ba 100644 Binary files a/src/lyrics/lyrics.ts and b/src/lyrics/lyrics.ts differ diff --git a/src/scope/waveform.ts b/src/scope/waveform.ts index 31eabce..e006f8c 100644 --- a/src/scope/waveform.ts +++ b/src/scope/waveform.ts @@ -5,7 +5,8 @@ import { AstraLibraryScanner } from '../../modules/astra-library-scanner'; import { openLibraryDb } from '@/db/database'; -import { getWaveformPeaks, putWaveformPeaks } from '@/db/waveformQueries'; +import { clearWaveformCache, getWaveformPeaks, putWaveformPeaks } from '@/db/waveformQueries'; +import { CacheInvalidationGate } from '@/lib/cacheInvalidation'; export const WAVEFORM_BINS = 512; export const WAVEFORM_PREVIEW_BINS = 96; @@ -17,6 +18,7 @@ export interface WaveformLoadOptions { // Dedupe concurrent requests for the same track (e.g. mini-player + now-playing). const inflight = new Map>(); const previewInflight = new Map>(); +const cacheGate = new CacheInvalidationGate(); export function getWaveform( trackPath: string, @@ -42,12 +44,15 @@ async function loadWaveform( const existing = inflight.get(trackPath); if (existing) return existing; - const task = decodeAccurateWaveform(trackPath).finally(() => inflight.delete(trackPath)); + const generation = cacheGate.capture(); + const task = decodeAccurateWaveform(trackPath, generation).finally(() => { + if (inflight.get(trackPath) === task) inflight.delete(trackPath); + }); inflight.set(trackPath, task); return task; } -async function decodeAccurateWaveform(trackPath: string): Promise { +async function decodeAccurateWaveform(trackPath: string, generation: number): Promise { let raw: number[]; try { raw = await AstraLibraryScanner.extractWaveform(trackPath, WAVEFORM_BINS); @@ -57,17 +62,33 @@ async function decodeAccurateWaveform(trackPath: string): Promise { + await cacheGate.enqueue(async () => { + if (!cacheGate.isCurrent(generation)) return; + const db = await openLibraryDb(); + if (!cacheGate.isCurrent(generation)) return; + await putWaveformPeaks(db, trackPath, peaks); + }).catch(() => { /* cache write failure is non-fatal */ }); return peaks; } +/** Deletes waveform rows and prevents decodes already in flight from writing them back. */ +export async function clearAllWaveformCache(): Promise { + inflight.clear(); + previewInflight.clear(); + await cacheGate.invalidate(async () => { + const db = await openLibraryDb(); + await clearWaveformCache(db); + }); +} + function getWaveformPreview(trackPath: string): Promise { const existing = previewInflight.get(trackPath); if (existing) return existing; - const task = decodePreviewWaveform(trackPath).finally(() => previewInflight.delete(trackPath)); + const task = decodePreviewWaveform(trackPath).finally(() => { + if (previewInflight.get(trackPath) === task) previewInflight.delete(trackPath); + }); previewInflight.set(trackPath, task); return task; } diff --git a/src/session/sessionState.test.mts b/src/session/sessionState.test.mts index 31a6d0b..25c122e 100644 --- a/src/session/sessionState.test.mts +++ b/src/session/sessionState.test.mts @@ -25,6 +25,9 @@ test('normalizes stable routes and rejects transient or unsafe routes', () => { assert.equal(normalizeStableHref('/library/album/album%3Aone'), '/library/album/album%3Aone'); assert.equal(normalizeStableHref('/library/artist/Artist?credit=1&ignored=yes'), '/library/artist/Artist?credit=1'); assert.equal(normalizeStableHref('/settings/audio?ignored=yes'), '/settings/audio'); + assert.equal(normalizeStableHref('/settings/playback'), '/settings/playback'); + assert.equal(normalizeStableHref('/settings/lyrics'), '/settings/lyrics'); + assert.equal(normalizeStableHref('/settings/troubleshooting'), '/settings/troubleshooting'); assert.equal(normalizeStableHref('/library/playlist/edit-dynamic?id=4'), null); assert.equal(normalizeStableHref('/eq/scan'), null); assert.equal(normalizeStableHref('/notification.click'), null); diff --git a/src/session/sessionState.ts b/src/session/sessionState.ts index 94b9b36..c6bb371 100644 --- a/src/session/sessionState.ts +++ b/src/session/sessionState.ts @@ -54,8 +54,11 @@ const STATIC_STABLE_PATHS = new Set([ '/settings/appearance', '/settings/library', '/settings/audio', + '/settings/playback', '/settings/services', + '/settings/lyrics', '/settings/experimental', + '/settings/troubleshooting', '/settings/info', '/settings/haptics-lab', '/sources', diff --git a/src/stores/libraryStore.ts b/src/stores/libraryStore.ts index 7228e06..4a89ae7 100644 --- a/src/stores/libraryStore.ts +++ b/src/stores/libraryStore.ts @@ -9,6 +9,7 @@ import { markTrackPlayed, setSetting, } from '@/db/queries'; +import { markLocalTracksStaleForRebuild } from '@/db/libraryMaintenance'; import { recomputeAlbumIdentity } from '@/library/albumIdentity'; import { buildAlbumList } from '@/library/albumSummary'; import { ensureArtworkThumbnails } from '@/library/artwork'; @@ -113,6 +114,7 @@ interface LibraryStore { addFolder: () => Promise; removeFolder: (folderId: number) => Promise; rescan: () => Promise; + rebuildLocalIndex: () => Promise; } let initPromise: Promise | null = null; @@ -300,5 +302,11 @@ export const useLibraryStore = create((set, get) => { }, rescan: () => runScan(() => rescanAll({ callbacks: { onProgress } })), + + rebuildLocalIndex: () => runScan(async () => { + const db = await openLibraryDb(); + await markLocalTracksStaleForRebuild(db); + return rescanAll({ callbacks: { onProgress } }); + }), }; }); diff --git a/src/stores/lyricsSettingsStore.ts b/src/stores/lyricsSettingsStore.ts new file mode 100644 index 0000000..6a619c0 --- /dev/null +++ b/src/stores/lyricsSettingsStore.ts @@ -0,0 +1,119 @@ +import { create } from 'zustand'; +import { openLibraryDb } from '@/db/database'; +import { getSetting, setSetting } from '@/db/queries'; +import { + DEFAULT_LYRICS_DISPLAY_SETTINGS, + normalizeLyricsDisplaySettings, + normalizeLyricsLanguagePriority, + type LyricsDisplaySettings, +} from '@/lyrics/displaySettings'; + +export { + DEFAULT_LYRICS_DISPLAY_SETTINGS, + DEFAULT_LYRICS_LANGUAGE_PRIORITY, + normalizeLyricsDisplaySettings, + normalizeLyricsLanguagePriority, + type LyricsDisplaySettings, +} from '@/lyrics/displaySettings'; + +const ONLINE_LOOKUP_KEY = 'lyrics_online_lookup_enabled'; +const DISPLAY_SETTINGS_KEY = 'lyrics_display_settings_v1'; + +interface LyricsSettingsStore extends LyricsDisplaySettings { + onlineLookupEnabled: boolean; + loaded: boolean; + load: () => Promise; + setOnlineLookupEnabled: (enabled: boolean) => Promise; + setWordTimingEnabled: (enabled: boolean) => Promise; + setFuriganaEnabled: (enabled: boolean) => Promise; + setTranslationsEnabled: (enabled: boolean) => Promise; + setTranslationPriority: (value: string | string[]) => Promise; + setVoiceLabelsEnabled: (enabled: boolean) => Promise; +} + +function displaySettingsFromState(state: LyricsSettingsStore): LyricsDisplaySettings { + return { + wordTimingEnabled: state.wordTimingEnabled, + furiganaEnabled: state.furiganaEnabled, + translationsEnabled: state.translationsEnabled, + translationPriority: state.translationPriority, + voiceLabelsEnabled: state.voiceLabelsEnabled, + }; +} + +async function persistDisplaySettings(settings: LyricsDisplaySettings): Promise { + const db = await openLibraryDb(); + await setSetting(db, DISPLAY_SETTINGS_KEY, JSON.stringify(settings)); +} + +let loadPromise: Promise | null = null; + +export const useLyricsSettingsStore = create((set, get) => ({ + onlineLookupEnabled: true, + ...DEFAULT_LYRICS_DISPLAY_SETTINGS, + loaded: false, + + load: async () => { + if (get().loaded) return; + if (loadPromise) return loadPromise; + loadPromise = (async () => { + const db = await openLibraryDb(); + const [onlineValue, displayValue] = await Promise.all([ + getSetting(db, ONLINE_LOOKUP_KEY), + getSetting(db, DISPLAY_SETTINGS_KEY), + ]); + let parsed: unknown = null; + try { + parsed = displayValue ? JSON.parse(displayValue) : null; + } catch { + parsed = null; + } + set({ + onlineLookupEnabled: onlineValue === null ? true : onlineValue === 'true', + ...normalizeLyricsDisplaySettings(parsed), + loaded: true, + }); + })().finally(() => { + loadPromise = null; + }); + return loadPromise; + }, + + setOnlineLookupEnabled: async (enabled) => { + await get().load(); + set({ onlineLookupEnabled: enabled }); + const db = await openLibraryDb(); + await setSetting(db, ONLINE_LOOKUP_KEY, enabled ? 'true' : 'false'); + }, + + setWordTimingEnabled: async (enabled) => { + await get().load(); + set({ wordTimingEnabled: enabled }); + await persistDisplaySettings({ ...displaySettingsFromState(get()), wordTimingEnabled: enabled }); + }, + + setFuriganaEnabled: async (enabled) => { + await get().load(); + set({ furiganaEnabled: enabled }); + await persistDisplaySettings({ ...displaySettingsFromState(get()), furiganaEnabled: enabled }); + }, + + setTranslationsEnabled: async (enabled) => { + await get().load(); + set({ translationsEnabled: enabled }); + await persistDisplaySettings({ ...displaySettingsFromState(get()), translationsEnabled: enabled }); + }, + + setTranslationPriority: async (value) => { + await get().load(); + const translationPriority = normalizeLyricsLanguagePriority(value); + set({ translationPriority }); + await persistDisplaySettings({ ...displaySettingsFromState(get()), translationPriority }); + }, + + setVoiceLabelsEnabled: async (enabled) => { + await get().load(); + set({ voiceLabelsEnabled: enabled }); + await persistDisplaySettings({ ...displaySettingsFromState(get()), voiceLabelsEnabled: enabled }); + }, +})); diff --git a/src/stores/lyricsStore.ts b/src/stores/lyricsStore.ts index 2a7f57f..7d83e41 100644 --- a/src/stores/lyricsStore.ts +++ b/src/stores/lyricsStore.ts @@ -6,6 +6,7 @@ import { create } from 'zustand'; import { buildLyricsQuery, getLyricsForTrack } from '@/lyrics/lyrics'; +import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore'; import type { LyricsLookupResult } from '@/lyrics/types'; import type { Track } from '@/types/audio'; @@ -17,10 +18,9 @@ export interface LyricsUiEntry { } interface LyricsStore { - onlineEnabled: boolean; byPath: Record; loadForTrack: (track: Track | null, options?: { force?: boolean }) => Promise; - setOnlineEnabled: (enabled: boolean) => void; + invalidateAll: () => void; } // Latest request id per path — guards against a stale response overwriting a @@ -40,11 +40,11 @@ function pruneToLru(byPath: Record): Record((set, get) => ({ - onlineEnabled: true, byPath: {}, loadForTrack: async (track, options = {}) => { if (!track?.path) return; + await useLyricsSettingsStore.getState().load(); const path = track.path; const force = Boolean(options.force); @@ -74,7 +74,10 @@ export const useLyricsStore = create((set, get) => ({ let result: LyricsLookupResult; try { - result = await getLyricsForTrack(query, { forceRefresh: force, onlineEnabled: get().onlineEnabled }); + result = await getLyricsForTrack(query, { + forceRefresh: force, + onlineEnabled: useLyricsSettingsStore.getState().onlineLookupEnabled, + }); } catch (error) { result = { status: 'transient_error', @@ -90,5 +93,8 @@ export const useLyricsStore = create((set, get) => ({ })); }, - setOnlineEnabled: (enabled) => set({ onlineEnabled: enabled }), + invalidateAll: () => { + requestIds.clear(); + set({ byPath: {} }); + }, })); diff --git a/src/stores/sleepTimerStore.ts b/src/stores/sleepTimerStore.ts new file mode 100644 index 0000000..a3e4898 --- /dev/null +++ b/src/stores/sleepTimerStore.ts @@ -0,0 +1,161 @@ +import TrackPlayer from 'react-native-track-player'; +import { create } from 'zustand'; +import { openLibraryDb } from '@/db/database'; +import { getSetting, setSetting } from '@/db/queries'; +import { setPauseAtEndOfItem } from '@/audio/trackPlayerExtensions'; +import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; +import { + getSleepTimerRemainingMs, + normalizePersistedSleepTimer, + normalizeSleepTimerMinutes, + shouldCompleteEndOfTrackTimer, + transitionSleepTimer, + type PersistedSleepTimerState, +} from '@/audio/sleepTimerState'; + +const SLEEP_TIMER_KEY = 'sleep_timer_state_v1'; + +interface SleepTimerStore { + timer: PersistedSleepTimerState | null; + remainingMs: number | null; + hydrated: boolean; + hydrate: () => Promise; + startMinutes: (minutes: number) => Promise; + startEndOfTrack: () => Promise; + cancel: () => Promise; + reconcile: (nowMs?: number) => Promise; + reconcileEndOfTrack: (position: number, duration: number, playWhenReady: boolean) => Promise; +} + +let deadlineTimer: ReturnType | null = null; +let hydrationPromise: Promise | null = null; +let reconcilePromise: Promise | null = null; + +function clearDeadlineTimer(): void { + if (deadlineTimer !== null) clearTimeout(deadlineTimer); + deadlineTimer = null; +} + +async function persistTimer(timer: PersistedSleepTimerState | null): Promise { + const db = await openLibraryDb(); + await setSetting(db, SLEEP_TIMER_KEY, timer ? JSON.stringify(timer) : ''); +} + +async function hasActivePhoneTrack(): Promise { + try { + return Boolean(await TrackPlayer.getActiveTrack()); + } catch { + return false; + } +} + +function scheduleDeadline(timer: PersistedSleepTimerState | null): void { + clearDeadlineTimer(); + if (timer?.mode !== 'minutes' || timer.expiresAtMs === null) return; + const delay = Math.max(0, timer.expiresAtMs - Date.now()); + deadlineTimer = setTimeout(() => { + deadlineTimer = null; + void useSleepTimerStore.getState().reconcile(); + }, delay); +} + +async function clearCompletedTimer(expectedTimer?: PersistedSleepTimerState): Promise { + if (expectedTimer && useSleepTimerStore.getState().timer !== expectedTimer) return; + clearDeadlineTimer(); + await setPauseAtEndOfItem(false).catch(() => {}); + if (expectedTimer && useSleepTimerStore.getState().timer !== expectedTimer) return; + useSleepTimerStore.setState({ timer: null, remainingMs: null, hydrated: true }); + await persistTimer(null); +} + +export const useSleepTimerStore = create((set, get) => ({ + timer: null, + remainingMs: null, + hydrated: false, + + hydrate: async () => { + if (get().hydrated) return; + if (hydrationPromise) return hydrationPromise; + hydrationPromise = (async () => { + const db = await openLibraryDb(); + const raw = await getSetting(db, SLEEP_TIMER_KEY); + let parsed: unknown = null; + try { + parsed = raw ? JSON.parse(raw) : null; + } catch { + parsed = null; + } + const timer = normalizePersistedSleepTimer(parsed); + const active = timer ? await hasActivePhoneTrack() : false; + if (!timer || !active) { + set({ timer: null, remainingMs: null, hydrated: true }); + if (raw) await persistTimer(null); + return; + } + set({ + timer, + remainingMs: getSleepTimerRemainingMs(timer, Date.now()), + hydrated: true, + }); + if (timer.mode === 'end-of-track') await setPauseAtEndOfItem(true); + scheduleDeadline(timer); + await get().reconcile(); + })().finally(() => { + hydrationPromise = null; + }); + return hydrationPromise; + }, + + startMinutes: async (value) => { + const minutes = normalizeSleepTimerMinutes(value); + if (minutes === null) throw new Error('Choose a whole number from 1 to 720 minutes.'); + if (usePlaybackTargetStore.getState().target !== 'phone') throw new Error('Sleep timers are available for phone playback only.'); + if (!await hasActivePhoneTrack()) throw new Error('Start phone playback before setting a sleep timer.'); + await setPauseAtEndOfItem(false).catch(() => {}); + const timer = transitionSleepTimer(get().timer, { type: 'start-minutes', minutes }, Date.now()); + if (!timer) throw new Error('Choose a whole number from 1 to 720 minutes.'); + set({ timer, remainingMs: minutes * 60_000, hydrated: true }); + await persistTimer(timer); + scheduleDeadline(timer); + }, + + startEndOfTrack: async () => { + if (usePlaybackTargetStore.getState().target !== 'phone') throw new Error('Sleep timers are available for phone playback only.'); + if (!await hasActivePhoneTrack()) throw new Error('Start phone playback before setting a sleep timer.'); + await setPauseAtEndOfItem(true); + clearDeadlineTimer(); + const timer = transitionSleepTimer(get().timer, { type: 'start-end-of-track' }, Date.now()); + set({ timer, remainingMs: null, hydrated: true }); + await persistTimer(timer); + }, + + cancel: async () => { + await clearCompletedTimer(); + }, + + reconcile: async (nowMs = Date.now()) => { + const timer = get().timer; + if (!timer) return; + if (timer.mode === 'end-of-track') return; + const remainingMs = getSleepTimerRemainingMs(timer, nowMs) ?? 0; + set({ remainingMs }); + if (remainingMs > 0) { + scheduleDeadline(timer); + return; + } + if (reconcilePromise) return reconcilePromise; + reconcilePromise = (async () => { + await TrackPlayer.pause().catch(() => {}); + await clearCompletedTimer(timer); + })().finally(() => { + reconcilePromise = null; + }); + return reconcilePromise; + }, + + reconcileEndOfTrack: async (position, duration, playWhenReady) => { + const timer = get().timer; + if (!shouldCompleteEndOfTrackTimer(timer, position, duration, playWhenReady) || !timer) return; + await clearCompletedTimer(timer); + }, +}));