bring over relevant desktop settings

This commit is contained in:
Boof2015
2026-07-14 01:14:16 -04:00
parent c9d065e662
commit 7b35e01fe0
36 changed files with 1646 additions and 126 deletions
+18 -1
View File
@@ -26,6 +26,8 @@ import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import { useLibraryStore } from '@/stores/libraryStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import { useThemeStore } from '@/stores/themeStore';
import { useSleepTimerStore } from '@/stores/sleepTimerStore';
import { formatSleepTimerStatus } from '@/audio/sleepTimerState';
function formatEnabled(value: boolean): string {
return value ? 'On' : 'Off';
@@ -48,6 +50,9 @@ export default function SettingsScreen() {
const desktopSyncStatus = useDesktopSyncStore((s) => s.status);
const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length);
const sleepTimer = useSleepTimerStore((s) => s.timer);
const sleepRemainingMs = useSleepTimerStore((s) => s.remainingMs);
void sleepRemainingMs;
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
@@ -103,6 +108,12 @@ export default function SettingsScreen() {
subtitle={`Normalization ${formatEnabled(normalizationEnabled)}. ReplayGain ${formatEnabled(replayGainEnabled)}.`}
onPress={() => router.push('/settings/audio' as never)}
/>
<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
icon="server-outline"
title="Services"
@@ -117,7 +128,13 @@ export default function SettingsScreen() {
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
icon="information-circle-outline"
title="Info"
+22 -1
View File
@@ -47,6 +47,8 @@ import { useOnboardingStore } from '@/stores/onboardingStore';
import { OnboardingFlow } from '@/components/onboarding/OnboardingFlow';
import { useTheme } from '@/theme/themed';
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
// (the widget's `recently-played`, the notification-click redirect) builds
@@ -81,6 +83,17 @@ function ThemeSystemSync() {
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. */
function ScopeLifecycle() {
useScopeLifecycle();
@@ -110,7 +123,10 @@ function PlaybackTargetSync() {
}, [loadTarget]);
useEffect(() => {
if (target === 'desktop') void initDesktopRemote();
if (target === 'desktop') {
if (useSleepTimerStore.getState().timer) void useSleepTimerStore.getState().cancel();
void initDesktopRemote();
}
}, [target, initDesktopRemote]);
return null;
@@ -346,6 +362,10 @@ export default function RootLayout() {
.getState()
.load()
.catch((err) => console.error('[audioSettings] load failed', err));
useLyricsSettingsStore
.getState()
.load()
.catch((err) => console.error('[lyricsSettings] load failed', err));
// Remote sources: load server rows + hydrate the URL registry from cached
// config/token (no network on launch). Runs after library init reads first.
useRemoteSourcesStore
@@ -373,6 +393,7 @@ export default function RootLayout() {
{onboardingComplete ? (
<>
<ThemeSystemSync />
<SleepTimerLifecycle />
<PlaybackSync />
<ScopeLifecycle />
<NormalizationSync />
+1 -18
View File
@@ -1,5 +1,5 @@
import { useEffect } from 'react';
import { Alert, InteractionManager } from 'react-native';
import { InteractionManager } from 'react-native';
import { useRouter } from 'expo-router';
import {
SettingsNavRow,
@@ -10,7 +10,6 @@ import { formatRelativeTime } from '@/lib/format';
import { useColors } from '@/theme/themed';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import { useOnboardingStore } from '@/stores/onboardingStore';
export default function ExperimentalSettingsScreen() {
const colors = useColors();
@@ -32,16 +31,6 @@ export default function ExperimentalSettingsScreen() {
const desktopRemoteSubtitle = desktopRemoteConnection
? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'}: ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
: 'Pair with Astra Desktop to control playback from this phone.';
const replayOnboarding = () => {
Alert.alert(
'Replay onboarding?',
'The first-run setup wizard will show again the next time you return to the home screen. Your library and settings are kept.',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Replay', onPress: () => void useOnboardingStore.getState().reset() },
]
);
};
const desktopSyncSubtitle = !desktopRemoteConnection
? 'Sync favorites and playlists with Astra Desktop.'
@@ -77,12 +66,6 @@ export default function ExperimentalSettingsScreen() {
subtitle="Audition semantic feedback, device primitives, and signature candidates."
onPress={() => router.push('/settings/haptics-lab' as never)}
/>
<SettingsNavRow
icon="refresh-outline"
title="Replay onboarding"
subtitle="Show the first-run setup wizard again."
onPress={replayOnboarding}
/>
</SettingsSectionScreen>
);
}
+146
View File
@@ -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,
},
}));
+17
View File
@@ -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>
);
}
+10
View File
@@ -6,12 +6,14 @@ import {
SettingsSectionScreen,
} from '@/components/settings/SettingsSectionScaffold';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import { useLyricsSettingsStore } from '@/stores/lyricsSettingsStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
export default function ServicesSettingsScreen() {
const router = useRouter();
const remoteSources = useRemoteSourcesStore((s) => s.sources);
const lastFmStatus = useLastFmSettingsStore((s) => s.status);
const onlineLookupEnabled = useLyricsSettingsStore((s) => s.onlineLookupEnabled);
return (
<SettingsSectionScreen title="Services">
@@ -27,6 +29,14 @@ export default function ServicesSettingsScreen() {
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>
<SettingsNavRow
icon="radio-outline"
+241
View File
@@ -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 },
}));