mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-20 04:30:54 +02:00
settings revamp
This commit is contained in:
+85
-662
@@ -1,352 +1,53 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
InteractionManager,
|
InteractionManager,
|
||||||
View,
|
|
||||||
Pressable,
|
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
|
||||||
Switch,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import Constants from 'expo-constants';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Screen } from '@/components/Screen';
|
import { Screen } from '@/components/Screen';
|
||||||
import { Text } from '@/components/Text';
|
import { Text } from '@/components/Text';
|
||||||
import { EQSlider } from '@/components/eq/EQSlider';
|
import {
|
||||||
import { ScanProgress } from '@/components/library/ScanProgress';
|
formatFolderCount,
|
||||||
import { SegmentedControl } from '@/components/SegmentedControl';
|
formatTrackCount,
|
||||||
import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
|
themeOptionTitle,
|
||||||
import { radius, spacing } from '@/theme';
|
} from '@/components/settings/SettingsPanels';
|
||||||
|
import {
|
||||||
|
SettingsNavRow,
|
||||||
|
SettingsSectionLabel,
|
||||||
|
} from '@/components/settings/SettingsSectionScaffold';
|
||||||
|
import { spacing } from '@/theme';
|
||||||
import { createThemedStyles, useColors } from '@/theme/themed';
|
import { createThemedStyles, useColors } from '@/theme/themed';
|
||||||
import type { BaseThemeId, PreferredDark } from '@/theme/resolve';
|
import { formatRelativeTime } from '@/lib/format';
|
||||||
import { useSettingsStore } from '@/stores/settingsStore';
|
|
||||||
import { useThemeStore } from '@/stores/themeStore';
|
|
||||||
import { useLibraryStore, type FolderWithCount } from '@/stores/libraryStore';
|
|
||||||
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
|
import { useAudioSettingsStore } from '@/stores/audioSettingsStore';
|
||||||
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
|
|
||||||
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
|
|
||||||
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
|
||||||
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
|
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
|
||||||
import { formatRelativeTime } from '@/lib/format';
|
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
|
||||||
import type { ReplayGainMode } from '@/audio/normalization';
|
import { useLibraryStore } from '@/stores/libraryStore';
|
||||||
import type { ArtistGroupingMode } from '@/library/artistGrouping';
|
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
|
||||||
import type { LastFmStatus } from '@/types/lastFm';
|
import { useThemeStore } from '@/stores/themeStore';
|
||||||
|
|
||||||
function lastFmScrobbleSubtitle(status: LastFmStatus | null): string {
|
function formatEnabled(value: boolean): string {
|
||||||
const connected = status?.profiles.filter((p) => p.connected).length ?? 0;
|
return value ? 'On' : 'Off';
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const styles = useStyles();
|
const styles = useStyles();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const router = useRouter();
|
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 remoteSources = useRemoteSourcesStore((s) => s.sources);
|
||||||
const lastFmStatus = useLastFmSettingsStore((s) => s.status);
|
const lastFmStatus = useLastFmSettingsStore((s) => s.status);
|
||||||
const desktopRemoteConnection = useDesktopRemoteStore((s) => s.connection);
|
const desktopRemoteConnection = useDesktopRemoteStore((s) => s.connection);
|
||||||
const desktopRemoteState = useDesktopRemoteStore((s) => s.connectionState);
|
const desktopRemoteState = useDesktopRemoteStore((s) => s.connectionState);
|
||||||
const initDesktopRemote = useDesktopRemoteStore((s) => s.init);
|
const initDesktopRemote = useDesktopRemoteStore((s) => s.init);
|
||||||
|
const desktopSyncStatus = useDesktopSyncStore((s) => s.status);
|
||||||
const groupingMode = useSettingsStore((s) => s.artistGroupingMode);
|
const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
|
||||||
const setArtistGroupingMode = useSettingsStore((s) => s.setArtistGroupingMode);
|
const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length);
|
||||||
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const task = InteractionManager.runAfterInteractions(() => {
|
const task = InteractionManager.runAfterInteractions(() => {
|
||||||
@@ -355,21 +56,26 @@ export default function SettingsScreen() {
|
|||||||
return () => task.cancel();
|
return () => task.cancel();
|
||||||
}, [initDesktopRemote]);
|
}, [initDesktopRemote]);
|
||||||
|
|
||||||
const desktopSyncStatus = useDesktopSyncStore((s) => s.status);
|
const totalTracks = folders.reduce((sum, folder) => sum + folder.track_count, 0);
|
||||||
const desktopLastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
|
const connectedScrobblers = lastFmStatus?.profiles.filter((p) => p.connected).length ?? 0;
|
||||||
const desktopSyncConflictCount = useDesktopSyncStore((s) => s.conflicts.length);
|
const appVersion = Constants.expoConfig?.version;
|
||||||
const desktopSyncSubtitle = !desktopRemoteConnection
|
|
||||||
? 'Sync favorites and playlists with Astra Desktop.'
|
const librarySubtitle = folders.length === 0
|
||||||
: desktopSyncConflictCount > 0
|
? 'Folders, artist grouping, album singles.'
|
||||||
? `${desktopSyncConflictCount} conflict${desktopSyncConflictCount === 1 ? '' : 's'} to resolve`
|
: `${formatFolderCount(folders.length)} / ${formatTrackCount(totalTracks)}. Artist grouping and albums.`;
|
||||||
: desktopSyncStatus === 'syncing'
|
const servicesSubtitle = remoteSources.length === 0 && connectedScrobblers === 0
|
||||||
? 'Syncing…'
|
? 'Remote sources and scrobbling.'
|
||||||
: desktopLastSyncAt !== null
|
: `${remoteSources.length} server${remoteSources.length === 1 ? '' : 's'}, ${connectedScrobblers} scrobble destination${connectedScrobblers === 1 ? '' : 's'}.`;
|
||||||
? `Synced ${formatRelativeTime(desktopLastSyncAt)}`
|
|
||||||
: `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'} · not synced yet`;
|
|
||||||
const desktopRemoteSubtitle = desktopRemoteConnection
|
const desktopRemoteSubtitle = desktopRemoteConnection
|
||||||
? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'} · ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
|
? `${desktopRemoteConnection.desktopName ?? 'Astra Desktop'}: ${desktopRemoteState === 'connected' ? 'connected' : desktopRemoteState}`
|
||||||
: 'Pair with Astra Desktop to control playback from this phone.';
|
: '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 (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
@@ -378,341 +84,58 @@ export default function SettingsScreen() {
|
|||||||
Settings
|
Settings
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text variant="label" color={colors.textTertiary} style={styles.sectionLabel}>
|
<SettingsSectionLabel>SETTINGS</SettingsSectionLabel>
|
||||||
APPEARANCE
|
<SettingsNavRow
|
||||||
</Text>
|
icon="color-palette-outline"
|
||||||
<AppearanceSettings />
|
title="Appearance"
|
||||||
|
subtitle={`${themeOptionTitle(baseTheme)} theme, dark style, accent color.`}
|
||||||
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
|
onPress={() => router.push('/settings/appearance' as never)}
|
||||||
LIBRARY FOLDERS
|
/>
|
||||||
</Text>
|
<SettingsNavRow
|
||||||
<LibraryFoldersSettings />
|
icon="library-outline"
|
||||||
|
title="Library"
|
||||||
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
|
subtitle={librarySubtitle}
|
||||||
AUDIO
|
onPress={() => router.push('/settings/library' as never)}
|
||||||
</Text>
|
/>
|
||||||
<View style={styles.card}>
|
<SettingsNavRow
|
||||||
<ToggleRow
|
icon="volume-high"
|
||||||
title="Loudness normalization"
|
title="Audio"
|
||||||
description="Level every track to a target loudness — easier on your ears and keeps the scopes consistent."
|
subtitle={`Normalization ${formatEnabled(normalizationEnabled)}. ReplayGain ${formatEnabled(replayGainEnabled)}.`}
|
||||||
value={normalizationEnabled}
|
onPress={() => router.push('/settings/audio' as never)}
|
||||||
onValueChange={(v) => void setNormalizationEnabled(v)}
|
/>
|
||||||
/>
|
<SettingsNavRow
|
||||||
{normalizationEnabled ? (
|
icon="server-outline"
|
||||||
<View style={styles.indent}>
|
title="Services"
|
||||||
<EQSlider
|
subtitle={servicesSubtitle}
|
||||||
label="Target"
|
onPress={() => router.push('/settings/services' as never)}
|
||||||
value={normalizationTargetLufs}
|
/>
|
||||||
min={-30}
|
<SettingsNavRow
|
||||||
max={-5}
|
icon="flask-outline"
|
||||||
format={(v) => `${Math.round(v)} LUFS`}
|
title="Experimental"
|
||||||
onChange={(v) => void setNormalizationTargetLufs(Math.round(v))}
|
subtitle={desktopSyncSubtitle}
|
||||||
/>
|
subtitleColor={desktopSyncConflictCount > 0 ? colors.warning : undefined}
|
||||||
</View>
|
onPress={() => router.push('/settings/experimental' as never)}
|
||||||
) : 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)}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
|
<SettingsSectionLabel spaced>ABOUT</SettingsSectionLabel>
|
||||||
REMOTE SOURCES
|
<SettingsNavRow
|
||||||
</Text>
|
icon="information-circle-outline"
|
||||||
<Pressable
|
title="Info"
|
||||||
style={styles.option}
|
subtitle={appVersion ? `v${appVersion}. Attribution, license, community links.` : 'Attribution, license, community links.'}
|
||||||
onPress={() => router.push('/sources')}
|
onPress={() => router.push('/settings/info' as never)}
|
||||||
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 & 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>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</Screen>
|
</Screen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const useStyles = createThemedStyles((colors) => ({
|
const useStyles = createThemedStyles(() => ({
|
||||||
content: {
|
content: {
|
||||||
paddingBottom: spacing.xxl,
|
paddingBottom: spacing.xxl,
|
||||||
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
heading: {
|
heading: {
|
||||||
marginTop: spacing.xl,
|
marginTop: spacing.xl,
|
||||||
marginBottom: spacing.xxl,
|
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,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -51,7 +51,19 @@ import type {
|
|||||||
import type { Playlist } from '@/types/playlist';
|
import type { Playlist } from '@/types/playlist';
|
||||||
|
|
||||||
type IconName = keyof typeof Ionicons.glyphMap;
|
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';
|
type LibraryViewMode = 'tracks' | 'albums' | 'artists' | 'playlists' | 'folders';
|
||||||
|
|
||||||
const NAV_RESULT_LIMIT = 3;
|
const NAV_RESULT_LIMIT = 3;
|
||||||
@@ -119,35 +131,51 @@ const SETTING_ENTRIES: {
|
|||||||
keywords: string[];
|
keywords: string[];
|
||||||
libraryViewMode?: LibraryViewMode;
|
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',
|
id: 'setting:audio',
|
||||||
label: 'Audio settings',
|
label: 'Audio settings',
|
||||||
subtitle: 'Normalization / ReplayGain',
|
subtitle: 'Normalization / ReplayGain',
|
||||||
href: '/settings',
|
href: '/settings/audio',
|
||||||
icon: 'volume-high',
|
icon: 'volume-high',
|
||||||
keywords: ['normalization', 'replaygain', 'loudness', 'gain', 'target lufs'],
|
keywords: ['audio', 'normalization', 'replaygain', 'loudness', 'gain', 'target lufs'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'setting:library',
|
id: 'setting:library',
|
||||||
label: 'Artist grouping',
|
label: 'Library settings',
|
||||||
subtitle: 'Astra grouping / file tags',
|
subtitle: 'Folders / artist grouping / albums',
|
||||||
href: '/settings',
|
href: '/settings/library',
|
||||||
icon: 'people',
|
icon: 'people',
|
||||||
keywords: ['library', 'artist grouping', 'collaborators', 'file tags'],
|
keywords: ['library', 'artist grouping', 'collaborators', 'file tags', 'singles', 'albums'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'setting:folders',
|
id: 'setting:folders',
|
||||||
label: 'Manage folders',
|
label: 'Manage folders',
|
||||||
subtitle: 'Add / rescan / remove folders',
|
subtitle: 'Add / rescan / remove folders',
|
||||||
href: '/settings',
|
href: '/settings/library',
|
||||||
icon: 'folder-open-outline',
|
icon: 'folder-open-outline',
|
||||||
keywords: ['folders', 'scan', 'rescan', 'add folder', 'remove folder', 'local files', 'storage'],
|
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',
|
id: 'setting:sources',
|
||||||
label: 'Remote sources',
|
label: 'Remote sources',
|
||||||
subtitle: 'Subsonic / Jellyfin servers',
|
subtitle: 'Subsonic / Jellyfin servers',
|
||||||
href: '/sources',
|
href: '/settings/services',
|
||||||
icon: 'server-outline',
|
icon: 'server-outline',
|
||||||
keywords: ['subsonic', 'jellyfin', 'server', 'streaming', 'remote'],
|
keywords: ['subsonic', 'jellyfin', 'server', 'streaming', 'remote'],
|
||||||
},
|
},
|
||||||
@@ -155,10 +183,26 @@ const SETTING_ENTRIES: {
|
|||||||
id: 'setting:lastfm',
|
id: 'setting:lastfm',
|
||||||
label: 'Scrobbling',
|
label: 'Scrobbling',
|
||||||
subtitle: 'Last.fm / ListenBrainz',
|
subtitle: 'Last.fm / ListenBrainz',
|
||||||
href: '/lastfm',
|
href: '/settings/services',
|
||||||
icon: 'radio-outline',
|
icon: 'radio-outline',
|
||||||
keywords: ['lastfm', 'last.fm', 'listenbrainz', 'scrobble', 'audioscrobbler'],
|
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'];
|
const EMPTY_SHORTCUT_IDS = ['nav:library', 'nav:eq', 'setting:sources', 'setting:lastfm'];
|
||||||
@@ -829,7 +873,7 @@ function QuickSearchPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navigateTo = (href: RouteHref) => {
|
const navigateTo = (href: RouteHref) => {
|
||||||
router.push(href);
|
router.push(href as never);
|
||||||
};
|
};
|
||||||
|
|
||||||
const executeResult = (result: SearchResult) => {
|
const executeResult = (result: SearchResult) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user