mirror of
https://github.com/Boof2015/astra-mobile.git
synced 2026-08-12 05:10:52 +02:00
custom accent color + port over desktop cover art color
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user