mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-21 04:59:53 +02:00
bring over relevant desktop settings
This commit is contained in:
@@ -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
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
} 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"
|
||||
|
||||
@@ -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 },
|
||||
}));
|
||||
Reference in New Issue
Block a user