From d2bda52c868ebb5e655b45fc4320e7be4f9d43dc Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:35:36 -0400 Subject: [PATCH] custom accent color + port over desktop cover art color --- package.json | 1 + src/components/onboarding/OnboardingFlow.tsx | 4 +- src/components/player/NowPlayingOverlay.tsx | 35 +- src/components/settings/AccentColorSheet.tsx | 370 +++++++++++++++++++ src/components/settings/AccentSwatchRow.tsx | 73 +++- src/components/settings/SettingsPanels.tsx | 58 ++- src/stores/themeStore.ts | 117 +++++- src/theme/accentColors.test.mts | 105 ++++++ src/theme/accents.ts | 65 +++- src/theme/artworkAccent.ts | 70 ++++ src/theme/artworkAccentCache.ts | 35 ++ src/theme/artworkAccentMath.test.mts | 77 ++++ src/theme/artworkAccentMath.ts | 266 +++++++++++++ src/theme/colorUtils.ts | 62 ++++ src/theme/resolve.ts | 14 +- src/theme/scopedAccent.ts | 12 + src/theme/themed.ts | 24 +- src/theme/useNowPlayingArtworkAccent.ts | 65 ++++ 18 files changed, 1402 insertions(+), 51 deletions(-) create mode 100644 src/components/settings/AccentColorSheet.tsx create mode 100644 src/theme/accentColors.test.mts create mode 100644 src/theme/artworkAccent.ts create mode 100644 src/theme/artworkAccentCache.ts create mode 100644 src/theme/artworkAccentMath.test.mts create mode 100644 src/theme/artworkAccentMath.ts create mode 100644 src/theme/scopedAccent.ts create mode 100644 src/theme/useNowPlayingArtworkAccent.ts diff --git a/package.json b/package.json index a8dc780..64ceea0 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts", "test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts", "test:home-greeting": "node --experimental-strip-types --test src/home/homeGreeting.test.mts", + "test:theme": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/theme/accentColors.test.mts src/theme/artworkAccentMath.test.mts", "test:session": "node --experimental-strip-types --test src/session/sessionState.test.mts src/session/playbackMaterialization.test.mts", "test:library-scan": "node --experimental-strip-types --test src/library/scanCancellation.test.mts", "test:release-config": "node --experimental-strip-types --test plugins/withAstraAndroidRelease.test.mjs scripts/release/android-release.test.mjs src/release/buildInfo.test.mts", diff --git a/src/components/onboarding/OnboardingFlow.tsx b/src/components/onboarding/OnboardingFlow.tsx index 18c0968..d55d52e 100644 --- a/src/components/onboarding/OnboardingFlow.tsx +++ b/src/components/onboarding/OnboardingFlow.tsx @@ -245,9 +245,7 @@ function ThemeStep() { const baseTheme = useThemeStore((s) => s.baseTheme); const materialYouAvailable = useThemeStore((s) => s.materialYouAvailable); const resolvedId = useThemeStore((s) => s.theme.id); - const accentId = useThemeStore((s) => s.accentId); const setBaseTheme = useThemeStore((s) => s.setBaseTheme); - const setAccent = useThemeStore((s) => s.setAccent); const options = WIZARD_THEME_OPTIONS.filter( (option) => option.id !== 'materialYou' || materialYouAvailable @@ -288,7 +286,7 @@ function ThemeStep() { {accentApplies ? ( - void setAccent(id)} /> + ) : null} diff --git a/src/components/player/NowPlayingOverlay.tsx b/src/components/player/NowPlayingOverlay.tsx index b3b1ef2..0588a38 100644 --- a/src/components/player/NowPlayingOverlay.tsx +++ b/src/components/player/NowPlayingOverlay.tsx @@ -62,9 +62,15 @@ import { radius, spacing, } from '@/theme'; -import { createThemedStyles, useColors } from '@/theme/themed'; +import { + createThemedStyles, + ScopedPaletteProvider, + useColors, +} from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; import { motion } from '@/theme/motion'; +import { paletteWithAccent } from '@/theme/scopedAccent'; +import { useNowPlayingArtworkAccent } from '@/theme/useNowPlayingArtworkAccent'; import { getNowPlayingLayout, getTabletCompanionLayout, @@ -98,6 +104,7 @@ import { markNowPlayingTrackTransitionDirection } from '@/stores/nowPlayingTrack import { isPlayerOnScreen } from '@/stores/playerPresence'; import { useSettingsStore, type ScopeMode } from '@/stores/settingsStore'; import { useSleepTimerStore } from '@/stores/sleepTimerStore'; +import { useThemeStore } from '@/stores/themeStore'; import type { DbTrack } from '@/types/library'; import { cycleRepeat, @@ -141,8 +148,7 @@ interface NowPlayingMenuItem { } export function NowPlayingOverlay() { - const styles = useStyles(); - const colors = useColors(); + const appColors = useColors(); const ripple = useRipple(); const router = useRouter(); const returnToTabs = useReturnToTabs(); @@ -164,6 +170,9 @@ export function NowPlayingOverlay() { const [sleepTimerOpen, setSleepTimerOpen] = useState(false); const [playlistActionTrack, setPlaylistActionTrack] = useState(null); const selectedTarget = usePlaybackTargetStore((s) => s.target); + const themeIsDark = useThemeStore((s) => s.theme.isDark); + const nowPlayingAccentSource = useThemeStore((s) => s.nowPlayingAccentSource); + const coverArtAccentMethod = useThemeStore((s) => s.coverArtAccentMethod); const scopeMode = useSettingsStore((s) => s.scopeMode); const scopeStageVisible = useSettingsStore((s) => s.scopeStageVisible); const setScopeStageVisible = useSettingsStore((s) => s.setScopeStageVisible); @@ -207,6 +216,24 @@ export function NowPlayingOverlay() { desktop: desktopPresentation, }); const isDesktopTarget = activePresentation.target === 'desktop'; + const artworkIdentity = activePresentation.trackKey + ? `${activePresentation.target}:${ + isDesktopTarget + ? activePresentation.trackKey + : track?.artworkHash ?? activePresentation.trackKey + }` + : null; + const coverArtAccent = useNowPlayingArtworkAccent({ + enabled: playerOpen && nowPlayingAccentSource === 'cover-art', + artworkUri: activePresentation.artworkUri, + artworkIdentity, + method: coverArtAccentMethod, + }); + const colors = useMemo( + () => paletteWithAccent(appColors, coverArtAccent, themeIsDark), + [appColors, coverArtAccent, themeIsDark], + ); + const styles = useStyles(colors); const effectiveScopeStageVisible = !isDesktopTarget && scopeStageVisible; // Backgrounding drops these two subtrees, which is what actually releases the // scope TextureViews and the decoded artwork. The overlay shell around them @@ -817,6 +844,7 @@ export function NowPlayingOverlay() { ); return ( + setTargetPickerOpen(false)} /> + ); } diff --git a/src/components/settings/AccentColorSheet.tsx b/src/components/settings/AccentColorSheet.tsx new file mode 100644 index 0000000..dbad7c2 --- /dev/null +++ b/src/components/settings/AccentColorSheet.tsx @@ -0,0 +1,370 @@ +/* eslint-disable react-hooks/refs -- HSV refs are read only by RNGH callbacks after render; keeping the gesture identity stable prevents an active color drag from being replaced mid-gesture. */ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Modal, + Pressable, + StyleSheet, + View, + type LayoutChangeEvent, +} from 'react-native'; +import { BottomSheetTextInput } from '@gorhom/bottom-sheet'; +import { + Gesture, + GestureDetector, + GestureHandlerRootView, +} from 'react-native-gesture-handler'; +import { + Canvas, + LinearGradient, + Rect, + vec, +} from '@shopify/react-native-skia'; +import { AppSheet, AppSheetTitle } from '@/components/sheets/AppSheet'; +import { Text } from '@/components/Text'; +import { fonts, radius, spacing } from '@/theme'; +import { createThemedStyles, useColors } from '@/theme/themed'; +import { useRipple } from '@/theme/ripple'; +import { + hexToHsv, + hsvToHex, + normalizeHexColor, + type HsvColor, +} from '@/theme/colorUtils'; + +const SV_HEIGHT = 180; +const HUE_HEIGHT = 28; +const HANDLE_SIZE = 22; +const HUE_COLORS = [ + '#ff0000', + '#ffff00', + '#00ff00', + '#00ffff', + '#0000ff', + '#ff00ff', + '#ff0000', +]; + +interface AccentColorSheetProps { + initialHex: string; + onPreview: (hex: string | null) => void; + onApply: (hex: string) => void; + onClose: () => void; +} + +export function AccentColorSheet({ + initialHex, + onPreview, + onApply, + onClose, +}: AccentColorSheetProps) { + const styles = useStyles(); + const colors = useColors(); + const ripple = useRipple(); + const normalizedInitial = normalizeHexColor(initialHex) ?? '#5b8aff'; + const [hsv, setHsv] = useState(() => hexToHsv(normalizedInitial)); + const hsvRef = useRef(hsv); + const [input, setInput] = useState(normalizedInitial.toUpperCase()); + const [pickerWidth, setPickerWidth] = useState(1); + const validInput = normalizeHexColor(input); + const previewHex = hsvToHex(hsv.h, hsv.s, hsv.v); + const hueColor = hsvToHex(hsv.h, 1, 1); + + useEffect(() => { + if (validInput) onPreview(validInput); + }, [onPreview, validInput]); + + useEffect( + () => () => onPreview(null), + [onPreview], + ); + + const commitHsv = useCallback((next: HsvColor) => { + hsvRef.current = next; + setHsv(next); + setInput(hsvToHex(next.h, next.s, next.v).toUpperCase()); + }, []); + + const updateSv = useCallback((x: number, y: number) => { + const next = { + ...hsvRef.current, + s: Math.min(1, Math.max(0, x / pickerWidth)), + v: 1 - Math.min(1, Math.max(0, y / SV_HEIGHT)), + }; + commitHsv(next); + }, [commitHsv, pickerWidth]); + + const updateHue = useCallback((x: number) => { + const next = { + ...hsvRef.current, + h: Math.min(359.999, Math.max(0, (x / pickerWidth) * 360)), + }; + commitHsv(next); + }, [commitHsv, pickerWidth]); + + const svGesture = useMemo( + () => + Gesture.Pan() + .minDistance(0) + .runOnJS(true) + .onBegin((event) => updateSv(event.x, event.y)) + .onUpdate((event) => updateSv(event.x, event.y)), + [updateSv], + ); + const hueGesture = useMemo( + () => + Gesture.Pan() + .minDistance(0) + .runOnJS(true) + .onBegin((event) => updateHue(event.x)) + .onUpdate((event) => updateHue(event.x)), + [updateHue], + ); + + const onPickerLayout = (event: LayoutChangeEvent) => { + setPickerWidth(Math.max(1, event.nativeEvent.layout.width)); + }; + + const changeInput = (next: string) => { + setInput(next); + const normalized = normalizeHexColor(next); + if (normalized) { + const nextHsv = hexToHsv(normalized); + hsvRef.current = nextHsv; + setHsv(nextHsv); + } + }; + + const apply = () => { + if (!validInput) return; + onApply(validInput); + onClose(); + }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 && !validInput && styles.inputInvalid]} + autoCapitalize="characters" + autoCorrect={false} + maxLength={7} + returnKeyType="done" + selectionColor={colors.accent} + onSubmitEditing={apply} + accessibilityLabel="Accent hex color" + /> + + + {validInput ? 'Use three or six hexadecimal digits.' : 'Enter a valid hex color.'} + + + + + Cancel + + + Apply + + + + + + ); +} + +const useStyles = createThemedStyles((colors) => ({ + modalRoot: { + flex: 1, + }, + sv: { + height: SV_HEIGHT, + borderRadius: radius.md, + overflow: 'hidden', + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + marginTop: spacing.md, + }, + hue: { + height: HUE_HEIGHT, + borderRadius: radius.pill, + overflow: 'hidden', + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + marginTop: spacing.md, + }, + handle: { + position: 'absolute', + width: HANDLE_SIZE, + height: HANDLE_SIZE, + borderRadius: HANDLE_SIZE / 2, + borderWidth: 3, + borderColor: '#ffffff', + shadowColor: '#000000', + shadowOpacity: 0.4, + shadowRadius: 2, + elevation: 3, + }, + hueHandle: { + position: 'absolute', + top: (HUE_HEIGHT - HANDLE_SIZE) / 2, + width: HANDLE_SIZE, + height: HANDLE_SIZE, + borderRadius: HANDLE_SIZE / 2, + borderWidth: 3, + borderColor: '#ffffff', + backgroundColor: 'transparent', + elevation: 3, + }, + inputRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + marginTop: spacing.lg, + }, + preview: { + width: 42, + height: 42, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + }, + input: { + flex: 1, + color: colors.textPrimary, + fontFamily: fonts.mono.medium, + fontSize: 17, + letterSpacing: 1, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm + 2, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + backgroundColor: colors.bgTertiary, + }, + inputInvalid: { + borderColor: colors.warning, + }, + validation: { + marginTop: spacing.xs, + }, + actions: { + flexDirection: 'row', + justifyContent: 'flex-end', + gap: spacing.sm, + marginTop: spacing.lg, + }, + button: { + minWidth: 92, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + borderRadius: radius.pill, + alignItems: 'center', + }, + cancel: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.glassBorder, + }, + apply: { + backgroundColor: colors.accent, + }, + disabled: { + opacity: 0.4, + }, +})); + +export default AccentColorSheet; diff --git a/src/components/settings/AccentSwatchRow.tsx b/src/components/settings/AccentSwatchRow.tsx index bf51f3e..97ef18d 100644 --- a/src/components/settings/AccentSwatchRow.tsx +++ b/src/components/settings/AccentSwatchRow.tsx @@ -1,36 +1,42 @@ +import { useState } from 'react'; import { Pressable, StyleSheet, View } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { Canvas, LinearGradient, Rect, vec } from '@shopify/react-native-skia'; import { Text } from '@/components/Text'; import { spacing } from '@/theme'; -import { ACCENTS, ACCENT_IDS, type AccentId } from '@/theme/accents'; +import { ACCENTS, ACCENT_IDS, accentPreferenceBase } from '@/theme/accents'; import { createThemedStyles, useColors } from '@/theme/themed'; import { useRipple } from '@/theme/ripple'; import { playHaptic } from '@/lib/haptics'; +import { useThemeStore } from '@/stores/themeStore'; +import { AccentColorSheet } from '@/components/settings/AccentColorSheet'; const SWATCH_SIZE = 36; - -interface AccentSwatchRowProps { - value: AccentId; - onChange: (id: AccentId) => void; -} +const RAINBOW = ['#ff5c5c', '#ffb454', '#2dd4a0', '#00b3ff', '#9d7bff', '#ff6b9d']; /** Circular accent swatches; the selected one gets a ring + checkmark. */ -export function AccentSwatchRow({ value, onChange }: AccentSwatchRowProps) { +export function AccentSwatchRow() { const styles = useStyles(); const ripple = useRipple(); const colors = useColors(); + const [pickerOpen, setPickerOpen] = useState(false); + const preference = useThemeStore((s) => s.accentPreference); + const setAccent = useThemeStore((s) => s.setAccent); + const setCustomAccent = useThemeStore((s) => s.setCustomAccent); + const previewCustomAccent = useThemeStore((s) => s.previewCustomAccent); + const initialHex = accentPreferenceBase(preference); return ( {ACCENT_IDS.map((id) => { - const selected = id === value; + const selected = preference.kind === 'preset' && id === preference.id; return ( { if (selected) return; playHaptic('selection'); - onChange(id); + void setAccent(id); }} accessibilityRole="radio" accessibilityState={{ selected }} @@ -48,10 +54,56 @@ export function AccentSwatchRow({ value, onChange }: AccentSwatchRowProps) { ); })} + { + playHaptic('selection'); + setPickerOpen(true); + }} + accessibilityRole="radio" + accessibilityState={{ selected: preference.kind === 'custom' }} + accessibilityLabel="Custom accent" + style={[ + styles.swatch, + preference.kind === 'custom' && { backgroundColor: preference.hex }, + preference.kind === 'custom' && styles.swatchSelected, + ]} + hitSlop={4} + > + {preference.kind !== 'custom' ? ( + + + + + + ) : null} + {preference.kind === 'custom' ? ( + + ) : ( + + )} + - Accent · {ACCENTS[value].label} + Accent · {preference.kind === 'preset' + ? ACCENTS[preference.id].label + : `Custom ${preference.hex.toUpperCase()}`} + {pickerOpen ? ( + void setCustomAccent(hex)} + onClose={() => { + previewCustomAccent(null); + setPickerOpen(false); + }} + /> + ) : null} ); } @@ -73,6 +125,7 @@ const useStyles = createThemedStyles((colors) => ({ justifyContent: 'center', borderWidth: StyleSheet.hairlineWidth, borderColor: colors.glassBorder, + overflow: 'hidden', }, swatchSelected: { borderWidth: 2, diff --git a/src/components/settings/SettingsPanels.tsx b/src/components/settings/SettingsPanels.tsx index 670d990..e5015e0 100644 --- a/src/components/settings/SettingsPanels.tsx +++ b/src/components/settings/SettingsPanels.tsx @@ -24,6 +24,10 @@ import { useAudioSettingsStore } from '@/stores/audioSettingsStore'; import { useLibraryStore, type FolderWithCount } from '@/stores/libraryStore'; import { useSettingsStore } from '@/stores/settingsStore'; import { useThemeStore } from '@/stores/themeStore'; +import type { + CoverArtAccentMethod, + NowPlayingAccentSource, +} from '@/stores/themeStore'; import type { LastFmStatus } from '@/types/lastFm'; import { Text } from '@/components/Text'; import { showAppDialog } from '@/components/dialogs/AppDialog'; @@ -66,6 +70,17 @@ const HOME_GREETING_SEGMENTS = [ { key: 'off', label: 'Off' }, ]; +const NOW_PLAYING_ACCENT_SEGMENTS = [ + { key: 'app', label: 'App' }, + { key: 'cover-art', label: 'Cover Art' }, +]; + +const COVER_ART_METHOD_SEGMENTS = [ + { key: 'dominant', label: 'Dominant' }, + { key: 'vibrant', label: 'Vibrant' }, + { key: 'average', label: 'Average' }, +]; + const ARTIST_GROUPING_OPTIONS: { mode: ArtistGroupingMode; title: string; description: string }[] = [ { mode: 'astra', @@ -95,12 +110,14 @@ export function AppearanceSettingsPanel() { 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 nowPlayingAccentSource = useThemeStore((s) => s.nowPlayingAccentSource); + const coverArtAccentMethod = useThemeStore((s) => s.coverArtAccentMethod); const setBaseTheme = useThemeStore((s) => s.setBaseTheme); const setPreferredDark = useThemeStore((s) => s.setPreferredDark); - const setAccent = useThemeStore((s) => s.setAccent); + const setNowPlayingAccentSource = useThemeStore((s) => s.setNowPlayingAccentSource); + const setCoverArtAccentMethod = useThemeStore((s) => s.setCoverArtAccentMethod); const options = THEME_OPTIONS.filter( (option) => option.id !== 'materialYou' || materialYouAvailable @@ -161,10 +178,41 @@ export function AppearanceSettingsPanel() { {accentApplies ? ( - void setAccent(id)} /> + ) : null} + NOW PLAYING ACCENT + + + Accent source + + + Cover Art colors only the open player. The app accent is used when artwork is unavailable. + + + void setNowPlayingAccentSource(key as NowPlayingAccentSource) + } + /> + {nowPlayingAccentSource === 'cover-art' ? ( + + + Cover art method + + + void setCoverArtAccentMethod(key as CoverArtAccentMethod) + } + /> + + ) : null} + + HOME @@ -496,6 +544,10 @@ const useStyles = createThemedStyles((colors) => ({ settingNote: { marginBottom: spacing.md, }, + coverArtMethod: { + gap: spacing.sm, + marginTop: spacing.lg, + }, options: { gap: spacing.sm, }, diff --git a/src/stores/themeStore.ts b/src/stores/themeStore.ts index 98b1121..10e3181 100644 --- a/src/stores/themeStore.ts +++ b/src/stores/themeStore.ts @@ -2,7 +2,14 @@ import { Appearance } from 'react-native'; import { create } from 'zustand'; import { AstraSystemColors, type SystemPalette } from '../../modules/astra-system-colors'; import { getNativeSetting, setNativeSetting } from '@/db/nativeSettings'; -import { parseAccentId, DEFAULT_ACCENT, type AccentId } from '@/theme/accents'; +import { + DEFAULT_ACCENT_PREFERENCE, + parseAccentPreference, + serializeAccentPreference, + type AccentId, + type AccentPreference, +} from '@/theme/accents'; +import { normalizeHexColor } from '@/theme/colorUtils'; import { parseBaseTheme, parsePreferredDark, @@ -21,8 +28,12 @@ import { const BASE_THEME_KEY = 'theme_base'; const PREFERRED_DARK_KEY = 'theme_preferred_dark'; const ACCENT_KEY = 'theme_accent'; +const NOW_PLAYING_ACCENT_SOURCE_KEY = 'now_playing_accent_source'; +const COVER_ART_ACCENT_METHOD_KEY = 'now_playing_cover_art_method'; type SystemScheme = 'light' | 'dark'; +export type NowPlayingAccentSource = 'app' | 'cover-art'; +export type CoverArtAccentMethod = 'dominant' | 'vibrant' | 'average'; function currentSystemScheme(): SystemScheme { return Appearance.getColorScheme() === 'light' ? 'light' : 'dark'; @@ -35,7 +46,7 @@ let materialYouRamps: SystemPalette | null = null; interface ResolutionInputs { baseTheme: BaseThemeId; preferredDark: PreferredDark; - accentId: AccentId; + accentPreference: AccentPreference; systemScheme: SystemScheme; } @@ -45,12 +56,19 @@ function recompute(inputs: ResolutionInputs): AppTheme { interface ThemeStore extends ResolutionInputs { materialYouAvailable: boolean; + accentPreviewHex: string | null; + nowPlayingAccentSource: NowPlayingAccentSource; + coverArtAccentMethod: CoverArtAccentMethod; theme: AppTheme; loaded: boolean; load: () => Promise; setBaseTheme: (id: BaseThemeId) => Promise; setPreferredDark: (id: PreferredDark) => Promise; setAccent: (id: AccentId) => Promise; + setCustomAccent: (hex: string) => Promise; + previewCustomAccent: (hex: string | null) => void; + setNowPlayingAccentSource: (source: NowPlayingAccentSource) => Promise; + setCoverArtAccentMethod: (method: CoverArtAccentMethod) => Promise; /** Re-reads OS scheme + monet ramps; no-op set when nothing changed. */ refreshSystemInputs: () => void; } @@ -58,22 +76,46 @@ interface ThemeStore extends ResolutionInputs { const DEFAULT_INPUTS: ResolutionInputs = { baseTheme: 'midnight', preferredDark: 'midnight', - accentId: DEFAULT_ACCENT, + accentPreference: DEFAULT_ACCENT_PREFERENCE, systemScheme: currentSystemScheme(), }; +function parseNowPlayingAccentSource(value: string | null): NowPlayingAccentSource { + return value === 'cover-art' ? 'cover-art' : 'app'; +} + +function parseCoverArtAccentMethod(value: string | null): CoverArtAccentMethod { + return value === 'average' || value === 'vibrant' ? value : 'dominant'; +} + +function effectiveInputs(state: ThemeStore): ResolutionInputs { + return { + baseTheme: state.baseTheme, + preferredDark: state.preferredDark, + accentPreference: state.accentPreviewHex + ? { kind: 'custom', hex: state.accentPreviewHex } + : state.accentPreference, + systemScheme: state.systemScheme, + }; +} + export const useThemeStore = create((set, get) => ({ ...DEFAULT_INPUTS, materialYouAvailable: AstraSystemColors.isAvailable(), + accentPreviewHex: null, + nowPlayingAccentSource: 'app', + coverArtAccentMethod: 'dominant', theme: recompute(DEFAULT_INPUTS), loaded: false, load: async () => { if (get().loaded) return; - const [base, dark, accent] = await Promise.all([ + const [base, dark, accent, nowPlayingAccentSource, coverArtAccentMethod] = await Promise.all([ getNativeSetting(BASE_THEME_KEY), getNativeSetting(PREFERRED_DARK_KEY), getNativeSetting(ACCENT_KEY), + getNativeSetting(NOW_PLAYING_ACCENT_SOURCE_KEY), + getNativeSetting(COVER_ART_ACCENT_METHOD_KEY), ]); if (get().materialYouAvailable) { materialYouRamps = AstraSystemColors.getSystemPalette(); @@ -81,40 +123,91 @@ export const useThemeStore = create((set, get) => ({ const inputs: ResolutionInputs = { baseTheme: parseBaseTheme(base), preferredDark: parsePreferredDark(dark), - accentId: parseAccentId(accent), + accentPreference: parseAccentPreference(accent), systemScheme: currentSystemScheme(), }; - set({ ...inputs, theme: recompute(inputs), loaded: true }); + set({ + ...inputs, + nowPlayingAccentSource: parseNowPlayingAccentSource(nowPlayingAccentSource), + coverArtAccentMethod: parseCoverArtAccentMethod(coverArtAccentMethod), + theme: recompute(inputs), + loaded: true, + }); }, setBaseTheme: async (id) => { if (get().baseTheme === id) return; - const inputs: ResolutionInputs = { ...get(), baseTheme: id }; + const inputs: ResolutionInputs = { ...effectiveInputs(get()), baseTheme: id }; set({ baseTheme: id, theme: recompute(inputs) }); await setNativeSetting(BASE_THEME_KEY, id); }, setPreferredDark: async (id) => { if (get().preferredDark === id) return; - const inputs: ResolutionInputs = { ...get(), preferredDark: id }; + const inputs: ResolutionInputs = { ...effectiveInputs(get()), preferredDark: id }; set({ preferredDark: id, theme: recompute(inputs) }); await setNativeSetting(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 preference: AccentPreference = { kind: 'preset', id }; + const current = get(); + if ( + current.accentPreviewHex === null && + current.accentPreference.kind === 'preset' && + current.accentPreference.id === id + ) return; + const inputs: ResolutionInputs = { + ...effectiveInputs(get()), + accentPreference: preference, + }; + set({ accentPreference: preference, accentPreviewHex: null, theme: recompute(inputs) }); await setNativeSetting(ACCENT_KEY, id); }, + setCustomAccent: async (hex) => { + const normalized = normalizeHexColor(hex); + if (!normalized) return; + const preference: AccentPreference = { kind: 'custom', hex: normalized }; + const inputs: ResolutionInputs = { + ...effectiveInputs(get()), + accentPreference: preference, + }; + set({ accentPreference: preference, accentPreviewHex: null, theme: recompute(inputs) }); + await setNativeSetting(ACCENT_KEY, serializeAccentPreference(preference)); + }, + + previewCustomAccent: (hex) => { + const normalized = hex === null ? null : normalizeHexColor(hex); + if (get().accentPreviewHex === normalized) return; + const inputs: ResolutionInputs = { + ...effectiveInputs(get()), + accentPreference: normalized + ? { kind: 'custom', hex: normalized } + : get().accentPreference, + }; + set({ accentPreviewHex: normalized, theme: recompute(inputs) }); + }, + + setNowPlayingAccentSource: async (source) => { + if (get().nowPlayingAccentSource === source) return; + set({ nowPlayingAccentSource: source }); + await setNativeSetting(NOW_PLAYING_ACCENT_SOURCE_KEY, source); + }, + + setCoverArtAccentMethod: async (method) => { + if (get().coverArtAccentMethod === method) return; + set({ coverArtAccentMethod: method }); + await setNativeSetting(COVER_ART_ACCENT_METHOD_KEY, method); + }, + 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 }; + const inputs: ResolutionInputs = { ...effectiveInputs(get()), systemScheme: scheme }; set({ systemScheme: scheme, theme: recompute(inputs) }); }, })); diff --git a/src/theme/accentColors.test.mts b/src/theme/accentColors.test.mts new file mode 100644 index 0000000..94074a0 --- /dev/null +++ b/src/theme/accentColors.test.mts @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + deriveAccentFromHex, + parseAccentPreference, + serializeAccentPreference, +} from './accents.ts'; +import { + hexToHsv, + hsvToHex, + normalizeHexColor, +} from './colorUtils.ts'; +import { resolveTheme } from './resolve.ts'; +import { paletteWithAccent } from './scopedAccent.ts'; + +test('parses legacy preset ids and normalized custom colors', () => { + assert.deepEqual(parseAccentPreference('cyan'), { kind: 'preset', id: 'cyan' }); + assert.deepEqual(parseAccentPreference('#AbC'), { kind: 'custom', hex: '#aabbcc' }); + assert.deepEqual(parseAccentPreference('12ef90'), { kind: 'custom', hex: '#12ef90' }); + assert.deepEqual(parseAccentPreference('nope'), { kind: 'preset', id: 'indigo' }); + assert.equal( + serializeAccentPreference({ kind: 'custom', hex: '#12ef90' }), + '#12ef90', + ); +}); + +test('normalizes hex and round-trips HSV colors', () => { + assert.equal(normalizeHexColor(' ABC '), '#aabbcc'); + assert.equal(normalizeHexColor('#12Ef90'), '#12ef90'); + assert.equal(normalizeHexColor('#12zz90'), null); + for (const hex of ['#ff0000', '#2dd4a0', '#5b8aff', '#000000', '#ffffff']) { + const hsv = hexToHsv(hex); + assert.equal(hsvToHex(hsv.h, hsv.s, hsv.v), hex); + } +}); + +test('derives complete light and dark ramps from a custom accent', () => { + const dark = deriveAccentFromHex('#2dd4a0', true); + const light = deriveAccentFromHex('#2dd4a0', false); + assert.equal(dark.accent, '#2dd4a0'); + assert.equal(light.accent, '#2dd4a0'); + assert.notEqual(dark.accentTextStrong, light.accentTextStrong); + assert.match(dark.accentGlow, /^rgba\(45, 212, 160, 0\.3\)$/); +}); + +test('static themes use custom accents while Material You ignores them', () => { + const custom = { kind: 'custom', hex: '#123456' } as const; + const staticTheme = resolveTheme({ + baseTheme: 'dark', + preferredDark: 'dark', + accentPreference: custom, + systemScheme: 'dark', + materialYouRamps: null, + }); + assert.equal(staticTheme.colors.accent, '#123456'); + + const ramp = Array.from({ length: 13 }, (_, index) => { + const channel = Math.max(0, 255 - index * 16).toString(16).padStart(2, '0'); + return `#${channel}${channel}${channel}`; + }); + const materialInput = { + baseTheme: 'materialYou' as const, + preferredDark: 'dark' as const, + systemScheme: 'dark' as const, + materialYouRamps: { + accent1: ramp, + accent2: ramp, + accent3: ramp, + neutral1: ramp, + neutral2: ramp, + }, + }; + const materialCustom = resolveTheme({ + ...materialInput, + accentPreference: custom, + }); + const materialPreset = resolveTheme({ + ...materialInput, + accentPreference: { kind: 'preset', id: 'crimson' }, + }); + assert.deepEqual(materialCustom.colors, materialPreset.colors); +}); + +test('scoped palettes replace only accent tokens', () => { + const theme = resolveTheme({ + baseTheme: 'midnight', + preferredDark: 'midnight', + accentPreference: { kind: 'preset', id: 'indigo' }, + systemScheme: 'dark', + materialYouRamps: null, + }); + const scoped = paletteWithAccent(theme.colors, '#ff5c5c', true); + assert.equal(scoped.accent, '#ff5c5c'); + for (const key of Object.keys(theme.colors)) { + if (['accent', 'accentHover', 'accentGlow', 'accentText', 'accentTextStrong'].includes(key)) { + continue; + } + assert.equal( + scoped[key as keyof typeof scoped], + theme.colors[key as keyof typeof theme.colors], + key, + ); + } + assert.equal(paletteWithAccent(theme.colors, null, true), theme.colors); +}); diff --git a/src/theme/accents.ts b/src/theme/accents.ts index c975cb4..0968754 100644 --- a/src/theme/accents.ts +++ b/src/theme/accents.ts @@ -1,4 +1,4 @@ -import { hexToHsl, hslToHex, rgbaFromHex } from './colorUtils'; +import { hexToHsl, hslToHex, normalizeHexColor, rgbaFromHex } from './colorUtils.ts'; /** * Named accent choices. Each is a single base hex; the full 5-token ramp is @@ -38,10 +38,35 @@ export type AccentId = keyof typeof ACCENTS; export const DEFAULT_ACCENT: AccentId = 'indigo'; export const ACCENT_IDS = Object.keys(ACCENTS) as AccentId[]; +export type AccentPreference = + | { kind: 'preset'; id: AccentId } + | { kind: 'custom'; hex: string }; + +export const DEFAULT_ACCENT_PREFERENCE: AccentPreference = { + kind: 'preset', + id: DEFAULT_ACCENT, +}; + export function parseAccentId(value: string | null): AccentId { return value !== null && value in ACCENTS ? (value as AccentId) : DEFAULT_ACCENT; } +export function parseAccentPreference(value: string | null): AccentPreference { + if (value !== null && value in ACCENTS) { + return { kind: 'preset', id: value as AccentId }; + } + const hex = value === null ? null : normalizeHexColor(value); + return hex ? { kind: 'custom', hex } : DEFAULT_ACCENT_PREFERENCE; +} + +export function serializeAccentPreference(preference: AccentPreference): string { + return preference.kind === 'preset' ? preference.id : preference.hex; +} + +export function accentPreferenceBase(preference: AccentPreference): string { + return preference.kind === 'preset' ? ACCENTS[preference.id].base : preference.hex; +} + export interface AccentTokens { accent: string; accentHover: string; @@ -55,23 +80,45 @@ export interface AccentTokens { * 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); +function deriveAccentBase( + base: string, + isDark: boolean, + overridesDark?: Partial, + overridesLight?: Partial, +): AccentTokens { + const { h, s, l } = hexToHsl(base); const derived: AccentTokens = isDark ? { - accent: def.base, + accent: base, accentHover: hslToHex(h, s, Math.min(l + 8, 96)), - accentGlow: rgbaFromHex(def.base, 0.3), + accentGlow: rgbaFromHex(base, 0.3), accentText: hslToHex(h, s, 83), accentTextStrong: hslToHex(h, s, 92), } : { - accent: def.base, + accent: base, accentHover: hslToHex(h, s, Math.max(l - 8, 20)), - accentGlow: rgbaFromHex(def.base, 0.25), + accentGlow: rgbaFromHex(base, 0.25), accentText: hslToHex(h, s, 36), accentTextStrong: hslToHex(h, s, 26), }; - return { ...derived, ...(isDark ? def.overridesDark : def.overridesLight) }; + return { ...derived, ...(isDark ? overridesDark : overridesLight) }; +} + +export function deriveAccent(id: AccentId, isDark: boolean): AccentTokens { + const def: AccentDef = ACCENTS[id]; + return deriveAccentBase(def.base, isDark, def.overridesDark, def.overridesLight); +} + +export function deriveAccentFromHex(hex: string, isDark: boolean): AccentTokens { + return deriveAccentBase(normalizeHexColor(hex) ?? ACCENTS[DEFAULT_ACCENT].base, isDark); +} + +export function deriveAccentPreference( + preference: AccentPreference, + isDark: boolean, +): AccentTokens { + return preference.kind === 'preset' + ? deriveAccent(preference.id, isDark) + : deriveAccentFromHex(preference.hex, isDark); } diff --git a/src/theme/artworkAccent.ts b/src/theme/artworkAccent.ts new file mode 100644 index 0000000..da56c8c --- /dev/null +++ b/src/theme/artworkAccent.ts @@ -0,0 +1,70 @@ +import { + AlphaType, + ColorType, + Skia, + rect, + type SkData, +} from '@shopify/react-native-skia'; +import type { CoverArtAccentMethod } from '@/stores/themeStore'; +import { extractArtworkAccentFromPixels } from './artworkAccentMath'; + +const SAMPLE_SIZE = 128; + +async function encodedArtworkData(uri: string) { + const dataUrl = /^data:[^;,]+;base64,(.+)$/s.exec(uri); + return dataUrl + ? Skia.Data.fromBase64(dataUrl[1]) + : Skia.Data.fromURI(uri); +} + +export async function extractArtworkAccent( + artworkUri: string, + method: CoverArtAccentMethod, +): Promise { + if (!artworkUri) return null; + let encoded: SkData | null = null; + try { + encoded = await encodedArtworkData(artworkUri); + const source = Skia.Image.MakeImageFromEncoded(encoded); + if (!source) return null; + try { + const surface = Skia.Surface.MakeOffscreen(SAMPLE_SIZE, SAMPLE_SIZE); + if (!surface) return null; + try { + const paint = Skia.Paint(); + try { + surface.getCanvas().drawImageRect( + source, + rect(0, 0, source.width(), source.height()), + rect(0, 0, SAMPLE_SIZE, SAMPLE_SIZE), + paint, + ); + surface.flush(); + const snapshot = surface.makeImageSnapshot(); + try { + const pixels = snapshot.readPixels(0, 0, { + width: SAMPLE_SIZE, + height: SAMPLE_SIZE, + colorType: ColorType.RGBA_8888, + alphaType: AlphaType.Unpremul, + }); + if (!pixels || pixels instanceof Float32Array) return null; + return extractArtworkAccentFromPixels(pixels, method); + } finally { + snapshot.dispose(); + } + } finally { + paint.dispose(); + } + } finally { + surface.dispose(); + } + } finally { + source.dispose(); + } + } catch { + return null; + } finally { + encoded?.dispose(); + } +} diff --git a/src/theme/artworkAccentCache.ts b/src/theme/artworkAccentCache.ts new file mode 100644 index 0000000..fa93c43 --- /dev/null +++ b/src/theme/artworkAccentCache.ts @@ -0,0 +1,35 @@ +export interface ArtworkAccentCacheResult { + found: boolean; + value: string | null; +} + +export class ArtworkAccentCache { + private readonly entries = new Map(); + private readonly maxEntries: number; + + constructor(maxEntries = 256) { + this.maxEntries = maxEntries; + } + + get(key: string): ArtworkAccentCacheResult { + if (!this.entries.has(key)) return { found: false, value: null }; + const value = this.entries.get(key) ?? null; + this.entries.delete(key); + this.entries.set(key, value); + return { found: true, value }; + } + + set(key: string, value: string | null): void { + this.entries.delete(key); + this.entries.set(key, value); + while (this.entries.size > this.maxEntries) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.entries.delete(oldest); + } + } + + get size(): number { + return this.entries.size; + } +} diff --git a/src/theme/artworkAccentMath.test.mts b/src/theme/artworkAccentMath.test.mts new file mode 100644 index 0000000..719ab70 --- /dev/null +++ b/src/theme/artworkAccentMath.test.mts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ArtworkAccentCache } from './artworkAccentCache.ts'; +import { extractArtworkAccentFromPixels } from './artworkAccentMath.ts'; + +type Pixel = [number, number, number, number?]; + +function pixels(entries: Array<{ pixel: Pixel; count: number }>): Uint8Array { + const values: number[] = []; + for (const { pixel, count } of entries) { + for (let index = 0; index < count; index += 1) { + values.push(pixel[0], pixel[1], pixel[2], pixel[3] ?? 255); + } + } + return Uint8Array.from(values); +} + +function rgb(hex: string): [number, number, number] { + return [ + Number.parseInt(hex.slice(1, 3), 16), + Number.parseInt(hex.slice(3, 5), 16), + Number.parseInt(hex.slice(5, 7), 16), + ]; +} + +test('transparent artwork has no usable accent', () => { + const transparent = pixels([{ pixel: [255, 0, 0, 0], count: 20 }]); + assert.equal(extractArtworkAccentFromPixels(transparent, 'average'), null); + assert.equal(extractArtworkAccentFromPixels(transparent, 'dominant'), null); + assert.equal(extractArtworkAccentFromPixels(transparent, 'vibrant'), null); +}); + +test('dominant extraction favors the largest usable color bucket', () => { + const sample = pixels([ + { pixel: [225, 30, 40], count: 30 }, + { pixel: [30, 60, 220], count: 5 }, + { pixel: [255, 255, 255], count: 20 }, + ]); + const result = extractArtworkAccentFromPixels(sample, 'dominant'); + assert.ok(result); + const [r, g, b] = rgb(result); + assert.ok(r > g * 2 && r > b * 2, result); +}); + +test('vibrant extraction can prefer a richer smaller bucket', () => { + const sample = pixels([ + { pixel: [115, 125, 130], count: 80 }, + { pixel: [20, 220, 90], count: 20 }, + ]); + const result = extractArtworkAccentFromPixels(sample, 'vibrant'); + assert.ok(result); + const [r, g, b] = rgb(result); + assert.ok(g > r * 2 && g > b, result); +}); + +test('average extraction ignores transparent pixels and normalizes the result', () => { + const sample = pixels([ + { pixel: [20, 80, 220], count: 10 }, + { pixel: [255, 0, 0, 0], count: 50 }, + ]); + const result = extractArtworkAccentFromPixels(sample, 'average'); + assert.ok(result); + const [r, g, b] = rgb(result); + assert.ok(b > r && b > g, result); +}); + +test('artwork accent cache is LRU and distinguishes a cached null', () => { + const cache = new ArtworkAccentCache(2); + cache.set('a', '#aa0000'); + cache.set('b', null); + assert.deepEqual(cache.get('a'), { found: true, value: '#aa0000' }); + cache.set('c', '#00cc00'); + assert.deepEqual(cache.get('b'), { found: false, value: null }); + assert.deepEqual(cache.get('a'), { found: true, value: '#aa0000' }); + assert.deepEqual(cache.get('c'), { found: true, value: '#00cc00' }); + assert.equal(cache.size, 2); +}); diff --git a/src/theme/artworkAccentMath.ts b/src/theme/artworkAccentMath.ts new file mode 100644 index 0000000..63f4fa9 --- /dev/null +++ b/src/theme/artworkAccentMath.ts @@ -0,0 +1,266 @@ +import type { CoverArtAccentMethod } from '@/stores/themeStore'; + +const MIN_ALPHA = 24; +const VIBRANT_MIN_ALPHA = 48; +const DOMINANT_BUCKET_SIZE = 24; + +type Rgb = { r: number; g: number; b: number }; + +interface AccentNormalizationOptions { + saturationFloor: number; + saturationCeiling: number; + lightnessFloor: number; + lightnessCeiling: number; +} + +interface AccentBucket { + count: number; + sumR: number; + sumG: number; + sumB: number; + saturationSum: number; + luminanceSum: number; + chromaSum: number; +} + +interface PixelAnalysis extends Rgb { + max: number; + min: number; + saturation: number; + luminance: number; + chroma: number; +} + +interface BucketExtractionOptions { + minAlpha: number; + normalizeColor: (color: Rgb) => Rgb; + isPixelAccepted: (pixel: PixelAnalysis) => boolean; + scoreBucket: (bucket: AccentBucket) => number; + fallback: () => string | null; +} + +const DEFAULT_NORMALIZATION: AccentNormalizationOptions = { + saturationFloor: 0.32, + saturationCeiling: 0.9, + lightnessFloor: 0.33, + lightnessCeiling: 0.68, +}; + +const VIBRANT_NORMALIZATION: AccentNormalizationOptions = { + saturationFloor: 0.5, + saturationCeiling: 0.98, + lightnessFloor: 0.38, + lightnessCeiling: 0.62, +}; + +const clamp = (value: number, min: number, max: number) => + Math.max(min, Math.min(max, value)); +const clampByte = (value: number) => clamp(Math.round(value), 0, 255); + +function rgbToHex({ r, g, b }: Rgb): string { + const toHex = (value: number) => clampByte(value).toString(16).padStart(2, '0'); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; +} + +function rgbToHsl({ r, g, b }: Rgb): { h: number; s: number; l: number } { + const nr = clampByte(r) / 255; + const ng = clampByte(g) / 255; + const nb = clampByte(b) / 255; + const max = Math.max(nr, ng, nb); + const min = Math.min(nr, ng, nb); + const delta = max - min; + let h = 0; + if (delta > 0) { + if (max === nr) h = ((ng - nb) / delta) % 6; + else if (max === ng) h = (nb - nr) / delta + 2; + else h = (nr - ng) / delta + 4; + h /= 6; + if (h < 0) h += 1; + } + const l = (max + min) / 2; + const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1)); + return { h, s, l }; +} + +function hueToRgb(p: number, q: number, t: number): number { + let value = t; + if (value < 0) value += 1; + if (value > 1) value -= 1; + if (value < 1 / 6) return p + (q - p) * 6 * value; + if (value < 1 / 2) return q; + if (value < 2 / 3) return p + (q - p) * (2 / 3 - value) * 6; + return p; +} + +function hslToRgb({ h, s, l }: { h: number; s: number; l: number }): Rgb { + const hue = ((h % 1) + 1) % 1; + const sat = clamp(s, 0, 1); + const light = clamp(l, 0, 1); + if (sat === 0) { + const gray = clampByte(light * 255); + return { r: gray, g: gray, b: gray }; + } + const q = light < 0.5 ? light * (1 + sat) : light + sat - light * sat; + const p = 2 * light - q; + return { + r: clampByte(hueToRgb(p, q, hue + 1 / 3) * 255), + g: clampByte(hueToRgb(p, q, hue) * 255), + b: clampByte(hueToRgb(p, q, hue - 1 / 3) * 255), + }; +} + +function normalizeAccentColor(color: Rgb, options: AccentNormalizationOptions): Rgb { + const hsl = rgbToHsl(color); + return hslToRgb({ + h: hsl.h, + s: clamp(Math.max(hsl.s, options.saturationFloor), 0, options.saturationCeiling), + l: clamp(hsl.l, options.lightnessFloor, options.lightnessCeiling), + }); +} + +function analyzePixel(r: number, g: number, b: number): PixelAnalysis { + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + return { + r, + g, + b, + max, + min, + saturation: max === 0 ? 0 : (max - min) / max, + luminance: (max + min) / (2 * 255), + chroma: (max - min) / 255, + }; +} + +function scoreDominantBucket(bucket: AccentBucket): number { + if (bucket.count <= 0) return -1; + const avgSaturation = bucket.saturationSum / bucket.count; + const avgLuminance = bucket.luminanceSum / bucket.count; + const midToneWeight = 1 - Math.min(1, Math.abs(avgLuminance - 0.52) / 0.52); + return bucket.count * (1 + avgSaturation * 0.9) * (0.55 + midToneWeight * 0.45); +} + +function scoreVibrantBucket(bucket: AccentBucket): number { + if (bucket.count <= 0) return -1; + const avgSaturation = bucket.saturationSum / bucket.count; + const avgLuminance = bucket.luminanceSum / bucket.count; + const avgChroma = bucket.chromaSum / bucket.count; + const midToneWeight = 1 - Math.min(1, Math.abs(avgLuminance - 0.52) / 0.52); + return ( + Math.pow(bucket.count, 0.55) * + (0.45 + avgSaturation * 1.6 + avgChroma * 0.9) * + (0.75 + midToneWeight * 0.35) + ); +} + +function extractAverageColor(pixels: Uint8Array): string | null { + let sumR = 0; + let sumG = 0; + let sumB = 0; + let totalWeight = 0; + for (let i = 0; i < pixels.length; i += 4) { + const alpha = pixels[i + 3]; + if (alpha < MIN_ALPHA) continue; + const weight = alpha / 255; + totalWeight += weight; + sumR += pixels[i] * weight; + sumG += pixels[i + 1] * weight; + sumB += pixels[i + 2] * weight; + } + if (totalWeight <= 0) return null; + return rgbToHex(normalizeAccentColor({ + r: sumR / totalWeight, + g: sumG / totalWeight, + b: sumB / totalWeight, + }, DEFAULT_NORMALIZATION)); +} + +function extractBucketedColor( + pixels: Uint8Array, + options: BucketExtractionOptions, +): string | null { + const buckets = new Map(); + for (let i = 0; i < pixels.length; i += 4) { + const alpha = pixels[i + 3]; + if (alpha < options.minAlpha) continue; + const pixel = analyzePixel(pixels[i], pixels[i + 1], pixels[i + 2]); + if (!options.isPixelAccepted(pixel)) continue; + const key = [ + Math.round(pixel.r / DOMINANT_BUCKET_SIZE), + Math.round(pixel.g / DOMINANT_BUCKET_SIZE), + Math.round(pixel.b / DOMINANT_BUCKET_SIZE), + ].join('-'); + const existing = buckets.get(key); + if (existing) { + existing.count += 1; + existing.sumR += pixel.r; + existing.sumG += pixel.g; + existing.sumB += pixel.b; + existing.saturationSum += pixel.saturation; + existing.luminanceSum += pixel.luminance; + existing.chromaSum += pixel.chroma; + } else { + buckets.set(key, { + count: 1, + sumR: pixel.r, + sumG: pixel.g, + sumB: pixel.b, + saturationSum: pixel.saturation, + luminanceSum: pixel.luminance, + chromaSum: pixel.chroma, + }); + } + } + if (buckets.size === 0) return options.fallback(); + let winner: AccentBucket | null = null; + let bestScore = -1; + for (const bucket of buckets.values()) { + const score = options.scoreBucket(bucket); + if (score > bestScore) { + bestScore = score; + winner = bucket; + } + } + if (!winner || winner.count <= 0) return options.fallback(); + return rgbToHex(options.normalizeColor({ + r: winner.sumR / winner.count, + g: winner.sumG / winner.count, + b: winner.sumB / winner.count, + })); +} + +function extractDominantColor(pixels: Uint8Array): string | null { + return extractBucketedColor(pixels, { + minAlpha: MIN_ALPHA, + normalizeColor: (color) => normalizeAccentColor(color, DEFAULT_NORMALIZATION), + isPixelAccepted: (pixel) => { + if (pixel.max < 24 || pixel.min > 240) return false; + return pixel.saturation >= 0.08; + }, + scoreBucket: scoreDominantBucket, + fallback: () => extractAverageColor(pixels), + }); +} + +function extractVibrantColor(pixels: Uint8Array): string | null { + return extractBucketedColor(pixels, { + minAlpha: VIBRANT_MIN_ALPHA, + normalizeColor: (color) => normalizeAccentColor(color, VIBRANT_NORMALIZATION), + isPixelAccepted: (pixel) => { + if (pixel.saturation < 0.16 || pixel.luminance < 0.1) return false; + return !(pixel.luminance > 0.93 && pixel.saturation < 0.35); + }, + scoreBucket: scoreVibrantBucket, + fallback: () => extractDominantColor(pixels), + }); +} + +export function extractArtworkAccentFromPixels( + pixels: Uint8Array, + method: CoverArtAccentMethod, +): string | null { + if (method === 'average') return extractAverageColor(pixels); + if (method === 'vibrant') return extractVibrantColor(pixels); + return extractDominantColor(pixels); +} diff --git a/src/theme/colorUtils.ts b/src/theme/colorUtils.ts index 5ae2c99..f8a9b8a 100644 --- a/src/theme/colorUtils.ts +++ b/src/theme/colorUtils.ts @@ -3,6 +3,17 @@ * All hex I/O is 6-digit `#rrggbb` (the solid-token invariant — see palettes.ts). */ +export function normalizeHexColor(value: string): string | null { + const trimmed = value.trim(); + const short = /^#?([0-9a-fA-F]{3})$/.exec(trimmed); + if (short) { + const [r, g, b] = short[1].split(''); + return `#${r}${r}${g}${g}${b}${b}`.toLowerCase(); + } + const full = /^#?([0-9a-fA-F]{6})$/.exec(trimmed); + return full ? `#${full[1].toLowerCase()}` : null; +} + export function hexToRgb(hex: string): { r: number; g: number; b: number } { return { r: parseInt(hex.slice(1, 3), 16), @@ -77,3 +88,54 @@ export function mixHex(a: string, b: string, t: number): string { ca.b + (cb.b - ca.b) * t, ); } + +/** h in degrees [0, 360), s/v as fractions [0, 1]. */ +export interface HsvColor { + h: number; + s: number; + v: number; +} + +export function hexToHsv(hex: string): HsvColor { + const normalized = normalizeHexColor(hex) ?? '#000000'; + const { r, g, b } = hexToRgb(normalized); + 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 delta = max - min; + let h = 0; + if (delta > 0) { + if (max === rn) h = 60 * (((gn - bn) / delta) % 6); + else if (max === gn) h = 60 * ((bn - rn) / delta + 2); + else h = 60 * ((rn - gn) / delta + 4); + } + if (h < 0) h += 360; + return { + h, + s: max === 0 ? 0 : delta / max, + v: max, + }; +} + +export function hsvToHex(h: number, s: number, v: number): string { + const hue = ((h % 360) + 360) % 360; + const saturation = Math.min(1, Math.max(0, s)); + const value = Math.min(1, Math.max(0, v)); + const chroma = value * saturation; + const x = chroma * (1 - Math.abs(((hue / 60) % 2) - 1)); + const match = value - chroma; + let channels: [number, number, number]; + if (hue < 60) channels = [chroma, x, 0]; + else if (hue < 120) channels = [x, chroma, 0]; + else if (hue < 180) channels = [0, chroma, x]; + else if (hue < 240) channels = [0, x, chroma]; + else if (hue < 300) channels = [x, 0, chroma]; + else channels = [chroma, 0, x]; + return rgbToHex( + (channels[0] + match) * 255, + (channels[1] + match) * 255, + (channels[2] + match) * 255, + ); +} diff --git a/src/theme/resolve.ts b/src/theme/resolve.ts index bb69ffc..e4d06c0 100644 --- a/src/theme/resolve.ts +++ b/src/theme/resolve.ts @@ -1,6 +1,6 @@ -import type { SystemPalette } from '../../modules/astra-system-colors'; -import { deriveAccent, type AccentId } from './accents'; -import { mixHex, rgbaFromHex } from './colorUtils'; +import type { SystemPalette } from '../../modules/astra-system-colors/index.ts'; +import { deriveAccentPreference, type AccentPreference } from './accents.ts'; +import { mixHex, rgbaFromHex } from './colorUtils.ts'; import { amoledBase, darkBase, @@ -8,7 +8,7 @@ import { midnightBase, type BasePalette, type Palette, -} from './palettes'; +} from './palettes.ts'; /** What the user picks in settings. */ export type BaseThemeId = 'system' | 'midnight' | 'dark' | 'amoled' | 'light' | 'materialYou'; @@ -129,7 +129,7 @@ export function buildMaterialYouPalette(ramps: SystemPalette, isDark: boolean): export interface ResolveThemeInput { baseTheme: BaseThemeId; preferredDark: PreferredDark; - accentId: AccentId; + accentPreference: AccentPreference; systemScheme: 'light' | 'dark'; /** null → Material You unavailable (iOS, (null); + +export function ScopedPaletteProvider({ + colors, + children, +}: { + colors: Palette; + children: ReactNode; +}) { + return createElement(ScopedPaletteContext.Provider, { value: colors }, children); +} + /** The resolved theme (id, isDark, statusBarStyle, colors). */ export function useTheme(): AppTheme { return useThemeStore((s) => s.theme); @@ -10,7 +23,9 @@ export function useTheme(): AppTheme { /** Just the palette — the common case for inline `colors.x` props. */ export function useColors(): Palette { - return useThemeStore((s) => s.theme.colors); + const scoped = useContext(ScopedPaletteContext); + const global = useThemeStore((s) => s.theme.colors); + return scoped ?? global; } /** @@ -28,10 +43,11 @@ export function useColors(): Palette { */ export function createThemedStyles>( factory: (colors: Palette) => T, -): () => T { +): (override?: Palette) => T { const cache = new WeakMap(); - return function useThemedStyles(): T { - const colors = useThemeStore((s) => s.theme.colors); + return function useThemedStyles(override?: Palette): T { + const inherited = useColors(); + const colors = override ?? inherited; let styles = cache.get(colors); if (styles === undefined) { styles = StyleSheet.create(factory(colors)); diff --git a/src/theme/useNowPlayingArtworkAccent.ts b/src/theme/useNowPlayingArtworkAccent.ts new file mode 100644 index 0000000..d8b14b6 --- /dev/null +++ b/src/theme/useNowPlayingArtworkAccent.ts @@ -0,0 +1,65 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import type { CoverArtAccentMethod } from '@/stores/themeStore'; +import { md5Hex } from '@/lib/hash'; +import { ArtworkAccentCache } from './artworkAccentCache'; +import { extractArtworkAccent } from './artworkAccent'; + +const accentCache = new ArtworkAccentCache(256); + +interface UseNowPlayingArtworkAccentInput { + enabled: boolean; + artworkUri: string | null; + artworkIdentity: string | null; + method: CoverArtAccentMethod; +} + +export function useNowPlayingArtworkAccent({ + enabled, + artworkUri, + artworkIdentity, + method, +}: UseNowPlayingArtworkAccentInput): string | null { + const artworkSourceHash = useMemo( + () => (enabled && artworkUri ? md5Hex(artworkUri) : null), + [artworkUri, enabled], + ); + const cacheKey = + enabled && artworkUri && artworkIdentity && artworkSourceHash + ? `${method}:${artworkIdentity}:${artworkSourceHash}` + : null; + const [resolved, setResolved] = useState<{ + key: string; + accent: string | null; + } | null>(null); + const requestToken = useRef(0); + + useEffect(() => { + requestToken.current += 1; + const token = requestToken.current; + if (!cacheKey || !artworkUri) return; + + const cached = accentCache.get(cacheKey); + if (cached.found) { + queueMicrotask(() => { + if (requestToken.current === token) { + setResolved({ key: cacheKey, accent: cached.value }); + } + }); + return () => { + requestToken.current += 1; + }; + } + + void extractArtworkAccent(artworkUri, method).then((nextAccent) => { + if (requestToken.current !== token) return; + accentCache.set(cacheKey, nextAccent); + setResolved({ key: cacheKey, accent: nextAccent }); + }); + + return () => { + requestToken.current += 1; + }; + }, [artworkUri, cacheKey, method]); + + return cacheKey && resolved?.key === cacheKey ? resolved.accent : null; +}