From bfcf6e48b233caced5e19e5cf7edc79165761e8c Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:52:57 +0300 Subject: [PATCH] fix: harmonize wave colors from album covers --- apps/mobile/src/ui/wave/WaveOrb.tsx | 6 +-- apps/web/src/pages/HomePage.tsx | 6 +-- apps/web/src/widgets/wave/WaveButton.tsx | 12 +++--- docs/design.md | 6 +++ packages/core/src/index.ts | 2 +- packages/core/src/wave-anim.test.ts | 50 ++++++++++++++++++++++++ packages/core/src/wave-anim.ts | 33 ++++++++++++++++ 7 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/wave-anim.test.ts diff --git a/apps/mobile/src/ui/wave/WaveOrb.tsx b/apps/mobile/src/ui/wave/WaveOrb.tsx index d74d11a..f2dcf6e 100644 --- a/apps/mobile/src/ui/wave/WaveOrb.tsx +++ b/apps/mobile/src/ui/wave/WaveOrb.tsx @@ -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(initialWaveState(size)); diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx index 6b10c10..56b0b71 100644 --- a/apps/web/src/pages/HomePage.tsx +++ b/apps/web/src/pages/HomePage.tsx @@ -41,8 +41,6 @@ const ICONS: Record = { 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) => { 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')} diff --git a/apps/web/src/widgets/wave/WaveButton.tsx b/apps/web/src/widgets/wave/WaveButton.tsx index 90b6e02..b6bfce6 100644 --- a/apps/web/src/widgets/wave/WaveButton.tsx +++ b/apps/web/src/widgets/wave/WaveButton.tsx @@ -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(null); @@ -36,7 +36,7 @@ export function WaveButton(props: WaveButtonProps) { const wrapRef = useRef(null); const st = useRef({ 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; diff --git a/docs/design.md b/docs/design.md index 1a96105..946a490 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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 использует отдельные адаптивный цветной diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 8a5d0fe..09df8e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; diff --git a/packages/core/src/wave-anim.test.ts b/packages/core/src/wave-anim.test.ts new file mode 100644 index 0000000..2cf80ac --- /dev/null +++ b/packages/core/src/wave-anim.test.ts @@ -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); + } + }); +}); diff --git a/packages/core/src/wave-anim.ts b/packages/core/src/wave-anim.ts index ae39e50..44a3002 100644 --- a/packages/core/src/wave-anim.ts +++ b/packages/core/src/wave-anim.ts @@ -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,