From 31f81282e01b3cc0a47ec32808dbb8ca3a666af8 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:03:58 -0400 Subject: [PATCH] settings revamp --- src/app/(tabs)/settings.tsx | 747 ++---------------- src/app/settings/appearance.tsx | 10 + src/app/settings/audio.tsx | 10 + src/app/settings/experimental.tsx | 62 ++ src/app/settings/info.tsx | 112 +++ src/app/settings/library.tsx | 10 + src/app/settings/services.tsx | 39 + src/components/search/QuickSearchOverlay.tsx | 66 +- src/components/settings/SettingsPanels.tsx | 541 +++++++++++++ .../settings/SettingsSectionScaffold.tsx | 223 ++++++ 10 files changed, 1147 insertions(+), 673 deletions(-) create mode 100644 src/app/settings/appearance.tsx create mode 100644 src/app/settings/audio.tsx create mode 100644 src/app/settings/experimental.tsx create mode 100644 src/app/settings/info.tsx create mode 100644 src/app/settings/library.tsx create mode 100644 src/app/settings/services.tsx create mode 100644 src/components/settings/SettingsPanels.tsx create mode 100644 src/components/settings/SettingsSectionScaffold.tsx diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx index 4719af8..fe8ef4e 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(tabs)/settings.tsx @@ -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 ( - <> - - {options.map((option) => { - const selected = option.id === baseTheme; - return ( - void setBaseTheme(option.id)} - accessibilityRole="radio" - accessibilityState={{ selected }} - > - - - {option.title} - - - {option.description} - - - {selected ? ( - - ) : ( - - )} - - ); - })} - - - {baseTheme === 'system' ? ( - - - Dark style used when the system is dark. - - void setPreferredDark(key as PreferredDark)} - /> - - ) : null} - - {accentApplies ? ( - - void setAccent(id)} /> - - ) : 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 ( - - - {title} - - {description} - - - - - ); -} - -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 ( - - - - - {folder.display_name} - - - {folder.available - ? formatTrackCount(folder.track_count) - : 'Access lost. Remove and add again.'} - - - onRemove(folder)} - accessibilityRole="button" - accessibilityLabel={`Remove ${folder.display_name}`} - style={disabled && styles.actionDisabled} - > - - - - ); -} - -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 ( - - - - Local music folders - - {folders.length === 0 - ? 'Choose folders to scan into Astra.' - : `${formatFolderCount(folders.length)} / ${formatTrackCount(totalTracks)}`} - - - void addFolder()} - accessibilityRole="button" - > - - - Add - - - - - {folders.length > 0 ? ( - - void rescan()} - accessibilityRole="button" - > - - - Rescan all - - - - ) : null} - - - - {scanError ? ( - - Scan problem: {scanError} - - ) : null} - - {unavailableCount > 0 ? ( - - {formatFolderCount(unavailableCount)} need access again. - - ) : null} - - {folders.length > 0 ? ( - - {folders.map((folder) => ( - - ))} - - ) : null} - - ); +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 ( @@ -378,341 +84,58 @@ export default function SettingsScreen() { Settings - - APPEARANCE - - - - - LIBRARY FOLDERS - - - - - AUDIO - - - void setNormalizationEnabled(v)} - /> - {normalizationEnabled ? ( - - `${Math.round(v)} LUFS`} - onChange={(v) => void setNormalizationTargetLufs(Math.round(v))} - /> - - ) : null} - - - - void setReplayGainEnabled(v)} - /> - {replayGainEnabled ? ( - - {REPLAYGAIN_MODES.map((m) => { - const selected = m.mode === replayGainMode; - return ( - void setReplayGainMode(m.mode)} - > - - {m.label} - - - ); - })} - - ) : null} - - - - LIBRARY - - - Artist grouping - - - How tracks are organized into artists in the library. - - - - {ARTIST_GROUPING_OPTIONS.map((option) => { - const selected = option.mode === groupingMode; - return ( - void setArtistGroupingMode(option.mode)} - accessibilityRole="radio" - accessibilityState={{ selected }} - > - - - {option.title} - - - {option.description} - - - {selected ? ( - - ) : ( - - )} - - ); - })} - - - void setIncludeSingles(v)} + SETTINGS + router.push('/settings/appearance' as never)} + /> + router.push('/settings/library' as never)} + /> + router.push('/settings/audio' as never)} + /> + router.push('/settings/services' as never)} + /> + 0 ? colors.warning : undefined} + onPress={() => router.push('/settings/experimental' as never)} /> - - REMOTE SOURCES - - router.push('/sources')} - accessibilityRole="button" - > - - - Subsonic / Jellyfin servers - - {remoteSources.length === 0 - ? 'Stream and browse your self-hosted library.' - : `${remoteSources.length} server${remoteSources.length === 1 ? '' : 's'} connected.`} - - - - - - - EXPERIMENTAL - - router.push('/desktop-remote' as never)} - accessibilityRole="button" - > - - - Desktop Remote - - {desktopRemoteSubtitle} - - - - - router.push('/desktop-sync' as never)} - accessibilityRole="button" - > - - - Desktop Sync - 0 ? colors.warning : colors.textSecondary} - style={styles.optionDescription} - > - {desktopSyncSubtitle} - - - - - - - SCROBBLING - - router.push('/lastfm')} - accessibilityRole="button" - > - - - Last.fm & scrobbling - - {lastFmScrobbleSubtitle(lastFmStatus)} - - - - + ABOUT + router.push('/settings/info' as never)} + /> ); } -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, - }, })); diff --git a/src/app/settings/appearance.tsx b/src/app/settings/appearance.tsx new file mode 100644 index 0000000..b75f0da --- /dev/null +++ b/src/app/settings/appearance.tsx @@ -0,0 +1,10 @@ +import { AppearanceSettingsPanel } from '@/components/settings/SettingsPanels'; +import { SettingsSectionScreen } from '@/components/settings/SettingsSectionScaffold'; + +export default function AppearanceSettingsScreen() { + return ( + + + + ); +} diff --git a/src/app/settings/audio.tsx b/src/app/settings/audio.tsx new file mode 100644 index 0000000..36f99b5 --- /dev/null +++ b/src/app/settings/audio.tsx @@ -0,0 +1,10 @@ +import { AudioSettingsPanel } from '@/components/settings/SettingsPanels'; +import { SettingsSectionScreen } from '@/components/settings/SettingsSectionScaffold'; + +export default function AudioSettingsScreen() { + return ( + + + + ); +} diff --git a/src/app/settings/experimental.tsx b/src/app/settings/experimental.tsx new file mode 100644 index 0000000..224f1e6 --- /dev/null +++ b/src/app/settings/experimental.tsx @@ -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 ( + + DESKTOP + router.push('/desktop-remote' as never)} + /> + 0 ? colors.warning : undefined} + onPress={() => router.push('/desktop-sync' as never)} + /> + + ); +} diff --git a/src/app/settings/info.tsx b/src/app/settings/info.tsx new file mode 100644 index 0000000..ef67013 --- /dev/null +++ b/src/app/settings/info.tsx @@ -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 ( + + APP + + + + App Version + + {appVersionLabel} + + + + ATTRIBUTION + + + Astra is created and maintained by Boof2015. + + + contact@novaml.ai + + + void openExternalLink(ASTRA_REPOSITORY_URL, 'GitHub Repository')} + /> + void openExternalLink(ASTRA_DISCORD_URL, 'Discord')} + /> + void openExternalLink(ASTRA_SUPPORT_URL, 'Ko-fi')} + /> + + LICENSE + + + Astra is distributed under GPL-3.0-only. + + + void openExternalLink(ASTRA_LICENSE_URL, 'the Astra license')} + /> + void openExternalLink(GPL_V3_URL, 'GPL v3 Text')} + /> + + ); +} + +const useStyles = createThemedStyles(() => ({ + infoRow: { + gap: spacing.xs, + }, + paragraph: { + lineHeight: 21, + marginBottom: spacing.sm, + }, +})); diff --git a/src/app/settings/library.tsx b/src/app/settings/library.tsx new file mode 100644 index 0000000..1c8c631 --- /dev/null +++ b/src/app/settings/library.tsx @@ -0,0 +1,10 @@ +import { LibrarySettingsPanel } from '@/components/settings/SettingsPanels'; +import { SettingsSectionScreen } from '@/components/settings/SettingsSectionScaffold'; + +export default function LibrarySettingsScreen() { + return ( + + + + ); +} diff --git a/src/app/settings/services.tsx b/src/app/settings/services.tsx new file mode 100644 index 0000000..d1a7e60 --- /dev/null +++ b/src/app/settings/services.tsx @@ -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 ( + + REMOTE SOURCES + router.push('/sources')} + /> + + SCROBBLING + router.push('/lastfm')} + /> + + ); +} diff --git a/src/components/search/QuickSearchOverlay.tsx b/src/components/search/QuickSearchOverlay.tsx index 34bc1c8..9602442 100644 --- a/src/components/search/QuickSearchOverlay.tsx +++ b/src/components/search/QuickSearchOverlay.tsx @@ -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) => { diff --git a/src/components/settings/SettingsPanels.tsx b/src/components/settings/SettingsPanels.tsx new file mode 100644 index 0000000..44fcf83 --- /dev/null +++ b/src/components/settings/SettingsPanels.tsx @@ -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 ( + <> + + {options.map((option) => { + const selected = option.id === baseTheme; + return ( + void setBaseTheme(option.id)} + accessibilityRole="radio" + accessibilityState={{ selected }} + > + + + {option.title} + + + {option.description} + + + {selected ? ( + + ) : ( + + )} + + ); + })} + + + {baseTheme === 'system' ? ( + + + Dark style used when the system is dark. + + void setPreferredDark(key as PreferredDark)} + /> + + ) : null} + + {accentApplies ? ( + + void setAccent(id)} /> + + ) : null} + + ); +} + +function LibraryFolderSettingsRow({ + folder, + disabled, + onRemove, +}: { + folder: FolderWithCount; + disabled: boolean; + onRemove: (folder: FolderWithCount) => void; +}) { + const styles = useStyles(); + const colors = useColors(); + return ( + + + + + {folder.display_name} + + + {folder.available + ? formatTrackCount(folder.track_count) + : 'Access lost. Remove and add again.'} + + + onRemove(folder)} + accessibilityRole="button" + accessibilityLabel={`Remove ${folder.display_name}`} + style={disabled && styles.actionDisabled} + > + + + + ); +} + +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 ( + + + + Local music folders + + {folders.length === 0 + ? 'Choose folders to scan into Astra.' + : `${formatFolderCount(folders.length)} / ${formatTrackCount(totalTracks)}`} + + + void addFolder()} + accessibilityRole="button" + > + + + Add + + + + + {folders.length > 0 ? ( + + void rescan()} + accessibilityRole="button" + > + + + Rescan all + + + + ) : null} + + + + {scanError ? ( + + Scan problem: {scanError} + + ) : null} + + {unavailableCount > 0 ? ( + + {formatFolderCount(unavailableCount)} need access again. + + ) : null} + + {folders.length > 0 ? ( + + {folders.map((folder) => ( + + ))} + + ) : null} + + ); +} + +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 ( + <> + LOCAL FOLDERS + + + LIBRARY VIEW + + Artist grouping + + + How tracks are organized into artists in the library. + + + + {ARTIST_GROUPING_OPTIONS.map((option) => { + const selected = option.mode === groupingMode; + return ( + void setArtistGroupingMode(option.mode)} + accessibilityRole="radio" + accessibilityState={{ selected }} + > + + + {option.title} + + + {option.description} + + + {selected ? ( + + ) : ( + + )} + + ); + })} + + + + void setIncludeSingles(v)} + /> + + + ); +} + +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 ( + <> + LOUDNESS + + void setNormalizationEnabled(v)} + /> + {normalizationEnabled ? ( + + `${Math.round(v)} LUFS`} + onChange={(v) => void setNormalizationTargetLufs(Math.round(v))} + /> + + ) : null} + + + REPLAYGAIN + + void setReplayGainEnabled(v)} + /> + {replayGainEnabled ? ( + + {REPLAYGAIN_MODES.map((m) => { + const selected = m.mode === replayGainMode; + return ( + void setReplayGainMode(m.mode)} + > + + {m.label} + + + ); + })} + + ) : null} + + + ); +} + +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, + }, +})); diff --git a/src/components/settings/SettingsSectionScaffold.tsx b/src/components/settings/SettingsSectionScaffold.tsx new file mode 100644 index 0000000..04543ec --- /dev/null +++ b/src/components/settings/SettingsSectionScaffold.tsx @@ -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 ( + + + router.back()} hitSlop={8}> + + + Settings + + + + + + {title} + + + + {children} + + + ); +} + +export function SettingsCard({ + children, + style, +}: { + children: ReactNode; + style?: StyleProp; +}) { + const styles = useStyles(); + return {children}; +} + +export function SettingsSectionLabel({ + children, + spaced = false, +}: { + children: ReactNode; + spaced?: boolean; +}) { + const styles = useStyles(); + const colors = useColors(); + return ( + + {children} + + ); +} + +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 ( + + + + + + {title} + + {subtitle} + + + + + ); +} + +export function SettingsToggleRow({ + title, + description, + value, + onValueChange, +}: { + title: string; + description: string; + value: boolean; + onValueChange: (v: boolean) => void; +}) { + const styles = useStyles(); + const colors = useColors(); + return ( + + + {title} + + {description} + + + + + ); +} + +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, + }, +}));