custom accent color + port over desktop cover art color

This commit is contained in:
Boof2015
2026-07-28 18:35:36 -04:00
parent 0b709725d2
commit d2bda52c86
18 changed files with 1402 additions and 51 deletions
+1
View File
@@ -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",
+1 -3
View File
@@ -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() {
</View>
{accentApplies ? (
<View style={styles.accentBlock}>
<AccentSwatchRow value={accentId} onChange={(id) => void setAccent(id)} />
<AccentSwatchRow />
</View>
) : null}
</View>
+32 -3
View File
@@ -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<DbTrack | null>(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 (
<ScopedPaletteProvider colors={colors}>
<View style={StyleSheet.absoluteFill} pointerEvents={playerOpen ? 'box-none' : 'none'}>
<GestureDetector gesture={pan}>
<Animated.View
@@ -1673,6 +1701,7 @@ export function NowPlayingOverlay() {
onClose={() => setTargetPickerOpen(false)}
/>
</View>
</ScopedPaletteProvider>
);
}
@@ -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<HsvColor>(() => 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 (
<Modal
visible
transparent
statusBarTranslucent
navigationBarTranslucent
animationType="none"
onRequestClose={onClose}
>
<GestureHandlerRootView style={styles.modalRoot}>
<AppSheet onClose={onClose}>
<AppSheetTitle
title="Custom accent"
subtitle="Drag to choose a color or enter an exact hex value."
/>
<GestureDetector gesture={svGesture}>
<View
style={styles.sv}
onLayout={onPickerLayout}
accessibilityRole="adjustable"
accessibilityLabel="Accent saturation and brightness"
>
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
<Rect x={0} y={0} width={pickerWidth} height={SV_HEIGHT} color={hueColor} />
<Rect x={0} y={0} width={pickerWidth} height={SV_HEIGHT}>
<LinearGradient
start={vec(0, 0)}
end={vec(pickerWidth, 0)}
colors={['#ffffff', '#ffffff00']}
/>
</Rect>
<Rect x={0} y={0} width={pickerWidth} height={SV_HEIGHT}>
<LinearGradient
start={vec(0, 0)}
end={vec(0, SV_HEIGHT)}
colors={['#00000000', '#000000']}
/>
</Rect>
</Canvas>
<View
pointerEvents="none"
style={[
styles.handle,
{
backgroundColor: previewHex,
left: hsv.s * pickerWidth - HANDLE_SIZE / 2,
top: (1 - hsv.v) * SV_HEIGHT - HANDLE_SIZE / 2,
},
]}
/>
</View>
</GestureDetector>
<GestureDetector gesture={hueGesture}>
<View
style={styles.hue}
accessibilityRole="adjustable"
accessibilityLabel="Accent hue"
>
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
<Rect x={0} y={0} width={pickerWidth} height={HUE_HEIGHT}>
<LinearGradient
start={vec(0, 0)}
end={vec(pickerWidth, 0)}
colors={HUE_COLORS}
/>
</Rect>
</Canvas>
<View
pointerEvents="none"
style={[
styles.hueHandle,
{ left: (hsv.h / 360) * pickerWidth - HANDLE_SIZE / 2 },
]}
/>
</View>
</GestureDetector>
<View style={styles.inputRow}>
<View style={[styles.preview, { backgroundColor: validInput ?? previewHex }]} />
<BottomSheetTextInput
value={input}
onChangeText={changeInput}
placeholder="#5B8AFF"
placeholderTextColor={colors.textTertiary}
style={[styles.input, input.length > 0 && !validInput && styles.inputInvalid]}
autoCapitalize="characters"
autoCorrect={false}
maxLength={7}
returnKeyType="done"
selectionColor={colors.accent}
onSubmitEditing={apply}
accessibilityLabel="Accent hex color"
/>
</View>
<Text
variant="caption"
color={validInput ? colors.textTertiary : colors.warning}
style={styles.validation}
>
{validInput ? 'Use three or six hexadecimal digits.' : 'Enter a valid hex color.'}
</Text>
<View style={styles.actions}>
<Pressable
android_ripple={ripple.bounded}
style={[styles.button, styles.cancel]}
onPress={onClose}
>
<Text variant="label" color={colors.textSecondary}>Cancel</Text>
</Pressable>
<Pressable
android_ripple={ripple.onAccent()}
style={[styles.button, styles.apply, !validInput && styles.disabled]}
disabled={!validInput}
onPress={apply}
>
<Text variant="label" color={colors.bgPrimary}>Apply</Text>
</Pressable>
</View>
</AppSheet>
</GestureHandlerRootView>
</Modal>
);
}
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;
+63 -10
View File
@@ -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 (
<View style={styles.wrap}>
<View style={styles.row}>
{ACCENT_IDS.map((id) => {
const selected = id === value;
const selected = preference.kind === 'preset' && id === preference.id;
return (
<Pressable android_ripple={ripple.bounded}
key={id}
onPress={() => {
if (selected) return;
playHaptic('selection');
onChange(id);
void setAccent(id);
}}
accessibilityRole="radio"
accessibilityState={{ selected }}
@@ -48,10 +54,56 @@ export function AccentSwatchRow({ value, onChange }: AccentSwatchRowProps) {
</Pressable>
);
})}
<Pressable
android_ripple={ripple.bounded}
onPress={() => {
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' ? (
<Canvas pointerEvents="none" style={StyleSheet.absoluteFill}>
<Rect x={0} y={0} width={SWATCH_SIZE} height={SWATCH_SIZE}>
<LinearGradient
start={vec(0, SWATCH_SIZE)}
end={vec(SWATCH_SIZE, 0)}
colors={RAINBOW}
/>
</Rect>
</Canvas>
) : null}
{preference.kind === 'custom' ? (
<Ionicons name="checkmark" size={18} color={colors.bgPrimary} />
) : (
<Ionicons name="add" size={18} color="#ffffff" />
)}
</Pressable>
</View>
<Text variant="caption" color={colors.textSecondary}>
Accent · {ACCENTS[value].label}
Accent · {preference.kind === 'preset'
? ACCENTS[preference.id].label
: `Custom ${preference.hex.toUpperCase()}`}
</Text>
{pickerOpen ? (
<AccentColorSheet
initialHex={initialHex}
onPreview={previewCustomAccent}
onApply={(hex) => void setCustomAccent(hex)}
onClose={() => {
previewCustomAccent(null);
setPickerOpen(false);
}}
/>
) : null}
</View>
);
}
@@ -73,6 +125,7 @@ const useStyles = createThemedStyles((colors) => ({
justifyContent: 'center',
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.glassBorder,
overflow: 'hidden',
},
swatchSelected: {
borderWidth: 2,
+55 -3
View File
@@ -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 ? (
<View style={styles.appearanceBlock}>
<AccentSwatchRow value={accentId} onChange={(id) => void setAccent(id)} />
<AccentSwatchRow />
</View>
) : null}
<SettingsSectionLabel spaced>NOW PLAYING ACCENT</SettingsSectionLabel>
<SettingsCard>
<Text variant="body" style={styles.settingTitle}>
Accent source
</Text>
<Text variant="caption" color={colors.textSecondary} style={styles.settingNote}>
Cover Art colors only the open player. The app accent is used when artwork is unavailable.
</Text>
<SegmentedControl
segments={NOW_PLAYING_ACCENT_SEGMENTS}
value={nowPlayingAccentSource}
onChange={(key) =>
void setNowPlayingAccentSource(key as NowPlayingAccentSource)
}
/>
{nowPlayingAccentSource === 'cover-art' ? (
<View style={styles.coverArtMethod}>
<Text variant="caption" color={colors.textSecondary}>
Cover art method
</Text>
<SegmentedControl
segments={COVER_ART_METHOD_SEGMENTS}
value={coverArtAccentMethod}
onChange={(key) =>
void setCoverArtAccentMethod(key as CoverArtAccentMethod)
}
/>
</View>
) : null}
</SettingsCard>
<SettingsSectionLabel spaced>HOME</SettingsSectionLabel>
<SettingsCard>
<Text variant="body" style={styles.settingTitle}>
@@ -496,6 +544,10 @@ const useStyles = createThemedStyles((colors) => ({
settingNote: {
marginBottom: spacing.md,
},
coverArtMethod: {
gap: spacing.sm,
marginTop: spacing.lg,
},
options: {
gap: spacing.sm,
},
+105 -12
View File
@@ -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<void>;
setBaseTheme: (id: BaseThemeId) => Promise<void>;
setPreferredDark: (id: PreferredDark) => Promise<void>;
setAccent: (id: AccentId) => Promise<void>;
setCustomAccent: (hex: string) => Promise<void>;
previewCustomAccent: (hex: string | null) => void;
setNowPlayingAccentSource: (source: NowPlayingAccentSource) => Promise<void>;
setCoverArtAccentMethod: (method: CoverArtAccentMethod) => Promise<void>;
/** 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<ThemeStore>((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<ThemeStore>((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) });
},
}));
+105
View File
@@ -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);
});
+56 -9
View File
@@ -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<AccentTokens>,
overridesLight?: Partial<AccentTokens>,
): 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);
}
+70
View File
@@ -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<string | null> {
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();
}
}
+35
View File
@@ -0,0 +1,35 @@
export interface ArtworkAccentCacheResult {
found: boolean;
value: string | null;
}
export class ArtworkAccentCache {
private readonly entries = new Map<string, string | null>();
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;
}
}
+77
View File
@@ -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);
});
+266
View File
@@ -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<string, AccentBucket>();
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);
}
+62
View File
@@ -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,
);
}
+7 -7
View File
@@ -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, <API 31, module absent). */
materialYouRamps: SystemPalette | null;
@@ -137,7 +137,7 @@ export interface ResolveThemeInput {
/** Pure resolution: settings + system inputs → one immutable AppTheme. */
export function resolveTheme(input: ResolveThemeInput): AppTheme {
const { baseTheme, preferredDark, accentId, systemScheme, materialYouRamps } = input;
const { baseTheme, preferredDark, accentPreference, systemScheme, materialYouRamps } = input;
if (baseTheme === 'materialYou' && materialYouRamps !== null) {
const isDark = systemScheme === 'dark';
@@ -162,6 +162,6 @@ export function resolveTheme(input: ResolveThemeInput): AppTheme {
id: staticId,
isDark,
statusBarStyle: isDark ? 'light' : 'dark',
colors: { ...base, ...deriveAccent(accentId, isDark) },
colors: { ...base, ...deriveAccentPreference(accentPreference, isDark) },
};
}
+12
View File
@@ -0,0 +1,12 @@
import type { Palette } from './palettes.ts';
import { deriveAccentFromHex } from './accents.ts';
export function paletteWithAccent(
colors: Palette,
accentHex: string | null,
isDark: boolean,
): Palette {
return accentHex
? { ...colors, ...deriveAccentFromHex(accentHex, isDark) }
: colors;
}
+20 -4
View File
@@ -1,8 +1,21 @@
import { createContext, createElement, useContext, type ReactNode } from 'react';
import { StyleSheet } from 'react-native';
import { useThemeStore } from '@/stores/themeStore';
import type { Palette } from './palettes';
import type { AppTheme } from './resolve';
const ScopedPaletteContext = createContext<Palette | null>(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<T extends StyleSheet.NamedStyles<T>>(
factory: (colors: Palette) => T,
): () => T {
): (override?: Palette) => T {
const cache = new WeakMap<Palette, T>();
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));
+65
View File
@@ -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;
}