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 { Canvas, createPicture, Picture } from '@shopify/react-native-skia';
|
||||||
import { useFocusEffect } from 'expo-router';
|
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 { AppState, Pressable, View } from 'react-native';
|
||||||
import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated';
|
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 [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 props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced });
|
||||||
const state = useSharedValue<WaveState>(initialWaveState(size));
|
const state = useSharedValue<WaveState>(initialWaveState(size));
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ const ICONS: Record<string, string> = {
|
|||||||
rainy: waveRainyIcon,
|
rainy: waveRainyIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
|
||||||
|
|
||||||
type Tr = (key: string) => string;
|
type Tr = (key: string) => string;
|
||||||
|
|
||||||
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
|
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
|
||||||
@@ -97,8 +95,6 @@ function WaveHome() {
|
|||||||
const waveOn = st.wave !== null;
|
const waveOn = st.wave !== null;
|
||||||
const playing = waveOn && st.playing;
|
const playing = waveOn && st.playing;
|
||||||
const track = waveOn ? st.track : null;
|
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) => {
|
const run = (fn: () => Promise<void> | void) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -129,7 +125,7 @@ function WaveHome() {
|
|||||||
readAudio={player.readAudio}
|
readAudio={player.readAudio}
|
||||||
bpm={track?.features?.bpm ?? undefined}
|
bpm={track?.features?.bpm ?? undefined}
|
||||||
energy={track?.features?.energy ?? undefined}
|
energy={track?.features?.energy ?? undefined}
|
||||||
colors={colors}
|
colors={track?.album.cover_colors}
|
||||||
theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}
|
theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}
|
||||||
size={320}
|
size={320}
|
||||||
label={t(playing ? 'wave.pause' : 'wave.start')}
|
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';
|
import { drawLiquid, drawOrb, step, type WaveState } from './draw';
|
||||||
|
|
||||||
@@ -8,7 +9,7 @@ export interface WaveButtonProps {
|
|||||||
readAudio?: () => { bass: number; mid: number; treble: number };
|
readAudio?: () => { bass: number; mid: number; treble: number };
|
||||||
bpm?: number;
|
bpm?: number;
|
||||||
energy?: number;
|
energy?: number;
|
||||||
colors?: [string, string, string];
|
colors?: readonly string[];
|
||||||
theme?: 'dark' | 'light';
|
theme?: 'dark' | 'light';
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
size?: number;
|
size?: number;
|
||||||
@@ -17,8 +18,6 @@ export interface WaveButtonProps {
|
|||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
|
||||||
|
|
||||||
function prefersReducedMotion(): boolean {
|
function prefersReducedMotion(): boolean {
|
||||||
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||||
}
|
}
|
||||||
@@ -29,6 +28,7 @@ function prefersReducedMotion(): boolean {
|
|||||||
*/
|
*/
|
||||||
export function WaveButton(props: WaveButtonProps) {
|
export function WaveButton(props: WaveButtonProps) {
|
||||||
const size = props.size ?? 320;
|
const size = props.size ?? 320;
|
||||||
|
const colors = useMemo(() => wavePalette(props.colors), [props.colors]);
|
||||||
const audioRef = useRef(props.readAudio);
|
const audioRef = useRef(props.readAudio);
|
||||||
audioRef.current = props.readAudio;
|
audioRef.current = props.readAudio;
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
@@ -36,7 +36,7 @@ export function WaveButton(props: WaveButtonProps) {
|
|||||||
const wrapRef = useRef<HTMLDivElement>(null);
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
const st = useRef<WaveState>({
|
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,
|
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,
|
dark: true, reduced: false, size,
|
||||||
});
|
});
|
||||||
// Пропсы читаются в цикле анимации через ref — перерисовка React не нужна.
|
// Пропсы читаются в цикле анимации через ref — перерисовка React не нужна.
|
||||||
@@ -45,7 +45,7 @@ export function WaveButton(props: WaveButtonProps) {
|
|||||||
s.playing = !!props.playing;
|
s.playing = !!props.playing;
|
||||||
s.bpm = props.bpm ?? 100;
|
s.bpm = props.bpm ?? 100;
|
||||||
s.energy = props.energy ?? 0.6;
|
s.energy = props.energy ?? 0.6;
|
||||||
s.colors = props.colors ?? DEFAULT_COLORS;
|
s.colors = colors;
|
||||||
s.dark = props.theme !== 'light';
|
s.dark = props.theme !== 'light';
|
||||||
s.size = size;
|
s.size = size;
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,12 @@
|
|||||||
|
|
||||||
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — светлая «p»-нота на сфере с радиальным градиентом `#9CC6FF → #2F80FF → #6354E8 → #3A1FA8`.
|
Палитра волны по умолчанию: `#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`; он же используется в шапке веб-приложения.
|
Исходник значка — `apps/web/public/favicon.svg`; он же используется в шапке веб-приложения.
|
||||||
Команда `pnpm icons` пересобирает PNG/ICO/ICNS для desktop, favicon PNG, Apple touch icon
|
Команда `pnpm icons` пересобирает PNG/ICO/ICNS для desktop, favicon PNG, Apple touch icon
|
||||||
и Android-ресурсы в `apps/mobile/assets/`. Android использует отдельные адаптивный цветной
|
и Android-ресурсы в `apps/mobile/assets/`. Android использует отдельные адаптивный цветной
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export {
|
|||||||
export { normalizeGain, TARGET_LUFS } from './gain';
|
export { normalizeGain, TARGET_LUFS } from './gain';
|
||||||
export { trackShareUrl } from './share';
|
export { trackShareUrl } from './share';
|
||||||
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
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';
|
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'];
|
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 {
|
export function initialWaveState(size: number): WaveState {
|
||||||
return {
|
return {
|
||||||
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
|
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