diff --git a/app.json b/app.json index b4248aa..64fd209 100644 --- a/app.json +++ b/app.json @@ -6,7 +6,7 @@ "orientation": "default", "icon": "./assets/images/icon.png", "scheme": "astra", - "userInterfaceStyle": "dark", + "userInterfaceStyle": "automatic", "backgroundColor": "#000000", "ios": { "supportsTablet": true diff --git a/modules/astra-system-colors/android/build.gradle b/modules/astra-system-colors/android/build.gradle new file mode 100644 index 0000000..d386e02 --- /dev/null +++ b/modules/astra-system-colors/android/build.gradle @@ -0,0 +1,18 @@ +plugins { + id 'com.android.library' + id 'expo-module-gradle-plugin' +} + +group = 'expo.modules.astrasystemcolors' +version = '0.1.0' + +android { + namespace "expo.modules.astrasystemcolors" + defaultConfig { + versionCode 1 + versionName "0.1.0" + } + lintOptions { + abortOnError false + } +} diff --git a/modules/astra-system-colors/android/src/main/java/expo/modules/astrasystemcolors/AstraSystemColorsModule.kt b/modules/astra-system-colors/android/src/main/java/expo/modules/astrasystemcolors/AstraSystemColorsModule.kt new file mode 100644 index 0000000..59092d8 --- /dev/null +++ b/modules/astra-system-colors/android/src/main/java/expo/modules/astrasystemcolors/AstraSystemColorsModule.kt @@ -0,0 +1,110 @@ +package expo.modules.astrasystemcolors + +import android.os.Build +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition + +/** + * Exposes Android 12+ monet system palettes (wallpaper-derived) to JS. + * Each ramp is 13 hex strings ordered by tone [0, 10, 50, 100..1000]. + * Sync Functions on purpose: resource reads are microseconds, and it avoids + * the zero-arg Coroutine {} overload ambiguity in expo-modules-core. + */ +class AstraSystemColorsModule : Module() { + override fun definition() = ModuleDefinition { + Name("AstraSystemColors") + + Function("isAvailable") { Build.VERSION.SDK_INT >= 31 } + + Function("getSystemPalette") { + if (Build.VERSION.SDK_INT < 31) return@Function null + val context = appContext.reactContext ?: return@Function null + fun ramp(ids: IntArray) = ids.map { String.format("#%06x", context.getColor(it) and 0xFFFFFF) } + mapOf( + "accent1" to ramp(ACCENT1), + "accent2" to ramp(ACCENT2), + "accent3" to ramp(ACCENT3), + "neutral1" to ramp(NEUTRAL1), + "neutral2" to ramp(NEUTRAL2), + ) + } + } + + companion object { + private val ACCENT1 = intArrayOf( + android.R.color.system_accent1_0, + android.R.color.system_accent1_10, + android.R.color.system_accent1_50, + android.R.color.system_accent1_100, + android.R.color.system_accent1_200, + android.R.color.system_accent1_300, + android.R.color.system_accent1_400, + android.R.color.system_accent1_500, + android.R.color.system_accent1_600, + android.R.color.system_accent1_700, + android.R.color.system_accent1_800, + android.R.color.system_accent1_900, + android.R.color.system_accent1_1000, + ) + private val ACCENT2 = intArrayOf( + android.R.color.system_accent2_0, + android.R.color.system_accent2_10, + android.R.color.system_accent2_50, + android.R.color.system_accent2_100, + android.R.color.system_accent2_200, + android.R.color.system_accent2_300, + android.R.color.system_accent2_400, + android.R.color.system_accent2_500, + android.R.color.system_accent2_600, + android.R.color.system_accent2_700, + android.R.color.system_accent2_800, + android.R.color.system_accent2_900, + android.R.color.system_accent2_1000, + ) + private val ACCENT3 = intArrayOf( + android.R.color.system_accent3_0, + android.R.color.system_accent3_10, + android.R.color.system_accent3_50, + android.R.color.system_accent3_100, + android.R.color.system_accent3_200, + android.R.color.system_accent3_300, + android.R.color.system_accent3_400, + android.R.color.system_accent3_500, + android.R.color.system_accent3_600, + android.R.color.system_accent3_700, + android.R.color.system_accent3_800, + android.R.color.system_accent3_900, + android.R.color.system_accent3_1000, + ) + private val NEUTRAL1 = intArrayOf( + android.R.color.system_neutral1_0, + android.R.color.system_neutral1_10, + android.R.color.system_neutral1_50, + android.R.color.system_neutral1_100, + android.R.color.system_neutral1_200, + android.R.color.system_neutral1_300, + android.R.color.system_neutral1_400, + android.R.color.system_neutral1_500, + android.R.color.system_neutral1_600, + android.R.color.system_neutral1_700, + android.R.color.system_neutral1_800, + android.R.color.system_neutral1_900, + android.R.color.system_neutral1_1000, + ) + private val NEUTRAL2 = intArrayOf( + android.R.color.system_neutral2_0, + android.R.color.system_neutral2_10, + android.R.color.system_neutral2_50, + android.R.color.system_neutral2_100, + android.R.color.system_neutral2_200, + android.R.color.system_neutral2_300, + android.R.color.system_neutral2_400, + android.R.color.system_neutral2_500, + android.R.color.system_neutral2_600, + android.R.color.system_neutral2_700, + android.R.color.system_neutral2_800, + android.R.color.system_neutral2_900, + android.R.color.system_neutral2_1000, + ) + } +} diff --git a/modules/astra-system-colors/expo-module.config.json b/modules/astra-system-colors/expo-module.config.json new file mode 100644 index 0000000..d06e196 --- /dev/null +++ b/modules/astra-system-colors/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["android"], + "android": { + "modules": ["expo.modules.astrasystemcolors.AstraSystemColorsModule"] + } +} diff --git a/modules/astra-system-colors/index.ts b/modules/astra-system-colors/index.ts new file mode 100644 index 0000000..37dcac6 --- /dev/null +++ b/modules/astra-system-colors/index.ts @@ -0,0 +1,28 @@ +import { requireOptionalNativeModule, type NativeModule } from 'expo-modules-core'; + +/** + * Android 12+ monet system palettes (wallpaper-derived). Each ramp is 13 hex + * strings ordered by tone [0, 10, 50, 100, 200, ..., 900, 1000] — tone 0 is + * white-ish, tone 1000 black-ish. + */ +export interface SystemPalette { + accent1: string[]; + accent2: string[]; + accent3: string[]; + neutral1: string[]; + neutral2: string[]; +} + +declare class AstraSystemColorsModuleType extends NativeModule { + /** True on Android 12+ builds that include the native module. */ + isAvailable(): boolean; + /** Current monet ramps, or null when unavailable. Sync — resource reads are microseconds. */ + getSystemPalette(): SystemPalette | null; +} + +const native = requireOptionalNativeModule('AstraSystemColors'); + +export const AstraSystemColors = (native ?? { + isAvailable: () => false, + getSystemPalette: () => null, +}) as AstraSystemColorsModuleType; diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(tabs)/_layout.tsx index b6c9249..aba9b43 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(tabs)/_layout.tsx @@ -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 ( ({ header: { flexDirection: 'row', alignItems: 'center', @@ -501,4 +503,4 @@ const styles = StyleSheet.create({ marginTop: spacing.xs, marginBottom: spacing.sm, }, -}); +})); diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index fb91459..1982383 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -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 ( @@ -115,6 +118,7 @@ function SectionHeader({ } function AlbumCover({ album, size }: { album: Album; size: number }) { + const styles = useStyles(); const artUri = albumArtworkSource(album); return ( @@ -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 ( @@ -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 ( @@ -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, }, -}); +})); diff --git a/src/app/(tabs)/library/_layout.tsx b/src/app/(tabs)/library/_layout.tsx index 83967f7..ca79269 100644 --- a/src/app/(tabs)/library/_layout.tsx +++ b/src/app/(tabs)/library/_layout.tsx @@ -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 ( @@ -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); diff --git a/src/app/(tabs)/library/artist/[name].tsx b/src/app/(tabs)/library/artist/[name].tsx index 195a5be..0ecddd2 100644 --- a/src/app/(tabs)/library/artist/[name].tsx +++ b/src/app/(tabs)/library/artist/[name].tsx @@ -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() { }} /> , +) { 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 ( @@ -312,6 +321,7 @@ function AlbumRail({ albums: ArtistAlbum[]; onAlbumPress: (album: ArtistAlbum) => void; }) { + const styles = useStyles(); return ( @@ -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', }, -}); +})); diff --git a/src/app/(tabs)/library/artist/[name]/albums.tsx b/src/app/(tabs)/library/artist/[name]/albums.tsx index 0f131f4..3f4f2e6 100644 --- a/src/app/(tabs)/library/artist/[name]/albums.tsx +++ b/src/app/(tabs)/library/artist/[name]/albums.tsx @@ -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 ( diff --git a/src/app/(tabs)/library/artist/[name]/appearances.tsx b/src/app/(tabs)/library/artist/[name]/appearances.tsx index bb3bf96..6c68748 100644 --- a/src/app/(tabs)/library/artist/[name]/appearances.tsx +++ b/src/app/(tabs)/library/artist/[name]/appearances.tsx @@ -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 ( diff --git a/src/app/(tabs)/library/artist/[name]/songs.tsx b/src/app/(tabs)/library/artist/[name]/songs.tsx index 001815c..79b132d 100644 --- a/src/app/(tabs)/library/artist/[name]/songs.tsx +++ b/src/app/(tabs)/library/artist/[name]/songs.tsx @@ -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 ( diff --git a/src/app/(tabs)/library/index.tsx b/src/app/(tabs)/library/index.tsx index 4f976e3..1a6d0fe 100644 --- a/src/app/(tabs)/library/index.tsx +++ b/src/app/(tabs)/library/index.tsx @@ -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); diff --git a/src/app/(tabs)/library/playlist/[id].tsx b/src/app/(tabs)/library/playlist/[id].tsx index 372d868..96044ad 100644 --- a/src/app/(tabs)/library/playlist/[id].tsx +++ b/src/app/(tabs)/library/playlist/[id].tsx @@ -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 ( @@ -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, }, -}); +})); diff --git a/src/app/(tabs)/library/playlist/edit-dynamic.tsx b/src/app/(tabs)/library/playlist/edit-dynamic.tsx index 9af03d9..fdd30b4 100644 --- a/src/app/(tabs)/library/playlist/edit-dynamic.tsx +++ b/src/app/(tabs)/library/playlist/edit-dynamic.tsx @@ -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 ( @@ -427,6 +430,8 @@ function ConditionValueEditor({ condition: DynamicPlaylistCondition; onChange: (condition: DynamicPlaylistCondition) => void; }) { + const styles = useStyles(); + const colors = useColors(); if (condition.kind === 'text') { return ( 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, }, -}); +})); diff --git a/src/app/(tabs)/settings.tsx b/src/app/(tabs)/settings.tsx index 2339448..4719af8 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(tabs)/settings.tsx @@ -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 ( + <> + + {options.map((option) => { + const selected = option.id === baseTheme; + return ( + void setBaseTheme(option.id)} + accessibilityRole="radio" + accessibilityState={{ selected }} + > + + + {option.title} + + + {option.description} + + + {selected ? ( + + ) : ( + + )} + + ); + })} + + + {baseTheme === 'system' ? ( + + + Dark style used when the system is dark. + + void setPreferredDark(key as PreferredDark)} + /> + + ) : null} + + {accentApplies ? ( + + void setAccent(id)} /> + + ) : null} + + ); +} + const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; description: string }[] = [ { mode: 'astra', @@ -64,6 +156,8 @@ function ToggleRow({ value: boolean; onValueChange: (v: boolean) => void; }) { + const styles = useStyles(); + const colors = useColors(); return ( @@ -99,6 +193,8 @@ function LibraryFolderSettingsRow({ disabled: boolean; onRemove: (folder: FolderWithCount) => void; }) { + const styles = useStyles(); + const colors = useColors(); return ( 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() { + APPEARANCE + + + + LIBRARY FOLDERS @@ -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, }, -}); +})); diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 1524a3f..00d14c9 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -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 ( - + - + + @@ -314,7 +340,7 @@ export default function RootLayout() { @@ -334,7 +360,3 @@ export default function RootLayout() { ); } - -const styles = { - root: { flex: 1, backgroundColor: colors.bgPrimary }, -} as const; diff --git a/src/app/desktop-remote.tsx b/src/app/desktop-remote.tsx index bc41543..d87265f 100644 --- a/src/app/desktop-remote.tsx +++ b/src/app/desktop-remote.tsx @@ -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 ( (); 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', }, -}); +})); diff --git a/src/app/desktop-remote/scan.tsx b/src/app/desktop-remote/scan.tsx index 2c775a3..383b1a6 100644 --- a/src/app/desktop-remote/scan.tsx +++ b/src/app/desktop-remote/scan.tsx @@ -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, }, -}); +})); diff --git a/src/app/desktop-sync.tsx b/src/app/desktop-sync.tsx index d3fa1bf..b504d31 100644 --- a/src/app/desktop-sync.tsx +++ b/src/app/desktop-sync.tsx @@ -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(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, }, -}); +})); diff --git a/src/app/lastfm/edit.tsx b/src/app/lastfm/edit.tsx index eb4877b..369123a 100644 --- a/src/app/lastfm/edit.tsx +++ b/src/app/lastfm/edit.tsx @@ -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 ( @@ -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, }, -}); +})); diff --git a/src/app/lastfm/index.tsx b/src/app/lastfm/index.tsx index 49e8aa5..e54cb4e 100644 --- a/src/app/lastfm/index.tsx +++ b/src/app/lastfm/index.tsx @@ -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, }, -}); +})); diff --git a/src/app/notification.click.tsx b/src/app/notification.click.tsx index 59a9c6d..e6e729d 100644 --- a/src/app/notification.click.tsx +++ b/src/app/notification.click.tsx @@ -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 ; } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ root: { flex: 1, backgroundColor: colors.bgPrimary, }, -}); +})); diff --git a/src/app/now-playing.tsx b/src/app/now-playing.tsx index 7ee8300..b7426f7 100644 --- a/src/app/now-playing.tsx +++ b/src/app/now-playing.tsx @@ -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, }, -}); +})); diff --git a/src/app/recently-played.tsx b/src/app/recently-played.tsx index b0ea136..5ae59bf 100644 --- a/src/app/recently-played.tsx +++ b/src/app/recently-played.tsx @@ -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 ( @@ -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); diff --git a/src/app/sources/edit.tsx b/src/app/sources/edit.tsx index 7772c82..a7af8db 100644 --- a/src/app/sources/edit.tsx +++ b/src/app/sources/edit.tsx @@ -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 ( @@ -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, }, -}); +})); diff --git a/src/app/sources/index.tsx b/src/app/sources/index.tsx index 58f180b..e939ecd 100644 --- a/src/app/sources/index.tsx +++ b/src/app/sources/index.tsx @@ -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, }, -}); +})); diff --git a/src/components/AstraLogo.tsx b/src/components/AstraLogo.tsx index 8abac66..d2caed4 100644 --- a/src/components/AstraLogo.tsx +++ b/src/components/AstraLogo.tsx @@ -1,5 +1,5 @@ import Svg, { G, Path } from 'react-native-svg'; -import { colors } from '@/theme'; +import { useColors } from '@/theme/themed'; /** * Astra mark — ported from desktop `astraLogoShared.ts` (same viewBox, paths, @@ -26,11 +26,9 @@ interface AstraLogoProps { includeBackground?: boolean; } -export function AstraLogo({ - size = 28, - color = colors.logoMain, - includeBackground = false, -}: AstraLogoProps) { +export function AstraLogo({ size = 28, color, includeBackground = false }: AstraLogoProps) { + const colors = useColors(); + const mainFill = color ?? colors.logoMain; return ( {includeBackground && ( @@ -47,8 +45,8 @@ export function AstraLogo({ - - + + ); diff --git a/src/components/FormatBadge.tsx b/src/components/FormatBadge.tsx index 9696453..6511541 100644 --- a/src/components/FormatBadge.tsx +++ b/src/components/FormatBadge.tsx @@ -1,14 +1,15 @@ import { View, StyleSheet } from 'react-native'; import { Text } from './Text'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles } from '@/theme/themed'; import type { Track } from '@/types/audio'; /** A single mono pill (e.g. "FLAC", "24-BIT", "48.0 kHz"). */ export function Badge({ label }: { label: string }) { + const styles = useStyles(); return ( @@ -34,6 +35,7 @@ export function FormatBadges({ wrap?: boolean; variant?: 'pill' | 'plain'; }) { + const styles = useStyles(); const labels: string[] = []; if (track.format) labels.push(track.format.toUpperCase()); if (track.bitDepth) labels.push(`${track.bitDepth}-BIT`); @@ -66,7 +68,7 @@ export function FormatBadges({ ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ row: { flexDirection: 'row', flexWrap: 'wrap', @@ -93,6 +95,6 @@ const styles = StyleSheet.create({ fontSize: 10, letterSpacing: 0.3, }, -}); +})); export default FormatBadges; diff --git a/src/components/MiniPlayer.tsx b/src/components/MiniPlayer.tsx index acd9df1..a12a6cc 100644 --- a/src/components/MiniPlayer.tsx +++ b/src/components/MiniPlayer.tsx @@ -12,10 +12,10 @@ import { Text } from './Text'; import { AstraLogo } from './AstraLogo'; import { SpectrumCurve } from './SpectrumCurve'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { usePlayerStore } from '@/stores/playerStore'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; @@ -46,6 +46,7 @@ function MiniProgress({ duration: number; isPlaying: boolean; }) { + const styles = useStyles(); const smoothTime = useSmoothPlaybackTime(currentTime, duration, isPlaying); const progress = duration > 0 ? Math.min(1, smoothTime / duration) : 0; return ( @@ -61,6 +62,8 @@ function MiniProgress({ * opens the full now-playing screen. */ export function MiniPlayer({ visible = true }: MiniPlayerProps) { + const styles = useStyles(); + const colors = useColors(); const router = useRouter(); const selectedTarget = usePlaybackTargetStore((s) => s.target); const track = usePlayerStore((s) => s.currentTrack); @@ -205,7 +208,7 @@ export function MiniPlayer({ visible = true }: MiniPlayerProps) { ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ pill: { height: PILL_HEIGHT, marginHorizontal: spacing.md, @@ -234,7 +237,7 @@ const styles = StyleSheet.create({ left: 0, right: 0, bottom: 0, - backgroundColor: 'rgba(8, 10, 15, 0.24)', + backgroundColor: colors.overlayFaint, }, row: { flexDirection: 'row', @@ -279,6 +282,6 @@ const styles = StyleSheet.create({ height: 2, backgroundColor: colors.accent, }, -}); +})); export default MiniPlayer; diff --git a/src/components/OscilloscopeWave.tsx b/src/components/OscilloscopeWave.tsx index 547a952..ef83d59 100644 --- a/src/components/OscilloscopeWave.tsx +++ b/src/components/OscilloscopeWave.tsx @@ -14,7 +14,7 @@ import { import { AstraScope, OSCILLOSCOPE_POINTS } from '../../modules/astra-scope'; import { useScopeStore } from '@/scope/scopeStore'; import { DEFAULT_OSC_GAIN } from '@/scope/oscilloscopeGain'; -import { colors } from '@/theme'; +import { useColors } from '@/theme/themed'; interface OscilloscopeWaveProps { active: boolean; @@ -111,11 +111,13 @@ export function OscilloscopeWave({ active, width, height, - color = colors.accent, + color: colorProp, lineWidth = 2, glow = false, edgeFade: _edgeFade = false, }: OscilloscopeWaveProps) { + const themeColors = useColors(); + const color = colorProp ?? themeColors.accent; const viewRef = useRef(null); const initialPicture = useMemo( () => diff --git a/src/components/PlaybackTargetPicker.tsx b/src/components/PlaybackTargetPicker.tsx index 09605ff..0862def 100644 --- a/src/components/PlaybackTargetPicker.tsx +++ b/src/components/PlaybackTargetPicker.tsx @@ -2,14 +2,14 @@ import { useEffect } from 'react'; import { Modal, Pressable, - StyleSheet, View } from 'react-native'; import { useRouter } from 'expo-router'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Ionicons } from '@expo/vector-icons'; import { Text } from './Text'; -import { colors, radius, spacing } from '@/theme'; +import { radius, spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { usePlaybackTargetStore, type PlaybackTarget } from '@/stores/playbackTargetStore'; import { usePlayerStore } from '@/stores/playerStore'; @@ -24,6 +24,8 @@ interface PlaybackTargetPickerProps { } export function PlaybackTargetPicker({ visible, onClose }: PlaybackTargetPickerProps) { + const styles = useStyles(); + const colors = useColors(); const router = useRouter(); const insets = useSafeAreaInsets(); const selectedTarget = usePlaybackTargetStore((s) => s.target); @@ -114,6 +116,8 @@ function TargetRow({ selected: boolean; onPress: () => void; }) { + const styles = useStyles(); + const colors = useColors(); return ( [styles.row, pressed && styles.rowPressed]} @@ -141,11 +145,11 @@ function TargetRow({ ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ backdrop: { flex: 1, justifyContent: 'flex-end', - backgroundColor: 'rgba(0, 0, 0, 0.58)', + backgroundColor: colors.backdrop, }, sheet: { borderTopLeftRadius: radius.lg, @@ -190,6 +194,6 @@ const styles = StyleSheet.create({ flex: 1, minWidth: 0, }, -}); +})); export default PlaybackTargetPicker; diff --git a/src/components/RemoteSourceBadge.tsx b/src/components/RemoteSourceBadge.tsx index e53b2cc..a598de6 100644 --- a/src/components/RemoteSourceBadge.tsx +++ b/src/components/RemoteSourceBadge.tsx @@ -1,5 +1,5 @@ import { Ionicons } from '@expo/vector-icons'; -import { colors } from '@/theme'; +import { useColors } from '@/theme/themed'; import type { TrackSourceType } from '@/types/library'; /** @@ -9,18 +9,19 @@ import type { TrackSourceType } from '@/types/library'; export function RemoteSourceBadge({ sourceType, size = 12, - color = colors.accent, + color, }: { sourceType?: TrackSourceType | null; size?: number; color?: string; }) { + const colors = useColors(); if (!sourceType || sourceType === 'local') return null; return ( ); diff --git a/src/components/Screen.tsx b/src/components/Screen.tsx index 70d3b98..dbbeefc 100644 --- a/src/components/Screen.tsx +++ b/src/components/Screen.tsx @@ -1,10 +1,10 @@ import { - StyleSheet, View, type ViewProps } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { colors, spacing } from '@/theme'; +import { spacing } from '@/theme'; +import { createThemedStyles } from '@/theme/themed'; interface ScreenProps extends ViewProps { /** Apply default horizontal padding. */ @@ -13,6 +13,7 @@ interface ScreenProps extends ViewProps { /** Base screen container: black background + top safe-area inset. */ export function Screen({ children, style, padded = true, ...rest }: ScreenProps) { + const styles = useStyles(); const insets = useSafeAreaInsets(); return ( @@ -21,7 +22,7 @@ export function Screen({ children, style, padded = true, ...rest }: ScreenProps) ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ root: { flex: 1, backgroundColor: colors.bgPrimary, @@ -32,6 +33,6 @@ const styles = StyleSheet.create({ padded: { paddingHorizontal: spacing.lg, }, -}); +})); export default Screen; diff --git a/src/components/SeekBar.tsx b/src/components/SeekBar.tsx index ac02336..2c8c89c 100644 --- a/src/components/SeekBar.tsx +++ b/src/components/SeekBar.tsx @@ -1,16 +1,15 @@ import { useRef, useState } from 'react'; import { - StyleSheet, View, type GestureResponderEvent, type LayoutChangeEvent } from 'react-native'; import { Text } from './Text'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles } from '@/theme/themed'; import { formatDuration } from '@/lib/format'; const THUMB_SIZE = 12; @@ -30,6 +29,7 @@ const clamp = (fraction: number) => Math.min(1, Math.max(0, fraction)); * waveform seek bar port (desktop WaveformSeekBar) replaces the visuals at M3+. */ export function SeekBar({ currentTime, duration, onSeek, trackKey }: SeekBarProps) { + const styles = useStyles(); const [scrubFraction, setScrubFraction] = useState(null); const [barWidth, setBarWidth] = useState(0); // Last released seek. Displayed instead of live progress until playback @@ -129,7 +129,7 @@ export function SeekBar({ currentTime, duration, onSeek, trackKey }: SeekBarProp ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ touchArea: { justifyContent: 'center', paddingVertical: spacing.md, // generous touch target around the 4px track @@ -166,6 +166,6 @@ const styles = StyleSheet.create({ timeActive: { color: colors.accentText, }, -}); +})); export default SeekBar; diff --git a/src/components/SegmentedControl.tsx b/src/components/SegmentedControl.tsx index adb8f2c..eb50bc5 100644 --- a/src/components/SegmentedControl.tsx +++ b/src/components/SegmentedControl.tsx @@ -12,10 +12,10 @@ import Animated, { withTiming } from 'react-native-reanimated'; import { - colors, fonts, - radius + radius, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { motion } from '@/theme/motion'; const THUMB_INSET = 3; @@ -37,6 +37,7 @@ interface SegmentedControlProps { * accent via interpolateColor on Animated.Text. Spring-free per theme/motion. */ export function SegmentedControl({ segments, value, onChange }: SegmentedControlProps) { + const styles = useStyles(); const count = segments.length; const activeIndex = Math.max( 0, @@ -86,6 +87,8 @@ function SegmentButton({ focused: boolean; onPress: () => void; }) { + const styles = useStyles(); + const colors = useColors(); // 0 = inactive, 1 = active; drives the label colour cross-fade. const progress = useSharedValue(focused ? 1 : 0); @@ -93,12 +96,12 @@ function SegmentButton({ progress.value = withTiming(focused ? 1 : 0, motion.quick); }, [focused, progress]); + // Locals so the worklet captures plain strings: a theme switch re-renders, + // the captured values change, and Reanimated rebuilds the worklet. + const inactiveColor = colors.textSecondary; + const activeColor = colors.accentTextStrong; const labelStyle = useAnimatedStyle(() => ({ - color: interpolateColor( - progress.value, - [0, 1], - [colors.textSecondary, colors.accentTextStrong], - ), + color: interpolateColor(progress.value, [0, 1], [inactiveColor, activeColor]), })); return ( @@ -115,7 +118,7 @@ function SegmentButton({ ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ track: { flexDirection: 'row', backgroundColor: colors.glassBg, @@ -147,6 +150,6 @@ const styles = StyleSheet.create({ fontSize: 12, fontFamily: fonts.sans.medium, }, -}); +})); export default SegmentedControl; diff --git a/src/components/SpectrumCurve.tsx b/src/components/SpectrumCurve.tsx index 14ecc40..8e7fe7d 100644 --- a/src/components/SpectrumCurve.tsx +++ b/src/components/SpectrumCurve.tsx @@ -13,7 +13,7 @@ import { type SkPicture } from '@shopify/react-native-skia'; import { AstraScope, SPECTRUM_BINS } from '../../modules/astra-scope'; -import { colors } from '@/theme'; +import { useColors } from '@/theme/themed'; interface SpectrumCurveProps { /** Normalized magnitudes in [0,1] for static rendering. Live rendering ignores this. */ @@ -305,16 +305,19 @@ export function SpectrumCurve({ dbMin = DISPLAY_DB_MIN, dbMax = DISPLAY_DB_MAX, tiltDbPerOctave = TILT_DB_PER_OCT, - color = colors.accent, + color: colorProp, lineWidth = 2, lineOpacity = 1, fillOpacity = 1, glow = false, glowOpacity = 0.18, edgeFade = false, - edgeFadeColor = colors.bgPrimary, + edgeFadeColor: edgeFadeColorProp, edgeFadeWidth = 28, }: SpectrumCurveProps) { + const themeColors = useColors(); + const color = colorProp ?? themeColors.accent; + const edgeFadeColor = edgeFadeColorProp ?? themeColors.bgPrimary; const viewRef = useRef(null); const activePointCount = Math.max(2, Math.floor(width)); const resolvedPointCount = pointCount ?? values?.length ?? (active ? activePointCount : DEFAULT_POINTS); diff --git a/src/components/SwipeableRow.tsx b/src/components/SwipeableRow.tsx index bf5c2dc..bf5f450 100644 --- a/src/components/SwipeableRow.tsx +++ b/src/components/SwipeableRow.tsx @@ -16,7 +16,7 @@ import Animated, { useSharedValue, withTiming } from 'react-native-reanimated'; -import { colors } from '@/theme'; +import { useColors } from '@/theme/themed'; import { motion } from '@/theme/motion'; import { commitHaptic, tickHaptic } from '@/lib/haptics'; @@ -59,6 +59,7 @@ export function SwipeableRow({ enabled = true, children, }: SwipeableRowProps) { + const colors = useColors(); const tx = useSharedValue(0); const armed = useSharedValue(false); const [rowWidth, setRowWidth] = useState(0); diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx index f8df24c..9914120 100644 --- a/src/components/TabBar.tsx +++ b/src/components/TabBar.tsx @@ -15,11 +15,11 @@ import Animated, { } from 'react-native-reanimated'; import { MiniPlayer } from './MiniPlayer'; import { - colors, fonts, layout, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { motion } from '@/theme/motion'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import { usePlaybackTargetStore } from '@/stores/playbackTargetStore'; @@ -54,6 +54,7 @@ interface TabBarProps { * logic stays in the layout's `tabBar` callback. */ export function TabBar({ items, onPress }: TabBarProps) { + const styles = useStyles(); const insets = useSafeAreaInsets(); const tabs = items.filter((item) => TAB_META[item.name]); const homeFocused = items.some((item) => item.name === 'index' && item.focused); @@ -158,6 +159,8 @@ interface TabButtonProps { * theme/motion. */ function TabButton({ meta, focused, onPress }: TabButtonProps) { + const styles = useStyles(); + const colors = useColors(); // 0 = inactive, 1 = active. Drives the accent fill, label colour, and bloom. const progress = useSharedValue(focused ? 1 : 0); // 0 = at rest, 1 = finger down. @@ -171,12 +174,12 @@ function TabButton({ meta, focused, onPress }: TabButtonProps) { transform: [{ scale: 1 - press.value * 0.12 }], })); const accentStyle = useAnimatedStyle(() => ({ opacity: progress.value })); + // Locals so the worklet captures plain strings: a theme switch re-renders, + // the captured values change, and Reanimated rebuilds the worklet. + const inactiveColor = colors.textTertiary; + const activeColor = colors.accent; const labelStyle = useAnimatedStyle(() => ({ - color: interpolateColor( - progress.value, - [0, 1], - [colors.textTertiary, colors.accent], - ), + color: interpolateColor(progress.value, [0, 1], [inactiveColor, activeColor]), })); return ( @@ -204,7 +207,7 @@ function TabButton({ meta, focused, onPress }: TabButtonProps) { ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ wrap: { backgroundColor: colors.bgSecondary, }, @@ -237,6 +240,6 @@ const styles = StyleSheet.create({ fontSize: 10, fontFamily: fonts.sans.regular, }, -}); +})); export default TabBar; diff --git a/src/components/Text.tsx b/src/components/Text.tsx index 4108719..606a8a9 100644 --- a/src/components/Text.tsx +++ b/src/components/Text.tsx @@ -1,14 +1,13 @@ import type { ReactNode } from 'react'; import { - StyleSheet, Text as RNText, type TextProps as RNTextProps } from 'react-native'; import { - colors, fonts, - fontSize + fontSize, } from '@/theme'; +import { createThemedStyles } from '@/theme/themed'; type Variant = 'title' | 'heading' | 'body' | 'label' | 'caption' | 'mono'; @@ -55,6 +54,7 @@ function collectText(node: ReactNode): string { /** Themed Text — applies Astra fonts/colors. Import this instead of RN's Text. */ export function Text({ variant = 'body', color, style, ...rest }: TextProps) { + const styles = useStyles(); const fallback = NON_LATIN.test(collectText(rest.children)); return ( ({ title: { fontSize: fontSize.xxl, color: colors.textPrimary, @@ -97,6 +97,6 @@ const styles = StyleSheet.create({ fontSize: fontSize.sm, color: colors.textSecondary, }, -}); +})); export default Text; diff --git a/src/components/Visualizer.tsx b/src/components/Visualizer.tsx index d0ce99f..aad8a59 100644 --- a/src/components/Visualizer.tsx +++ b/src/components/Visualizer.tsx @@ -1,6 +1,5 @@ import { useState } from 'react'; import { - StyleSheet, Pressable, View } from 'react-native'; @@ -8,7 +7,8 @@ import { Ionicons } from '@expo/vector-icons'; import { Text } from './Text'; import { SpectrumCurve } from './SpectrumCurve'; import { OscilloscopeWave } from './OscilloscopeWave'; -import { colors, spacing } from '@/theme'; +import { spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { useScopeActive } from '@/scope/scopeStore'; const CANVAS_HEIGHT = 96; @@ -38,6 +38,8 @@ export function Visualizer({ mode: controlledMode, edgeFade = false, }: VisualizerProps) { + const styles = useStyles(); + const colors = useColors(); const [uncontrolledMode, setUncontrolledMode] = useState('spectrum'); const mode = controlledMode ?? uncontrolledMode; const scopeActive = useScopeActive(); @@ -103,7 +105,7 @@ export function Visualizer({ ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ wrap: { paddingVertical: spacing.xs, }, @@ -121,6 +123,6 @@ const styles = StyleSheet.create({ letterSpacing: 1.5, fontSize: 10, }, -}); +})); export default Visualizer; diff --git a/src/components/WaveformSeekBar.tsx b/src/components/WaveformSeekBar.tsx index 5d7aa51..d0aac23 100644 --- a/src/components/WaveformSeekBar.tsx +++ b/src/components/WaveformSeekBar.tsx @@ -5,7 +5,6 @@ import { useState } from 'react'; import { - StyleSheet, View, type GestureResponderEvent, type LayoutChangeEvent @@ -18,7 +17,8 @@ import { rect } from '@shopify/react-native-skia'; import { Text } from './Text'; -import { colors, spacing } from '@/theme'; +import { spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { formatDuration } from '@/lib/format'; import { downsampleWaveform, getWaveform } from '@/scope/waveform'; import { useSmoothPlaybackTime } from '@/audio/useSmoothPlaybackTime'; @@ -58,6 +58,8 @@ export function WaveformSeekBar({ touchPadding = spacing.md, trackPath, }: WaveformSeekBarProps) { + const styles = useStyles(); + const colors = useColors(); const [scrubFraction, setScrubFraction] = useState(null); const [barWidth, setBarWidth] = useState(0); const pendingSeek = usePlayerStore((s) => s.pendingSeek); @@ -199,7 +201,7 @@ export function WaveformSeekBar({ ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ touchArea: { justifyContent: 'center', }, @@ -215,6 +217,6 @@ const styles = StyleSheet.create({ timeActive: { color: colors.accentText, }, -}); +})); export default WaveformSeekBar; diff --git a/src/components/eq/BandDetailPanel.tsx b/src/components/eq/BandDetailPanel.tsx index 5fa35e8..21b4784 100644 --- a/src/components/eq/BandDetailPanel.tsx +++ b/src/components/eq/BandDetailPanel.tsx @@ -7,10 +7,10 @@ import { import { Ionicons } from '@expo/vector-icons'; import { Text } from '@/components/Text'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import type { EQBand } from '@/types/audio'; import { EQ_MAX_FREQUENCY, @@ -41,6 +41,8 @@ export type EQEditableValue = 'frequency' | 'gain' | 'Q'; /** "Band N" + type dropdown + On toggle + Frequency / Gain / Q sliders. */ export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEditValue }: BandDetailPanelProps) { + const styles = useStyles(); + const colors = useColors(); if (!band) { return ( @@ -108,7 +110,7 @@ export function BandDetailPanel({ band, bandNumber, onUpdate, onEditType, onEdit ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ card: { borderRadius: radius.lg, borderWidth: StyleSheet.hairlineWidth, @@ -140,6 +142,6 @@ const styles = StyleSheet.create({ alignItems: 'center', gap: spacing.sm, }, -}); +})); export default BandDetailPanel; diff --git a/src/components/eq/BandStrip.tsx b/src/components/eq/BandStrip.tsx index d658038..f634cf9 100644 --- a/src/components/eq/BandStrip.tsx +++ b/src/components/eq/BandStrip.tsx @@ -6,10 +6,10 @@ import { import { Ionicons } from '@expo/vector-icons'; import { Text } from '@/components/Text'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import type { EQBand } from '@/types/audio'; import { formatFreq, @@ -27,6 +27,8 @@ interface BandStripProps { /** Horizontal strip of per-band cells (freq + gain) + a trailing "+" add cell. */ export function BandStrip({ bands, activeBandId, canAdd, onSelect, onAdd }: BandStripProps) { + const styles = useStyles(); + const colors = useColors(); return ( {formatGain(band.gain)} @@ -62,7 +64,7 @@ export function BandStrip({ bands, activeBandId, canAdd, onSelect, onAdd }: Band ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ content: { gap: spacing.sm, paddingVertical: spacing.xs, @@ -94,6 +96,6 @@ const styles = StyleSheet.create({ gain: { fontSize: 15, }, -}); +})); export default BandStrip; diff --git a/src/components/eq/EQGraph.tsx b/src/components/eq/EQGraph.tsx index fc71189..9ff214d 100644 --- a/src/components/eq/EQGraph.tsx +++ b/src/components/eq/EQGraph.tsx @@ -20,7 +20,7 @@ import { } from '@shopify/react-native-skia'; import { Text } from '@/components/Text'; import { SpectrumCurve } from '@/components/SpectrumCurve'; -import { colors } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import type { EQBand } from '@/types/audio'; import { FREQ_TICKS, @@ -59,6 +59,8 @@ export function EQGraph({ onSelectBand, onChangeBand, }: EQGraphProps) { + const styles = useStyles(); + const colors = useColors(); const [size, setSize] = useStableSize(); const width = size.width; const height = size.height; @@ -302,7 +304,7 @@ function useStableSize(): [ return [size, set]; } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ container: { flex: 1, borderRadius: 16, @@ -329,6 +331,6 @@ const styles = StyleSheet.create({ textAlign: 'center', color: colors.textTertiary, }, -}); +})); export default EQGraph; diff --git a/src/components/eq/EQSlider.tsx b/src/components/eq/EQSlider.tsx index 7099551..6e11210 100644 --- a/src/components/eq/EQSlider.tsx +++ b/src/components/eq/EQSlider.tsx @@ -8,10 +8,10 @@ import { } from 'react-native'; import { Text } from '@/components/Text'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles } from '@/theme/themed'; const THUMB = 16; @@ -42,6 +42,7 @@ export function EQSlider({ disabled, onValuePress, }: EQSliderProps) { + const styles = useStyles(); const [width, setWidth] = useState(0); const [active, setActive] = useState(false); const widthRef = useRef(0); @@ -136,7 +137,7 @@ export function EQSlider({ ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ row: { flexDirection: 'row', alignItems: 'center', @@ -195,6 +196,6 @@ const styles = StyleSheet.create({ textAlign: 'right', color: colors.textPrimary, }, -}); +})); export default EQSlider; diff --git a/src/components/eq/EQValueEditSheet.tsx b/src/components/eq/EQValueEditSheet.tsx index 7647047..65b262f 100644 --- a/src/components/eq/EQValueEditSheet.tsx +++ b/src/components/eq/EQValueEditSheet.tsx @@ -8,11 +8,11 @@ import { import { BottomSheetTextInput } from '@gorhom/bottom-sheet'; import { Text } from '@/components/Text'; import { - colors, fonts, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { EqSheet } from './EqSheet'; interface EQValueEditSheetProps { @@ -39,6 +39,8 @@ export function EQValueEditSheet({ onApply, onClose, }: EQValueEditSheetProps) { + const styles = useStyles(); + const colors = useColors(); const [value, setValue] = useState(initialValue); const trimmed = value.trim(); const parsed = trimmed.length > 0 ? parseValue(trimmed) : null; @@ -97,7 +99,7 @@ export function EQValueEditSheet({ ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ title: { marginTop: spacing.xs, marginBottom: spacing.md, @@ -156,6 +158,6 @@ const styles = StyleSheet.create({ applyDisabled: { opacity: 0.4, }, -}); +})); export default EQValueEditSheet; diff --git a/src/components/eq/GraphicEQPanel.tsx b/src/components/eq/GraphicEQPanel.tsx index 6a4a419..ba00faf 100644 --- a/src/components/eq/GraphicEQPanel.tsx +++ b/src/components/eq/GraphicEQPanel.tsx @@ -1,6 +1,7 @@ import { StyleSheet, View } from 'react-native'; import { Text } from '@/components/Text'; -import { colors, spacing } from '@/theme'; +import { spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { EQ_MAX_GAIN_DB, EQ_MIN_GAIN_DB } from '@/audio/eq'; import { GRAPHIC_BANDS } from '@/audio/graphicEq'; import { GraphicResponseCurve } from './GraphicResponseCurve'; @@ -25,6 +26,8 @@ interface GraphicEQPanelProps { * curve's evenly spaced band positions. */ export function GraphicEQPanel({ gains, enabled, onChangeGain }: GraphicEQPanelProps) { + const styles = useStyles(); + const colors = useColors(); return ( @@ -32,7 +35,7 @@ export function GraphicEQPanel({ gains, enabled, onChangeGain }: GraphicEQPanelP {formatGain(gains[i] ?? 0)} @@ -71,7 +74,7 @@ export function GraphicEQPanel({ gains, enabled, onChangeGain }: GraphicEQPanelP ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ container: { flex: 1, }, @@ -100,6 +103,6 @@ const styles = StyleSheet.create({ caption: { color: colors.textTertiary, }, -}); +})); export default GraphicEQPanel; diff --git a/src/components/eq/GraphicResponseCurve.tsx b/src/components/eq/GraphicResponseCurve.tsx index bb1da4f..fec40b0 100644 --- a/src/components/eq/GraphicResponseCurve.tsx +++ b/src/components/eq/GraphicResponseCurve.tsx @@ -12,7 +12,7 @@ import { Skia, type SkPath } from '@shopify/react-native-skia'; -import { colors } from '@/theme'; +import { useColors } from '@/theme/themed'; import type { EQBand } from '@/types/audio'; import { EQ_MAX_FREQUENCY, @@ -39,6 +39,7 @@ interface GraphicResponseCurveProps { * the chrome). */ export function GraphicResponseCurve({ gains, enabled }: GraphicResponseCurveProps) { + const colors = useColors(); const [size, setSize] = useState({ width: 0, height: 0 }); const width = size.width; const height = size.height; diff --git a/src/components/eq/PresetSheet.tsx b/src/components/eq/PresetSheet.tsx index bb5af32..af41319 100644 --- a/src/components/eq/PresetSheet.tsx +++ b/src/components/eq/PresetSheet.tsx @@ -1,7 +1,8 @@ import { Pressable, StyleSheet } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Text } from '@/components/Text'; -import { colors, spacing } from '@/theme'; +import { spacing } from '@/theme'; +import { useColors } from '@/theme/themed'; import type { EQPreset } from '@/types/audio'; import { EqSheet, @@ -35,6 +36,7 @@ export function PresetSheet({ onSaveNew, onClose, }: PresetSheetProps) { + const colors = useColors(); const builtIn = presets.filter((p) => !p.isCustom); const custom = presets.filter((p) => p.isCustom); diff --git a/src/components/eq/SavePresetSheet.tsx b/src/components/eq/SavePresetSheet.tsx index b34e84b..5e4cc35 100644 --- a/src/components/eq/SavePresetSheet.tsx +++ b/src/components/eq/SavePresetSheet.tsx @@ -7,11 +7,11 @@ import { import { BottomSheetTextInput } from '@gorhom/bottom-sheet'; import { Text } from '@/components/Text'; import { - colors, fonts, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { EqSheet } from './EqSheet'; interface SavePresetSheetProps { @@ -22,6 +22,8 @@ interface SavePresetSheetProps { /** Name + save a custom preset from the current bands/preamp. */ export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetSheetProps) { + const styles = useStyles(); + const colors = useColors(); const [name, setName] = useState(defaultName); const trimmed = name.trim(); @@ -70,7 +72,7 @@ export function SavePresetSheet({ defaultName, onSave, onClose }: SavePresetShee ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ title: { marginTop: spacing.xs, marginBottom: spacing.md, @@ -109,6 +111,6 @@ const styles = StyleSheet.create({ saveDisabled: { opacity: 0.4, }, -}); +})); export default SavePresetSheet; diff --git a/src/components/eq/VerticalEQSlider.tsx b/src/components/eq/VerticalEQSlider.tsx index 5908baf..b854c53 100644 --- a/src/components/eq/VerticalEQSlider.tsx +++ b/src/components/eq/VerticalEQSlider.tsx @@ -1,11 +1,11 @@ import { useRef, useState } from 'react'; import { - StyleSheet, View, type GestureResponderEvent, type LayoutChangeEvent } from 'react-native'; -import { colors, radius } from '@/theme'; +import { radius } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; // Vertical fader cap (mixer-style): tall capsule with a horizontal grip line // marking the exact value position. Half its height overshoots the rail at the @@ -37,6 +37,8 @@ const clamp01 = (f: number) => Math.min(1, Math.max(0, f)); * same code as the readouts). */ export function VerticalEQSlider({ label, value, min, max, onChange }: VerticalEQSliderProps) { + const styles = useStyles(); + const colors = useColors(); const [height, setHeight] = useState(0); const [active, setActive] = useState(false); const heightRef = useRef(0); @@ -106,7 +108,7 @@ export function VerticalEQSlider({ label, value, min, max, onChange }: VerticalE ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ touch: { flex: 1, alignItems: 'center', @@ -151,6 +153,6 @@ const styles = StyleSheet.create({ borderRadius: 1, backgroundColor: colors.bgSecondary, }, -}); +})); export default VerticalEQSlider; diff --git a/src/components/eq/format.ts b/src/components/eq/format.ts index 6eb8c86..7ddf01c 100644 --- a/src/components/eq/format.ts +++ b/src/components/eq/format.ts @@ -1,6 +1,6 @@ // Shared EQ value formatting for the band strip + detail panel. -import { colors } from '@/theme'; +import type { Palette } from '@/theme/palettes'; import type { EQBandType } from '@/types/audio'; export function formatFreq(hz: number): string { @@ -25,7 +25,7 @@ export function formatGain(db: number): string { return `${db > 0 ? '+' : ''}${db.toFixed(1)}`; } -export function gainColor(db: number): string { +export function gainColor(db: number, colors: Palette): string { if (db > 0.05) return colors.accentText; if (db < -0.05) return colors.warning; return colors.textTertiary; diff --git a/src/components/library/AlbumGridItem.tsx b/src/components/library/AlbumGridItem.tsx index a89c760..954d103 100644 --- a/src/components/library/AlbumGridItem.tsx +++ b/src/components/library/AlbumGridItem.tsx @@ -7,14 +7,15 @@ import { Image } from 'expo-image'; import { Text } from '@/components/Text'; import { AstraLogo } from '@/components/AstraLogo'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles } from '@/theme/themed'; import { albumArtworkSource } from '@/library/artwork'; import type { Album } from '@/types/library'; export function AlbumGridItem({ album, onPress }: { album: Album; onPress: () => void }) { + const styles = useStyles(); const artUri = albumArtworkSource(album); return ( @@ -41,7 +42,7 @@ export function AlbumGridItem({ album, onPress }: { album: Album; onPress: () => ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ item: { flex: 1, marginBottom: spacing.lg, @@ -64,4 +65,4 @@ const styles = StyleSheet.create({ title: { fontSize: 14, }, -}); +})); diff --git a/src/components/library/AlbumRow.tsx b/src/components/library/AlbumRow.tsx index 68e288b..5867121 100644 --- a/src/components/library/AlbumRow.tsx +++ b/src/components/library/AlbumRow.tsx @@ -7,15 +7,17 @@ import { Image } from 'expo-image'; import { Ionicons } from '@expo/vector-icons'; import { Text } from '@/components/Text'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { albumArtworkSource } from '@/library/artwork'; import type { Album } from '@/types/library'; /** Compact album list row (search results) — the grid uses AlbumGridItem. */ export function AlbumRow({ album, onPress }: { album: Album; onPress: () => void }) { + const styles = useStyles(); + const colors = useColors(); const artUri = albumArtworkSource(album); return ( @@ -43,7 +45,7 @@ export function AlbumRow({ album, onPress }: { album: Album; onPress: () => void ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ row: { flexDirection: 'row', alignItems: 'center', @@ -71,4 +73,4 @@ const styles = StyleSheet.create({ flex: 1, gap: 2, }, -}); +})); diff --git a/src/components/library/AlphabetRail.tsx b/src/components/library/AlphabetRail.tsx index 0c30216..e9e72d5 100644 --- a/src/components/library/AlphabetRail.tsx +++ b/src/components/library/AlphabetRail.tsx @@ -4,7 +4,9 @@ import { StyleSheet, View, type LayoutChangeEvent } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from 'react-native-reanimated'; import { Text } from '@/components/Text'; -import { colors, radius, spacing } from '@/theme'; +import { radius, spacing } from '@/theme'; +import { createThemedStyles } from '@/theme/themed'; +import { rgbaFromHex } from '@/theme/colorUtils'; import { tickHaptic } from '@/lib/haptics'; import { usePullSearchGestureRef } from '@/components/search/PullSearchGesture'; import { RAIL_LETTERS } from '@/lib/letterIndex'; @@ -29,6 +31,7 @@ interface AlphabetRailProps { * scroll-top never arms the search indicator. */ export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProps) { + const styles = useStyles(); const pullSearchRef = usePullSearchGestureRef(); const [scrubLetter, setScrubLetter] = useState(null); const lastLetter = useSharedValue(''); @@ -128,7 +131,7 @@ export function AlphabetRail({ activeLetters, onJumpToLetter }: AlphabetRailProp ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ wrap: { position: 'absolute', top: 0, @@ -144,7 +147,7 @@ const styles = StyleSheet.create({ width: 16, paddingVertical: RAIL_PAD, alignItems: 'center', - backgroundColor: 'rgba(8, 10, 15, 0.35)', + backgroundColor: rgbaFromHex(colors.bgPrimary, 0.35), borderRadius: radius.pill, }, cell: { @@ -189,4 +192,4 @@ const styles = StyleSheet.create({ lineHeight: 30, color: colors.accentTextStrong, }, -}); +})); diff --git a/src/components/library/ArtistGridItem.tsx b/src/components/library/ArtistGridItem.tsx index 23d9f53..058da40 100644 --- a/src/components/library/ArtistGridItem.tsx +++ b/src/components/library/ArtistGridItem.tsx @@ -7,15 +7,17 @@ import { Image } from 'expo-image'; import { Ionicons } from '@expo/vector-icons'; import { Text } from '@/components/Text'; import { - colors, spacing, - radius + radius, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { artworkUri } from '@/library/artwork'; import type { Artist } from '@/types/library'; /** 2-column grid cell: square art (2x2 album mosaic when available) + counts, matching the album grid. */ export function ArtistGridItem({ artist, onPress }: { artist: Artist; onPress: () => void }) { + const styles = useStyles(); + const colors = useColors(); const useMosaic = artist.artwork_hashes.length >= 4; const hashes = useMosaic ? artist.artwork_hashes.slice(0, 4) : artist.artwork_hashes.slice(0, 1); @@ -58,7 +60,7 @@ export function ArtistGridItem({ artist, onPress }: { artist: Artist; onPress: ( ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ item: { flex: 1, marginBottom: spacing.lg, @@ -87,4 +89,4 @@ const styles = StyleSheet.create({ name: { fontSize: 14, }, -}); +})); diff --git a/src/components/library/ArtistRow.tsx b/src/components/library/ArtistRow.tsx index 3124d04..44a1e97 100644 --- a/src/components/library/ArtistRow.tsx +++ b/src/components/library/ArtistRow.tsx @@ -7,14 +7,16 @@ import { Image } from 'expo-image'; import { Ionicons } from '@expo/vector-icons'; import { Text } from '@/components/Text'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { artworkUri } from '@/library/artwork'; import type { Artist } from '@/types/library'; export function ArtistRow({ artist, onPress }: { artist: Artist; onPress: () => void }) { + const styles = useStyles(); + const colors = useColors(); return ( @@ -41,7 +43,7 @@ export function ArtistRow({ artist, onPress }: { artist: Artist; onPress: () => ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ row: { flexDirection: 'row', alignItems: 'center', @@ -66,4 +68,4 @@ const styles = StyleSheet.create({ meta: { flex: 1, }, -}); +})); diff --git a/src/components/library/CollapsingDetail.tsx b/src/components/library/CollapsingDetail.tsx index c0c85de..0bd845a 100644 --- a/src/components/library/CollapsingDetail.tsx +++ b/src/components/library/CollapsingDetail.tsx @@ -30,10 +30,10 @@ import { } from '@shopify/react-native-skia'; import { Text } from '@/components/Text'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; // Collapsing detail header. An absolute container whose height shrinks with the // scroll and clips its faded content, so the track list (padded to the expanded @@ -97,6 +97,8 @@ export function useDetailCollapse() { } function BottomFade() { + const styles = useStyles(); + const colors = useColors(); const [width, setWidth] = useState(0); return ( setWidth(e.nativeEvent.layout.width)}> @@ -152,6 +154,8 @@ export function CollapsingHeader({ expandedHeight: number; onHeroBlockLayout: (e: LayoutChangeEvent) => void; }) { + const styles = useStyles(); + const colors = useColors(); const insets = useSafeAreaInsets(); const { width: W } = useWindowDimensions(); const dist = expandedHeight - BAR_H; @@ -330,7 +334,7 @@ export function CollapsingHeader({ ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ container: { position: 'absolute', top: 0, @@ -481,4 +485,4 @@ const styles = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, -}); +})); diff --git a/src/components/library/EmptyLibrary.tsx b/src/components/library/EmptyLibrary.tsx index 6a2c574..85fabef 100644 --- a/src/components/library/EmptyLibrary.tsx +++ b/src/components/library/EmptyLibrary.tsx @@ -1,5 +1,4 @@ import { - StyleSheet, View, Pressable } from 'react-native'; @@ -7,12 +6,14 @@ import { Ionicons } from '@expo/vector-icons'; import { useRouter } from 'expo-router'; import { Text } from '@/components/Text'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; export function EmptyLibrary() { + const styles = useStyles(); + const colors = useColors(); const router = useRouter(); return ( @@ -34,7 +35,7 @@ export function EmptyLibrary() { ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ empty: { flex: 1, alignItems: 'center', @@ -63,4 +64,4 @@ const styles = StyleSheet.create({ color: colors.bgPrimary, fontWeight: '600', }, -}); +})); diff --git a/src/components/library/FoldersView.tsx b/src/components/library/FoldersView.tsx index fba0e1e..9bb715c 100644 --- a/src/components/library/FoldersView.tsx +++ b/src/components/library/FoldersView.tsx @@ -32,10 +32,10 @@ import { } from '@/library/folderTree'; import { formatDuration } from '@/lib/format'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { useLibraryStore } from '@/stores/libraryStore'; import { usePlayerStore } from '@/stores/playerStore'; import type { DbTrack } from '@/types/library'; @@ -58,6 +58,8 @@ function FolderRow({ onShuffle: (node: FolderTreeNode) => void; onOpenActions: (node: FolderTreeNode) => void; }) { + const styles = useStyles(); + const colors = useColors(); const { node, depth, isExpanded } = row; const play = (event: GestureResponderEvent) => { @@ -132,6 +134,8 @@ function FolderTrackRow({ active: boolean; onOpenActions: () => void; }) { + const styles = useStyles(); + const colors = useColors(); const index = row.folderTracks.findIndex((track) => track.path === row.track.path); const playFolderTrack = () => { @@ -180,6 +184,8 @@ function FolderTrackRow({ } export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps) { + const styles = useStyles(); + const colors = useColors(); const folders = useLibraryStore((s) => s.folders); const tracks = useLibraryStore((s) => s.tracks); const currentPath = usePlayerStore((s) => s.currentTrack?.path); @@ -306,7 +312,7 @@ export function FoldersView({ onScroll, scrollEventThrottle }: FoldersViewProps) ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ listContent: { paddingBottom: spacing.xxl, }, @@ -398,4 +404,4 @@ const styles = StyleSheet.create({ textAlign: 'center', maxWidth: 260, }, -}); +})); diff --git a/src/components/library/PlaylistRow.tsx b/src/components/library/PlaylistRow.tsx index 1191d42..f772e1b 100644 --- a/src/components/library/PlaylistRow.tsx +++ b/src/components/library/PlaylistRow.tsx @@ -7,10 +7,10 @@ import { Image } from 'expo-image'; import { Ionicons } from '@expo/vector-icons'; import { Text } from '@/components/Text'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { artworkUri } from '@/library/artwork'; export function PlaylistRow({ @@ -37,6 +37,8 @@ export function PlaylistRow({ onPress: () => void; onLongPress?: () => void; }) { + const styles = useStyles(); + const colors = useColors(); return ( ({ row: { flexDirection: 'row', alignItems: 'center', @@ -120,4 +122,4 @@ const styles = StyleSheet.create({ title: { flexShrink: 1, }, -}); +})); diff --git a/src/components/library/PlaylistsView.tsx b/src/components/library/PlaylistsView.tsx index f74b4d7..a4f60c1 100644 --- a/src/components/library/PlaylistsView.tsx +++ b/src/components/library/PlaylistsView.tsx @@ -20,10 +20,10 @@ import { TextPromptModal } from '@/components/sheets/TextPromptModal'; import { PlaylistRow } from '@/components/library/PlaylistRow'; import { PullSearchScrollView } from '@/components/search/PullSearchGesture'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { usePlaylistStore } from '@/stores/playlistStore'; import type { Playlist } from '@/types/playlist'; @@ -46,6 +46,8 @@ export function PlaylistsView({ onScroll?: (event: NativeSyntheticEvent) => void; scrollEventThrottle?: number; }) { + const styles = useStyles(); + const colors = useColors(); const router = useRouter(); const playlists = usePlaylistStore((s) => s.playlists); const favoriteCount = usePlaylistStore((s) => s.favoriteTracks.length); @@ -273,7 +275,7 @@ export function PlaylistsView({ ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ container: { flex: 1, }, @@ -308,4 +310,4 @@ const styles = StyleSheet.create({ borderRadius: radius.pill, backgroundColor: colors.accentGlow, }, -}); +})); diff --git a/src/components/library/ScanProgress.tsx b/src/components/library/ScanProgress.tsx index 53ca2c9..6c6a7dc 100644 --- a/src/components/library/ScanProgress.tsx +++ b/src/components/library/ScanProgress.tsx @@ -1,10 +1,13 @@ -import { StyleSheet, View } from 'react-native'; +import { View } from 'react-native'; import { Text } from '@/components/Text'; -import { colors, spacing } from '@/theme'; +import { spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { useLibraryStore } from '@/stores/libraryStore'; /** Thin accent bar + caption shown under the library header while scanning. */ export function ScanProgress() { + const styles = useStyles(); + const colors = useColors(); const isScanning = useLibraryStore((s) => s.isScanning); const progress = useLibraryStore((s) => s.scanProgress); @@ -42,7 +45,7 @@ export function ScanProgress() { ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ container: { gap: spacing.xs, marginBottom: spacing.md, @@ -61,4 +64,4 @@ const styles = StyleSheet.create({ width: '100%', opacity: 0.35, }, -}); +})); diff --git a/src/components/library/SelectionActionBar.tsx b/src/components/library/SelectionActionBar.tsx index 771debb..358a969 100644 --- a/src/components/library/SelectionActionBar.tsx +++ b/src/components/library/SelectionActionBar.tsx @@ -5,7 +5,8 @@ import { } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Text } from '@/components/Text'; -import { colors, spacing } from '@/theme'; +import { spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; interface SelectionActionBarProps { count: number; @@ -21,6 +22,7 @@ export function SelectionActionBar({ onAddToQueue, onAddToPlaylist, }: SelectionActionBarProps) { + const styles = useStyles(); const disabled = count === 0; return ( @@ -62,6 +64,8 @@ function BarButton({ disabled: boolean; onPress: () => void; }) { + const styles = useStyles(); + const colors = useColors(); return ( [ @@ -82,7 +86,7 @@ function BarButton({ ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ bar: { flexDirection: 'row', borderTopColor: colors.glassBorder, @@ -106,4 +110,4 @@ const styles = StyleSheet.create({ label: { color: colors.accent, }, -}); +})); diff --git a/src/components/library/TrackRow.tsx b/src/components/library/TrackRow.tsx index b77844a..89dacdd 100644 --- a/src/components/library/TrackRow.tsx +++ b/src/components/library/TrackRow.tsx @@ -13,10 +13,10 @@ import { FormatBadges } from '@/components/FormatBadge'; import { RemoteSourceBadge } from '@/components/RemoteSourceBadge'; import { SwipeableRow } from '@/components/SwipeableRow'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { formatDuration } from '@/lib/format'; import { trackArtworkThumbSource } from '@/library/artwork'; import { dbTrackToTrack } from '@/library/trackAdapter'; @@ -58,6 +58,8 @@ export function TrackRow({ selected?: boolean; onToggleSelect?: () => void; }) { + const styles = useStyles(); + const colors = useColors(); // Key the artwork by hash (local) or identity path (remote) so the error fallback // and FlashList recycling work for both. const artKey = track.source_type !== 'local' ? track.path : track.artwork_hash; @@ -174,7 +176,7 @@ export function TrackRow({ ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ row: { flexDirection: 'row', alignItems: 'center', @@ -258,4 +260,4 @@ const styles = StyleSheet.create({ actionsButtonPressed: { backgroundColor: colors.glassBg, }, -}); +})); diff --git a/src/components/queue/QueueTray.tsx b/src/components/queue/QueueTray.tsx index 7baf8eb..0271c2d 100644 --- a/src/components/queue/QueueTray.tsx +++ b/src/components/queue/QueueTray.tsx @@ -39,10 +39,10 @@ import { Text } from '@/components/Text'; import { AstraLogo } from '@/components/AstraLogo'; import { SwipeableRow } from '@/components/SwipeableRow'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { motion } from '@/theme/motion'; import { dragArmHaptic, tickHaptic } from '@/lib/haptics'; import { useQueueStore } from '@/stores/queueStore'; @@ -139,6 +139,8 @@ interface QueueTrayProps { } export function QueueTray({ onClose }: QueueTrayProps) { + const styles = useStyles(); + const colors = useColors(); const insets = useSafeAreaInsets(); const { height: windowHeight } = useWindowDimensions(); const snapPoints = useMemo(() => ['58%', '100%'], []); @@ -530,7 +532,7 @@ export function QueueTray({ onClose }: QueueTrayProps) { )} ), - [isLoadingQueue] + [isLoadingQueue, colors, styles] ); const selectedCount = visibleSelectedKeys.size; @@ -645,6 +647,7 @@ export function QueueTray({ onClose }: QueueTrayProps) { } const Artwork = memo(function Artwork({ uri, title }: { uri?: string; title?: string }) { + const styles = useStyles(); return ( {uri ? ( @@ -711,6 +714,8 @@ const QueueRow = memo(function QueueRow({ onRemoveIndex, onToggleSelectKey, }: QueueRowProps) { + const styles = useStyles(); + const colors = useColors(); const entryKey = entry.key; const title = trackTitle(entry.track); const artist = trackArtist(entry.track); @@ -791,13 +796,18 @@ const QueueRow = memo(function QueueRow({ }; }); + // Locals so the worklet captures plain strings: a theme switch re-renders, + // the captured values change, and Reanimated rebuilds the worklet. + const dragSurface = colors.bgTertiary; + const selectedSurface = colors.glassHighlight; + const restSurface = colors.bgSecondary; const rowSurfaceStyle = useAnimatedStyle(() => ({ backgroundColor: dActive.value && dKey.value === entryKey - ? colors.bgTertiary + ? dragSurface : selected - ? colors.glassHighlight - : colors.bgSecondary, + ? selectedSurface + : restSurface, })); const rowContent = ( @@ -894,7 +904,7 @@ const QueueRow = memo(function QueueRow({ ); }); -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ sheetBg: { backgroundColor: colors.bgSecondary, borderTopLeftRadius: radius.lg, @@ -1058,6 +1068,6 @@ const styles = StyleSheet.create({ actionTextDestructive: { color: colors.warning, }, -}); +})); export default QueueTray; diff --git a/src/components/queue/RemoteQueueSheet.tsx b/src/components/queue/RemoteQueueSheet.tsx index 9db580d..67ca072 100644 --- a/src/components/queue/RemoteQueueSheet.tsx +++ b/src/components/queue/RemoteQueueSheet.tsx @@ -6,7 +6,7 @@ // this app's screen setups (see queue-tray-sheet gotcha). import { useCallback, useEffect, useMemo } from 'react'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { Pressable, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import BottomSheet, { BottomSheetBackdrop, @@ -15,7 +15,8 @@ import BottomSheet, { } from '@gorhom/bottom-sheet'; import { FlashList, type ListRenderItemInfo } from '@shopify/flash-list'; import { Text } from '@/components/Text'; -import { colors, radius, spacing } from '@/theme'; +import { radius, spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { formatDuration } from '@/lib/format'; import { useDesktopRemoteStore } from '@/stores/desktopRemoteStore'; import type { DesktopRemoteQueueItem } from '@/types/desktopRemote'; @@ -25,6 +26,8 @@ interface RemoteQueueSheetProps { } export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) { + const styles = useStyles(); + const colors = useColors(); const queue = useDesktopRemoteStore((s) => s.queue); const snapPoints = useMemo(() => ['58%', '100%'], []); const renderFlashListScrollComponent = useBottomSheetScrollableCreator(); @@ -92,7 +95,7 @@ export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) { ) : null} ), - [playItem] + [playItem, colors, styles] ); return ( @@ -131,7 +134,7 @@ export function RemoteQueueSheet({ onClose }: RemoteQueueSheetProps) { ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ sheetBg: { backgroundColor: colors.bgSecondary, borderRadius: radius.lg, @@ -170,4 +173,4 @@ const styles = StyleSheet.create({ paddingVertical: spacing.xl, alignItems: 'center', }, -}); +})); diff --git a/src/components/search/PullSearchGesture.tsx b/src/components/search/PullSearchGesture.tsx index fc94c7d..f5dcdc7 100644 --- a/src/components/search/PullSearchGesture.tsx +++ b/src/components/search/PullSearchGesture.tsx @@ -33,10 +33,10 @@ import { } from 'react-native-reanimated'; import { Text } from '@/components/Text'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { commitHaptic, tickHaptic } from '@/lib/haptics'; const OPEN_THRESHOLD = 76; @@ -133,6 +133,8 @@ export function PullSearchGesture({ atTop: boolean; onOpen: () => void; }) { + const styles = useStyles(); + const colors = useColors(); const [pull, setPull] = useState(0); const [armed, setArmed] = useState(false); const [dragging, setDragging] = useState(false); @@ -269,7 +271,7 @@ export function PullSearchGesture({ ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ root: { flex: 1, }, @@ -293,4 +295,4 @@ const styles = StyleSheet.create({ shadowOffset: { width: 0, height: 8 }, elevation: 10, }, -}); +})); diff --git a/src/components/search/QuickSearchOverlay.tsx b/src/components/search/QuickSearchOverlay.tsx index d08345b..34bc1c8 100644 --- a/src/components/search/QuickSearchOverlay.tsx +++ b/src/components/search/QuickSearchOverlay.tsx @@ -22,12 +22,13 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Text } from '@/components/Text'; import { AstraLogo } from '@/components/AstraLogo'; import { - colors, fonts, fontSize, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; +import { rgbaFromHex } from '@/theme/colorUtils'; import { enqueueTop, playTracks } from '@/audio/playbackController'; import { dbTrackToTrack } from '@/library/trackAdapter'; import { @@ -336,6 +337,7 @@ function resultIcon(result: SearchResult): IconName { } function HighlightedLabel({ text, query }: { text: string; query: string }) { + const styles = useStyles(); const normalizedText = text.toLocaleLowerCase(); const normalizedQuery = query.toLocaleLowerCase().trim(); @@ -390,6 +392,8 @@ function HighlightedLabel({ text, query }: { text: string; query: string }) { } function ResultThumb({ result }: { result: SearchResult }) { + const styles = useStyles(); + const colors = useColors(); const uri = result.kind === 'track' ? trackArtworkThumbSource(result.track) @@ -431,6 +435,8 @@ function ResultRow({ onPress: () => void; onQueueTrack: (track: DbTrack) => void; }) { + const styles = useStyles(); + const colors = useColors(); const isTrack = result.kind === 'track'; const isShowMode = result.kind === 'show-all' || result.kind === 'show-top'; @@ -481,6 +487,8 @@ function QuickSearchPanel({ initialQuery: string; onClose: () => void; }) { + const styles = useStyles(); + const colors = useColors(); const router = useRouter(); const insets = useSafeAreaInsets(); const { height } = useWindowDimensions(); @@ -978,6 +986,7 @@ function QuickSearchPanel({ } export function QuickSearchOverlay() { + const styles = useStyles(); const isOpen = useSearchStore((s) => s.isQuickSearchOpen); const initialQuery = useSearchStore((s) => s.initialQuery); const openVersion = useSearchStore((s) => s.openVersion); @@ -1006,11 +1015,11 @@ export function QuickSearchOverlay() { ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ modalRoot: { flex: 1, alignItems: 'center', - backgroundColor: 'rgba(2, 4, 8, 0.66)', + backgroundColor: colors.backdrop, paddingHorizontal: spacing.md, }, panel: { @@ -1077,7 +1086,7 @@ const styles = StyleSheet.create({ paddingVertical: spacing.sm, }, resultRowActive: { - backgroundColor: 'rgba(91, 138, 255, 0.12)', + backgroundColor: rgbaFromHex(colors.accent, 0.12), }, showModeRow: { marginTop: spacing.sm, @@ -1119,7 +1128,7 @@ const styles = StyleSheet.create({ }, highlight: { color: colors.accentTextStrong, - backgroundColor: 'rgba(91, 138, 255, 0.22)', + backgroundColor: rgbaFromHex(colors.accent, 0.22), borderRadius: 2, }, queueButton: { @@ -1143,4 +1152,4 @@ const styles = StyleSheet.create({ emptyText: { textAlign: 'center', }, -}); +})); diff --git a/src/components/settings/AccentSwatchRow.tsx b/src/components/settings/AccentSwatchRow.tsx new file mode 100644 index 0000000..1530948 --- /dev/null +++ b/src/components/settings/AccentSwatchRow.tsx @@ -0,0 +1,74 @@ +import { Pressable, StyleSheet, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { Text } from '@/components/Text'; +import { spacing } from '@/theme'; +import { ACCENTS, ACCENT_IDS, type AccentId } from '@/theme/accents'; +import { createThemedStyles, useColors } from '@/theme/themed'; + +const SWATCH_SIZE = 36; + +interface AccentSwatchRowProps { + value: AccentId; + onChange: (id: AccentId) => void; +} + +/** Circular accent swatches; the selected one gets a ring + checkmark. */ +export function AccentSwatchRow({ value, onChange }: AccentSwatchRowProps) { + const styles = useStyles(); + const colors = useColors(); + return ( + + + {ACCENT_IDS.map((id) => { + const selected = id === value; + return ( + onChange(id)} + accessibilityRole="radio" + accessibilityState={{ selected }} + accessibilityLabel={`${ACCENTS[id].label} accent`} + style={[ + styles.swatch, + { backgroundColor: ACCENTS[id].base }, + selected && styles.swatchSelected, + ]} + hitSlop={4} + > + {selected ? ( + + ) : null} + + ); + })} + + + Accent · {ACCENTS[value].label} + + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + wrap: { + gap: spacing.sm, + }, + row: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.md, + }, + swatch: { + width: SWATCH_SIZE, + height: SWATCH_SIZE, + borderRadius: SWATCH_SIZE / 2, + alignItems: 'center', + justifyContent: 'center', + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + }, + swatchSelected: { + borderWidth: 2, + borderColor: colors.textPrimary, + }, +})); diff --git a/src/components/sheets/ActionSheet.tsx b/src/components/sheets/ActionSheet.tsx index 505216f..a691539 100644 --- a/src/components/sheets/ActionSheet.tsx +++ b/src/components/sheets/ActionSheet.tsx @@ -8,10 +8,10 @@ import { Ionicons } from '@expo/vector-icons'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { Text } from '@/components/Text'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; export interface ActionSheetItem { key: string; @@ -37,6 +37,8 @@ export function ActionSheet({ items: ActionSheetItem[]; onClose: () => void; }) { + const styles = useStyles(); + const colors = useColors(); const insets = useSafeAreaInsets(); return ( @@ -88,10 +90,10 @@ export function ActionSheet({ ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ backdrop: { flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.55)', + backgroundColor: colors.backdrop, justifyContent: 'flex-end', }, card: { @@ -126,4 +128,4 @@ const styles = StyleSheet.create({ itemLabel: { flex: 1, }, -}); +})); diff --git a/src/components/sheets/AppSheet.tsx b/src/components/sheets/AppSheet.tsx index 2115d9b..9e593c7 100644 --- a/src/components/sheets/AppSheet.tsx +++ b/src/components/sheets/AppSheet.tsx @@ -1,6 +1,5 @@ import { useCallback, type ReactNode } from 'react'; import { - StyleSheet, Pressable, View } from 'react-native'; @@ -13,12 +12,13 @@ import BottomSheet, { } from '@gorhom/bottom-sheet'; import { Text } from '@/components/Text'; import { - colors, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; export function AppSheet({ onClose, children }: { onClose: () => void; children: ReactNode }) { + const styles = useStyles(); const insets = useSafeAreaInsets(); const renderBackdrop = useCallback( (props: BottomSheetBackdropProps) => ( @@ -51,6 +51,7 @@ export function AppSheet({ onClose, children }: { onClose: () => void; children: } export function AppSheetSection({ label }: { label: string }) { + const styles = useStyles(); return ( {label} @@ -59,6 +60,8 @@ export function AppSheetSection({ label }: { label: string }) { } export function AppSheetTitle({ title, subtitle }: { title: string; subtitle?: string }) { + const styles = useStyles(); + const colors = useColors(); return ( @@ -90,6 +93,8 @@ export function AppSheetItem({ onPress, trailing, }: AppSheetItemProps) { + const styles = useStyles(); + const colors = useColors(); const tint = destructive ? colors.warning : selected ? colors.accentTextStrong : colors.textPrimary; return ( @@ -112,7 +117,7 @@ export function AppSheetItem({ ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ sheetBg: { backgroundColor: colors.bgSecondary, borderTopLeftRadius: radius.lg, @@ -157,6 +162,6 @@ const styles = StyleSheet.create({ itemLabel: { flex: 1, }, -}); +})); export default AppSheet; diff --git a/src/components/sheets/PlaylistPickerSheet.tsx b/src/components/sheets/PlaylistPickerSheet.tsx index 8df61e8..361593f 100644 --- a/src/components/sheets/PlaylistPickerSheet.tsx +++ b/src/components/sheets/PlaylistPickerSheet.tsx @@ -12,11 +12,11 @@ import { AppSheetTitle } from '@/components/sheets/AppSheet'; import { - colors, fonts, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { usePlaylistStore } from '@/stores/playlistStore'; import type { DbTrack } from '@/types/library'; @@ -40,6 +40,8 @@ export function PlaylistPickerSheet({ onBackToMenu, onAdded, }: PlaylistPickerSheetProps) { + const styles = useStyles(); + const colors = useColors(); const [step, setStep] = useState<'pick' | 'create'>('pick'); const [playlistName, setPlaylistName] = useState(''); const playlists = usePlaylistStore((s) => s.playlists); @@ -123,7 +125,7 @@ export function PlaylistPickerSheet({ ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ empty: { paddingVertical: spacing.sm, }, @@ -161,4 +163,4 @@ const styles = StyleSheet.create({ createDisabled: { opacity: 0.4, }, -}); +})); diff --git a/src/components/sheets/TextPromptModal.tsx b/src/components/sheets/TextPromptModal.tsx index 1736299..75fb864 100644 --- a/src/components/sheets/TextPromptModal.tsx +++ b/src/components/sheets/TextPromptModal.tsx @@ -8,12 +8,12 @@ import { } from 'react-native'; import { Text } from '@/components/Text'; import { - colors, fonts, fontSize, radius, - spacing + spacing, } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; interface TextPromptModalProps { visible: boolean; @@ -40,6 +40,8 @@ function TextPromptModalInner({ onSubmit, onClose, }: TextPromptModalProps) { + const styles = useStyles(); + const colors = useColors(); const [value, setValue] = useState(initialValue); const trimmed = value.trim(); @@ -89,10 +91,10 @@ function TextPromptModalInner({ ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ backdrop: { flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.55)', + backgroundColor: colors.backdrop, justifyContent: 'center', padding: spacing.xl, }, @@ -130,4 +132,4 @@ const styles = StyleSheet.create({ actionDisabled: { opacity: 0.4, }, -}); +})); diff --git a/src/components/sync/SyncConflictDetails.tsx b/src/components/sync/SyncConflictDetails.tsx index 77afbe9..d321563 100644 --- a/src/components/sync/SyncConflictDetails.tsx +++ b/src/components/sync/SyncConflictDetails.tsx @@ -1,7 +1,8 @@ import { StyleSheet, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { Text } from '@/components/Text'; -import { colors, radius, spacing } from '@/theme'; +import { radius, spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { formatRelativeTime } from '@/lib/format'; import { buildSyncPlaylistEntryDiff, @@ -77,6 +78,8 @@ function TrackDiffRow({ side: 'desktop' | 'phone'; previewResolution: DesktopSyncConflictResolution | null; }) { + const styles = useStyles(); + const colors = useColors(); const subtitle = [row.artist, row.album].filter((part) => part.trim().length > 0).join(' · '); const previewLabel = previewStatusLabel(row, side, previewResolution); const moveLabel = row.status === 'moved' && !previewLabel ? moveStatusLabel(row, side) : null; @@ -125,6 +128,8 @@ function SideTrackList({ previewResolution: DesktopSyncConflictResolution | null; maxRows: number; }) { + const styles = useStyles(); + const colors = useColors(); const rows = [...sideOnlyRows, ...movedRows].slice(0, maxRows); const hiddenCount = Math.max(0, sideOnlyRows.length + movedRows.length - rows.length); const sideName = side === 'desktop' ? 'desktop' : 'phone'; @@ -177,6 +182,8 @@ export function SyncConflictDetails({ maxRows?: number; previewResolution?: DesktopSyncConflictResolution | null; }) { + const styles = useStyles(); + const colors = useColors(); const desktop = syncPlaylistToSnapshot(conflict.remote); const phone = syncPlaylistToSnapshot(conflict.local); const isNormal = desktop.kind === 'normal' && phone.kind === 'normal'; @@ -266,7 +273,7 @@ export function SyncConflictDetails({ ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ container: { gap: spacing.sm, }, @@ -344,4 +351,4 @@ const styles = StyleSheet.create({ padding: spacing.sm, gap: spacing.xs, }, -}); +})); diff --git a/src/components/sync/SyncConflictPrompt.tsx b/src/components/sync/SyncConflictPrompt.tsx index a9c53b8..ed44766 100644 --- a/src/components/sync/SyncConflictPrompt.tsx +++ b/src/components/sync/SyncConflictPrompt.tsx @@ -11,7 +11,8 @@ import { Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { router, usePathname } from 'expo-router'; import { Text } from '@/components/Text'; -import { colors, radius, spacing } from '@/theme'; +import { radius, spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; import { formatRelativeTime } from '@/lib/format'; import { buildSyncConflictResolutionPreview, @@ -62,6 +63,8 @@ function diffLine(desktop: SyncPlaylistSnapshot, phone: SyncPlaylistSnapshot): s } export function SyncConflictPrompt() { + const styles = useStyles(); + const colors = useColors(); const conflicts = useDesktopSyncStore((s) => s.conflicts); const status = useDesktopSyncStore((s) => s.status); const promptVisible = useDesktopSyncStore((s) => s.conflictPromptVisible); @@ -209,10 +212,10 @@ export function SyncConflictPrompt() { ); } -const styles = StyleSheet.create({ +const useStyles = createThemedStyles((colors) => ({ backdrop: { flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.55)', + backgroundColor: colors.backdrop, alignItems: 'center', justifyContent: 'center', padding: spacing.xl, @@ -311,4 +314,4 @@ const styles = StyleSheet.create({ disabled: { opacity: 0.5, }, -}); +})); diff --git a/src/stores/themeStore.ts b/src/stores/themeStore.ts new file mode 100644 index 0000000..1a70bdc --- /dev/null +++ b/src/stores/themeStore.ts @@ -0,0 +1,130 @@ +import { Appearance } from 'react-native'; +import { create } from 'zustand'; +import { AstraSystemColors, type SystemPalette } from '../../modules/astra-system-colors'; +import { openLibraryDb } from '@/db/database'; +import { getSetting, setSetting } from '@/db/queries'; +import { parseAccentId, DEFAULT_ACCENT, type AccentId } from '@/theme/accents'; +import { + parseBaseTheme, + parsePreferredDark, + resolveTheme, + type AppTheme, + type BaseThemeId, + type PreferredDark, +} from '@/theme/resolve'; + +/** + * Theme preferences + the resolved palette. SQLite (settings table) is the + * source of truth, mirrored in memory (same shape as settingsStore). Every + * change recomputes `theme` exactly once — one new object identity per switch + * is what invalidates the per-palette style caches in `createThemedStyles`. + */ +const BASE_THEME_KEY = 'theme_base'; +const PREFERRED_DARK_KEY = 'theme_preferred_dark'; +const ACCENT_KEY = 'theme_accent'; + +type SystemScheme = 'light' | 'dark'; + +function currentSystemScheme(): SystemScheme { + return Appearance.getColorScheme() === 'light' ? 'light' : 'dark'; +} + +// Monet ramps are an input to resolution, not reactive state — nothing renders +// them directly, they only matter through the recomputed `theme`. +let materialYouRamps: SystemPalette | null = null; + +interface ResolutionInputs { + baseTheme: BaseThemeId; + preferredDark: PreferredDark; + accentId: AccentId; + systemScheme: SystemScheme; +} + +function recompute(inputs: ResolutionInputs): AppTheme { + return resolveTheme({ ...inputs, materialYouRamps }); +} + +interface ThemeStore extends ResolutionInputs { + materialYouAvailable: boolean; + theme: AppTheme; + loaded: boolean; + load: () => Promise; + setBaseTheme: (id: BaseThemeId) => Promise; + setPreferredDark: (id: PreferredDark) => Promise; + setAccent: (id: AccentId) => Promise; + /** Re-reads OS scheme + monet ramps; no-op set when nothing changed. */ + refreshSystemInputs: () => void; +} + +const DEFAULT_INPUTS: ResolutionInputs = { + baseTheme: 'midnight', + preferredDark: 'midnight', + accentId: DEFAULT_ACCENT, + systemScheme: currentSystemScheme(), +}; + +export const useThemeStore = create((set, get) => ({ + ...DEFAULT_INPUTS, + materialYouAvailable: AstraSystemColors.isAvailable(), + theme: recompute(DEFAULT_INPUTS), + loaded: false, + + load: async () => { + if (get().loaded) return; + const db = await openLibraryDb(); + const [base, dark, accent] = await Promise.all([ + getSetting(db, BASE_THEME_KEY), + getSetting(db, PREFERRED_DARK_KEY), + getSetting(db, ACCENT_KEY), + ]); + if (get().materialYouAvailable) { + materialYouRamps = AstraSystemColors.getSystemPalette(); + } + const inputs: ResolutionInputs = { + baseTheme: parseBaseTheme(base), + preferredDark: parsePreferredDark(dark), + accentId: parseAccentId(accent), + systemScheme: currentSystemScheme(), + }; + set({ ...inputs, theme: recompute(inputs), loaded: true }); + }, + + setBaseTheme: async (id) => { + if (get().baseTheme === id) return; + const inputs: ResolutionInputs = { ...get(), baseTheme: id }; + set({ baseTheme: id, theme: recompute(inputs) }); + const db = await openLibraryDb(); + await setSetting(db, BASE_THEME_KEY, id); + }, + + setPreferredDark: async (id) => { + if (get().preferredDark === id) return; + const inputs: ResolutionInputs = { ...get(), preferredDark: id }; + set({ preferredDark: id, theme: recompute(inputs) }); + const db = await openLibraryDb(); + await setSetting(db, PREFERRED_DARK_KEY, id); + }, + + setAccent: async (id) => { + if (get().accentId === id) return; + const inputs: ResolutionInputs = { ...get(), accentId: id }; + set({ accentId: id, theme: recompute(inputs) }); + const db = await openLibraryDb(); + await setSetting(db, ACCENT_KEY, id); + }, + + refreshSystemInputs: () => { + const scheme = currentSystemScheme(); + const ramps = get().materialYouAvailable ? AstraSystemColors.getSystemPalette() : null; + const rampsChanged = JSON.stringify(ramps) !== JSON.stringify(materialYouRamps); + if (scheme === get().systemScheme && !rampsChanged) return; + materialYouRamps = ramps; + const inputs: ResolutionInputs = { ...get(), systemScheme: scheme }; + set({ systemScheme: scheme, theme: recompute(inputs) }); + }, +})); + +// OS dark/light toggles while the app runs. Wallpaper (monet) changes can't +// happen while Astra is foregrounded — those are covered by the AppState +// 'active' refresh wired in _layout.tsx. +Appearance.addChangeListener(() => useThemeStore.getState().refreshSystemInputs()); diff --git a/src/theme/accents.ts b/src/theme/accents.ts new file mode 100644 index 0000000..c975cb4 --- /dev/null +++ b/src/theme/accents.ts @@ -0,0 +1,77 @@ +import { hexToHsl, hslToHex, rgbaFromHex } from './colorUtils'; + +/** + * Named accent choices. Each is a single base hex; the full 5-token ramp is + * derived in `deriveAccent`. Base hexes are picked at similar perceived + * brightness so the derived ramps land consistently. `overrides` is the + * escape hatch if a hue derives badly (HSL lightness is perceptually off for + * yellows — amber is the likely candidate). + */ +export interface AccentDef { + label: string; + base: string; + overridesDark?: Partial; + overridesLight?: Partial; +} + +export const ACCENTS = { + // The original default. Derivation lands within ±2/255 of the hand-tuned + // ramp; overrides pin the legacy values exactly so Midnight is unchanged. + indigo: { + label: 'Indigo', + base: '#5b8aff', + overridesDark: { + accentHover: '#82a6ff', + accentText: '#a9c0ff', + accentTextStrong: '#d6e2ff', + }, + }, + cyan: { label: 'Astra Cyan', base: '#00b3ff' }, // logoMain + violet: { label: 'Violet', base: '#9d7bff' }, + magenta: { label: 'Magenta', base: '#ff6b9d' }, + emerald: { label: 'Emerald', base: '#2dd4a0' }, + amber: { label: 'Amber', base: '#ffb454' }, + crimson: { label: 'Crimson', base: '#ff5c5c' }, +} as const satisfies Record; + +export type AccentId = keyof typeof ACCENTS; +export const DEFAULT_ACCENT: AccentId = 'indigo'; +export const ACCENT_IDS = Object.keys(ACCENTS) as AccentId[]; + +export function parseAccentId(value: string | null): AccentId { + return value !== null && value in ACCENTS ? (value as AccentId) : DEFAULT_ACCENT; +} + +export interface AccentTokens { + accent: string; + accentHover: string; + accentGlow: string; + accentText: string; + accentTextStrong: string; +} + +/** + * Reproduces the hand-tuned indigo ramp (base hsl(223,100%,68%) → hover L+8, + * text L=83, textStrong L=92, glow rgba(base, .30)). Light themes ramp DOWN + * (text darker than base) because accentText sits on light surfaces. + */ +export function deriveAccent(id: AccentId, isDark: boolean): AccentTokens { + const def: AccentDef = ACCENTS[id]; + const { h, s, l } = hexToHsl(def.base); + const derived: AccentTokens = isDark + ? { + accent: def.base, + accentHover: hslToHex(h, s, Math.min(l + 8, 96)), + accentGlow: rgbaFromHex(def.base, 0.3), + accentText: hslToHex(h, s, 83), + accentTextStrong: hslToHex(h, s, 92), + } + : { + accent: def.base, + accentHover: hslToHex(h, s, Math.max(l - 8, 20)), + accentGlow: rgbaFromHex(def.base, 0.25), + accentText: hslToHex(h, s, 36), + accentTextStrong: hslToHex(h, s, 26), + }; + return { ...derived, ...(isDark ? def.overridesDark : def.overridesLight) }; +} diff --git a/src/theme/colorUtils.ts b/src/theme/colorUtils.ts new file mode 100644 index 0000000..5ae2c99 --- /dev/null +++ b/src/theme/colorUtils.ts @@ -0,0 +1,79 @@ +/** + * Tiny color math helpers for palette derivation. No dependencies. + * All hex I/O is 6-digit `#rrggbb` (the solid-token invariant — see palettes.ts). + */ + +export function hexToRgb(hex: string): { r: number; g: number; b: number } { + return { + r: parseInt(hex.slice(1, 3), 16), + g: parseInt(hex.slice(3, 5), 16), + b: parseInt(hex.slice(5, 7), 16), + }; +} + +function channelToHex(value: number): string { + return Math.round(Math.min(255, Math.max(0, value))) + .toString(16) + .padStart(2, '0'); +} + +export function rgbToHex(r: number, g: number, b: number): string { + return `#${channelToHex(r)}${channelToHex(g)}${channelToHex(b)}`; +} + +/** h in degrees [0,360), s/l in percent [0,100]. */ +export function hexToHsl(hex: string): { h: number; s: number; l: number } { + const { r, g, b } = hexToRgb(hex); + const rn = r / 255; + const gn = g / 255; + const bn = b / 255; + const max = Math.max(rn, gn, bn); + const min = Math.min(rn, gn, bn); + const l = (max + min) / 2; + if (max === min) return { h: 0, s: 0, l: l * 100 }; + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h: number; + if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; + else if (max === gn) h = ((bn - rn) / d + 2) / 6; + else h = ((rn - gn) / d + 4) / 6; + return { h: h * 360, s: s * 100, l: l * 100 }; +} + +export function hslToHex(h: number, s: number, l: number): string { + const sn = Math.min(100, Math.max(0, s)) / 100; + const ln = Math.min(100, Math.max(0, l)) / 100; + const hn = (((h % 360) + 360) % 360) / 360; + if (sn === 0) { + const v = ln * 255; + return rgbToHex(v, v, v); + } + const q = ln < 0.5 ? ln * (1 + sn) : ln + sn - ln * sn; + const p = 2 * ln - q; + const hue = (t: number) => { + let tn = t; + if (tn < 0) tn += 1; + if (tn > 1) tn -= 1; + if (tn < 1 / 6) return p + (q - p) * 6 * tn; + if (tn < 1 / 2) return q; + if (tn < 2 / 3) return p + (q - p) * (2 / 3 - tn) * 6; + return p; + }; + return rgbToHex(hue(hn + 1 / 3) * 255, hue(hn) * 255, hue(hn - 1 / 3) * 255); +} + +export function rgbaFromHex(hex: string, alpha: number): string { + const { r, g, b } = hexToRgb(hex); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +/** Linear RGB mix of two hex colors: t=0 → a, t=1 → b. */ +export function mixHex(a: string, b: string, t: number): string { + const ca = hexToRgb(a); + const cb = hexToRgb(b); + return rgbToHex( + ca.r + (cb.r - ca.r) * t, + ca.g + (cb.g - ca.g) * t, + ca.b + (cb.b - ca.b) * t, + ); +} diff --git a/src/theme/colors.ts b/src/theme/colors.ts deleted file mode 100644 index 3a9ba26..0000000 --- a/src/theme/colors.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Astra color tokens. Dark-only. M3 redesign shifted the palette from - * cyan-on-black toward a softer indigo-on-navy "mobile-first" language; these - * tokens are the single source of truth, so a future theming pass can swap them. - */ -export const colors = { - // Base backgrounds (navy) - bgPrimary: '#080a0f', - bgSecondary: '#0c0f18', - bgTertiary: '#11162a', - - // Glass / surface overlays (subtle blue-tinted alphas) - glassBg: 'rgba(124, 146, 196, 0.05)', - glassBorder: 'rgba(124, 146, 196, 0.16)', - glassHighlight: 'rgba(140, 162, 208, 0.08)', - - // Text (blue-tinted neutrals) - textPrimary: '#e2e8f4', - textSecondary: '#8a98b8', - textTertiary: '#52607f', - - // Warning amber (desktop .graph-meta-chip-warning) - warning: '#f3d27d', - - // Indigo accent - accent: '#5b8aff', - accentHover: '#82a6ff', - accentGlow: 'rgba(91, 138, 255, 0.3)', - accentText: '#a9c0ff', - accentTextStrong: '#d6e2ff', - - // Astra mark fills (hsl(198 …) from the desktop logo) - logoMain: '#00b3ff', // hsl(198 100% 50%) - logoShadow: '#152932', // hsl(198 40% 14%) - logoBackdrop: '#05070a', -} as const; - -export type ColorToken = keyof typeof colors; diff --git a/src/theme/index.ts b/src/theme/index.ts index e256518..0fa3e3f 100644 --- a/src/theme/index.ts +++ b/src/theme/index.ts @@ -1,9 +1,10 @@ -import { colors } from './colors'; import { fonts, fontSize, lineHeight } from './typography'; import { spacing, radius, layout, durations } from './spacing'; +// Colors are theme-resolved at runtime now — consume them via +// `useColors()` / `createThemedStyles()` from '@/theme/themed'. +// Palette types + base palettes live in '@/theme/palettes'. export const theme = { - colors, fonts, fontSize, lineHeight, @@ -15,4 +16,4 @@ export const theme = { export type Theme = typeof theme; -export { colors, fonts, fontSize, lineHeight, spacing, radius, layout, durations }; +export { fonts, fontSize, lineHeight, spacing, radius, layout, durations }; diff --git a/src/theme/palettes.ts b/src/theme/palettes.ts new file mode 100644 index 0000000..0c8439f --- /dev/null +++ b/src/theme/palettes.ts @@ -0,0 +1,164 @@ +/** + * Astra palettes. Every theme resolves to a `Palette` — same token names the + * old dark-only `colors` object had, so consumers are theme-agnostic. + * + * INVARIANT: solid-color tokens must stay 6-digit `#rrggbb` hex — the Skia + * visualizers (SpectrumCurve, OscilloscopeWave, EQGraph, GraphicResponseCurve) + * slice hex chars to build alpha variants. Only `glass*`, `*Glow`, + * `overlayFaint`, and `backdrop` may be `rgba()` strings (they are never fed + * through those helpers). + */ + +export interface Palette { + // Base backgrounds + bgPrimary: string; + bgSecondary: string; + bgTertiary: string; + + // Glass / surface overlays + glassBg: string; + glassBorder: string; + glassHighlight: string; + + // Text ramp + textPrimary: string; + textSecondary: string; + textTertiary: string; + + warning: string; + + // Accent ramp (derived per accent choice, or from Material You) + accent: string; + accentHover: string; + accentGlow: string; + accentText: string; + accentTextStrong: string; + + // Astra mark fills + logoMain: string; + logoShadow: string; + logoBackdrop: string; + + /** Faint bg-tinted wash (mini-player artwork overlay, alphabet rail, home overlay). */ + overlayFaint: string; + /** Modal/sheet scrim behind ActionSheet, prompts, target picker. */ + backdrop: string; +} + +export type ColorToken = keyof Palette; + +/** A base theme without the accent ramp — accents merge in at resolve time. */ +export type BasePalette = Omit< + Palette, + 'accent' | 'accentHover' | 'accentGlow' | 'accentText' | 'accentTextStrong' +>; + +/** + * Midnight — the original Astra dark. M3 redesign shifted the palette from + * cyan-on-black toward a softer indigo-on-navy "mobile-first" language. + */ +export const midnightBase: BasePalette = { + bgPrimary: '#080a0f', + bgSecondary: '#0c0f18', + bgTertiary: '#11162a', + + glassBg: 'rgba(124, 146, 196, 0.05)', + glassBorder: 'rgba(124, 146, 196, 0.16)', + glassHighlight: 'rgba(140, 162, 208, 0.08)', + + textPrimary: '#e2e8f4', + textSecondary: '#8a98b8', + textTertiary: '#52607f', + + warning: '#f3d27d', + + logoMain: '#00b3ff', // hsl(198 100% 50%) + logoShadow: '#152932', // hsl(198 40% 14%) + logoBackdrop: '#05070a', + + overlayFaint: 'rgba(8, 10, 15, 0.24)', + backdrop: 'rgba(0, 0, 0, 0.55)', +}; + +/** + * Dark — neutral gray dark, no navy cast. Matches the tone zone Material You + * produces on a neutral wallpaper (which is where these values came from). + */ +export const darkBase: BasePalette = { + bgPrimary: '#0f0f12', + bgSecondary: '#141519', + bgTertiary: '#1e2026', + + glassBg: 'rgba(165, 175, 195, 0.05)', + glassBorder: 'rgba(165, 175, 195, 0.16)', + glassHighlight: 'rgba(175, 185, 205, 0.08)', + + textPrimary: '#e6e8ec', + textSecondary: '#979da8', + textTertiary: '#5b616c', + + warning: '#f3d27d', + + logoMain: '#00b3ff', + logoShadow: '#152932', + logoBackdrop: '#0a0a0c', + + overlayFaint: 'rgba(15, 15, 18, 0.24)', + backdrop: 'rgba(0, 0, 0, 0.55)', +}; + +/** + * AMOLED — true black base, faint navy kept on raised surfaces so cards still + * read as surfaces. Glass borders slightly stronger: they carry the structure + * that background contrast provides on Midnight. + */ +export const amoledBase: BasePalette = { + bgPrimary: '#000000', + bgSecondary: '#05060a', + bgTertiary: '#0b0e1a', + + glassBg: 'rgba(124, 146, 196, 0.06)', + glassBorder: 'rgba(124, 146, 196, 0.20)', + glassHighlight: 'rgba(140, 162, 208, 0.10)', + + textPrimary: '#e2e8f4', + textSecondary: '#8a98b8', + textTertiary: '#52607f', + + warning: '#f3d27d', + + logoMain: '#00b3ff', + logoShadow: '#101f26', + logoBackdrop: '#000000', + + overlayFaint: 'rgba(0, 0, 0, 0.30)', + backdrop: 'rgba(0, 0, 0, 0.62)', +}; + +/** + * Light — same navy hue family (H≈222) inverted: cool near-white surfaces, + * navy-ink text ramp, glass flips to dark ink at low alpha. Starting values; + * expect an on-device tuning pass (glass alphas, EQ fills, glow visibility). + */ +export const lightBase: BasePalette = { + bgPrimary: '#f4f6fb', + bgSecondary: '#eaeef7', + bgTertiary: '#dde4f2', + + glassBg: 'rgba(52, 74, 130, 0.06)', + glassBorder: 'rgba(52, 74, 130, 0.18)', + glassHighlight: 'rgba(52, 74, 130, 0.10)', + + textPrimary: '#171d2e', + textSecondary: '#4d5a78', + textTertiary: '#8a94ad', + + warning: '#9a7b1f', + + logoMain: '#0087cc', + logoShadow: '#c9dbe4', + logoBackdrop: '#eef2f8', + + overlayFaint: 'rgba(244, 246, 251, 0.30)', + backdrop: 'rgba(23, 29, 46, 0.40)', +}; diff --git a/src/theme/resolve.ts b/src/theme/resolve.ts new file mode 100644 index 0000000..d6940ba --- /dev/null +++ b/src/theme/resolve.ts @@ -0,0 +1,165 @@ +import type { SystemPalette } from '../../modules/astra-system-colors'; +import { deriveAccent, type AccentId } from './accents'; +import { mixHex, rgbaFromHex } from './colorUtils'; +import { + amoledBase, + darkBase, + lightBase, + midnightBase, + type BasePalette, + type Palette, +} from './palettes'; + +/** What the user picks in settings. */ +export type BaseThemeId = 'system' | 'midnight' | 'dark' | 'amoled' | 'light' | 'materialYou'; +/** Which dark theme "System" resolves to when the OS is dark. */ +export type PreferredDark = 'midnight' | 'dark' | 'amoled'; +/** What actually renders after resolution. */ +export type ResolvedThemeId = + | 'midnight' + | 'dark' + | 'amoled' + | 'light' + | 'materialYouDark' + | 'materialYouLight'; + +export interface AppTheme { + id: ResolvedThemeId; + isDark: boolean; + statusBarStyle: 'light' | 'dark'; + colors: Palette; +} + +export function parseBaseTheme(value: string | null): BaseThemeId { + switch (value) { + case 'system': + case 'midnight': + case 'dark': + case 'amoled': + case 'light': + case 'materialYou': + return value; + default: + return 'midnight'; + } +} + +export function parsePreferredDark(value: string | null): PreferredDark { + return value === 'amoled' || value === 'dark' ? value : 'midnight'; +} + +const BASES: Record< + 'midnight' | 'dark' | 'amoled' | 'light', + { base: BasePalette; isDark: boolean } +> = { + midnight: { base: midnightBase, isDark: true }, + dark: { base: darkBase, isDark: true }, + amoled: { base: amoledBase, isDark: true }, + light: { base: lightBase, isDark: false }, +}; + +/** Monet ramp index for tone t ∈ {0,10,50,100,200,...,900,1000}. */ +function tone(ramp: string[], t: number): string { + const index = t === 0 ? 0 : t === 10 ? 1 : t === 50 ? 2 : t / 100 + 2; + return ramp[index]; +} + +/** + * Maps monet ramps onto Astra tokens. Dark surfaces come from the accent2 + * ramp (monet's muted wallpaper-hue ramp, built exactly for tinted surfaces) + * so the wallpaper color is actually visible — neutral1 is too low-chroma and + * mixing it toward black erased the tint entirely. The neutral-gray look that + * produced is now its own static "Dark" theme. Fractions are a starting + * point; expect a device tuning pass. All solid outputs stay #rrggbb hex + * (Skia invariant — see palettes.ts). + */ +export function buildMaterialYouPalette(ramps: SystemPalette, isDark: boolean): Palette { + if (isDark) { + const bgPrimary = mixHex(tone(ramps.accent2, 900), '#000000', 0.35); + return { + bgPrimary, + bgSecondary: mixHex(tone(ramps.accent2, 900), '#000000', 0.12), + bgTertiary: mixHex(tone(ramps.accent2, 800), '#000000', 0.25), + glassBg: rgbaFromHex(tone(ramps.accent2, 200), 0.06), + glassBorder: rgbaFromHex(tone(ramps.accent2, 200), 0.18), + glassHighlight: rgbaFromHex(tone(ramps.accent2, 200), 0.09), + textPrimary: tone(ramps.neutral1, 50), + textSecondary: tone(ramps.neutral2, 300), + textTertiary: tone(ramps.neutral2, 500), + warning: '#f3d27d', + accent: tone(ramps.accent1, 200), + accentHover: tone(ramps.accent1, 100), + accentGlow: rgbaFromHex(tone(ramps.accent1, 200), 0.3), + accentText: tone(ramps.accent1, 100), + accentTextStrong: tone(ramps.accent1, 50), + logoMain: '#00b3ff', + logoShadow: '#152932', + logoBackdrop: '#05070a', + overlayFaint: rgbaFromHex(bgPrimary, 0.24), + backdrop: 'rgba(0, 0, 0, 0.55)', + }; + } + const bgPrimary = tone(ramps.neutral1, 10); + return { + bgPrimary, + bgSecondary: tone(ramps.accent2, 50), + bgTertiary: tone(ramps.accent2, 100), + glassBg: rgbaFromHex(tone(ramps.neutral2, 700), 0.06), + glassBorder: rgbaFromHex(tone(ramps.neutral2, 700), 0.16), + glassHighlight: rgbaFromHex(tone(ramps.neutral2, 700), 0.09), + textPrimary: tone(ramps.neutral1, 900), + textSecondary: tone(ramps.neutral2, 700), + textTertiary: tone(ramps.neutral2, 500), + warning: '#9a7b1f', + accent: tone(ramps.accent1, 600), + accentHover: tone(ramps.accent1, 500), + accentGlow: rgbaFromHex(tone(ramps.accent1, 600), 0.25), + accentText: tone(ramps.accent1, 700), + accentTextStrong: tone(ramps.accent1, 800), + logoMain: '#0087cc', + logoShadow: '#c9dbe4', + logoBackdrop: '#eef2f8', + overlayFaint: rgbaFromHex(bgPrimary, 0.24), + backdrop: rgbaFromHex(tone(ramps.neutral1, 900), 0.45), + }; +} + +export interface ResolveThemeInput { + baseTheme: BaseThemeId; + preferredDark: PreferredDark; + accentId: AccentId; + systemScheme: 'light' | 'dark'; + /** null → Material You unavailable (iOS, s.theme); +} + +/** Just the palette — the common case for inline `colors.x` props. */ +export function useColors(): Palette { + return useThemeStore((s) => s.theme.colors); +} + +/** + * Module-scope factory for theme-aware styles: + * + * const useStyles = createThemedStyles((colors) => ({ ... })); + * // in the component: + * const styles = useStyles(); + * + * Styles are built lazily and cached per palette OBJECT IDENTITY — one + * StyleSheet build per theme per file, referentially stable across renders + * (so downstream memoization behaves exactly like the old module-scope + * constant). The WeakMap write during render is an idempotent lazy-init on an + * immutable key, which is safe under React's concurrent re-renders. + */ +export function createThemedStyles>( + factory: (colors: Palette) => T, +): () => T { + const cache = new WeakMap(); + return function useThemedStyles(): T { + const colors = useThemeStore((s) => s.theme.colors); + let styles = cache.get(colors); + if (styles === undefined) { + styles = StyleSheet.create(factory(colors)); + cache.set(colors, styles); + } + return styles; + }; +}