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;
+}