ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m30s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m57s
ci / ts / ts (packages + web) (push) Successful in 1m16s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 33s
- My Wave is rendered with react-native-skia on the UI thread; physics and colour helpers moved to @pipu/core (wave-anim) and shared with web. - Full-screen player honours ui_prefs.mobile_player (cover / wave). - Theme switching (dark / light / system) via a theme context and useColors() across all screens. - Profile: theme, wave variant, player view, sound quality sheet (Wi-Fi / mobile / downloads), recent-tracks cache clearing on next cold start, and a change-password sheet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
111 lines
4.5 KiB
TypeScript
111 lines
4.5 KiB
TypeScript
import { initialWaveState, step, WAVE_COLORS, 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 { 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 = (colors?.length === 3 ? colors : WAVE_COLORS) as [string, string, string];
|
|
|
|
const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced });
|
|
const state = useSharedValue<WaveState>(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 = (
|
|
<View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
|
|
<Canvas style={{ position: 'absolute', width: S, height: S, left: (size - S) / 2, top: (size - S) / 2 }} pointerEvents="none">
|
|
<Picture picture={picture} />
|
|
</Canvas>
|
|
<Animated.View style={[{ alignItems: 'center', justifyContent: 'center', gap: 4 }, labelStyle]} pointerEvents="none">
|
|
{children}
|
|
</Animated.View>
|
|
</View>
|
|
);
|
|
if (!onPress) return <View accessibilityElementsHidden importantForAccessibility="no-hide-descendants">{body}</View>;
|
|
return (
|
|
<Pressable
|
|
onPress={onPress}
|
|
onPressIn={() => (pressT.value = 1)}
|
|
onPressOut={() => (pressT.value = 0)}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={label}
|
|
accessibilityState={{ selected: playing }}
|
|
>
|
|
{body}
|
|
</Pressable>
|
|
);
|
|
}
|