settings revamp

This commit is contained in:
Boof2015
2026-07-06 21:03:58 -04:00
parent efd2ebc91a
commit 31f81282e0
10 changed files with 1147 additions and 673 deletions
+85 -662
View File
@@ -1,352 +1,53 @@
import { useEffect } from 'react';
import {
Alert,
InteractionManager,
View,
Pressable,
ScrollView,
StyleSheet,
Switch,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Constants from 'expo-constants';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { EQSlider } from '@/components/eq/EQSlider';
import { ScanProgress } from '@/components/library/ScanProgress';
import { SegmentedControl } from '@/components/SegmentedControl';
import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
import { radius, spacing } from '@/theme';
import {
formatFolderCount,
formatTrackCount,
themeOptionTitle,
} from '@/components/settings/SettingsPanels';
import {
SettingsNavRow,
SettingsSectionLabel,
} from '@/components/settings/SettingsSectionScaffold';
import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import type { BaseThemeId, PreferredDark } from '@/theme/resolve';
import { useSettingsStore } from '@/stores/settingsStore';
import { useThemeStore } from '@/stores/themeStore';
import { useLibraryStore, type FolderWithCount } from '@/stores/libraryStore';
import { formatRelativeTime } from '@/lib/format';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import { formatRelativeTime } from '@/lib/format';
import type { ReplayGainMode } from '@/audio/normalization';
import type { ArtistGroupingMode } from '@/library/artistGrouping';
import type { LastFmStatus } from '@/types/lastFm';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import { useLibraryStore } from '@/stores/libraryStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import { useThemeStore } from '@/stores/themeStore';
function lastFmScrobbleSubtitle(status: LastFmStatus | null): string {
const connected = status?.profiles.filter((p) => p.connected).length ?? 0;
if (connected === 0) return 'Scrobble plays to Last.fm, ListenBrainz, and more.';
const base = `${connected} destination${connected === 1 ? '' : 's'} connected`;
return status?.enabled ? `${base}.` : `${base} · paused.`;
}
const THEME_OPTIONS: { id: BaseThemeId; title: string; description: string }[] = [
{ id: 'system', title: 'System', description: 'Follow the Android dark/light setting.' },
{ id: 'midnight', title: 'Midnight', description: 'Deep navy. The classic Astra look.' },
{ id: 'dark', title: 'Dark', description: 'Neutral dark gray, no navy cast.' },
{ id: 'amoled', title: 'AMOLED', description: 'True black. Easy on OLED screens and batteries.' },
{ id: 'light', title: 'Light', description: 'Cool near-white with navy ink.' },
{ id: 'materialYou', title: 'Material You', description: 'Colors from your wallpaper.' },
];
const DARK_STYLE_SEGMENTS = [
{ key: 'midnight', label: 'Midnight' },
{ key: 'dark', label: 'Dark' },
{ key: 'amoled', label: 'AMOLED' },
];
/** Theme picker + accent swatches. Lives first in settings — it demos live switching. */
function AppearanceSettings() {
const styles = useStyles();
const colors = useColors();
const baseTheme = useThemeStore((s) => s.baseTheme);
const preferredDark = useThemeStore((s) => s.preferredDark);
const accentId = useThemeStore((s) => s.accentId);
const materialYouAvailable = useThemeStore((s) => s.materialYouAvailable);
const resolvedId = useThemeStore((s) => s.theme.id);
const setBaseTheme = useThemeStore((s) => s.setBaseTheme);
const setPreferredDark = useThemeStore((s) => s.setPreferredDark);
const setAccent = useThemeStore((s) => s.setAccent);
const options = THEME_OPTIONS.filter(
(option) => option.id !== 'materialYou' || materialYouAvailable
);
// Material You picks its own accent from the wallpaper — hide the swatches.
const accentApplies = !resolvedId.startsWith('materialYou');
return (
<>
<View style={styles.options}>
{options.map((option) => {
const selected = option.id === baseTheme;
return (
<Pressable
key={option.id}
style={[styles.option, selected && styles.optionSelected]}
onPress={() => void setBaseTheme(option.id)}
accessibilityRole="radio"
accessibilityState={{ selected }}
>
<View style={styles.optionText}>
<Text variant="body" color={selected ? colors.accentTextStrong : colors.textPrimary}>
{option.title}
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{option.description}
</Text>
</View>
{selected ? (
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
) : (
<Ionicons name="ellipse-outline" size={20} color={colors.textTertiary} />
)}
</Pressable>
);
})}
</View>
{baseTheme === 'system' ? (
<View style={styles.appearanceBlock}>
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
Dark style used when the system is dark.
</Text>
<SegmentedControl
segments={DARK_STYLE_SEGMENTS}
value={preferredDark}
onChange={(key) => void setPreferredDark(key as PreferredDark)}
/>
</View>
) : null}
{accentApplies ? (
<View style={styles.appearanceBlock}>
<AccentSwatchRow value={accentId} onChange={(id) => void setAccent(id)} />
</View>
) : null}
</>
);
}
const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; description: string }[] = [
{
mode: 'astra',
title: 'Astra grouping',
description: 'Parse collaborators ("feat.", "&", "x") — featured artists get their own entry.',
},
{
mode: 'fileTags',
title: 'File tags',
description: 'Group by the album artist / artist tag exactly as written.',
},
];
const REPLAYGAIN_MODES: { mode: ReplayGainMode; label: string }[] = [
{ mode: 'auto', label: 'Auto' },
{ mode: 'track', label: 'Track' },
{ mode: 'album', label: 'Album' },
];
function ToggleRow({
title,
description,
value,
onValueChange,
}: {
title: string;
description: string;
value: boolean;
onValueChange: (v: boolean) => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.toggleRow}>
<View style={styles.toggleText}>
<Text variant="body">{title}</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{description}
</Text>
</View>
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
thumbColor={colors.textPrimary}
/>
</View>
);
}
function formatFolderCount(count: number): string {
return `${count} ${count === 1 ? 'folder' : 'folders'}`;
}
function formatTrackCount(count: number): string {
return `${count} ${count === 1 ? 'track' : 'tracks'}`;
}
function LibraryFolderSettingsRow({
folder,
disabled,
onRemove,
}: {
folder: FolderWithCount;
disabled: boolean;
onRemove: (folder: FolderWithCount) => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.folderSettingsRow}>
<Ionicons
name={folder.available ? 'folder-outline' : 'alert-circle-outline'}
size={20}
color={folder.available ? colors.textSecondary : colors.warning}
/>
<View style={styles.folderSettingsMeta}>
<Text variant="body" numberOfLines={1}>
{folder.display_name}
</Text>
<Text
variant="caption"
color={folder.available ? colors.textSecondary : colors.warning}
numberOfLines={1}
>
{folder.available
? formatTrackCount(folder.track_count)
: 'Access lost. Remove and add again.'}
</Text>
</View>
<Pressable
hitSlop={8}
disabled={disabled}
onPress={() => onRemove(folder)}
accessibilityRole="button"
accessibilityLabel={`Remove ${folder.display_name}`}
style={disabled && styles.actionDisabled}
>
<Ionicons name="trash-outline" size={18} color={colors.textTertiary} />
</Pressable>
</View>
);
}
function LibraryFoldersSettings() {
const styles = useStyles();
const colors = useColors();
const folders = useLibraryStore((s) => s.folders);
const isScanning = useLibraryStore((s) => s.isScanning);
const scanError = useLibraryStore((s) => s.scanError);
const addFolder = useLibraryStore((s) => s.addFolder);
const removeFolder = useLibraryStore((s) => s.removeFolder);
const rescan = useLibraryStore((s) => s.rescan);
const unavailableCount = folders.filter((folder) => !folder.available).length;
const totalTracks = folders.reduce((sum, folder) => sum + folder.track_count, 0);
const confirmRemove = (folder: FolderWithCount) => {
Alert.alert(
'Remove folder?',
`"${folder.display_name}" and its ${formatTrackCount(folder.track_count)} will be removed from the library. Files on disk are not touched.`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Remove', style: 'destructive', onPress: () => void removeFolder(folder.id) },
]
);
};
return (
<View style={styles.card}>
<View style={styles.folderSettingsHeader}>
<View style={styles.folderSettingsTitleBlock}>
<Text variant="body">Local music folders</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{folders.length === 0
? 'Choose folders to scan into Astra.'
: `${formatFolderCount(folders.length)} / ${formatTrackCount(totalTracks)}`}
</Text>
</View>
<Pressable
style={[styles.folderPrimaryAction, isScanning && styles.actionDisabled]}
disabled={isScanning}
onPress={() => void addFolder()}
accessibilityRole="button"
>
<Ionicons name="add" size={17} color={colors.bgPrimary} />
<Text variant="label" style={styles.folderPrimaryActionText}>
Add
</Text>
</Pressable>
</View>
{folders.length > 0 ? (
<View style={styles.folderSettingsActions}>
<Pressable
style={[styles.folderSecondaryAction, isScanning && styles.actionDisabled]}
disabled={isScanning}
onPress={() => void rescan()}
accessibilityRole="button"
>
<Ionicons name="refresh" size={16} color={colors.textSecondary} />
<Text variant="label" color={colors.textSecondary}>
Rescan all
</Text>
</Pressable>
</View>
) : null}
<ScanProgress />
{scanError ? (
<Text variant="caption" color={colors.warning} style={styles.folderSettingsNotice} numberOfLines={2}>
Scan problem: {scanError}
</Text>
) : null}
{unavailableCount > 0 ? (
<Text variant="caption" color={colors.warning} style={styles.folderSettingsNotice} numberOfLines={2}>
{formatFolderCount(unavailableCount)} need access again.
</Text>
) : null}
{folders.length > 0 ? (
<View style={styles.folderSettingsList}>
{folders.map((folder) => (
<LibraryFolderSettingsRow
key={folder.id}
folder={folder}
disabled={isScanning}
onRemove={confirmRemove}
/>
))}
</View>
) : null}
</View>
);
function formatEnabled(value: boolean): string {
return value ? 'On' : 'Off';
}
export default function SettingsScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const baseTheme = useThemeStore((s) => s.baseTheme);
const folders = useLibraryStore((s) => s.folders);
const normalizationEnabled = useAudioSettingsStore((s) => s.normalizationEnabled);
const replayGainEnabled = useAudioSettingsStore((s) => s.replayGainEnabled);
const remoteSources = useRemoteSourcesStore((s) => s.sources);
const lastFmStatus = useLastFmSettingsStore((s) => s.status);
const desktopRemoteConnection = useDesktopRemoteStore((s) => s.connection);
const desktopRemoteState = useDesktopRemoteStore((s) => s.connectionState);
const initDesktopRemote = useDesktopRemoteStore((s) => s.init);
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
const includeSingles = useSettingsStore((s) => s.includeSingles);
const setIncludeSingles = useSettingsStore((s) => s.setIncludeSingles);
const normalizationEnabled = useAudioSettingsStore((s) => s.normalizationEnabled);
const normalizationTargetLufs = useAudioSettingsStore((s) => s.normalizationTargetLufs);
const replayGainEnabled = useAudioSettingsStore((s) => s.replayGainEnabled);
const replayGainMode = useAudioSettingsStore((s) => s.replayGainMode);
const setNormalizationEnabled = useAudioSettingsStore((s) => s.setNormalizationEnabled);
const setNormalizationTargetLufs = useAudioSettingsStore((s) => s.setNormalizationTargetLufs);
const setReplayGainEnabled = useAudioSettingsStore((s) => s.setReplayGainEnabled);
const setReplayGainMode = useAudioSettingsStore((s) => s.setReplayGainMode);
const desktopSyncStatus = useDesktopSyncStore((s) => s.status);
const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length);
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
@@ -355,21 +56,26 @@ export default function SettingsScreen() {
return () => task.cancel();
}, [initDesktopRemote]);
const desktopSyncStatus = useDesktopSyncStore((s) => s.status);
const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length);
const desktopSyncSubtitle = !desktopRemoteConnection
? 'Sync favorites and playlists with Astra Desktop.'
: desktopSyncConflictCount > 0
? `${desktopSyncConflictCount} conflict${desktopSyncConflictCount === 1 ? '' : 's'} to resolve`
: desktopSyncStatus === 'syncing'
? 'Syncing'
: desktopLastSyncAt !== null
? `Synced ${formatRelativeTime(desktopLastSyncAt)}`
: `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'} · not synced yet`;
const totalTracks = folders.reduce((sum, folder) => sum + folder.track_count, 0);
const connectedScrobblers = lastFmStatus?.profiles.filter((p) => p.connected).length ?? 0;
const appVersion = Constants.expoConfig?.version;
const librarySubtitle = folders.length === 0
? 'Folders, artist grouping, album singles.'
: `${formatFolderCount(folders.length)} / ${formatTrackCount(totalTracks)}. Artist grouping and albums.`;
const servicesSubtitle = remoteSources.length === 0 && connectedScrobblers === 0
? 'Remote sources and scrobbling.'
: `${remoteSources.length} server${remoteSources.length === 1 ? '' : 's'}, ${connectedScrobblers} scrobble destination${connectedScrobblers === 1 ? '' : 's'}.`;
const desktopRemoteSubtitle = desktopRemoteConnection
? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'} · ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
: 'Pair with Astra Desktop to control playback from this phone.';
? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'}: ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
: 'Desktop Remote and Desktop Sync.';
const desktopSyncSubtitle = desktopSyncConflictCount > 0
? `${desktopSyncConflictCount} sync conflict${desktopSyncConflictCount === 1 ? '' : 's'} to resolve.`
: desktopSyncStatus === 'syncing'
? 'Desktop Sync is running.'
: desktopLastSyncAt !== null
? `Desktop Sync ${formatRelativeTime(desktopLastSyncAt)}.`
: desktopRemoteSubtitle;
return (
<Screen>
@@ -378,341 +84,58 @@ export default function SettingsScreen() {
Settings
</Text>
<Text variant="label" color={colors.textTertiary} style={styles.sectionLabel}>
APPEARANCE
</Text>
<AppearanceSettings />
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
LIBRARY FOLDERS
</Text>
<LibraryFoldersSettings />
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
AUDIO
</Text>
<View style={styles.card}>
<ToggleRow
title="Loudness normalization"
description="Level every track to a target loudness — easier on your ears and keeps the scopes consistent."
value={normalizationEnabled}
onValueChange={(v) => void setNormalizationEnabled(v)}
/>
{normalizationEnabled ? (
<View style={styles.indent}>
<EQSlider
label="Target"
value={normalizationTargetLufs}
min={-30}
max={-5}
format={(v) => `${Math.round(v)} LUFS`}
onChange={(v) => void setNormalizationTargetLufs(Math.round(v))}
/>
</View>
) : null}
</View>
<View style={[styles.card, styles.cardSpacing]}>
<ToggleRow
title="ReplayGain"
description="Use ReplayGain tags when present; falls back to the measured loudness above."
value={replayGainEnabled}
onValueChange={(v) => void setReplayGainEnabled(v)}
/>
{replayGainEnabled ? (
<View style={styles.modeRow}>
{REPLAYGAIN_MODES.map((m) => {
const selected = m.mode === replayGainMode;
return (
<Pressable
key={m.mode}
style={[styles.modePill, selected && styles.modePillSelected]}
onPress={() => void setReplayGainMode(m.mode)}
>
<Text variant="label" color={selected ? colors.accentTextStrong : colors.textSecondary}>
{m.label}
</Text>
</Pressable>
);
})}
</View>
) : null}
</View>
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
LIBRARY
</Text>
<Text variant="body" style={styles.settingTitle}>
Artist grouping
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
How tracks are organized into artists in the library.
</Text>
<View style={styles.options}>
{ARTIST_GROUPING_OPTIONS.map((option) => {
const selected = option.mode === groupingMode;
return (
<Pressable
key={option.mode}
style={[styles.option, selected && styles.optionSelected]}
onPress={() => void setArtistGroupingMode(option.mode)}
accessibilityRole="radio"
accessibilityState={{ selected }}
>
<View style={styles.optionText}>
<Text variant="body" color={selected ? colors.accentTextStrong : colors.textPrimary}>
{option.title}
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{option.description}
</Text>
</View>
{selected ? (
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
) : (
<Ionicons name="ellipse-outline" size={20} color={colors.textTertiary} />
)}
</Pressable>
);
})}
</View>
<ToggleRow
title="Show singles in Albums"
description="Include 1-track albums in the Albums view. Off matches desktop."
value={includeSingles}
onValueChange={(v) => void setIncludeSingles(v)}
<SettingsSectionLabel>SETTINGS</SettingsSectionLabel>
<SettingsNavRow
icon="color-palette-outline"
title="Appearance"
subtitle={`${themeOptionTitle(baseTheme)} theme, dark style, accent color.`}
onPress={() => router.push('/settings/appearance' as never)}
/>
<SettingsNavRow
icon="library-outline"
title="Library"
subtitle={librarySubtitle}
onPress={() => router.push('/settings/library' as never)}
/>
<SettingsNavRow
icon="volume-high"
title="Audio"
subtitle={`Normalization ${formatEnabled(normalizationEnabled)}. ReplayGain ${formatEnabled(replayGainEnabled)}.`}
onPress={() => router.push('/settings/audio' as never)}
/>
<SettingsNavRow
icon="server-outline"
title="Services"
subtitle={servicesSubtitle}
onPress={() => router.push('/settings/services' as never)}
/>
<SettingsNavRow
icon="flask-outline"
title="Experimental"
subtitle={desktopSyncSubtitle}
subtitleColor={desktopSyncConflictCount > 0 ? colors.warning : undefined}
onPress={() => router.push('/settings/experimental' as never)}
/>
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
REMOTE SOURCES
</Text>
<Pressable
style={styles.option}
onPress={() => router.push('/sources')}
accessibilityRole="button"
>
<Ionicons name="server-outline" size={20} color={colors.textSecondary} />
<View style={styles.optionText}>
<Text variant="body">Subsonic / Jellyfin servers</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{remoteSources.length === 0
? 'Stream and browse your self-hosted library.'
: `${remoteSources.length} server${remoteSources.length === 1 ? '' : 's'} connected.`}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
EXPERIMENTAL
</Text>
<Pressable
style={styles.option}
onPress={() => router.push('/desktop-remote' as never)}
accessibilityRole="button"
>
<Ionicons name="phone-portrait-outline" size={20} color={colors.textSecondary} />
<View style={styles.optionText}>
<Text variant="body">Desktop Remote</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{desktopRemoteSubtitle}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
<Pressable
style={styles.option}
onPress={() => router.push('/desktop-sync' as never)}
accessibilityRole="button"
>
<Ionicons name="sync-outline" size={20} color={colors.textSecondary} />
<View style={styles.optionText}>
<Text variant="body">Desktop Sync</Text>
<Text
variant="caption"
color={desktopSyncConflictCount > 0 ? colors.warning : colors.textSecondary}
style={styles.optionDescription}
>
{desktopSyncSubtitle}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
<Text
variant="label"
color={colors.textTertiary}
style={[styles.sectionLabel, styles.sectionSpacing]}
>
SCROBBLING
</Text>
<Pressable
style={styles.option}
onPress={() => router.push('/lastfm')}
accessibilityRole="button"
>
<Ionicons name="radio-outline" size={20} color={colors.textSecondary} />
<View style={styles.optionText}>
<Text variant="body">Last.fm &amp; scrobbling</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{lastFmScrobbleSubtitle(lastFmStatus)}
</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</Pressable>
<SettingsSectionLabel spaced>ABOUT</SettingsSectionLabel>
<SettingsNavRow
icon="information-circle-outline"
title="Info"
subtitle={appVersion ? `v${appVersion}. Attribution, license, community links.` : 'Attribution, license, community links.'}
onPress={() => router.push('/settings/info' as never)}
/>
</ScrollView>
</Screen>
);
}
const useStyles = createThemedStyles((colors) => ({
const useStyles = createThemedStyles(() => ({
content: {
paddingBottom: spacing.xxl,
gap: spacing.sm,
},
heading: {
marginTop: spacing.xl,
marginBottom: spacing.xxl,
},
sectionLabel: {
letterSpacing: 1,
marginBottom: spacing.sm,
},
sectionSpacing: {
marginTop: spacing.xxl,
},
card: {
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
padding: spacing.lg,
},
cardSpacing: {
marginTop: spacing.sm,
},
folderSettingsHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
folderSettingsTitleBlock: {
flex: 1,
minWidth: 0,
gap: 2,
},
folderPrimaryAction: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderRadius: radius.pill,
backgroundColor: colors.accent,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
folderPrimaryActionText: {
color: colors.bgPrimary,
fontWeight: '600',
},
folderSettingsActions: {
flexDirection: 'row',
marginTop: spacing.md,
},
folderSecondaryAction: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
folderSettingsNotice: {
marginTop: spacing.sm,
},
folderSettingsList: {
marginTop: spacing.md,
borderTopColor: colors.glassBorder,
borderTopWidth: StyleSheet.hairlineWidth,
},
folderSettingsRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
minHeight: 52,
borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
paddingVertical: spacing.sm,
},
folderSettingsMeta: {
flex: 1,
minWidth: 0,
gap: 2,
},
actionDisabled: {
opacity: 0.4,
},
toggleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
toggleText: {
flex: 1,
gap: 2,
},
indent: {
marginTop: spacing.sm,
},
modeRow: {
flexDirection: 'row',
gap: spacing.sm,
marginTop: spacing.md,
},
modePill: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
modePillSelected: {
borderColor: colors.accent,
backgroundColor: colors.accentGlow,
},
settingTitle: {
marginBottom: spacing.xs,
},
appearanceBlock: {
marginTop: spacing.md,
},
settingNote: {
marginBottom: spacing.md,
},
options: {
gap: spacing.sm,
},
option: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
padding: spacing.lg,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
optionSelected: {
borderColor: colors.accent,
backgroundColor: colors.glassHighlight,
},
optionText: {
flex: 1,
gap: 2,
},
optionDescription: {
lineHeight: 16,
},
}));
+10
View File
@@ -0,0 +1,10 @@
import { AppearanceSettingsPanel } from '@/components/settings/SettingsPanels';
import { SettingsSectionScreen } from '@/components/settings/SettingsSectionScaffold';
export default function AppearanceSettingsScreen() {
return (
<SettingsSectionScreen title="Appearance">
<AppearanceSettingsPanel />
</SettingsSectionScreen>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { AudioSettingsPanel } from '@/components/settings/SettingsPanels';
import { SettingsSectionScreen } from '@/components/settings/SettingsSectionScaffold';
export default function AudioSettingsScreen() {
return (
<SettingsSectionScreen title="Audio">
<AudioSettingsPanel />
</SettingsSectionScreen>
);
}
+62
View File
@@ -0,0 +1,62 @@
import { useEffect } from 'react';
import { InteractionManager } from 'react-native';
import { useRouter } from 'expo-router';
import {
SettingsNavRow,
SettingsSectionLabel,
SettingsSectionScreen,
} from '@/components/settings/SettingsSectionScaffold';
import { formatRelativeTime } from '@/lib/format';
import { useColors } from '@/theme/themed';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
export default function ExperimentalSettingsScreen() {
const colors = useColors();
const router = useRouter();
const desktopRemoteConnection = useDesktopRemoteStore((s) => s.connection);
const desktopRemoteState = useDesktopRemoteStore((s) => s.connectionState);
const initDesktopRemote = useDesktopRemoteStore((s) => s.init);
const desktopSyncStatus = useDesktopSyncStore((s) => s.status);
const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length);
useEffect(() => {
const task = InteractionManager.runAfterInteractions(() => {
void initDesktopRemote();
});
return () => task.cancel();
}, [initDesktopRemote]);
const desktopRemoteSubtitle = desktopRemoteConnection
? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'}: ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
: 'Pair with Astra Desktop to control playback from this phone.';
const desktopSyncSubtitle = !desktopRemoteConnection
? 'Sync favorites and playlists with Astra Desktop.'
: desktopSyncConflictCount > 0
? `${desktopSyncConflictCount} conflict${desktopSyncConflictCount === 1 ? '' : 's'} to resolve.`
: desktopSyncStatus === 'syncing'
? 'Syncing.'
: desktopLastSyncAt !== null
? `Synced ${formatRelativeTime(desktopLastSyncAt)}.`
: `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'}: not synced yet.`;
return (
<SettingsSectionScreen title="Experimental">
<SettingsSectionLabel>DESKTOP</SettingsSectionLabel>
<SettingsNavRow
icon="phone-portrait-outline"
title="Desktop Remote"
subtitle={desktopRemoteSubtitle}
onPress={() => router.push('/desktop-remote' as never)}
/>
<SettingsNavRow
icon="sync-outline"
title="Desktop Sync"
subtitle={desktopSyncSubtitle}
subtitleColor={desktopSyncConflictCount > 0 ? colors.warning : undefined}
onPress={() => router.push('/desktop-sync' as never)}
/>
</SettingsSectionScreen>
);
}
+112
View File
@@ -0,0 +1,112 @@
import {
Alert,
Linking,
View,
} from 'react-native';
import Constants from 'expo-constants';
import {
SettingsCard,
SettingsNavRow,
SettingsSectionLabel,
SettingsSectionScreen,
} from '@/components/settings/SettingsSectionScaffold';
import { Text } from '@/components/Text';
import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
const ASTRA_REPOSITORY_URL = 'https://github.com/Boof2015/astra-mobile';
const ASTRA_DISCORD_URL = 'https://discord.gg/hsKK8Kr9Nj';
const ASTRA_SUPPORT_URL = 'https://ko-fi.com/boof2015';
const ASTRA_LICENSE_URL = 'https://github.com/Boof2015/astra-mobile/blob/main/LICENSE';
const GPL_V3_URL = 'https://www.gnu.org/licenses/gpl-3.0.html';
async function openExternalLink(url: string, label: string) {
try {
await Linking.openURL(url);
} catch {
Alert.alert('Unable to open link', `Astra could not open ${label}.`);
}
}
export default function InfoSettingsScreen() {
const styles = useStyles();
const colors = useColors();
const appVersion = Constants.expoConfig?.version;
const appVersionLabel = appVersion ? `v${appVersion}` : 'Unavailable';
return (
<SettingsSectionScreen title="Info">
<SettingsSectionLabel>APP</SettingsSectionLabel>
<SettingsCard>
<View style={styles.infoRow}>
<Text variant="caption" color={colors.textSecondary}>
App Version
</Text>
<Text variant="body">{appVersionLabel}</Text>
</View>
</SettingsCard>
<SettingsSectionLabel spaced>ATTRIBUTION</SettingsSectionLabel>
<SettingsCard>
<Text variant="body" style={styles.paragraph}>
Astra is created and maintained by Boof2015.
</Text>
<Text variant="mono" color={colors.textSecondary}>
contact@novaml.ai
</Text>
</SettingsCard>
<SettingsNavRow
icon="logo-github"
title="GitHub Repository"
subtitle="Boof2015/astra-mobile"
rightIcon="open-outline"
onPress={() => void openExternalLink(ASTRA_REPOSITORY_URL, 'GitHub Repository')}
/>
<SettingsNavRow
icon="logo-discord"
title="Discord"
subtitle="discord.gg/hsKK8Kr9Nj"
rightIcon="open-outline"
onPress={() => void openExternalLink(ASTRA_DISCORD_URL, 'Discord')}
/>
<SettingsNavRow
icon="heart-outline"
title="Ko-fi"
subtitle="ko-fi.com/boof2015"
rightIcon="open-outline"
onPress={() => void openExternalLink(ASTRA_SUPPORT_URL, 'Ko-fi')}
/>
<SettingsSectionLabel spaced>LICENSE</SettingsSectionLabel>
<SettingsCard>
<Text variant="body" style={styles.paragraph}>
Astra is distributed under GPL-3.0-only.
</Text>
</SettingsCard>
<SettingsNavRow
icon="document-text-outline"
title="View LICENSE"
subtitle="Repository license file"
rightIcon="open-outline"
onPress={() => void openExternalLink(ASTRA_LICENSE_URL, 'the Astra license')}
/>
<SettingsNavRow
icon="reader-outline"
title="GPL v3 Text"
subtitle="gnu.org/licenses/gpl-3.0.html"
rightIcon="open-outline"
onPress={() => void openExternalLink(GPL_V3_URL, 'GPL v3 Text')}
/>
</SettingsSectionScreen>
);
}
const useStyles = createThemedStyles(() => ({
infoRow: {
gap: spacing.xs,
},
paragraph: {
lineHeight: 21,
marginBottom: spacing.sm,
},
}));
+10
View File
@@ -0,0 +1,10 @@
import { LibrarySettingsPanel } from '@/components/settings/SettingsPanels';
import { SettingsSectionScreen } from '@/components/settings/SettingsSectionScaffold';
export default function LibrarySettingsScreen() {
return (
<SettingsSectionScreen title="Library">
<LibrarySettingsPanel />
</SettingsSectionScreen>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useRouter } from 'expo-router';
import { lastFmScrobbleSubtitle } from '@/components/settings/SettingsPanels';
import {
SettingsNavRow,
SettingsSectionLabel,
SettingsSectionScreen,
} from '@/components/settings/SettingsSectionScaffold';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
export default function ServicesSettingsScreen() {
const router = useRouter();
const remoteSources = useRemoteSourcesStore((s) => s.sources);
const lastFmStatus = useLastFmSettingsStore((s) => s.status);
return (
<SettingsSectionScreen title="Services">
<SettingsSectionLabel>REMOTE SOURCES</SettingsSectionLabel>
<SettingsNavRow
icon="server-outline"
title="Subsonic / Jellyfin servers"
subtitle={
remoteSources.length === 0
? 'Stream and browse your self-hosted library.'
: `${remoteSources.length} server${remoteSources.length === 1 ? '' : 's'} connected.`
}
onPress={() => router.push('/sources')}
/>
<SettingsSectionLabel spaced>SCROBBLING</SettingsSectionLabel>
<SettingsNavRow
icon="radio-outline"
title="Last.fm & scrobbling"
subtitle={lastFmScrobbleSubtitle(lastFmStatus)}
onPress={() => router.push('/lastfm')}
/>
</SettingsSectionScreen>
);
}
+55 -11
View File
@@ -51,7 +51,19 @@ import type {
import type { Playlist } from '@/types/playlist';
type IconName = keyof typeof Ionicons.glyphMap;
type RouteHref = '/' | '/library' | '/eq' | '/settings' | '/sources' | '/lastfm';
type RouteHref =
| '/'
| '/library'
| '/eq'
| '/settings'
| '/settings/appearance'
| '/settings/library'
| '/settings/audio'
| '/settings/services'
| '/settings/experimental'
| '/settings/info'
| '/sources'
| '/lastfm';
type LibraryViewMode = 'tracks' | 'albums' | 'artists' | 'playlists' | 'folders';
const NAV_RESULT_LIMIT = 3;
@@ -119,35 +131,51 @@ const SETTING_ENTRIES: {
keywords: string[];
libraryViewMode?: LibraryViewMode;
}[] = [
{
id: 'setting:appearance',
label: 'Appearance settings',
subtitle: 'Theme / dark style / accent',
href: '/settings/appearance',
icon: 'color-palette-outline',
keywords: ['appearance', 'theme', 'dark mode', 'amoled', 'material you', 'accent', 'color'],
},
{
id: 'setting:audio',
label: 'Audio settings',
subtitle: 'Normalization / ReplayGain',
href: '/settings',
href: '/settings/audio',
icon: 'volume-high',
keywords: ['normalization', 'replaygain', 'loudness', 'gain', 'target lufs'],
keywords: ['audio', 'normalization', 'replaygain', 'loudness', 'gain', 'target lufs'],
},
{
id: 'setting:library',
label: 'Artist grouping',
subtitle: 'Astra grouping / file tags',
href: '/settings',
label: 'Library settings',
subtitle: 'Folders / artist grouping / albums',
href: '/settings/library',
icon: 'people',
keywords: ['library', 'artist grouping', 'collaborators', 'file tags'],
keywords: ['library', 'artist grouping', 'collaborators', 'file tags', 'singles', 'albums'],
},
{
id: 'setting:folders',
label: 'Manage folders',
subtitle: 'Add / rescan / remove folders',
href: '/settings',
href: '/settings/library',
icon: 'folder-open-outline',
keywords: ['folders', 'scan', 'rescan', 'add folder', 'remove folder', 'local files', 'storage'],
},
{
id: 'setting:services',
label: 'Services settings',
subtitle: 'Remote sources / scrobbling',
href: '/settings/services',
icon: 'server-outline',
keywords: ['services', 'integrations', 'remote sources', 'scrobbling'],
},
{
id: 'setting:sources',
label: 'Remote sources',
subtitle: 'Subsonic / Jellyfin servers',
href: '/sources',
href: '/settings/services',
icon: 'server-outline',
keywords: ['subsonic', 'jellyfin', 'server', 'streaming', 'remote'],
},
@@ -155,10 +183,26 @@ const SETTING_ENTRIES: {
id: 'setting:lastfm',
label: 'Scrobbling',
subtitle: 'Last.fm / ListenBrainz',
href: '/lastfm',
href: '/settings/services',
icon: 'radio-outline',
keywords: ['lastfm', 'last.fm', 'listenbrainz', 'scrobble', 'audioscrobbler'],
},
{
id: 'setting:experimental',
label: 'Experimental settings',
subtitle: 'Desktop Remote / Desktop Sync',
href: '/settings/experimental',
icon: 'flask-outline',
keywords: ['experimental', 'desktop remote', 'desktop sync', 'pairing', 'phone remote'],
},
{
id: 'setting:info',
label: 'Info',
subtitle: 'Version / attribution / license',
href: '/settings/info',
icon: 'information-circle-outline',
keywords: ['info', 'about', 'version', 'license', 'attribution', 'github', 'repo', 'repository', 'discord', 'kofi', 'ko-fi', 'support', 'gpl'],
},
];
const EMPTY_SHORTCUT_IDS = ['nav:library', 'nav:eq', 'setting:sources', 'setting:lastfm'];
@@ -829,7 +873,7 @@ function QuickSearchPanel({
};
const navigateTo = (href: RouteHref) => {
router.push(href);
router.push(href as never);
};
const executeResult = (result: SearchResult) => {
+541
View File
@@ -0,0 +1,541 @@
import {
Alert,
Pressable,
StyleSheet,
View,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { EQSlider } from '@/components/eq/EQSlider';
import { ScanProgress } from '@/components/library/ScanProgress';
import { SegmentedControl } from '@/components/SegmentedControl';
import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
import {
SettingsCard,
SettingsSectionLabel,
SettingsToggleRow,
} from '@/components/settings/SettingsSectionScaffold';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import type { ReplayGainMode } from '@/audio/normalization';
import type { ArtistGroupingMode } from '@/library/artistGrouping';
import type { BaseThemeId, PreferredDark } from '@/theme/resolve';
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { useLibraryStore, type FolderWithCount } from '@/stores/libraryStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { useThemeStore } from '@/stores/themeStore';
import type { LastFmStatus } from '@/types/lastFm';
import { Text } from '@/components/Text';
export function lastFmScrobbleSubtitle(status: LastFmStatus | null): string {
const connected = status?.profiles.filter((p) => p.connected).length ?? 0;
if (connected === 0) return 'Scrobble plays to Last.fm, ListenBrainz, and more.';
const base = `${connected} destination${connected === 1 ? '' : 's'} connected`;
return status?.enabled ? `${base}.` : `${base}. Paused.`;
}
export function formatFolderCount(count: number): string {
return `${count} ${count === 1 ? 'folder' : 'folders'}`;
}
export function formatTrackCount(count: number): string {
return `${count} ${count === 1 ? 'track' : 'tracks'}`;
}
const THEME_OPTIONS: { id: BaseThemeId; title: string; description: string }[] = [
{ id: 'system', title: 'System', description: 'Follow the Android dark/light setting.' },
{ id: 'midnight', title: 'Midnight', description: 'Deep navy. The classic Astra look.' },
{ id: 'dark', title: 'Dark', description: 'Neutral dark gray, no navy cast.' },
{ id: 'amoled', title: 'AMOLED', description: 'True black. Easy on OLED screens and batteries.' },
{ id: 'light', title: 'Light', description: 'Cool near-white with navy ink.' },
{ id: 'materialYou', title: 'Material You', description: 'Colors from your wallpaper.' },
];
const DARK_STYLE_SEGMENTS = [
{ key: 'midnight', label: 'Midnight' },
{ key: 'dark', label: 'Dark' },
{ key: 'amoled', label: 'AMOLED' },
];
const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; description: string }[] = [
{
mode: 'astra',
title: 'Astra grouping',
description: 'Parse collaborators ("feat.", "&", "x"). Featured artists get their own entry.',
},
{
mode: 'fileTags',
title: 'File tags',
description: 'Group by the album artist / artist tag exactly as written.',
},
];
const REPLAYGAIN_MODES: { mode: ReplayGainMode; label: string }[] = [
{ mode: 'auto', label: 'Auto' },
{ mode: 'track', label: 'Track' },
{ mode: 'album', label: 'Album' },
];
export function themeOptionTitle(id: BaseThemeId): string {
return THEME_OPTIONS.find((option) => option.id === id)?.title ?? 'System';
}
export function AppearanceSettingsPanel() {
const styles = useStyles();
const colors = useColors();
const baseTheme = useThemeStore((s) => s.baseTheme);
const preferredDark = useThemeStore((s) => s.preferredDark);
const accentId = useThemeStore((s) => s.accentId);
const materialYouAvailable = useThemeStore((s) => s.materialYouAvailable);
const resolvedId = useThemeStore((s) => s.theme.id);
const setBaseTheme = useThemeStore((s) => s.setBaseTheme);
const setPreferredDark = useThemeStore((s) => s.setPreferredDark);
const setAccent = useThemeStore((s) => s.setAccent);
const options = THEME_OPTIONS.filter(
(option) => option.id !== 'materialYou' || materialYouAvailable
);
const accentApplies = !resolvedId.startsWith('materialYou');
return (
<>
<View style={styles.options}>
{options.map((option) => {
const selected = option.id === baseTheme;
return (
<Pressable
key={option.id}
style={[styles.option, selected && styles.optionSelected]}
onPress={() => void setBaseTheme(option.id)}
accessibilityRole="radio"
accessibilityState={{ selected }}
>
<View style={styles.optionText}>
<Text variant="body" color={selected ? colors.accentTextStrong : colors.textPrimary}>
{option.title}
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{option.description}
</Text>
</View>
{selected ? (
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
) : (
<Ionicons name="ellipse-outline" size={20} color={colors.textTertiary} />
)}
</Pressable>
);
})}
</View>
{baseTheme === 'system' ? (
<View style={styles.appearanceBlock}>
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
Dark style used when the system is dark.
</Text>
<SegmentedControl
segments={DARK_STYLE_SEGMENTS}
value={preferredDark}
onChange={(key) => void setPreferredDark(key as PreferredDark)}
/>
</View>
) : null}
{accentApplies ? (
<View style={styles.appearanceBlock}>
<AccentSwatchRow value={accentId} onChange={(id) => void setAccent(id)} />
</View>
) : null}
</>
);
}
function LibraryFolderSettingsRow({
folder,
disabled,
onRemove,
}: {
folder: FolderWithCount;
disabled: boolean;
onRemove: (folder: FolderWithCount) => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.folderSettingsRow}>
<Ionicons
name={folder.available ? 'folder-outline' : 'alert-circle-outline'}
size={20}
color={folder.available ? colors.textSecondary : colors.warning}
/>
<View style={styles.folderSettingsMeta}>
<Text variant="body" numberOfLines={1}>
{folder.display_name}
</Text>
<Text
variant="caption"
color={folder.available ? colors.textSecondary : colors.warning}
numberOfLines={1}
>
{folder.available
? formatTrackCount(folder.track_count)
: 'Access lost. Remove and add again.'}
</Text>
</View>
<Pressable
hitSlop={8}
disabled={disabled}
onPress={() => onRemove(folder)}
accessibilityRole="button"
accessibilityLabel={`Remove ${folder.display_name}`}
style={disabled && styles.actionDisabled}
>
<Ionicons name="trash-outline" size={18} color={colors.textTertiary} />
</Pressable>
</View>
);
}
function LibraryFoldersSettings() {
const styles = useStyles();
const colors = useColors();
const folders = useLibraryStore((s) => s.folders);
const isScanning = useLibraryStore((s) => s.isScanning);
const scanError = useLibraryStore((s) => s.scanError);
const addFolder = useLibraryStore((s) => s.addFolder);
const removeFolder = useLibraryStore((s) => s.removeFolder);
const rescan = useLibraryStore((s) => s.rescan);
const unavailableCount = folders.filter((folder) => !folder.available).length;
const totalTracks = folders.reduce((sum, folder) => sum + folder.track_count, 0);
const confirmRemove = (folder: FolderWithCount) => {
Alert.alert(
'Remove folder?',
`"${folder.display_name}" and its ${formatTrackCount(folder.track_count)} will be removed from the library. Files on disk are not touched.`,
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Remove', style: 'destructive', onPress: () => void removeFolder(folder.id) },
]
);
};
return (
<SettingsCard>
<View style={styles.folderSettingsHeader}>
<View style={styles.folderSettingsTitleBlock}>
<Text variant="body">Local music folders</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{folders.length === 0
? 'Choose folders to scan into Astra.'
: `${formatFolderCount(folders.length)} / ${formatTrackCount(totalTracks)}`}
</Text>
</View>
<Pressable
style={[styles.folderPrimaryAction, isScanning && styles.actionDisabled]}
disabled={isScanning}
onPress={() => void addFolder()}
accessibilityRole="button"
>
<Ionicons name="add" size={17} color={colors.bgPrimary} />
<Text variant="label" style={styles.folderPrimaryActionText}>
Add
</Text>
</Pressable>
</View>
{folders.length > 0 ? (
<View style={styles.folderSettingsActions}>
<Pressable
style={[styles.folderSecondaryAction, isScanning && styles.actionDisabled]}
disabled={isScanning}
onPress={() => void rescan()}
accessibilityRole="button"
>
<Ionicons name="refresh" size={16} color={colors.textSecondary} />
<Text variant="label" color={colors.textSecondary}>
Rescan all
</Text>
</Pressable>
</View>
) : null}
<ScanProgress />
{scanError ? (
<Text variant="caption" color={colors.warning} style={styles.folderSettingsNotice} numberOfLines={2}>
Scan problem: {scanError}
</Text>
) : null}
{unavailableCount > 0 ? (
<Text variant="caption" color={colors.warning} style={styles.folderSettingsNotice} numberOfLines={2}>
{formatFolderCount(unavailableCount)} need access again.
</Text>
) : null}
{folders.length > 0 ? (
<View style={styles.folderSettingsList}>
{folders.map((folder) => (
<LibraryFolderSettingsRow
key={folder.id}
folder={folder}
disabled={isScanning}
onRemove={confirmRemove}
/>
))}
</View>
) : null}
</SettingsCard>
);
}
export function LibrarySettingsPanel() {
const styles = useStyles();
const colors = useColors();
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
const includeSingles = useSettingsStore((s) => s.includeSingles);
const setIncludeSingles = useSettingsStore((s) => s.setIncludeSingles);
return (
<>
<SettingsSectionLabel>LOCAL FOLDERS</SettingsSectionLabel>
<LibraryFoldersSettings />
<SettingsSectionLabel spaced>LIBRARY VIEW</SettingsSectionLabel>
<Text variant="body" style={styles.settingTitle}>
Artist grouping
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
How tracks are organized into artists in the library.
</Text>
<View style={styles.options}>
{ARTIST_GROUPING_OPTIONS.map((option) => {
const selected = option.mode === groupingMode;
return (
<Pressable
key={option.mode}
style={[styles.option, selected && styles.optionSelected]}
onPress={() => void setArtistGroupingMode(option.mode)}
accessibilityRole="radio"
accessibilityState={{ selected }}
>
<View style={styles.optionText}>
<Text variant="body" color={selected ? colors.accentTextStrong : colors.textPrimary}>
{option.title}
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.optionDescription}>
{option.description}
</Text>
</View>
{selected ? (
<Ionicons name="checkmark-circle" size={20} color={colors.accent} />
) : (
<Ionicons name="ellipse-outline" size={20} color={colors.textTertiary} />
)}
</Pressable>
);
})}
</View>
<SettingsCard style={styles.cardSpacing}>
<SettingsToggleRow
title="Show singles in Albums"
description="Include 1-track albums in the Albums view. Off matches desktop."
value={includeSingles}
onValueChange={(v) => void setIncludeSingles(v)}
/>
</SettingsCard>
</>
);
}
export function AudioSettingsPanel() {
const styles = useStyles();
const colors = useColors();
const normalizationEnabled = useAudioSettingsStore((s) => s.normalizationEnabled);
const normalizationTargetLufs = useAudioSettingsStore((s) => s.normalizationTargetLufs);
const replayGainEnabled = useAudioSettingsStore((s) => s.replayGainEnabled);
const replayGainMode = useAudioSettingsStore((s) => s.replayGainMode);
const setNormalizationEnabled = useAudioSettingsStore((s) => s.setNormalizationEnabled);
const setNormalizationTargetLufs = useAudioSettingsStore((s) => s.setNormalizationTargetLufs);
const setReplayGainEnabled = useAudioSettingsStore((s) => s.setReplayGainEnabled);
const setReplayGainMode = useAudioSettingsStore((s) => s.setReplayGainMode);
return (
<>
<SettingsSectionLabel>LOUDNESS</SettingsSectionLabel>
<SettingsCard>
<SettingsToggleRow
title="Loudness normalization"
description="Level every track to a target loudness. Keeps playback volume and scopes consistent."
value={normalizationEnabled}
onValueChange={(v) => void setNormalizationEnabled(v)}
/>
{normalizationEnabled ? (
<View style={styles.indent}>
<EQSlider
label="Target"
value={normalizationTargetLufs}
min={-30}
max={-5}
format={(v) => `${Math.round(v)} LUFS`}
onChange={(v) => void setNormalizationTargetLufs(Math.round(v))}
/>
</View>
) : null}
</SettingsCard>
<SettingsSectionLabel spaced>REPLAYGAIN</SettingsSectionLabel>
<SettingsCard>
<SettingsToggleRow
title="ReplayGain"
description="Use ReplayGain tags when present; falls back to the measured loudness above."
value={replayGainEnabled}
onValueChange={(v) => void setReplayGainEnabled(v)}
/>
{replayGainEnabled ? (
<View style={styles.modeRow}>
{REPLAYGAIN_MODES.map((m) => {
const selected = m.mode === replayGainMode;
return (
<Pressable
key={m.mode}
style={[styles.modePill, selected && styles.modePillSelected]}
onPress={() => void setReplayGainMode(m.mode)}
>
<Text variant="label" color={selected ? colors.accentTextStrong : colors.textSecondary}>
{m.label}
</Text>
</Pressable>
);
})}
</View>
) : null}
</SettingsCard>
</>
);
}
const useStyles = createThemedStyles((colors) => ({
appearanceBlock: {
marginTop: spacing.md,
},
settingTitle: {
marginBottom: spacing.xs,
},
settingNote: {
marginBottom: spacing.md,
},
options: {
gap: spacing.sm,
},
option: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
padding: spacing.lg,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
optionSelected: {
borderColor: colors.accent,
backgroundColor: colors.glassHighlight,
},
optionText: {
flex: 1,
minWidth: 0,
gap: 2,
},
optionDescription: {
lineHeight: 16,
},
folderSettingsHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
folderSettingsTitleBlock: {
flex: 1,
minWidth: 0,
gap: 2,
},
folderPrimaryAction: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderRadius: radius.pill,
backgroundColor: colors.accent,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
folderPrimaryActionText: {
color: colors.bgPrimary,
fontWeight: '600',
},
folderSettingsActions: {
flexDirection: 'row',
marginTop: spacing.md,
},
folderSecondaryAction: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
},
folderSettingsNotice: {
marginTop: spacing.sm,
},
folderSettingsList: {
marginTop: spacing.md,
borderTopColor: colors.glassBorder,
borderTopWidth: StyleSheet.hairlineWidth,
},
folderSettingsRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
minHeight: 52,
borderBottomColor: colors.glassBorder,
borderBottomWidth: StyleSheet.hairlineWidth,
paddingVertical: spacing.sm,
},
folderSettingsMeta: {
flex: 1,
minWidth: 0,
gap: 2,
},
actionDisabled: {
opacity: 0.4,
},
cardSpacing: {
marginTop: spacing.sm,
},
indent: {
marginTop: spacing.sm,
},
modeRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
marginTop: spacing.md,
},
modePill: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.pill,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
},
modePillSelected: {
borderColor: colors.accent,
backgroundColor: colors.accentGlow,
},
}));
@@ -0,0 +1,223 @@
import type { ReactNode } from 'react';
import {
Pressable,
ScrollView,
StyleSheet,
Switch,
View,
type StyleProp,
type ViewStyle,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
export type SettingsIconName = keyof typeof Ionicons.glyphMap;
export function SettingsSectionScreen({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
return (
<Screen>
<View style={styles.header}>
<Pressable style={styles.back} onPress={() => router.back()} hitSlop={8}>
<Ionicons name="chevron-back" size={22} color={colors.textSecondary} />
<Text variant="body" color={colors.textSecondary}>
Settings
</Text>
</Pressable>
</View>
<Text variant="title" style={styles.heading}>
{title}
</Text>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.content}>
{children}
</ScrollView>
</Screen>
);
}
export function SettingsCard({
children,
style,
}: {
children: ReactNode;
style?: StyleProp<ViewStyle>;
}) {
const styles = useStyles();
return <View style={[styles.card, style]}>{children}</View>;
}
export function SettingsSectionLabel({
children,
spaced = false,
}: {
children: ReactNode;
spaced?: boolean;
}) {
const styles = useStyles();
const colors = useColors();
return (
<Text
variant="label"
color={colors.textTertiary}
style={[styles.sectionLabel, spaced && styles.sectionSpacing]}
>
{children}
</Text>
);
}
export function SettingsNavRow({
icon,
title,
subtitle,
onPress,
subtitleColor,
rightIcon = 'chevron-forward',
}: {
icon: SettingsIconName;
title: string;
subtitle: string;
onPress: () => void;
subtitleColor?: string;
rightIcon?: SettingsIconName;
}) {
const styles = useStyles();
const colors = useColors();
return (
<Pressable style={styles.row} onPress={onPress} accessibilityRole="button">
<View style={styles.rowIcon}>
<Ionicons name={icon} size={20} color={colors.accent} />
</View>
<View style={styles.rowMeta}>
<Text variant="body">{title}</Text>
<Text
variant="caption"
color={subtitleColor ?? colors.textSecondary}
numberOfLines={2}
style={styles.rowSubtitle}
>
{subtitle}
</Text>
</View>
<Ionicons name={rightIcon} size={18} color={colors.textTertiary} />
</Pressable>
);
}
export function SettingsToggleRow({
title,
description,
value,
onValueChange,
}: {
title: string;
description: string;
value: boolean;
onValueChange: (v: boolean) => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.toggleRow}>
<View style={styles.toggleText}>
<Text variant="body">{title}</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.rowSubtitle}>
{description}
</Text>
</View>
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ false: colors.glassBorder, true: colors.accent }}
thumbColor={colors.textPrimary}
/>
</View>
);
}
const useStyles = createThemedStyles((colors) => ({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginTop: spacing.md,
},
back: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
heading: {
marginTop: spacing.lg,
marginBottom: spacing.lg,
},
content: {
paddingBottom: spacing.xxl,
gap: spacing.sm,
},
sectionLabel: {
letterSpacing: 1,
marginBottom: spacing.sm,
},
sectionSpacing: {
marginTop: spacing.xxl,
},
card: {
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
padding: spacing.lg,
},
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
padding: spacing.lg,
borderRadius: radius.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
backgroundColor: colors.glassBg,
},
rowIcon: {
width: 36,
height: 36,
borderRadius: radius.sm,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgTertiary,
},
rowMeta: {
flex: 1,
minWidth: 0,
gap: 2,
},
rowSubtitle: {
lineHeight: 16,
},
toggleRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
toggleText: {
flex: 1,
minWidth: 0,
gap: 2,
},
}));