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, useMemo, useState, type ReactNode } from 'react'; import { AppState, Pressable, View } from 'react-native'; import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated'; import { useThemeName } from '../../theme'; import { drawLiquid, drawOrb } from './draw'; export interface WaveOrbProps { variant: 'orb' | 'liquid'; playing: boolean; bpm?: number; energy?: number; colors?: readonly string[]; size: number; label?: string; onPress?: () => void; children?: ReactNode; } /** * «Моя волна» на Skia (docs/design.md#3): в покое «дышит», при игре пульсирует в такт BPM с силой от energy, * при нажатии сжимается с отскоком. Кадры считаются в UI-потоке; вне экрана и в фоне анимация стоит. * Без `onPress` — декоративная визуализация (полноэкранный плеер в виде «волна»). */ export function WaveOrb({ variant, playing, bpm = 100, energy = 0.6, colors, size, label, onPress, children }: WaveOrbProps) { const dark = useThemeName() === 'dark'; const reduced = useReducedMotion(); const [focused, setFocused] = useState(true); useFocusEffect( useCallback(() => { setFocused(true); return () => setFocused(false); }, []), ); const [foreground, setForeground] = useState(AppState.currentState === 'active'); const palette = useMemo(() => wavePalette(colors), [colors]); const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced }); const state = useSharedValue(initialWaveState(size)); const pressT = useSharedValue(0); // Новый объект на каждом кадре — пересчёт картинки (одно и то же число могло бы не вызвать его). const time = useSharedValue({ ts: 0, dt: 0 }); useEffect(() => { props.value = { variant, playing, bpm, energy, colors: palette, dark, reduced }; }, [variant, playing, bpm, energy, palette[0], palette[1], palette[2], dark, reduced]); useEffect(() => { const sub = AppState.addEventListener('change', (st) => setForeground(st === 'active')); return () => sub.remove(); }, []); const frame = useFrameCallback((info) => { time.value = { ts: info.timestamp, dt: Math.min(0.05, (info.timeSincePreviousFrame ?? 16) / 1000) }; }, false); useEffect(() => { frame.setActive(focused && foreground); }, [focused, foreground]); const S = size * 1.6; const picture = useDerivedValue(() => { const dt = time.value.dt; const s = state.value; Object.assign(s, props.value); s.size = size; if (pressT.value === 0 && s.pressT === 1) s.pressV -= 0.12; // отпустили — отскок s.pressT = pressT.value; step(s, dt); const r = size * 0.5 * waveScale(s); return createPicture( (canvas) => { if (s.variant === 'liquid') drawLiquid(canvas, S / 2, S / 2, r, s); else drawOrb(canvas, S / 2, S / 2, r, s); }, { width: S, height: S }, ); }); const labelStyle = useAnimatedStyle(() => { void time.value.ts; // пересчёт на каждом кадре const s = state.value; return { transform: [{ scale: waveScale(s) + s.pulse * 0.03 }] }; }); const body = ( {children} ); if (!onPress) return {body}; return ( (pressT.value = 1)} onPressOut={() => (pressT.value = 0)} accessibilityRole="button" accessibilityLabel={label} accessibilityState={{ selected: playing }} > {body} ); }