fix: harmonize wave colors from album covers
This commit is contained in:
1 parent
02307802bc
commit
bfcf6e48b2
7 files changed
+100
-15
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import { initialWaveState, step, WAVE_COLORS, waveScale, type WaveState } from '@pipu/core';
|
||||
import { initialWaveState, step, wavePalette, waveScale, type WaveState } from '@pipu/core';
|
||||
import { Canvas, createPicture, Picture } from '@shopify/react-native-skia';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { AppState, Pressable, View } from 'react-native';
|
||||
import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated';
|
||||
|
||||
@@ -36,7 +36,7 @@ export function WaveOrb({ variant, playing, bpm = 100, energy = 0.6, colors, siz
|
||||
}, []),
|
||||
);
|
||||
const [foreground, setForeground] = useState(AppState.currentState === 'active');
|
||||
const palette = (colors?.length === 3 ? colors : WAVE_COLORS) as [string, string, string];
|
||||
const palette = useMemo(() => wavePalette(colors), [colors]);
|
||||
|
||||
const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced });
|
||||
const state = useSharedValue<WaveState>(initialWaveState(size));
|
||||
|
||||
@@ -41,8 +41,6 @@ const ICONS: Record<string, string> = {
|
||||
rainy: waveRainyIcon,
|
||||
};
|
||||
|
||||
const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
||||
|
||||
type Tr = (key: string) => string;
|
||||
|
||||
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
|
||||
@@ -97,8 +95,6 @@ function WaveHome() {
|
||||
const waveOn = st.wave !== null;
|
||||
const playing = waveOn && st.playing;
|
||||
const track = waveOn ? st.track : null;
|
||||
const colors: [string, string, string] =
|
||||
track?.album.cover_colors.length === 3 ? (track.album.cover_colors as [string, string, string]) : WAVE_COLORS;
|
||||
|
||||
const run = (fn: () => Promise<void> | void) => {
|
||||
setError(null);
|
||||
@@ -129,7 +125,7 @@ function WaveHome() {
|
||||
readAudio={player.readAudio}
|
||||
bpm={track?.features?.bpm ?? undefined}
|
||||
energy={track?.features?.energy ?? undefined}
|
||||
colors={colors}
|
||||
colors={track?.album.cover_colors}
|
||||
theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}
|
||||
size={320}
|
||||
label={t(playing ? 'wave.pause' : 'wave.start')}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
|
||||
import { wavePalette, WAVE_COLORS } from '@pipu/core';
|
||||
import { useEffect, useMemo, useRef, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
|
||||
|
||||
import { drawLiquid, drawOrb, step, type WaveState } from './draw';
|
||||
|
||||
@@ -8,7 +9,7 @@ export interface WaveButtonProps {
|
||||
readAudio?: () => { bass: number; mid: number; treble: number };
|
||||
bpm?: number;
|
||||
energy?: number;
|
||||
colors?: [string, string, string];
|
||||
colors?: readonly string[];
|
||||
theme?: 'dark' | 'light';
|
||||
interactive?: boolean;
|
||||
size?: number;
|
||||
@@ -17,8 +18,6 @@ export interface WaveButtonProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
const DEFAULT_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
}
|
||||
@@ -29,6 +28,7 @@ function prefersReducedMotion(): boolean {
|
||||
*/
|
||||
export function WaveButton(props: WaveButtonProps) {
|
||||
const size = props.size ?? 320;
|
||||
const colors = useMemo(() => wavePalette(props.colors), [props.colors]);
|
||||
const audioRef = useRef(props.readAudio);
|
||||
audioRef.current = props.readAudio;
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
@@ -36,7 +36,7 @@ export function WaveButton(props: WaveButtonProps) {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const st = useRef<WaveState>({
|
||||
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
|
||||
speed: 1, phase: 0, variant: 'orb', playing: false, bpm: 100, energy: 0.6, colors: DEFAULT_COLORS,
|
||||
speed: 1, phase: 0, variant: 'orb', playing: false, bpm: 100, energy: 0.6, colors: WAVE_COLORS,
|
||||
dark: true, reduced: false, size,
|
||||
});
|
||||
// Пропсы читаются в цикле анимации через ref — перерисовка React не нужна.
|
||||
@@ -45,7 +45,7 @@ export function WaveButton(props: WaveButtonProps) {
|
||||
s.playing = !!props.playing;
|
||||
s.bpm = props.bpm ?? 100;
|
||||
s.energy = props.energy ?? 0.6;
|
||||
s.colors = props.colors ?? DEFAULT_COLORS;
|
||||
s.colors = colors;
|
||||
s.dark = props.theme !== 'light';
|
||||
s.size = size;
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
|
||||
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — светлая «p»-нота на сфере с радиальным градиентом `#9CC6FF → #2F80FF → #6354E8 → #3A1FA8`.
|
||||
|
||||
Цвета обложки адаптируются в `wavePalette` (`packages/core/src/wave-anim.ts`) одинаково для веба и Android:
|
||||
основой служит оттенок с наибольшей разницей RGB-каналов (> 0.08), акценты отклоняются на ±28°,
|
||||
насыщенность ограничена 72–82%, светлота — 46–58%. Так тёмные цвета остаются видимыми,
|
||||
а противоположные оттенки не обесцвечивают друг друга при наложении. Для почти бесцветных обложек
|
||||
и отсутствующей или некорректной палитры используется стандартная палитра волны.
|
||||
|
||||
Исходник значка — `apps/web/public/favicon.svg`; он же используется в шапке веб-приложения.
|
||||
Команда `pnpm icons` пересобирает PNG/ICO/ICNS для desktop, favicon PNG, Apple touch icon
|
||||
и Android-ресурсы в `apps/mobile/assets/`. Android использует отдельные адаптивный цветной
|
||||
|
||||
@@ -24,7 +24,7 @@ export {
|
||||
export { normalizeGain, TARGET_LUFS } from './gain';
|
||||
export { trackShareUrl } from './share';
|
||||
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
||||
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
|
||||
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, wavePalette, waveScale, type Rgb, type WaveState } from './wave-anim';
|
||||
|
||||
export { WaveCoordinator, EARLY_SKIP_MS, WAVE_REFILL_AT, type WaveInfo, type WaveOutcome, type WaveFeedback, type WaveGateway } from './wave';
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { rgb, wavePalette, WAVE_COLORS } from './wave-anim';
|
||||
|
||||
function hsl(color: string) {
|
||||
const [r, g, b] = rgb(color).map((v) => v / 255) as [number, number, number];
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const c = max - min;
|
||||
const l = (max + min) / 2;
|
||||
const h = c === 0 ? 0 : max === r ? (g - b) / c : max === g ? (b - r) / c + 2 : (r - g) / c + 4;
|
||||
return { h: (h * 60 + 360) % 360, s: c === 0 ? 0 : c / (1 - Math.abs(2 * l - 1)), l };
|
||||
}
|
||||
|
||||
describe('wave cover palette', () => {
|
||||
it.each([
|
||||
undefined, [], ['#fff'], ['#000', '#888', '#fff'],
|
||||
['#010203', '#fefdfc', '#808182'], ['#ff0000', 'invalid', '#0000ff'],
|
||||
])('uses the default palette for missing, neutral or malformed colors: %j', (colors) => {
|
||||
expect(wavePalette(colors)).toEqual(WAVE_COLORS);
|
||||
});
|
||||
|
||||
it('ignores neutral colors and preserves the strongest cover hue', () => {
|
||||
const colors = ['#ffffff', '#007733', '#222222'];
|
||||
const before = [...colors];
|
||||
expect(hsl(wavePalette(colors)[0]).h).toBeCloseTo(hsl('#007733').h, 0);
|
||||
expect(wavePalette(colors)).toEqual(wavePalette(['#007733', '#222222', '#ffffff']));
|
||||
expect(colors).toEqual(before);
|
||||
expect(wavePalette(['#fff', '#073', '#222'])).toEqual(wavePalette(colors));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['#040e08', '#18281d', '#374450'], // dark, muted cover like the reported case
|
||||
['#ff0000', '#00ff00', '#0000ff'], // opposing hues must not mix to gray
|
||||
['#ffff00', '#ffffff', '#000000'],
|
||||
['#ff0080', '#540029', '#fefefe'], // hue offsets wrap around red
|
||||
])('keeps a visible, saturated, harmonious palette for %j', (...colors) => {
|
||||
const palette = wavePalette(colors);
|
||||
const base = hsl(palette[0]);
|
||||
for (const color of palette) {
|
||||
expect(color).toMatch(/^#[\da-f]{6}$/i);
|
||||
const { h, s, l } = hsl(color);
|
||||
expect(s).toBeGreaterThanOrEqual(0.70);
|
||||
expect(s).toBeLessThanOrEqual(0.84);
|
||||
expect(l).toBeGreaterThanOrEqual(0.45);
|
||||
expect(l).toBeLessThanOrEqual(0.59);
|
||||
const distance = Math.abs(h - base.h);
|
||||
expect(Math.min(distance, 360 - distance)).toBeLessThan(29);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,39 @@ export interface WaveState {
|
||||
|
||||
export const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
||||
|
||||
/** Keep the cover's strongest hue, but avoid muddy or complementary additive blends. */
|
||||
export function wavePalette(colors?: readonly string[]): [string, string, string] {
|
||||
if (colors?.length !== 3 || !colors.every((c) => /^#(?:[\da-f]{3}|[\da-f]{6})$/i.test(c))) {
|
||||
return [...WAVE_COLORS];
|
||||
}
|
||||
let hue = 0;
|
||||
let strongest = 0;
|
||||
for (const color of colors) {
|
||||
const [r, g, b] = rgb(color).map((v) => v / 255) as Rgb;
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const chroma = max - min;
|
||||
// Absolute chroma rejects both near-black noise and nearly white/gray covers.
|
||||
if (chroma <= Math.max(0.08, strongest)) continue;
|
||||
strongest = chroma;
|
||||
hue = max === r ? ((g - b) / chroma) % 6 : max === g ? (b - r) / chroma + 2 : (r - g) / chroma + 4;
|
||||
hue = (hue * 60 + 360) % 360;
|
||||
}
|
||||
if (!strongest) return [...WAVE_COLORS];
|
||||
// A narrow hue range retains color when the translucent layers overlap.
|
||||
return [paletteColor(hue, 0.78, 0.50), paletteColor(hue + 28, 0.72, 0.46), paletteColor(hue - 28, 0.82, 0.58)];
|
||||
}
|
||||
|
||||
function paletteColor(hue: number, saturation: number, lightness: number): string {
|
||||
const h = ((hue % 360) + 360) % 360 / 60;
|
||||
const c = (1 - Math.abs(2 * lightness - 1)) * saturation;
|
||||
const x = c * (1 - Math.abs(h % 2 - 1));
|
||||
const m = lightness - c / 2;
|
||||
const channels = h < 1 ? [c, x, 0] : h < 2 ? [x, c, 0] : h < 3 ? [0, c, x]
|
||||
: h < 4 ? [0, x, c] : h < 5 ? [x, 0, c] : [c, 0, x];
|
||||
return `#${channels.map((v) => Math.round((v + m) * 255).toString(16).padStart(2, '0')).join('')}`;
|
||||
}
|
||||
|
||||
export function initialWaveState(size: number): WaveState {
|
||||
return {
|
||||
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
|
||||
|
||||
Reference in new issue
Block a user