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 -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,
},