mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
bring over relevant desktop settings
This commit is contained in:
+4
-1
@@ -74,7 +74,10 @@
|
|||||||
"test:eq-share": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/audio/eqShare.test.mts",
|
"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: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: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: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: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",
|
"test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts",
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ index b2409a0..4491bad 100644
|
|||||||
if (verifyServiceBoundOrReject(callback)) return@launch
|
if (verifyServiceBoundOrReject(callback)) return@launch
|
||||||
|
|
||||||
musicService.stop()
|
musicService.stop()
|
||||||
@@ -431,188 +450,213 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
@@ -431,188 +450,222 @@ class MusicModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaM
|
||||||
|
|
||||||
callback.resolve(null)
|
callback.resolve(null)
|
||||||
}
|
}
|
||||||
@@ -215,6 +215,15 @@ index b2409a0..4491bad 100644
|
|||||||
musicService.pause()
|
musicService.pause()
|
||||||
callback.resolve(null)
|
callback.resolve(null)
|
||||||
}
|
}
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ @ReactMethod
|
||||||
|
+ fun setPauseAtEndOfItem(enabled: Boolean, callback: Promise) { scope.launch {
|
||||||
|
+ if (verifyServiceBoundOrReject(callback)) return@launch
|
||||||
|
+
|
||||||
|
+ musicService.setPauseAtEndOfItem(enabled)
|
||||||
|
+ callback.resolve(null)
|
||||||
|
+ }
|
||||||
+ }
|
+ }
|
||||||
|
|
||||||
@ReactMethod
|
@ReactMethod
|
||||||
@@ -452,7 +461,12 @@ diff --git a/node_modules/react-native-track-player/android/src/main/java/com/do
|
|||||||
index afa6b0f..c6f01d5 100644
|
index afa6b0f..c6f01d5 100644
|
||||||
--- a/node_modules/react-native-track-player/android/src/main/java/com/doublesymmetry/trackplayer/service/MusicService.kt
|
--- 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
|
+++ 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
|
@MainThread
|
||||||
private fun emit(event: String, data: Bundle? = null) {
|
private fun emit(event: String, data: Bundle? = null) {
|
||||||
@@ -461,7 +475,7 @@ index afa6b0f..c6f01d5 100644
|
|||||||
?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
?.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
||||||
?.emit(event, data?.let { Arguments.fromBundle(it) })
|
?.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()
|
val payload = Arguments.createArray()
|
||||||
data.forEach { payload.pushMap(Arguments.fromBundle(it)) }
|
data.forEach { payload.pushMap(Arguments.fromBundle(it)) }
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
|
|||||||
import { useLibraryStore } from '@/stores/libraryStore';
|
import { useLibraryStore } from '@/stores/libraryStore';
|
||||||
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
|
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
|
||||||
import { useThemeStore } from '@/stores/themeStore';
|
import { useThemeStore } from '@/stores/themeStore';
|
||||||
|
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
|
||||||
|
import { formatSleepTimerStatus } from '@/audio/sleepTimerState';
|
||||||
|
|
||||||
function formatEnabled(value: boolean): string {
|
function formatEnabled(value: boolean): string {
|
||||||
return value ? 'On' : 'Off';
|
return value ? 'On' : 'Off';
|
||||||
@@ -48,6 +50,9 @@ export default function SettingsScreen() {
|
|||||||
const desktopSyncStatus = useDesktopSyncStore((s) => s.status);
|
const desktopSyncStatus = useDesktopSyncStore((s) => s.status);
|
||||||
const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
|
const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
|
||||||
const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length);
|
const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length);
|
||||||
|
const sleepTimer = useSleepTimerStore((s) => s.timer);
|
||||||
|
const sleepRemainingMs = useSleepTimerStore((s) => s.remainingMs);
|
||||||
|
void sleepRemainingMs;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const task = InteractionManager.runAfterInteractions(() => {
|
const task = InteractionManager.runAfterInteractions(() => {
|
||||||
@@ -103,6 +108,12 @@ export default function SettingsScreen() {
|
|||||||
subtitle={`Normalization ${formatEnabled(normalizationEnabled)}. ReplayGain ${formatEnabled(replayGainEnabled)}.`}
|
subtitle={`Normalization ${formatEnabled(normalizationEnabled)}. ReplayGain ${formatEnabled(replayGainEnabled)}.`}
|
||||||
onPress={() => router.push('/settings/audio' as never)}
|
onPress={() => router.push('/settings/audio' as never)}
|
||||||
/>
|
/>
|
||||||
|
<SettingsNavRow
|
||||||
|
icon="play-circle-outline"
|
||||||
|
title="Playback"
|
||||||
|
subtitle={sleepTimer ? `Sleep timer: ${formatSleepTimerStatus(sleepTimer)}.` : 'Sleep timer and playback behavior.'}
|
||||||
|
onPress={() => router.push('/settings/playback' as never)}
|
||||||
|
/>
|
||||||
<SettingsNavRow
|
<SettingsNavRow
|
||||||
icon="server-outline"
|
icon="server-outline"
|
||||||
title="Services"
|
title="Services"
|
||||||
@@ -117,7 +128,13 @@ export default function SettingsScreen() {
|
|||||||
onPress={() => router.push('/settings/experimental' as never)}
|
onPress={() => router.push('/settings/experimental' as never)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SettingsSectionLabel spaced>ABOUT</SettingsSectionLabel>
|
<SettingsSectionLabel spaced>SUPPORT</SettingsSectionLabel>
|
||||||
|
<SettingsNavRow
|
||||||
|
icon="build-outline"
|
||||||
|
title="Troubleshooting"
|
||||||
|
subtitle="Library maintenance, cache tools, and onboarding."
|
||||||
|
onPress={() => router.push('/settings/troubleshooting' as never)}
|
||||||
|
/>
|
||||||
<SettingsNavRow
|
<SettingsNavRow
|
||||||
icon="information-circle-outline"
|
icon="information-circle-outline"
|
||||||
title="Info"
|
title="Info"
|
||||||
|
|||||||
+22
-1
@@ -47,6 +47,8 @@ import { useOnboardingStore } from '@/stores/onboardingStore';
|
|||||||
import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow';
|
import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow';
|
||||||
import { useTheme } from '@/theme/themed';
|
import { useTheme } from '@/theme/themed';
|
||||||
import { SessionLifecycle } from '@/session/SessionLifecycle';
|
import { SessionLifecycle } from '@/session/SessionLifecycle';
|
||||||
|
import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore';
|
||||||
|
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
|
||||||
|
|
||||||
// Anchor the root stack at the tabs so a deep link straight to a top-level route
|
// Anchor the root stack at the tabs so a deep link straight to a top-level route
|
||||||
// (the widget's `recently-played`, the notification-click redirect) builds
|
// (the widget's `recently-played`, the notification-click redirect) builds
|
||||||
@@ -81,6 +83,17 @@ function ThemeSystemSync() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SleepTimerLifecycle() {
|
||||||
|
useEffect(() => {
|
||||||
|
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. */
|
/** Owns the visualizer on/off gate (foreground + playing + motion). Renders nothing. */
|
||||||
function ScopeLifecycle() {
|
function ScopeLifecycle() {
|
||||||
useScopeLifecycle();
|
useScopeLifecycle();
|
||||||
@@ -110,7 +123,10 @@ function PlaybackTargetSync() {
|
|||||||
}, [loadTarget]);
|
}, [loadTarget]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (target === 'desktop') void initDesktopRemote();
|
if (target === 'desktop') {
|
||||||
|
if (useSleepTimerStore.getState().timer) void useSleepTimerStore.getState().cancel();
|
||||||
|
void initDesktopRemote();
|
||||||
|
}
|
||||||
}, [target, initDesktopRemote]);
|
}, [target, initDesktopRemote]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -346,6 +362,10 @@ export default function RootLayout() {
|
|||||||
.getState()
|
.getState()
|
||||||
.load()
|
.load()
|
||||||
.catch((err) => console.error('[audioSettings] load failed', err));
|
.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
|
// Remote sources: load server rows + hydrate the URL registry from cached
|
||||||
// config/token (no network on launch). Runs after library init reads first.
|
// config/token (no network on launch). Runs after library init reads first.
|
||||||
useRemoteSourcesStore
|
useRemoteSourcesStore
|
||||||
@@ -373,6 +393,7 @@ export default function RootLayout() {
|
|||||||
{onboardingComplete ? (
|
{onboardingComplete ? (
|
||||||
<>
|
<>
|
||||||
<ThemeSystemSync />
|
<ThemeSystemSync />
|
||||||
|
<SleepTimerLifecycle />
|
||||||
<PlaybackSync />
|
<PlaybackSync />
|
||||||
<ScopeLifecycle />
|
<ScopeLifecycle />
|
||||||
<NormalizationSync />
|
<NormalizationSync />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Alert, InteractionManager } from 'react-native';
|
import { InteractionManager } from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import {
|
import {
|
||||||
SettingsNavRow,
|
SettingsNavRow,
|
||||||
@@ -10,7 +10,6 @@ import { formatRelativeTime } from '@/lib/format';
|
|||||||
import { useColors } from '@/theme/themed';
|
import { useColors } from '@/theme/themed';
|
||||||
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
||||||
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
|
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
|
||||||
import { useOnboardingStore } from '@/stores/onboardingStore';
|
|
||||||
|
|
||||||
export default function ExperimentalSettingsScreen() {
|
export default function ExperimentalSettingsScreen() {
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
@@ -32,16 +31,6 @@ export default function ExperimentalSettingsScreen() {
|
|||||||
const desktopRemoteSubtitle = desktopRemoteConnection
|
const desktopRemoteSubtitle = desktopRemoteConnection
|
||||||
? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'}: ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
|
? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'}: ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
|
||||||
: 'Pair with Astra Desktop to control playback from this phone.';
|
: '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
|
const desktopSyncSubtitle = !desktopRemoteConnection
|
||||||
? 'Sync favorites and playlists with Astra Desktop.'
|
? 'Sync favorites and playlists with Astra Desktop.'
|
||||||
@@ -77,12 +66,6 @@ export default function ExperimentalSettingsScreen() {
|
|||||||
subtitle="Audition semantic feedback, device primitives, and signature candidates."
|
subtitle="Audition semantic feedback, device primitives, and signature candidates."
|
||||||
onPress={() => router.push('/settings/haptics-lab' as never)}
|
onPress={() => router.push('/settings/haptics-lab' as never)}
|
||||||
/>
|
/>
|
||||||
<SettingsNavRow
|
|
||||||
icon="refresh-outline"
|
|
||||||
title="Replay onboarding"
|
|
||||||
subtitle="Show the first-run setup wizard again."
|
|
||||||
onPress={replayOnboarding}
|
|
||||||
/>
|
|
||||||
</SettingsSectionScreen>
|
</SettingsSectionScreen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string | null>(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 (
|
||||||
|
<SettingsSectionScreen title="Lyrics">
|
||||||
|
<SettingsSectionLabel>LOOKUP</SettingsSectionLabel>
|
||||||
|
<SettingsCard>
|
||||||
|
<SettingsToggleRow
|
||||||
|
title="Online lookup"
|
||||||
|
description="Try XLRCDB, then LRCLIB, after local and cached lyrics."
|
||||||
|
value={onlineLookupEnabled}
|
||||||
|
onValueChange={(enabled) => void setOnlineLookup(enabled)}
|
||||||
|
/>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsSectionLabel spaced>XLRC DISPLAY</SettingsSectionLabel>
|
||||||
|
<SettingsCard style={styles.stack}>
|
||||||
|
<SettingsToggleRow
|
||||||
|
title="Word timing"
|
||||||
|
description="Sweep the accent across the active timed word."
|
||||||
|
value={wordTimingEnabled}
|
||||||
|
onValueChange={(enabled) => void setWordTimingEnabled(enabled)}
|
||||||
|
/>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
<SettingsToggleRow
|
||||||
|
title="Furigana"
|
||||||
|
description="Show pronunciation guides included with XLRC lyrics."
|
||||||
|
value={furiganaEnabled}
|
||||||
|
onValueChange={(enabled) => void setFuriganaEnabled(enabled)}
|
||||||
|
/>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
<SettingsToggleRow
|
||||||
|
title="Translations"
|
||||||
|
description="Show the best available translated line."
|
||||||
|
value={translationsEnabled}
|
||||||
|
onValueChange={(enabled) => void setTranslationsEnabled(enabled)}
|
||||||
|
/>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
<SettingsToggleRow
|
||||||
|
title="Voice labels"
|
||||||
|
description="Show singer or voice labels supplied by XLRC."
|
||||||
|
value={voiceLabelsEnabled}
|
||||||
|
onValueChange={(enabled) => void setVoiceLabelsEnabled(enabled)}
|
||||||
|
/>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsSectionLabel spaced>TRANSLATION PRIORITY</SettingsSectionLabel>
|
||||||
|
<SettingsCard style={styles.inputCard}>
|
||||||
|
<Text variant="caption" color={colors.textSecondary}>
|
||||||
|
Comma-separated language tags, in preferred order.
|
||||||
|
</Text>
|
||||||
|
<TextInput
|
||||||
|
value={priorityValue}
|
||||||
|
onChangeText={setPriorityDraft}
|
||||||
|
onBlur={commitPriority}
|
||||||
|
onSubmitEditing={commitPriority}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
returnKeyType="done"
|
||||||
|
placeholder="en, ja-Latn"
|
||||||
|
placeholderTextColor={colors.textTertiary}
|
||||||
|
style={styles.input}
|
||||||
|
accessibilityLabel="Translation language priority"
|
||||||
|
/>
|
||||||
|
<Text variant="caption" color={colors.textTertiary}>
|
||||||
|
Duplicates are removed. Leaving this empty restores en, ja-Latn.
|
||||||
|
</Text>
|
||||||
|
</SettingsCard>
|
||||||
|
</SettingsSectionScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { SleepTimerControls } from '@/components/player/SleepTimerControls';
|
||||||
|
import {
|
||||||
|
SettingsCard,
|
||||||
|
SettingsSectionLabel,
|
||||||
|
SettingsSectionScreen,
|
||||||
|
} from '@/components/settings/SettingsSectionScaffold';
|
||||||
|
|
||||||
|
export default function PlaybackSettingsScreen() {
|
||||||
|
return (
|
||||||
|
<SettingsSectionScreen title="Playback">
|
||||||
|
<SettingsSectionLabel>SLEEP TIMER</SettingsSectionLabel>
|
||||||
|
<SettingsCard>
|
||||||
|
<SleepTimerControls />
|
||||||
|
</SettingsCard>
|
||||||
|
</SettingsSectionScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,12 +6,14 @@ import {
|
|||||||
SettingsSectionScreen,
|
SettingsSectionScreen,
|
||||||
} from '@/components/settings/SettingsSectionScaffold';
|
} from '@/components/settings/SettingsSectionScaffold';
|
||||||
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
|
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
|
||||||
|
import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore';
|
||||||
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
|
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
|
||||||
|
|
||||||
export default function ServicesSettingsScreen() {
|
export default function ServicesSettingsScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const remoteSources = useRemoteSourcesStore((s) => s.sources);
|
const remoteSources = useRemoteSourcesStore((s) => s.sources);
|
||||||
const lastFmStatus = useLastFmSettingsStore((s) => s.status);
|
const lastFmStatus = useLastFmSettingsStore((s) => s.status);
|
||||||
|
const onlineLookupEnabled = useLyricsSettingsStore((s) => s.onlineLookupEnabled);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSectionScreen title="Services">
|
<SettingsSectionScreen title="Services">
|
||||||
@@ -27,6 +29,14 @@ export default function ServicesSettingsScreen() {
|
|||||||
onPress={() => router.push('/sources')}
|
onPress={() => router.push('/sources')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SettingsSectionLabel spaced>LYRICS</SettingsSectionLabel>
|
||||||
|
<SettingsNavRow
|
||||||
|
icon="musical-notes-outline"
|
||||||
|
title="Lyrics"
|
||||||
|
subtitle={`Online lookup ${onlineLookupEnabled ? 'on' : 'off'}. XLRC display and language priority.`}
|
||||||
|
onPress={() => router.push('/settings/lyrics' as never)}
|
||||||
|
/>
|
||||||
|
|
||||||
<SettingsSectionLabel spaced>SCROBBLING</SettingsSectionLabel>
|
<SettingsSectionLabel spaced>SCROBBLING</SettingsSectionLabel>
|
||||||
<SettingsNavRow
|
<SettingsNavRow
|
||||||
icon="radio-outline"
|
icon="radio-outline"
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { ActivityIndicator, Alert, Pressable, View } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { Text } from '@/components/Text';
|
||||||
|
import { ScanProgress } from '@/components/library/ScanProgress';
|
||||||
|
import {
|
||||||
|
SettingsSectionLabel,
|
||||||
|
SettingsSectionScreen,
|
||||||
|
type SettingsIconName,
|
||||||
|
} from '@/components/settings/SettingsSectionScaffold';
|
||||||
|
import { openLibraryDb } from '@/db/database';
|
||||||
|
import { getLyricsCacheCount } from '@/db/lyricsQueries';
|
||||||
|
import { getWaveformCacheCount } from '@/db/waveformQueries';
|
||||||
|
import { clearAllLyricsCache } from '@/lyrics/lyrics';
|
||||||
|
import { clearAllWaveformCache } from '@/scope/waveform';
|
||||||
|
import { useLyricsStore } from '@/stores/lyricsStore';
|
||||||
|
import { useLibraryStore } from '@/stores/libraryStore';
|
||||||
|
import { useOnboardingStore } from '@/stores/onboardingStore';
|
||||||
|
import { radius, spacing } from '@/theme';
|
||||||
|
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||||
|
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
||||||
|
|
||||||
|
type ActionKey = 'scan' | 'rebuild' | 'lyrics' | 'waveform' | 'onboarding';
|
||||||
|
|
||||||
|
interface CacheCounts {
|
||||||
|
lyrics: number;
|
||||||
|
waveforms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function countLabel(count: number | null, noun: string): string {
|
||||||
|
if (count === null) return 'Counting cached entries…';
|
||||||
|
return `${count} cached ${noun}${count === 1 ? '' : 's'}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TroubleshootingSettingsScreen() {
|
||||||
|
const styles = useStyles();
|
||||||
|
const colors = useColors();
|
||||||
|
const isScanning = useLibraryStore((s) => s.isScanning);
|
||||||
|
const [runningAction, setRunningAction] = useState<ActionKey | null>(null);
|
||||||
|
const [feedback, setFeedback] = useState<{ kind: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const [counts, setCounts] = useState<CacheCounts | null>(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<void>, 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 (
|
||||||
|
<SettingsSectionScreen title="Troubleshooting">
|
||||||
|
<ScanProgress />
|
||||||
|
|
||||||
|
<SettingsSectionLabel>LIBRARY</SettingsSectionLabel>
|
||||||
|
<MaintenanceRow
|
||||||
|
icon="scan-outline"
|
||||||
|
title="Scan for Changes"
|
||||||
|
description="Run the normal foreground rescan for configured folders."
|
||||||
|
disabled={disabled}
|
||||||
|
running={runningAction === 'scan'}
|
||||||
|
onPress={() => void run('scan', () => useLibraryStore.getState().rescan(), 'Library scan complete.')}
|
||||||
|
/>
|
||||||
|
<MaintenanceRow
|
||||||
|
icon="construct-outline"
|
||||||
|
title="Rebuild Local Library Index"
|
||||||
|
description="Re-read local track metadata without touching user data or remote sources."
|
||||||
|
disabled={disabled}
|
||||||
|
running={runningAction === 'rebuild'}
|
||||||
|
onPress={confirmRebuild}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsSectionLabel spaced>CACHES</SettingsSectionLabel>
|
||||||
|
<MaintenanceRow
|
||||||
|
icon="musical-notes-outline"
|
||||||
|
title="Clear Lyrics Cache"
|
||||||
|
description={`${countLabel(counts?.lyrics ?? null, 'entry')} Preferences are kept.`}
|
||||||
|
disabled={disabled}
|
||||||
|
running={runningAction === 'lyrics'}
|
||||||
|
onPress={() => void run('lyrics', async () => {
|
||||||
|
await clearAllLyricsCache();
|
||||||
|
useLyricsStore.getState().invalidateAll();
|
||||||
|
}, 'Lyrics cache cleared.')}
|
||||||
|
/>
|
||||||
|
<MaintenanceRow
|
||||||
|
icon="pulse-outline"
|
||||||
|
title="Clear Waveform Cache"
|
||||||
|
description={`${countLabel(counts?.waveforms ?? null, 'waveform')} Tracks recompute on their next load.`}
|
||||||
|
disabled={disabled}
|
||||||
|
running={runningAction === 'waveform'}
|
||||||
|
onPress={() => void run('waveform', clearAllWaveformCache, 'Waveform cache cleared.')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsSectionLabel spaced>SETUP</SettingsSectionLabel>
|
||||||
|
<MaintenanceRow
|
||||||
|
icon="refresh-outline"
|
||||||
|
title="Replay Onboarding"
|
||||||
|
description="Open the first-run setup again without resetting library data."
|
||||||
|
disabled={disabled}
|
||||||
|
running={runningAction === 'onboarding'}
|
||||||
|
onPress={confirmOnboarding}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{feedback ? (
|
||||||
|
<View style={[styles.feedback, feedback.kind === 'error' && styles.errorFeedback]}>
|
||||||
|
<Ionicons
|
||||||
|
name={feedback.kind === 'success' ? 'checkmark-circle-outline' : 'alert-circle-outline'}
|
||||||
|
size={18}
|
||||||
|
color={feedback.kind === 'success' ? colors.accent : colors.warning}
|
||||||
|
/>
|
||||||
|
<Text variant="caption" color={feedback.kind === 'success' ? colors.textSecondary : colors.warning} style={styles.feedbackText}>
|
||||||
|
{feedback.text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</SettingsSectionScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Pressable
|
||||||
|
disabled={disabled}
|
||||||
|
android_ripple={ripple.bounded}
|
||||||
|
unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||||
|
onPress={onPress}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityState={{ disabled, busy: running }}
|
||||||
|
style={[styles.row, disabled && !running && styles.disabled]}
|
||||||
|
>
|
||||||
|
<View style={styles.icon}>
|
||||||
|
<Ionicons name={icon} size={20} color={colors.accent} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.meta}>
|
||||||
|
<Text variant="body">{title}</Text>
|
||||||
|
<Text variant="caption" color={colors.textSecondary} style={styles.description}>{description}</Text>
|
||||||
|
</View>
|
||||||
|
{running ? <ActivityIndicator size="small" color={colors.accent} /> : <Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />}
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
}));
|
||||||
@@ -6,6 +6,7 @@ import { startAudioProcessingWarmup } from './audioProcessingStartup';
|
|||||||
import { playForCar, skipToNext, skipToPrevious } from './playbackController';
|
import { playForCar, skipToNext, skipToPrevious } from './playbackController';
|
||||||
import { nativeIndexToAbsolute } from './queueLoader';
|
import { nativeIndexToAbsolute } from './queueLoader';
|
||||||
import { useQueueStore } from '@/stores/queueStore';
|
import { useQueueStore } from '@/stores/queueStore';
|
||||||
|
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RNTP playback service — registered in `index.js`. Runs in a headless context
|
* 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.
|
* controls to the player. Must not depend on React or the JS UI tree.
|
||||||
*/
|
*/
|
||||||
export async function PlaybackService(): Promise<void> {
|
export async function PlaybackService(): Promise<void> {
|
||||||
|
void useSleepTimerStore.getState().hydrate().catch(() => {});
|
||||||
// Begin the small fail-closed warm-up before a car/Bluetooth play command can
|
// 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.
|
// arrive. Full-queue registration and analysis start only after it is safe.
|
||||||
void startAudioProcessingWarmup('playback-service-start').catch((error) => {
|
void startAudioProcessingWarmup('playback-service-start').catch((error) => {
|
||||||
@@ -72,6 +74,28 @@ export async function PlaybackService(): Promise<void> {
|
|||||||
});
|
});
|
||||||
TrackPlayer.addEventListener(Event.PlaybackState, () => {
|
TrackPlayer.addEventListener(Event.PlaybackState, () => {
|
||||||
scheduleSync();
|
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, () => {
|
TrackPlayer.addEventListener(Event.RemotePlay, () => {
|
||||||
void playForCar()
|
void playForCar()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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<PersistedSleepTimerState>;
|
||||||
|
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`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NativeModules, Platform } from 'react-native';
|
||||||
|
|
||||||
|
interface TrackPlayerModuleExtension {
|
||||||
|
setPauseAtEndOfItem?: (enabled: boolean) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
if (!supportsNativePauseAtEndOfItem()) {
|
||||||
|
if (enabled) throw new Error('End-of-track timers are unavailable on this device.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await nativeTrackPlayer?.setPauseAtEndOfItem?.(enabled);
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { useColors } from '@/theme/themed';
|
|||||||
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
||||||
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
|
import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime';
|
||||||
import { useLyricsStore } from '@/stores/lyricsStore';
|
import { useLyricsStore } from '@/stores/lyricsStore';
|
||||||
|
import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore';
|
||||||
import {
|
import {
|
||||||
getLyricsLineSeekTimeSeconds,
|
getLyricsLineSeekTimeSeconds,
|
||||||
getSyncedLyricsDisplayLines,
|
getSyncedLyricsDisplayLines,
|
||||||
@@ -24,7 +25,6 @@ import {
|
|||||||
import { LyricsLine, type LyricsLineTier } from './LyricsLine';
|
import { LyricsLine, type LyricsLineTier } from './LyricsLine';
|
||||||
import type { Track } from '@/types/audio';
|
import type { Track } from '@/types/audio';
|
||||||
|
|
||||||
const TRANSLATION_PRIORITY = ['en', 'ja-Latn'];
|
|
||||||
const ANCHOR_RATIO = 0.4;
|
const ANCHOR_RATIO = 0.4;
|
||||||
const H_PADDING = 22;
|
const H_PADDING = 22;
|
||||||
// The displayed active line lags the audio by a fixed pipeline delay (RNTP
|
// 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 ripple = useRipple();
|
||||||
const entry = useLyricsStore((s) => s.byPath[track.path]);
|
const entry = useLyricsStore((s) => s.byPath[track.path]);
|
||||||
const loadForTrack = useLyricsStore((s) => s.loadForTrack);
|
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(() => {
|
useEffect(() => {
|
||||||
void loadForTrack(track);
|
void loadForTrack(track);
|
||||||
@@ -109,7 +114,7 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }:
|
|||||||
}, 90);
|
}, 90);
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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.
|
// Follow the active/focus line as playback advances.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -184,8 +189,8 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (displayLine.kind === 'gap') {
|
if (displayLine.kind === 'gap') {
|
||||||
const progress = getSyncedLyricsGapProgress(displayLine, lyricsTime) ?? 0;
|
|
||||||
const isCurrentGap = displayLine.displayIndex === focusIndex && timing.isNeutral;
|
const isCurrentGap = displayLine.displayIndex === focusIndex && timing.isNeutral;
|
||||||
|
const progress = isCurrentGap ? getSyncedLyricsGapProgress(displayLine, lyricsTime) ?? 0 : 0;
|
||||||
return (
|
return (
|
||||||
<View
|
<View
|
||||||
key={displayLine.key}
|
key={displayLine.key}
|
||||||
@@ -219,7 +224,12 @@ export function LyricsBand({ track, currentTime, duration, isPlaying, onSeek }:
|
|||||||
line={displayLine.line}
|
line={displayLine.line}
|
||||||
tier={tier}
|
tier={tier}
|
||||||
baseSize={baseSize}
|
baseSize={baseSize}
|
||||||
translationPriority={TRANSLATION_PRIORITY}
|
activeTimeSeconds={isActive && wordTimingEnabled ? lyricsTime : null}
|
||||||
|
wordTimingEnabled={wordTimingEnabled}
|
||||||
|
furiganaEnabled={furiganaEnabled}
|
||||||
|
translationsEnabled={translationsEnabled}
|
||||||
|
translationPriority={translationPriority}
|
||||||
|
voiceLabelsEnabled={voiceLabelsEnabled}
|
||||||
onSeek={() => {
|
onSeek={() => {
|
||||||
if (seekSeconds != null) onSeek(seekSeconds);
|
if (seekSeconds != null) onSeek(seekSeconds);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,28 +1,11 @@
|
|||||||
// One synced lyric line for the now-playing lyrics view. Left-aligned (mobile /
|
import { memo, useEffect, useMemo, useState } from 'react';
|
||||||
// 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 <ruby>, 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 { Pressable, View, type LayoutChangeEvent } from 'react-native';
|
import { Pressable, View, type LayoutChangeEvent } from 'react-native';
|
||||||
import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
import { useColors } from '@/theme/themed';
|
import { useColors } from '@/theme/themed';
|
||||||
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
import { SCROLL_PRESS_DELAY, useRipple } from '@/theme/ripple';
|
||||||
import { getPreferredLyricsTranslation } from '@/lyrics/presentation';
|
import { getPreferredLyricsTranslation, resolveLyricsWordTiming } from '@/lyrics/presentation';
|
||||||
import type { LyricsFurigana, LyricsLine as LyricsLineData } from '@/lyrics/types';
|
import type { LyricsFurigana, LyricsLine as LyricsLineData, LyricsWord } from '@/lyrics/types';
|
||||||
|
|
||||||
export type LyricsLineTier = 'active' | 'near' | 'far' | 'distant';
|
export type LyricsLineTier = 'active' | 'near' | 'far' | 'distant';
|
||||||
|
|
||||||
@@ -30,16 +13,19 @@ interface LyricsLineProps {
|
|||||||
line: LyricsLineData;
|
line: LyricsLineData;
|
||||||
tier: LyricsLineTier;
|
tier: LyricsLineTier;
|
||||||
baseSize: number;
|
baseSize: number;
|
||||||
|
activeTimeSeconds: number | null;
|
||||||
|
wordTimingEnabled: boolean;
|
||||||
|
furiganaEnabled: boolean;
|
||||||
|
translationsEnabled: boolean;
|
||||||
translationPriority: string[];
|
translationPriority: string[];
|
||||||
|
voiceLabelsEnabled: boolean;
|
||||||
onSeek: () => void;
|
onSeek: () => void;
|
||||||
onLayout?: (event: LayoutChangeEvent) => 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<LyricsLineTier, { scale: number; opacity: number }> = {
|
const TIER: Record<LyricsLineTier, { scale: number; opacity: number }> = {
|
||||||
active: { scale: 1.06, opacity: 1 },
|
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 },
|
far: { scale: 0.965, opacity: 0.27 },
|
||||||
distant: { scale: 0.93, opacity: 0.13 },
|
distant: { scale: 0.93, opacity: 0.13 },
|
||||||
};
|
};
|
||||||
@@ -64,24 +50,157 @@ function buildSegments(text: string, furigana: LyricsFurigana[] | undefined): Se
|
|||||||
cursor = entry.end;
|
cursor = entry.end;
|
||||||
}
|
}
|
||||||
if (cursor < text.length) segments.push({ text: text.slice(cursor) });
|
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 variant="heading" color={color} style={{ fontSize: size, lineHeight, ...shadow }}>{text}</Text>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||||
|
{segments.map((segment, index) => (
|
||||||
|
<View key={`${index}:${segment.text}`} style={{ paddingTop: readingHeight }}>
|
||||||
|
<Text variant="heading" color={color} style={{ fontSize: size, lineHeight, ...shadow }}>
|
||||||
|
{segment.text}
|
||||||
|
</Text>
|
||||||
|
{segment.reading ? (
|
||||||
|
<Text
|
||||||
|
variant="caption"
|
||||||
|
color={readingColor}
|
||||||
|
numberOfLines={1}
|
||||||
|
adjustsFontSizeToFit
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 0, left: 0, right: 0,
|
||||||
|
height: readingHeight, fontSize: readingSize,
|
||||||
|
lineHeight: readingHeight, textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{segment.reading}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View
|
||||||
|
onLayout={(event) => setWidth(event.nativeEvent.layout.width)}
|
||||||
|
>
|
||||||
|
<RubyText
|
||||||
|
text={word.text}
|
||||||
|
furigana={word.furigana}
|
||||||
|
enabled={furiganaEnabled}
|
||||||
|
size={size}
|
||||||
|
lineHeight={lineHeight}
|
||||||
|
readingSize={readingSize}
|
||||||
|
readingHeight={readingHeight}
|
||||||
|
color={colors.textPrimary}
|
||||||
|
readingColor={colors.textSecondary}
|
||||||
|
/>
|
||||||
|
{width > 0 && progress > 0 ? (
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
style={{
|
||||||
|
position: 'absolute', left: 0, top: textTop,
|
||||||
|
width: width * progress, height: lineHeight, overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
variant="heading"
|
||||||
|
color={colors.accentTextStrong}
|
||||||
|
numberOfLines={1}
|
||||||
|
style={{ width, fontSize: size, lineHeight }}
|
||||||
|
>
|
||||||
|
{word.text}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LyricsLineComponent({
|
||||||
|
line,
|
||||||
|
tier,
|
||||||
|
baseSize,
|
||||||
|
activeTimeSeconds,
|
||||||
|
wordTimingEnabled,
|
||||||
|
furiganaEnabled,
|
||||||
|
translationsEnabled,
|
||||||
|
translationPriority,
|
||||||
|
voiceLabelsEnabled,
|
||||||
|
onSeek,
|
||||||
|
onLayout,
|
||||||
|
}: LyricsLineProps) {
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const ripple = useRipple();
|
const ripple = useRipple();
|
||||||
const target = TIER[tier];
|
const target = TIER[tier];
|
||||||
// Uniform metrics for every line (the whole point — no wrap/reflow between tiers).
|
|
||||||
const size = baseSize;
|
const size = baseSize;
|
||||||
const lineHeight = Math.round(size * 1.2);
|
const lineHeight = Math.round(size * 1.2);
|
||||||
const readingSize = Math.max(9, Math.round(size * 0.5));
|
const readingSize = Math.max(9, Math.round(size * 0.5));
|
||||||
const readingHeight = Math.round(readingSize * 1.25);
|
const readingHeight = Math.round(readingSize * 1.25);
|
||||||
const translation = useMemo(
|
const translation = useMemo(
|
||||||
() => getPreferredLyricsTranslation(line, translationPriority),
|
() => translationsEnabled ? getPreferredLyricsTranslation(line, translationPriority) : null,
|
||||||
[line, translationPriority]
|
[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 opacity = useSharedValue(target.opacity);
|
||||||
const scale = useSharedValue(target.scale);
|
const scale = useSharedValue(target.scale);
|
||||||
@@ -93,71 +212,57 @@ function LyricsLineComponent({ line, tier, baseSize, translationPriority, onSeek
|
|||||||
opacity: opacity.value,
|
opacity: opacity.value,
|
||||||
transform: [{ scale: scale.value }],
|
transform: [{ scale: scale.value }],
|
||||||
}));
|
}));
|
||||||
|
const mainShadow = tier === 'active'
|
||||||
const textColor = colors.textPrimary;
|
? { textShadowColor: colors.accentGlow, textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 16 }
|
||||||
const mainShadow =
|
: undefined;
|
||||||
tier === 'active'
|
|
||||||
? { textShadowColor: colors.accentGlow, textShadowOffset: { width: 0, height: 0 }, textShadowRadius: 16 }
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Animated.View onLayout={onLayout} style={[{ width: '100%', transformOrigin: 'left center' }, animatedStyle]}>
|
<Animated.View onLayout={onLayout} style={[{ width: '100%', transformOrigin: 'left center' }, animatedStyle]}>
|
||||||
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onSeek} style={{ paddingVertical: 7 }}>
|
<Pressable android_ripple={ripple.bounded} unstable_pressDelay={SCROLL_PRESS_DELAY} onPress={onSeek} style={{ paddingVertical: 7 }}>
|
||||||
<View style={{ alignItems: 'flex-start' }}>
|
<View style={{ alignItems: 'flex-start' }}>
|
||||||
{hasFurigana ? (
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', alignItems: 'center', gap: 5 }}>
|
||||||
<View
|
{voiceLabelsEnabled && line.voice?.trim() ? (
|
||||||
style={{
|
<View style={{ borderWidth: 1, borderColor: colors.accent, borderRadius: 999, paddingHorizontal: 5, paddingVertical: 1 }}>
|
||||||
flexDirection: 'row',
|
<Text variant="caption" color={colors.accentTextStrong} style={{ fontSize: Math.max(9, Math.round(size * 0.45)), textTransform: 'uppercase' }}>
|
||||||
flexWrap: 'wrap',
|
{line.voice.trim()}
|
||||||
justifyContent: 'flex-start',
|
</Text>
|
||||||
alignItems: 'flex-start',
|
</View>
|
||||||
}}
|
) : null}
|
||||||
>
|
{words.length > 0 ? (
|
||||||
{segments.map((segment, index) => (
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
||||||
<View key={index} style={{ paddingTop: readingHeight }}>
|
{words.map((word, index) => (
|
||||||
<Text variant="heading" color={textColor} style={{ fontSize: size, lineHeight, ...mainShadow }}>
|
<TimedWord
|
||||||
{segment.text}
|
key={`${word.timestampMs}:${index}`}
|
||||||
</Text>
|
word={word}
|
||||||
{segment.reading ? (
|
progress={wordTiming?.progressByIndex[index] ?? 0}
|
||||||
<Text
|
furiganaEnabled={furiganaEnabled}
|
||||||
variant="caption"
|
size={size}
|
||||||
color={colors.textSecondary}
|
lineHeight={lineHeight}
|
||||||
numberOfLines={1}
|
readingSize={readingSize}
|
||||||
adjustsFontSizeToFit
|
readingHeight={readingHeight}
|
||||||
style={{
|
/>
|
||||||
position: 'absolute',
|
))}
|
||||||
top: 0,
|
</View>
|
||||||
left: 0,
|
) : (
|
||||||
right: 0,
|
<RubyText
|
||||||
height: readingHeight,
|
text={line.text}
|
||||||
fontSize: readingSize,
|
furigana={line.furigana}
|
||||||
lineHeight: readingHeight,
|
enabled={furiganaEnabled}
|
||||||
textAlign: 'center',
|
size={size}
|
||||||
}}
|
lineHeight={lineHeight}
|
||||||
>
|
readingSize={readingSize}
|
||||||
{segment.reading}
|
readingHeight={readingHeight}
|
||||||
</Text>
|
color={colors.textPrimary}
|
||||||
) : null}
|
readingColor={colors.textSecondary}
|
||||||
</View>
|
shadow={mainShadow}
|
||||||
))}
|
/>
|
||||||
</View>
|
)}
|
||||||
) : (
|
</View>
|
||||||
<Text variant="heading" color={textColor} style={{ fontSize: size, lineHeight, textAlign: 'left', ...mainShadow }}>
|
|
||||||
{line.text}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{translation ? (
|
{translation ? (
|
||||||
<Text
|
<Text
|
||||||
variant="body"
|
variant="body"
|
||||||
color={colors.textSecondary}
|
color={colors.textSecondary}
|
||||||
style={{
|
style={{ fontSize: Math.max(11, Math.round(size * 0.52)), lineHeight: Math.round(size * 0.66), marginTop: 3, opacity: 0.85 }}
|
||||||
fontSize: Math.max(11, Math.round(size * 0.52)),
|
|
||||||
lineHeight: Math.round(size * 0.66),
|
|
||||||
textAlign: 'left',
|
|
||||||
marginTop: 3,
|
|
||||||
opacity: 0.85,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{translation.text}
|
{translation.text}
|
||||||
</Text>
|
</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);
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ import { NowPlayingCompanionPane } from '@/components/player/NowPlayingCompanion
|
|||||||
import { PlayerStateIcon } from '@/components/player/PlayerStateIcon';
|
import { PlayerStateIcon } from '@/components/player/PlayerStateIcon';
|
||||||
import { CachedLyricPeek } from '@/components/player/CachedLyricPeek';
|
import { CachedLyricPeek } from '@/components/player/CachedLyricPeek';
|
||||||
import { useDelayedUnmountPresence } from '@/components/delayedPresence';
|
import { useDelayedUnmountPresence } from '@/components/delayedPresence';
|
||||||
|
import { SleepTimerControls } from '@/components/player/SleepTimerControls';
|
||||||
|
import { AppSheet, AppSheetTitle } from '@/components/sheets/AppSheet';
|
||||||
import {
|
import {
|
||||||
radius,
|
radius,
|
||||||
spacing,
|
spacing,
|
||||||
@@ -73,6 +75,7 @@ import { usePlaylistStore } from '@/stores/playlistStore';
|
|||||||
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
import { usePlaybackTargetStore } from '@/stores/playbackTargetStore';
|
||||||
import { usePlayerUiStore } from '@/stores/playerUiStore';
|
import { usePlayerUiStore } from '@/stores/playerUiStore';
|
||||||
import { useSettingsStore, type ScopeMode } from '@/stores/settingsStore';
|
import { useSettingsStore, type ScopeMode } from '@/stores/settingsStore';
|
||||||
|
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
|
||||||
import type { DbTrack } from '@/types/library';
|
import type { DbTrack } from '@/types/library';
|
||||||
import {
|
import {
|
||||||
cycleRepeat,
|
cycleRepeat,
|
||||||
@@ -89,6 +92,7 @@ import {
|
|||||||
getPhonePlaybackPresentation,
|
getPhonePlaybackPresentation,
|
||||||
hostFromBaseUrl,
|
hostFromBaseUrl,
|
||||||
} from '@/playback/playbackTargetPresentation';
|
} from '@/playback/playbackTargetPresentation';
|
||||||
|
import { formatSleepTimerStatus } from '@/audio/sleepTimerState';
|
||||||
|
|
||||||
const DISMISS_DISTANCE = 140;
|
const DISMISS_DISTANCE = 140;
|
||||||
const DISMISS_VELOCITY = 1000;
|
const DISMISS_VELOCITY = 1000;
|
||||||
@@ -127,6 +131,7 @@ export function NowPlayingOverlay() {
|
|||||||
const closeQueue = useCallback(() => setQueueOpen(false), []);
|
const closeQueue = useCallback(() => setQueueOpen(false), []);
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [targetPickerOpen, setTargetPickerOpen] = useState(false);
|
const [targetPickerOpen, setTargetPickerOpen] = useState(false);
|
||||||
|
const [sleepTimerOpen, setSleepTimerOpen] = useState(false);
|
||||||
const [playlistActionTrack, setPlaylistActionTrack] = useState<DbTrack | null>(null);
|
const [playlistActionTrack, setPlaylistActionTrack] = useState<DbTrack | null>(null);
|
||||||
const selectedTarget = usePlaybackTargetStore((s) => s.target);
|
const selectedTarget = usePlaybackTargetStore((s) => s.target);
|
||||||
const scopeMode = useSettingsStore((s) => s.scopeMode);
|
const scopeMode = useSettingsStore((s) => s.scopeMode);
|
||||||
@@ -152,6 +157,9 @@ export function NowPlayingOverlay() {
|
|||||||
const desktopQueue = useDesktopRemoteStore((s) => s.queue);
|
const desktopQueue = useDesktopRemoteStore((s) => s.queue);
|
||||||
const sendDesktopControl = useDesktopRemoteStore((s) => s.sendControl);
|
const sendDesktopControl = useDesktopRemoteStore((s) => s.sendControl);
|
||||||
const reconnectDesktop = useDesktopRemoteStore((s) => s.reconnect);
|
const reconnectDesktop = useDesktopRemoteStore((s) => s.reconnect);
|
||||||
|
const sleepTimer = useSleepTimerStore((s) => s.timer);
|
||||||
|
const sleepRemainingMs = useSleepTimerStore((s) => s.remainingMs);
|
||||||
|
void sleepRemainingMs;
|
||||||
const phonePresentation = getPhonePlaybackPresentation({
|
const phonePresentation = getPhonePlaybackPresentation({
|
||||||
track,
|
track,
|
||||||
playbackState,
|
playbackState,
|
||||||
@@ -311,6 +319,17 @@ export function NowPlayingOverlay() {
|
|||||||
setTargetPickerOpen(true);
|
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) {
|
if (!isDesktopTarget && artistName) {
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
key: 'artist',
|
key: 'artist',
|
||||||
@@ -1351,6 +1370,12 @@ export function NowPlayingOverlay() {
|
|||||||
initialStep="pickPlaylist"
|
initialStep="pickPlaylist"
|
||||||
onClose={() => setPlaylistActionTrack(null)}
|
onClose={() => setPlaylistActionTrack(null)}
|
||||||
/>
|
/>
|
||||||
|
{sleepTimerOpen ? (
|
||||||
|
<AppSheet onClose={() => setSleepTimerOpen(false)}>
|
||||||
|
<AppSheetTitle title="Sleep timer" subtitle={sleepTimer ? formatSleepTimerStatus(sleepTimer) : undefined} />
|
||||||
|
<SleepTimerControls />
|
||||||
|
</AppSheet>
|
||||||
|
) : null}
|
||||||
{queueOpen && !hasTabletCompanion && (
|
{queueOpen && !hasTabletCompanion && (
|
||||||
isDesktopTarget ? (
|
isDesktopTarget ? (
|
||||||
<RemoteQueueSheet onClose={closeQueue} />
|
<RemoteQueueSheet onClose={closeQueue} />
|
||||||
|
|||||||
@@ -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<string | null>(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<void>, 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 (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.statusBlock}>
|
||||||
|
<Text variant="body">{timer ? formatSleepTimerStatus(timer) : 'No sleep timer'}</Text>
|
||||||
|
<Text variant="caption" color={colors.textSecondary}>
|
||||||
|
{!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.'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.presets}>
|
||||||
|
{SLEEP_TIMER_PRESETS.map((minutes) => (
|
||||||
|
<Pressable
|
||||||
|
key={minutes}
|
||||||
|
disabled={!available}
|
||||||
|
android_ripple={ripple.bounded}
|
||||||
|
unstable_pressDelay={SCROLL_PRESS_DELAY}
|
||||||
|
onPress={() => void run(() => startMinutes(minutes), `Timer set for ${minutes} minutes.`)}
|
||||||
|
style={({ pressed }) => [styles.preset, !available && styles.disabled, pressed && available && styles.pressed]}
|
||||||
|
accessibilityRole="button"
|
||||||
|
>
|
||||||
|
<Text variant="label" color={colors.textPrimary}>{minutes} min</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.customRow}>
|
||||||
|
<TextInput
|
||||||
|
value={customMinutes}
|
||||||
|
onChangeText={setCustomMinutes}
|
||||||
|
editable={available}
|
||||||
|
keyboardType="number-pad"
|
||||||
|
returnKeyType="done"
|
||||||
|
placeholder="1–720"
|
||||||
|
placeholderTextColor={colors.textTertiary}
|
||||||
|
onSubmitEditing={startCustom}
|
||||||
|
style={[styles.input, !available && styles.disabled]}
|
||||||
|
accessibilityLabel="Custom sleep timer minutes"
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
disabled={!available}
|
||||||
|
android_ripple={ripple.bounded}
|
||||||
|
onPress={startCustom}
|
||||||
|
style={({ pressed }) => [styles.action, !available && styles.disabled, pressed && available && styles.pressed]}
|
||||||
|
>
|
||||||
|
<Text variant="label" color={colors.accentTextStrong}>Set custom</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
disabled={!available || !supportsNativePauseAtEndOfItem()}
|
||||||
|
android_ripple={ripple.bounded}
|
||||||
|
onPress={() => void run(startEndOfTrack, 'Timer set for the end of the track.')}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.fullAction,
|
||||||
|
(!available || !supportsNativePauseAtEndOfItem()) && styles.disabled,
|
||||||
|
pressed && available && styles.pressed,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text variant="body">End of track</Text>
|
||||||
|
<Text variant="caption" color={colors.textSecondary}>
|
||||||
|
{supportsNativePauseAtEndOfItem() ? 'Pause exactly before the next track begins.' : 'Requires the Android playback engine.'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
{timer ? (
|
||||||
|
<Pressable android_ripple={ripple.bounded} onPress={() => void run(cancel, 'Sleep timer canceled.')} style={styles.cancel}>
|
||||||
|
<Text variant="label" color={colors.warning}>Cancel sleep timer</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{feedback ? <Text variant="caption" color={colors.textSecondary}>{feedback}</Text> : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
}));
|
||||||
@@ -50,6 +50,7 @@ import type {
|
|||||||
DbTrack
|
DbTrack
|
||||||
} from '@/types/library';
|
} from '@/types/library';
|
||||||
import type { Playlist } from '@/types/playlist';
|
import type { Playlist } from '@/types/playlist';
|
||||||
|
import { SETTINGS_SEARCH_ROUTES } from '@/components/search/settingsSearchRoutes';
|
||||||
|
|
||||||
type IconName = keyof typeof Ionicons.glyphMap;
|
type IconName = keyof typeof Ionicons.glyphMap;
|
||||||
type RouteHref =
|
type RouteHref =
|
||||||
@@ -60,8 +61,11 @@ type RouteHref =
|
|||||||
| '/settings/appearance'
|
| '/settings/appearance'
|
||||||
| '/settings/library'
|
| '/settings/library'
|
||||||
| '/settings/audio'
|
| '/settings/audio'
|
||||||
|
| '/settings/playback'
|
||||||
| '/settings/services'
|
| '/settings/services'
|
||||||
|
| '/settings/lyrics'
|
||||||
| '/settings/experimental'
|
| '/settings/experimental'
|
||||||
|
| '/settings/troubleshooting'
|
||||||
| '/settings/info'
|
| '/settings/info'
|
||||||
| '/sources'
|
| '/sources'
|
||||||
| '/lastfm';
|
| '/lastfm';
|
||||||
@@ -149,6 +153,14 @@ const SETTING_ENTRIES: {
|
|||||||
icon: 'volume-high',
|
icon: 'volume-high',
|
||||||
keywords: ['audio', 'normalization', 'replaygain', 'loudness', 'gain', 'target lufs'],
|
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',
|
id: 'setting:library',
|
||||||
label: 'Library settings',
|
label: 'Library settings',
|
||||||
@@ -173,6 +185,14 @@ const SETTING_ENTRIES: {
|
|||||||
icon: 'server-outline',
|
icon: 'server-outline',
|
||||||
keywords: ['services', 'integrations', 'remote sources', 'scrobbling'],
|
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',
|
id: 'setting:sources',
|
||||||
label: 'Remote sources',
|
label: 'Remote sources',
|
||||||
@@ -205,6 +225,14 @@ const SETTING_ENTRIES: {
|
|||||||
icon: 'information-circle-outline',
|
icon: 'information-circle-outline',
|
||||||
keywords: ['info', 'about', 'version', 'license', 'attribution', 'github', 'repo', 'repository', 'discord', 'kofi', 'ko-fi', 'support', 'gpl'],
|
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'];
|
const EMPTY_SHORTCUT_IDS = ['nav:library', 'nav:eq', 'setting:sources', 'setting:lastfm'];
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const SETTINGS_SEARCH_ROUTES = {
|
||||||
|
playback: '/settings/playback',
|
||||||
|
lyrics: '/settings/lyrics',
|
||||||
|
troubleshooting: '/settings/troubleshooting',
|
||||||
|
} as const;
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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<number> {
|
||||||
|
const result = await db.run(REBUILD_LOCAL_LIBRARY_INDEX_SQL);
|
||||||
|
return result.changes;
|
||||||
|
}
|
||||||
@@ -105,3 +105,12 @@ export async function putLyricsCache(db: LibraryDatabase, entry: LyricsCacheWrit
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getLyricsCacheCount(db: LibraryDatabase): Promise<number> {
|
||||||
|
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<void> {
|
||||||
|
await db.run('DELETE FROM lyrics_cache');
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ export async function putWaveformPeaks(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getWaveformCacheCount(db: LibraryDatabase): Promise<number> {
|
||||||
|
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<void> {
|
||||||
|
await db.run('DELETE FROM waveform_peaks');
|
||||||
|
}
|
||||||
|
|
||||||
function toFloat32(blob: ArrayBuffer | ArrayBufferView): Float32Array {
|
function toFloat32(blob: ArrayBuffer | ArrayBufferView): Float32Array {
|
||||||
if (blob instanceof Float32Array) return blob;
|
if (blob instanceof Float32Array) return blob;
|
||||||
if (ArrayBuffer.isView(blob)) {
|
if (ArrayBuffer.isView(blob)) {
|
||||||
|
|||||||
@@ -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<void>((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']);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** Serializes cache writes and gives clears a generation boundary. */
|
||||||
|
export class CacheInvalidationGate {
|
||||||
|
private generation = 0;
|
||||||
|
private mutationQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
capture(): number {
|
||||||
|
return this.generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCurrent(generation: number): boolean {
|
||||||
|
return generation === this.generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
enqueue(operation: () => Promise<void>): Promise<void> {
|
||||||
|
const task = this.mutationQueue.then(operation, operation);
|
||||||
|
this.mutationQueue = task.catch(() => {});
|
||||||
|
return task;
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(operation: () => Promise<void>): Promise<void> {
|
||||||
|
this.generation += 1;
|
||||||
|
return this.enqueue(operation);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -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<string>();
|
||||||
|
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<LyricsDisplaySettings>
|
||||||
|
: {};
|
||||||
|
return {
|
||||||
|
wordTimingEnabled: candidate.wordTimingEnabled !== false,
|
||||||
|
furiganaEnabled: candidate.furiganaEnabled !== false,
|
||||||
|
translationsEnabled: candidate.translationsEnabled !== false,
|
||||||
|
translationPriority: normalizeLyricsLanguagePriority(candidate.translationPriority),
|
||||||
|
voiceLabelsEnabled: candidate.voiceLabelsEnabled === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
Binary file not shown.
+27
-6
@@ -5,7 +5,8 @@
|
|||||||
|
|
||||||
import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
|
import { AstraLibraryScanner } from '../../modules/astra-library-scanner';
|
||||||
import { openLibraryDb } from '@/db/database';
|
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_BINS = 512;
|
||||||
export const WAVEFORM_PREVIEW_BINS = 96;
|
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).
|
// Dedupe concurrent requests for the same track (e.g. mini-player + now-playing).
|
||||||
const inflight = new Map<string, Promise<Float32Array | null>>();
|
const inflight = new Map<string, Promise<Float32Array | null>>();
|
||||||
const previewInflight = new Map<string, Promise<Float32Array | null>>();
|
const previewInflight = new Map<string, Promise<Float32Array | null>>();
|
||||||
|
const cacheGate = new CacheInvalidationGate();
|
||||||
|
|
||||||
export function getWaveform(
|
export function getWaveform(
|
||||||
trackPath: string,
|
trackPath: string,
|
||||||
@@ -42,12 +44,15 @@ async function loadWaveform(
|
|||||||
|
|
||||||
const existing = inflight.get(trackPath);
|
const existing = inflight.get(trackPath);
|
||||||
if (existing) return existing;
|
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);
|
inflight.set(trackPath, task);
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decodeAccurateWaveform(trackPath: string): Promise<Float32Array | null> {
|
async function decodeAccurateWaveform(trackPath: string, generation: number): Promise<Float32Array | null> {
|
||||||
let raw: number[];
|
let raw: number[];
|
||||||
try {
|
try {
|
||||||
raw = await AstraLibraryScanner.extractWaveform(trackPath, WAVEFORM_BINS);
|
raw = await AstraLibraryScanner.extractWaveform(trackPath, WAVEFORM_BINS);
|
||||||
@@ -57,17 +62,33 @@ async function decodeAccurateWaveform(trackPath: string): Promise<Float32Array |
|
|||||||
if (!raw || raw.length === 0) return null;
|
if (!raw || raw.length === 0) return null;
|
||||||
|
|
||||||
const peaks = Float32Array.from(raw);
|
const peaks = Float32Array.from(raw);
|
||||||
const db = await openLibraryDb();
|
await cacheGate.enqueue(async () => {
|
||||||
await putWaveformPeaks(db, trackPath, peaks).catch(() => {
|
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 */
|
/* cache write failure is non-fatal */
|
||||||
});
|
});
|
||||||
return peaks;
|
return peaks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Deletes waveform rows and prevents decodes already in flight from writing them back. */
|
||||||
|
export async function clearAllWaveformCache(): Promise<void> {
|
||||||
|
inflight.clear();
|
||||||
|
previewInflight.clear();
|
||||||
|
await cacheGate.invalidate(async () => {
|
||||||
|
const db = await openLibraryDb();
|
||||||
|
await clearWaveformCache(db);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getWaveformPreview(trackPath: string): Promise<Float32Array | null> {
|
function getWaveformPreview(trackPath: string): Promise<Float32Array | null> {
|
||||||
const existing = previewInflight.get(trackPath);
|
const existing = previewInflight.get(trackPath);
|
||||||
if (existing) return existing;
|
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);
|
previewInflight.set(trackPath, task);
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/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('/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/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('/library/playlist/edit-dynamic?id=4'), null);
|
||||||
assert.equal(normalizeStableHref('/eq/scan'), null);
|
assert.equal(normalizeStableHref('/eq/scan'), null);
|
||||||
assert.equal(normalizeStableHref('/notification.click'), null);
|
assert.equal(normalizeStableHref('/notification.click'), null);
|
||||||
|
|||||||
@@ -54,8 +54,11 @@ const STATIC_STABLE_PATHS = new Set([
|
|||||||
'/settings/appearance',
|
'/settings/appearance',
|
||||||
'/settings/library',
|
'/settings/library',
|
||||||
'/settings/audio',
|
'/settings/audio',
|
||||||
|
'/settings/playback',
|
||||||
'/settings/services',
|
'/settings/services',
|
||||||
|
'/settings/lyrics',
|
||||||
'/settings/experimental',
|
'/settings/experimental',
|
||||||
|
'/settings/troubleshooting',
|
||||||
'/settings/info',
|
'/settings/info',
|
||||||
'/settings/haptics-lab',
|
'/settings/haptics-lab',
|
||||||
'/sources',
|
'/sources',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
markTrackPlayed,
|
markTrackPlayed,
|
||||||
setSetting,
|
setSetting,
|
||||||
} from '@/db/queries';
|
} from '@/db/queries';
|
||||||
|
import { markLocalTracksStaleForRebuild } from '@/db/libraryMaintenance';
|
||||||
import { recomputeAlbumIdentity } from '@/library/albumIdentity';
|
import { recomputeAlbumIdentity } from '@/library/albumIdentity';
|
||||||
import { buildAlbumList } from '@/library/albumSummary';
|
import { buildAlbumList } from '@/library/albumSummary';
|
||||||
import { ensureArtworkThumbnails } from '@/library/artwork';
|
import { ensureArtworkThumbnails } from '@/library/artwork';
|
||||||
@@ -113,6 +114,7 @@ interface LibraryStore {
|
|||||||
addFolder: () => Promise<void>;
|
addFolder: () => Promise<void>;
|
||||||
removeFolder: (folderId: number) => Promise<void>;
|
removeFolder: (folderId: number) => Promise<void>;
|
||||||
rescan: () => Promise<void>;
|
rescan: () => Promise<void>;
|
||||||
|
rebuildLocalIndex: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let initPromise: Promise<void> | null = null;
|
let initPromise: Promise<void> | null = null;
|
||||||
@@ -300,5 +302,11 @@ export const useLibraryStore = create<LibraryStore>((set, get) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
rescan: () => runScan(() => rescanAll({ callbacks: { onProgress } })),
|
rescan: () => runScan(() => rescanAll({ callbacks: { onProgress } })),
|
||||||
|
|
||||||
|
rebuildLocalIndex: () => runScan(async () => {
|
||||||
|
const db = await openLibraryDb();
|
||||||
|
await markLocalTracksStaleForRebuild(db);
|
||||||
|
return rescanAll({ callbacks: { onProgress } });
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
setOnlineLookupEnabled: (enabled: boolean) => Promise<void>;
|
||||||
|
setWordTimingEnabled: (enabled: boolean) => Promise<void>;
|
||||||
|
setFuriganaEnabled: (enabled: boolean) => Promise<void>;
|
||||||
|
setTranslationsEnabled: (enabled: boolean) => Promise<void>;
|
||||||
|
setTranslationPriority: (value: string | string[]) => Promise<void>;
|
||||||
|
setVoiceLabelsEnabled: (enabled: boolean) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
const db = await openLibraryDb();
|
||||||
|
await setSetting(db, DISPLAY_SETTINGS_KEY, JSON.stringify(settings));
|
||||||
|
}
|
||||||
|
|
||||||
|
let loadPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
export const useLyricsSettingsStore = create<LyricsSettingsStore>((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 });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { buildLyricsQuery, getLyricsForTrack } from '@/lyrics/lyrics';
|
import { buildLyricsQuery, getLyricsForTrack } from '@/lyrics/lyrics';
|
||||||
|
import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore';
|
||||||
import type { LyricsLookupResult } from '@/lyrics/types';
|
import type { LyricsLookupResult } from '@/lyrics/types';
|
||||||
import type { Track } from '@/types/audio';
|
import type { Track } from '@/types/audio';
|
||||||
|
|
||||||
@@ -17,10 +18,9 @@ export interface LyricsUiEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface LyricsStore {
|
interface LyricsStore {
|
||||||
onlineEnabled: boolean;
|
|
||||||
byPath: Record<string, LyricsUiEntry>;
|
byPath: Record<string, LyricsUiEntry>;
|
||||||
loadForTrack: (track: Track | null, options?: { force?: boolean }) => Promise<void>;
|
loadForTrack: (track: Track | null, options?: { force?: boolean }) => Promise<void>;
|
||||||
setOnlineEnabled: (enabled: boolean) => void;
|
invalidateAll: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Latest request id per path — guards against a stale response overwriting a
|
// Latest request id per path — guards against a stale response overwriting a
|
||||||
@@ -40,11 +40,11 @@ function pruneToLru(byPath: Record<string, LyricsUiEntry>): Record<string, Lyric
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useLyricsStore = create<LyricsStore>((set, get) => ({
|
export const useLyricsStore = create<LyricsStore>((set, get) => ({
|
||||||
onlineEnabled: true,
|
|
||||||
byPath: {},
|
byPath: {},
|
||||||
|
|
||||||
loadForTrack: async (track, options = {}) => {
|
loadForTrack: async (track, options = {}) => {
|
||||||
if (!track?.path) return;
|
if (!track?.path) return;
|
||||||
|
await useLyricsSettingsStore.getState().load();
|
||||||
const path = track.path;
|
const path = track.path;
|
||||||
const force = Boolean(options.force);
|
const force = Boolean(options.force);
|
||||||
|
|
||||||
@@ -74,7 +74,10 @@ export const useLyricsStore = create<LyricsStore>((set, get) => ({
|
|||||||
|
|
||||||
let result: LyricsLookupResult;
|
let result: LyricsLookupResult;
|
||||||
try {
|
try {
|
||||||
result = await getLyricsForTrack(query, { forceRefresh: force, onlineEnabled: get().onlineEnabled });
|
result = await getLyricsForTrack(query, {
|
||||||
|
forceRefresh: force,
|
||||||
|
onlineEnabled: useLyricsSettingsStore.getState().onlineLookupEnabled,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
result = {
|
result = {
|
||||||
status: 'transient_error',
|
status: 'transient_error',
|
||||||
@@ -90,5 +93,8 @@ export const useLyricsStore = create<LyricsStore>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
setOnlineEnabled: (enabled) => set({ onlineEnabled: enabled }),
|
invalidateAll: () => {
|
||||||
|
requestIds.clear();
|
||||||
|
set({ byPath: {} });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
startMinutes: (minutes: number) => Promise<void>;
|
||||||
|
startEndOfTrack: () => Promise<void>;
|
||||||
|
cancel: () => Promise<void>;
|
||||||
|
reconcile: (nowMs?: number) => Promise<void>;
|
||||||
|
reconcileEndOfTrack: (position: number, duration: number, playWhenReady: boolean) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let deadlineTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let hydrationPromise: Promise<void> | null = null;
|
||||||
|
let reconcilePromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function clearDeadlineTimer(): void {
|
||||||
|
if (deadlineTimer !== null) clearTimeout(deadlineTimer);
|
||||||
|
deadlineTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistTimer(timer: PersistedSleepTimerState | null): Promise<void> {
|
||||||
|
const db = await openLibraryDb();
|
||||||
|
await setSetting(db, SLEEP_TIMER_KEY, timer ? JSON.stringify(timer) : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasActivePhoneTrack(): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<SleepTimerStore>((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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user