theming + material you

This commit is contained in:
Boof2015
2026-07-06 20:38:42 -04:00
parent e1f99f6d61
commit efd2ebc91a
87 changed files with 1528 additions and 327 deletions
+2 -1
View File
@@ -3,11 +3,12 @@ import { Tabs } from 'expo-router';
// Animated.timing and may use the native driver, so the easing must be serializable.
import { Easing } from 'react-native';
import { TabBar, type TabItem } from '@/components/TabBar';
import { colors } from '@/theme';
import { useColors } from '@/theme/themed';
const TAB_TRANSITION_MS = 160;
export default function TabsLayout() {
const colors = useColors();
return (
<Tabs
detachInactiveScreens
+6 -4
View File
@@ -24,10 +24,10 @@ import { GraphicEQPanel } from '@/components/eq/GraphicEQPanel';
import { PresetSheet } from '@/components/eq/PresetSheet';
import { SavePresetSheet } from '@/components/eq/SavePresetSheet';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { isWideWindow } from '@/theme/adaptive';
import { useEQStore } from '@/stores/eqStore';
import { useScopeActive } from '@/scope/scopeStore';
@@ -52,6 +52,8 @@ type SheetKind = 'none' | 'preset' | 'save' | 'overflow' | 'type';
const BAND_TYPES: EQBandType[] = ['lowshelf', 'peaking', 'highshelf', 'highpass', 'lowpass'];
export default function EQScreen() {
const styles = useStyles();
const colors = useColors();
const eq = useEQStore();
const scopeActive = useScopeActive();
const [focused, setFocused] = useState(false);
@@ -394,7 +396,7 @@ function parsePlainNumber(value: string): number | null {
return Number.isFinite(parsed) ? parsed : null;
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
header: {
flexDirection: 'row',
alignItems: 'center',
@@ -501,4 +503,4 @@ const styles = StyleSheet.create({
marginTop: spacing.xs,
marginBottom: spacing.sm,
},
});
}));
+18 -5
View File
@@ -23,11 +23,12 @@ import {
useScrollTopGate
} from '@/components/search/PullSearchGesture';
import {
colors,
fonts,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { rgbaFromHex } from '@/theme/colorUtils';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlayerStore } from '@/stores/playerStore';
@@ -90,6 +91,8 @@ function SectionHeader({
actionLabel?: string;
onActionPress?: () => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.sectionHeader}>
<View style={styles.sectionTitleGroup}>
@@ -115,6 +118,7 @@ function SectionHeader({
}
function AlbumCover({ album, size }: { album: Album; size: number }) {
const styles = useStyles();
const artUri = albumArtworkSource(album);
return (
<View style={[styles.albumArt, { width: size, height: size }]}>
@@ -145,6 +149,8 @@ function NowPlayingCard({
duration: number;
onOpen: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const isPlaying = playbackState === 'playing';
const isLoading = playbackState === 'loading';
const progress = duration > 0 ? Math.min(1, currentTime / duration) : 0;
@@ -231,6 +237,7 @@ function RecentlyAddedAlbum({
album: Album;
onPress: () => void;
}) {
const styles = useStyles();
return (
<Pressable style={styles.recentAlbum} onPress={onPress} accessibilityRole="button">
<AlbumCover album={album} size={112} />
@@ -259,6 +266,8 @@ function RandomAlbumCard({
onReroll: () => void;
onOpen: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const disabled = tracks.length === 0;
return (
@@ -325,6 +334,8 @@ function EmptyHomeCard({
scanError: string | null;
onManageFolders: () => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.emptyCard}>
<Ionicons name="folder-open-outline" size={34} color={colors.textTertiary} />
@@ -354,6 +365,8 @@ function EmptyHomeCard({
}
export default function HomeScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const tracks = useLibraryStore((s) => s.tracks);
const albums = useLibraryStore((s) => s.albums);
@@ -588,7 +601,7 @@ export default function HomeScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
content: {
paddingBottom: spacing.xxl,
},
@@ -636,7 +649,7 @@ const styles = StyleSheet.create({
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(8, 10, 15, 0.28)',
backgroundColor: rgbaFromHex(colors.bgPrimary, 0.28),
},
playerArt: {
width: 112,
@@ -813,4 +826,4 @@ const styles = StyleSheet.create({
emptyCopy: {
gap: spacing.xs,
},
});
}));
+2 -1
View File
@@ -1,11 +1,12 @@
import { Stack } from 'expo-router';
import { colors } from '@/theme';
import { useColors } from '@/theme/themed';
/**
* Nested stack inside the Library tab so album/artist detail screens keep the
* tab bar + mini-player visible.
*/
export default function LibraryLayout() {
const colors = useColors();
return (
<Stack
screenOptions={{
+4 -1
View File
@@ -11,7 +11,8 @@ import { AstraLogo } from '@/components/AstraLogo';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { useColors } from '@/theme/themed';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
@@ -27,6 +28,7 @@ type AlbumRow =
| { kind: 'disc'; disc: number };
function DiscHeader({ disc }: { disc: number }) {
const colors = useColors();
return (
<View style={styles.discHeader}>
<Ionicons name="disc-outline" size={16} color={colors.textSecondary} />
@@ -36,6 +38,7 @@ function DiscHeader({ disc }: { disc: number }) {
}
export default function AlbumScreen() {
const colors = useColors();
const { key, from } = useLocalSearchParams<{ key: string; from?: string }>();
const albums = useLibraryStore((s) => s.albums);
const allTracks = useLibraryStore((s) => s.tracks);
+18 -6
View File
@@ -21,11 +21,12 @@ import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
import {
colors,
fontSize,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import type { Palette } from '@/theme/palettes';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSettingsStore } from '@/stores/settingsStore';
@@ -60,6 +61,8 @@ type ArtistPageItem =
| { key: 'empty'; type: 'empty' };
export default function ArtistScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const { name = 'Artist', from } = useLocalSearchParams<{ name: string; from?: string }>();
const handleBack = useLibraryDetailBack(from);
@@ -165,7 +168,7 @@ export default function ArtistScreen() {
}}
/>
<CollapsingHeader
artwork={artistArtwork(detail.artworkHashes)}
artwork={artistArtwork(detail.artworkHashes, colors, styles)}
backdropUri={backdropHash ? artworkThumbUri(backdropHash) : null}
title={name}
heroMeta={
@@ -247,7 +250,11 @@ function buildListItems(detail: ArtistDetail): ArtistPageItem[] {
}
/** Inner artwork for the collapsing header: 2x2 album mosaic, single cover, or fallback. */
function artistArtwork(hashes: string[]) {
function artistArtwork(
hashes: string[],
colors: Palette,
styles: ReturnType<typeof useStyles>,
) {
const useMosaic = hashes.length >= 4;
const display = useMosaic ? hashes.slice(0, 4) : hashes.slice(0, 1);
@@ -283,6 +290,8 @@ function SectionHeader({
trailing: string;
onPress?: () => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.sectionHeader}>
<View style={styles.sectionTitleGroup}>
@@ -312,6 +321,7 @@ function AlbumRail({
albums: ArtistAlbum[];
onAlbumPress: (album: ArtistAlbum) => void;
}) {
const styles = useStyles();
return (
<ScrollView
horizontal
@@ -352,6 +362,8 @@ function AlbumRail({
}
function StatChip({ icon, label }: { icon: IconName; label: string }) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.statChip}>
<Ionicons name={icon} size={13} color={colors.accentText} />
@@ -380,7 +392,7 @@ function trackSubtitle(track: DbTrack, section: 'appearances' | 'songs'): string
return track.album;
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
// The backdrop runs behind the status bar; content pads itself instead.
screen: {
paddingTop: 0,
@@ -482,4 +494,4 @@ const styles = StyleSheet.create({
emptyText: {
textAlign: 'center',
},
});
}));
@@ -10,12 +10,14 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { AlbumGridItem } from '@/components/library/AlbumGridItem';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { useColors } from '@/theme/themed';
import { useLibraryStore } from '@/stores/libraryStore';
import { useSettingsStore } from '@/stores/settingsStore';
import { buildArtistDetail } from '@/library/artistDetail';
export default function ArtistAlbumsScreen() {
const colors = useColors();
const router = useRouter();
const { name = 'Artist' } = useLocalSearchParams<{ name: string }>();
const allTracks = useLibraryStore((s) => s.tracks);
@@ -68,6 +70,7 @@ export default function ArtistAlbumsScreen() {
}
function EmptyList({ label }: { label: string }) {
const colors = useColors();
return (
<View style={styles.emptyState}>
<Ionicons name="albums-outline" size={24} color={colors.textTertiary} />
@@ -11,7 +11,8 @@ import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { useColors } from '@/theme/themed';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSettingsStore } from '@/stores/settingsStore';
@@ -21,6 +22,7 @@ import { buildArtistDetail } from '@/library/artistDetail';
import type { DbTrack } from '@/types/library';
export default function ArtistAppearancesScreen() {
const colors = useColors();
const router = useRouter();
const { name = 'Artist' } = useLocalSearchParams<{ name: string }>();
const allTracks = useLibraryStore((s) => s.tracks);
@@ -79,6 +81,7 @@ export default function ArtistAppearancesScreen() {
}
function EmptyList({ label }: { label: string }) {
const colors = useColors();
return (
<View style={styles.emptyState}>
<Ionicons name="people-outline" size={24} color={colors.textTertiary} />
@@ -11,7 +11,8 @@ import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { useColors } from '@/theme/themed';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSettingsStore } from '@/stores/settingsStore';
@@ -21,6 +22,7 @@ import { buildArtistDetail } from '@/library/artistDetail';
import type { DbTrack } from '@/types/library';
export default function ArtistSongsScreen() {
const colors = useColors();
const router = useRouter();
const { name = 'Artist' } = useLocalSearchParams<{ name: string }>();
const allTracks = useLibraryStore((s) => s.tracks);
@@ -79,6 +81,7 @@ export default function ArtistSongsScreen() {
}
function EmptyList({ label }: { label: string }) {
const colors = useColors();
return (
<View style={styles.emptyState}>
<Ionicons name="musical-notes" size={24} color={colors.textTertiary} />
+3 -1
View File
@@ -37,7 +37,8 @@ import {
PullSearchScrollView,
useScrollTopGate
} from '@/components/search/PullSearchGesture';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { useColors } from '@/theme/themed';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { useSearchStore } from '@/stores/searchStore';
@@ -75,6 +76,7 @@ const ALBUM_SORT_OPTIONS: AlbumSort[] = ['artist', 'name', 'recently_added', 'ye
const ARTIST_SORT_OPTIONS: ArtistSort[] = ['name', 'track_count'];
export default function LibraryScreen() {
const colors = useColors();
const router = useRouter();
const viewMode = useLibraryStore((s) => s.viewMode);
const setViewMode = useLibraryStore((s) => s.setViewMode);
+8 -3
View File
@@ -25,7 +25,8 @@ import {
} from '@/components/sheets/AppSheet';
import { TextPromptModal } from '@/components/sheets/TextPromptModal';
import { CollapsingHeader, useDetailCollapse } from '@/components/library/CollapsingDetail';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { usePlaylistStore } from '@/stores/playlistStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks, shuffleTracks } from '@/audio/playbackController';
@@ -52,6 +53,8 @@ function basename(path: string): string {
}
function MissingRow({ entry, onLongPress }: { entry: PlaylistTrackEntry; onLongPress: () => void }) {
const styles = useStyles();
const colors = useColors();
return (
<Pressable style={styles.missingRow} onLongPress={onLongPress} accessibilityRole="button">
<View style={styles.missingMeta}>
@@ -70,6 +73,8 @@ function MissingRow({ entry, onLongPress }: { entry: PlaylistTrackEntry; onLongP
type Prompt = { kind: 'rename'; playlist: Playlist } | null;
export default function PlaylistScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const { id, from } = useLocalSearchParams<{ id: string; from?: string }>();
const handleBack = useLibraryDetailBack(from);
@@ -404,7 +409,7 @@ export default function PlaylistScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
// The backdrop runs behind the status bar; content pads itself instead.
screen: {
paddingTop: 0,
@@ -438,4 +443,4 @@ const styles = StyleSheet.create({
paddingVertical: spacing.xs,
marginTop: spacing.sm,
},
});
}));
@@ -25,7 +25,8 @@ import {
AppSheetSection,
AppSheetTitle,
} from '@/components/sheets/AppSheet';
import { colors, fonts, fontSize, radius, spacing } from '@/theme';
import { fonts, fontSize, radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { usePlaylistStore } from '@/stores/playlistStore';
import {
DYNAMIC_PLAYLIST_PRESETS,
@@ -325,6 +326,8 @@ function DraftActions({
onCancel: () => void;
onApply: () => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.sheetActions}>
<Pressable style={[styles.sheetButton, styles.cancelButton]} onPress={onCancel} accessibilityRole="button">
@@ -427,6 +430,8 @@ function ConditionValueEditor({
condition: DynamicPlaylistCondition;
onChange: (condition: DynamicPlaylistCondition) => void;
}) {
const styles = useStyles();
const colors = useColors();
if (condition.kind === 'text') {
return (
<BottomSheetTextInput
@@ -523,6 +528,8 @@ function ConditionEditorSheet({
onCancel: () => void;
onApply: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const draft = target.draft;
const error = validateCondition(draft);
@@ -591,6 +598,8 @@ function SortLimitSheet({
onCancel: () => void;
onApply: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const trimmedLimit = limitText.trim();
const parsedLimit = trimmedLimit ? Number(trimmedLimit) : null;
const limitValid =
@@ -661,6 +670,8 @@ function PreviewSheet({
isLoading: boolean;
onClose: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const count = preview?.track_count ?? 0;
return (
@@ -701,6 +712,8 @@ function FilterCard({
onPress: () => void;
onRemove: () => void;
}) {
const styles = useStyles();
const colors = useColors();
const option = fieldOptionForKey(getConditionFieldKey(condition));
return (
@@ -730,6 +743,8 @@ function FilterCard({
}
export default function DynamicPlaylistEditorScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const { id } = useLocalSearchParams<{ id?: string }>();
const playlistId = id ? Number(id) : null;
@@ -1175,7 +1190,7 @@ export default function DynamicPlaylistEditorScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
keyboardRoot: {
flex: 1,
backgroundColor: colors.bgPrimary,
@@ -1449,4 +1464,4 @@ const styles = StyleSheet.create({
previewMessage: {
paddingVertical: spacing.md,
},
});
}));
+111 -3
View File
@@ -14,8 +14,13 @@ import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { EQSlider } from '@/components/eq/EQSlider';
import { ScanProgress } from '@/components/library/ScanProgress';
import { colors, radius, spacing } from '@/theme';
import { SegmentedControl } from '@/components/SegmentedControl';
import { AccentSwatchRow } from '@/components/settings/AccentSwatchRow';
import { radius, 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 { useAudioSettingsStore } from '@/stores/audioSettingsStore';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
@@ -34,6 +39,93 @@ function lastFmScrobbleSubtitle(status: LastFmStatus | null): string {
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',
@@ -64,6 +156,8 @@ function ToggleRow({
value: boolean;
onValueChange: (v: boolean) => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.toggleRow}>
<View style={styles.toggleText}>
@@ -99,6 +193,8 @@ function LibraryFolderSettingsRow({
disabled: boolean;
onRemove: (folder: FolderWithCount) => void;
}) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.folderSettingsRow}>
<Ionicons
@@ -135,6 +231,8 @@ function LibraryFolderSettingsRow({
}
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);
@@ -227,6 +325,8 @@ function LibraryFoldersSettings() {
}
export default function SettingsScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const remoteSources = useRemoteSourcesStore((s) => s.sources);
const lastFmStatus = useLastFmSettingsStore((s) => s.status);
@@ -279,6 +379,11 @@ export default function SettingsScreen() {
</Text>
<Text variant="label" color={colors.textTertiary} style={styles.sectionLabel}>
APPEARANCE
</Text>
<AppearanceSettings />
<Text variant="label" color={colors.textTertiary} style={[styles.sectionLabel, styles.sectionSpacing]}>
LIBRARY FOLDERS
</Text>
<LibraryFoldersSettings />
@@ -462,7 +567,7 @@ export default function SettingsScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
content: {
paddingBottom: spacing.xxl,
},
@@ -580,6 +685,9 @@ const styles = StyleSheet.create({
settingTitle: {
marginBottom: spacing.xs,
},
appearanceBlock: {
marginTop: spacing.md,
},
settingNote: {
marginBottom: spacing.md,
},
@@ -607,4 +715,4 @@ const styles = StyleSheet.create({
optionDescription: {
lineHeight: 16,
},
});
}));
+31 -9
View File
@@ -40,7 +40,8 @@ import {
import { fetchDesktopRemoteIdentity } from '@/services/desktopRemoteClient';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
import { SyncConflictPrompt } from '@/components/sync/SyncConflictPrompt';
import { colors } from '@/theme';
import { useThemeStore } from '@/stores/themeStore';
import { useTheme } from '@/theme/themed';
// Anchor the root stack at the tabs so a deep link straight to a top-level route (the
// widget/notification opening `now-playing`, or `recently-played`) builds `[(tabs), route]`
@@ -58,6 +59,21 @@ function PlaybackSync() {
return null;
}
/**
* Re-reads system theme inputs (OS scheme + monet wallpaper ramps) on each
* return to foreground — wallpaper changes can only happen while backgrounded.
* OS dark/light toggles are covered by the Appearance listener in themeStore.
*/
function ThemeSystemSync() {
useEffect(() => {
const subscription = AppState.addEventListener('change', (state) => {
if (state === 'active') useThemeStore.getState().refreshSystemInputs();
});
return () => subscription.remove();
}, []);
return null;
}
/** Owns the visualizer on/off gate (foreground + playing + motion). Renders nothing. */
function ScopeLifecycle() {
useScopeLifecycle();
@@ -260,7 +276,12 @@ export default function RootLayout() {
const timer = setTimeout(() => setSplashTimedOut(true), 2000);
return () => clearTimeout(timer);
}, []);
const ready = fontsLoaded || splashTimedOut;
// Theme joins the gate so the first painted frame is already in the
// persisted theme (no flash). The failsafe path paints the default theme
// and snaps once the SQLite read lands — accepted degradation.
const themeLoaded = useThemeStore((s) => s.loaded);
const theme = useTheme();
const ready = (fontsLoaded && themeLoaded) || splashTimedOut;
useEffect(() => {
if (ready) {
@@ -272,6 +293,10 @@ export default function RootLayout() {
// Library tab + playback adapters get data immediately. EQ + audio settings load
// alongside so the native EQ/gain reflect persisted prefs from the first play.
useEffect(() => {
useThemeStore
.getState()
.load()
.catch((err) => console.error('[theme] load failed', err));
useLibraryStore
.getState()
.initialize()
@@ -301,9 +326,10 @@ export default function RootLayout() {
if (!ready) return null;
return (
<GestureHandlerRootView style={styles.root}>
<GestureHandlerRootView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary }}>
<SafeAreaProvider>
<StatusBar style="light" />
<StatusBar style={theme.statusBarStyle} />
<ThemeSystemSync />
<PlaybackSync />
<ScopeLifecycle />
<NormalizationSync />
@@ -314,7 +340,7 @@ export default function RootLayout() {
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.bgPrimary },
contentStyle: { backgroundColor: theme.colors.bgPrimary },
}}
>
<Stack.Screen name="(tabs)" />
@@ -334,7 +360,3 @@ export default function RootLayout() {
</GestureHandlerRootView>
);
}
const styles = {
root: { flex: 1, backgroundColor: colors.bgPrimary },
} as const;
+8 -4
View File
@@ -23,10 +23,10 @@ import { AstraLogo } from '@/components/AstraLogo';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { isWideWindow, WIDE_MIN_WIDTH } from '@/theme/adaptive';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
import type { DesktopRemoteDiscoveredDesktop } from '@/types/desktopRemote';
@@ -188,6 +188,8 @@ function DiscoveredDesktopRow({ desktop, onPair, disabled }: {
onPair: (desktop: DesktopRemoteDiscoveredDesktop) => void;
disabled: boolean;
}) {
const styles = useStyles();
const colors = useColors();
return (
<Pressable
style={[styles.discoveredRow, disabled && styles.buttonDisabled]}
@@ -216,6 +218,8 @@ function DiscoveredDesktopRow({ desktop, onPair, disabled }: {
}
export default function DesktopRemoteScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const { pair } = useLocalSearchParams<{ pair?: string }>();
const insets = useSafeAreaInsets();
@@ -641,7 +645,7 @@ export default function DesktopRemoteScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
flex: {
flex: 1,
},
@@ -993,4 +997,4 @@ const styles = StyleSheet.create({
paddingHorizontal: CONTENT_SIDE_PADDING,
textAlign: 'center',
},
});
}));
+6 -4
View File
@@ -15,13 +15,15 @@ import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore';
export default function DesktopRemoteScanScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const pairFromInput = useDesktopRemoteStore((s) => s.pairFromInput);
const [permission, requestPermission] = useCameraPermissions();
@@ -89,7 +91,7 @@ export default function DesktopRemoteScanScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
header: {
marginTop: spacing.md,
marginBottom: spacing.lg,
@@ -156,4 +158,4 @@ const styles = StyleSheet.create({
flexDirection: 'row',
gap: spacing.sm,
},
});
}));
+8 -3
View File
@@ -18,7 +18,8 @@ import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { SyncConflictDetails } from '@/components/sync/SyncConflictDetails';
import { colors, radius, spacing } from '@/theme';
import { radius, spacing } from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { formatRelativeTime } from '@/lib/format';
import { getDesktopRemoteConnection } from '@/services/desktopRemoteCredentials';
import { useDesktopSyncStore } from '@/stores/desktopSyncStore';
@@ -65,6 +66,8 @@ function ConflictCard({
busy: boolean;
onResolve: (resolution: DesktopSyncConflictResolution) => void;
}) {
const styles = useStyles();
const colors = useColors();
const [selectedResolution, setSelectedResolution] = useState<DesktopSyncConflictResolution | null>(null);
const desktopSnapshot = syncPlaylistToSnapshot(conflict.remote);
const phoneSnapshot = syncPlaylistToSnapshot(conflict.local);
@@ -126,6 +129,8 @@ function ConflictCard({
}
export default function DesktopSyncScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const status = useDesktopSyncStore((s) => s.status);
const lastSyncAt = useDesktopSyncStore((s) => s.lastSyncAt);
@@ -295,7 +300,7 @@ export default function DesktopSyncScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
topBar: {
marginTop: spacing.md,
marginBottom: spacing.sm,
@@ -405,4 +410,4 @@ const styles = StyleSheet.create({
disabled: {
opacity: 0.5,
},
});
}));
+8 -4
View File
@@ -15,10 +15,10 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import type { LastFmScrobbleProtocol } from '@/types/lastFm';
@@ -79,6 +79,8 @@ function Field({
autoCapitalize = 'none',
keyboardType = 'default',
}: FieldProps) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.field}>
<Text variant="label" color={colors.textTertiary} style={styles.fieldLabel}>
@@ -100,6 +102,8 @@ function Field({
}
export default function LastFmEditScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const { id } = useLocalSearchParams<{ id?: string }>();
const status = useLastFmSettingsStore((s) => s.status);
@@ -318,7 +322,7 @@ export default function LastFmEditScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
flex: { flex: 1 },
header: {
flexDirection: 'row',
@@ -409,4 +413,4 @@ const styles = StyleSheet.create({
paddingVertical: spacing.md,
marginTop: spacing.lg,
},
});
}));
+6 -4
View File
@@ -12,10 +12,10 @@ import { useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useLastFmSettingsStore } from '@/stores/lastFmSettingsStore';
import { requestLastFmFlush } from '@/services/lastfm';
import type { LastFmProfileStatus } from '@/types/lastFm';
@@ -44,6 +44,8 @@ function customStatusLine(profile: LastFmProfileStatus): { text: string; tone: '
}
export default function LastFmScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const status = useLastFmSettingsStore((s) => s.status);
const authHint = useLastFmSettingsStore((s) => s.authHint);
@@ -285,7 +287,7 @@ export default function LastFmScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
header: {
flexDirection: 'row',
alignItems: 'center',
@@ -416,4 +418,4 @@ const styles = StyleSheet.create({
marginTop: spacing.xl,
lineHeight: 16,
},
});
}));
+5 -4
View File
@@ -1,10 +1,11 @@
import { useEffect } from 'react';
import { StyleSheet, View } from 'react-native';
import { View } from 'react-native';
import { useRouter } from 'expo-router';
import { getNotificationClickRedirectPath } from '@/audio/notificationIntent';
import { colors } from '@/theme';
import { createThemedStyles } from '@/theme/themed';
export default function NotificationClickRoute() {
const styles = useStyles();
const router = useRouter();
useEffect(() => {
@@ -26,9 +27,9 @@ export default function NotificationClickRoute() {
return <View style={styles.root} />;
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
root: {
flex: 1,
backgroundColor: colors.bgPrimary,
},
});
}));
+6 -4
View File
@@ -31,10 +31,10 @@ import { PlaybackTargetPicker } from '@/components/PlaybackTargetPicker';
import { QueueTray } from '@/components/queue/QueueTray';
import { RemoteQueueSheet } from '@/components/queue/RemoteQueueSheet';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { WIDE_MIN_WIDTH, isWideWindow } from '@/theme/adaptive';
import { motion } from '@/theme/motion';
import { resolveNavigationArtist } from '@/library/artistGrouping';
@@ -264,6 +264,8 @@ function getNowPlayingLayout(
}
export default function NowPlayingScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const insets = useSafeAreaInsets();
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
@@ -1059,7 +1061,7 @@ export default function NowPlayingScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
backdrop: {
flex: 1,
backgroundColor: 'transparent',
@@ -1330,4 +1332,4 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
backgroundColor: colors.glassBg,
},
});
}));
+4 -1
View File
@@ -11,7 +11,8 @@ import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { TrackRow } from '@/components/library/TrackRow';
import { TrackActionsSheet } from '@/components/library/TrackActionsSheet';
import { colors, spacing } from '@/theme';
import { spacing } from '@/theme';
import { useColors } from '@/theme/themed';
import { useLibraryStore } from '@/stores/libraryStore';
import { usePlayerStore } from '@/stores/playerStore';
import { playTracks } from '@/audio/playbackController';
@@ -23,6 +24,7 @@ function formatCount(count: number, noun: string): string {
}
function EmptyList() {
const colors = useColors();
return (
<View style={styles.emptyState}>
<Ionicons name="time-outline" size={24} color={colors.textTertiary} />
@@ -34,6 +36,7 @@ function EmptyList() {
}
export default function RecentlyPlayedScreen() {
const colors = useColors();
const router = useRouter();
const tracks = useLibraryStore((s) => s.recentlyPlayedTracks);
const currentPath = usePlayerStore((s) => s.currentTrack?.path);
+8 -4
View File
@@ -14,10 +14,10 @@ import { useLocalSearchParams, useRouter } from 'expo-router';
import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import type { RemoteSourceType } from '@/types/remote';
@@ -60,6 +60,8 @@ function Field({
autoCapitalize = 'none',
keyboardType = 'default',
}: FieldProps) {
const styles = useStyles();
const colors = useColors();
return (
<View style={styles.field}>
<Text variant="label" color={colors.textTertiary} style={styles.fieldLabel}>
@@ -81,6 +83,8 @@ function Field({
}
export default function SourceEditScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const { id } = useLocalSearchParams<{ id?: string }>();
const sources = useRemoteSourcesStore((s) => s.sources);
@@ -308,7 +312,7 @@ export default function SourceEditScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
flex: { flex: 1 },
header: {
flexDirection: 'row',
@@ -403,4 +407,4 @@ const styles = StyleSheet.create({
buttonDisabled: {
opacity: 0.5,
},
});
}));
+6 -4
View File
@@ -12,10 +12,10 @@ import { Screen } from '@/components/Screen';
import { Text } from '@/components/Text';
import { ActionSheet, type ActionSheetItem } from '@/components/sheets/ActionSheet';
import {
colors,
radius,
spacing
spacing,
} from '@/theme';
import { createThemedStyles, useColors } from '@/theme/themed';
import { useRemoteSourcesStore } from '@/stores/remoteSourcesStore';
import type { RemoteSourceRow, RemoteSyncProgress } from '@/types/remote';
@@ -38,6 +38,8 @@ function statusLine(
}
export default function SourcesScreen() {
const styles = useStyles();
const colors = useColors();
const router = useRouter();
const sources = useRemoteSourcesStore((s) => s.sources);
const progressById = useRemoteSourcesStore((s) => s.progressById);
@@ -196,7 +198,7 @@ export default function SourcesScreen() {
);
}
const styles = StyleSheet.create({
const useStyles = createThemedStyles((colors) => ({
header: {
flexDirection: 'row',
alignItems: 'center',
@@ -274,4 +276,4 @@ const styles = StyleSheet.create({
rowName: {
flex: 1,
},
});
}));