fix: harmonize wave colors from album covers
ci / ts / ts (packages + web) (push) Canceled after 5m28s
ci / module boundaries / boundaries (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m18s

This commit is contained in:
mrleo1nid committed 2026-10-22 14:52:57 +03:00
1 parent 02307802bc
commit bfcf6e48b2
7 files changed
+100 -15

No files matched your search

+3 -3
View File
@@ -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));
+1 -5
View File
@@ -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')}
+6 -6
View File
@@ -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;
+6
View File
@@ -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 использует отдельные адаптивный цветной
+1 -1
View File
@@ -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';
+50
View File
@@ -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);
}
});
});
+33
View File
@@ -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,