feat(mobile): Skia wave, wave player view, themes and playback settings (M5)
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
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>
This commit is contained in:
1 parent
756e915ff9
commit
ed063d3763
28 files changed
+923
-188
No files matched your search
@@ -4,7 +4,7 @@ import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native
|
||||
|
||||
import { useSession, useSessionCtx } from '../lib/session';
|
||||
import { updater } from '../lib/update';
|
||||
import { colors, radius } from '../theme';
|
||||
import { radius, useColors } from '../theme';
|
||||
import { Button } from './kit';
|
||||
|
||||
export function useUpdateState() {
|
||||
@@ -13,6 +13,7 @@ export function useUpdateState() {
|
||||
|
||||
/** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */
|
||||
export function UpdateDialog() {
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const { server } = useSessionCtx();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ActivityIndicator, Pressable } from 'react-native';
|
||||
|
||||
import { downloads, type DownloadItem, type DownloadsState } from '../offline/downloads';
|
||||
import { colors } from '../theme';
|
||||
import { useColors } from '../theme';
|
||||
import { Icon } from './kit';
|
||||
|
||||
export function useDownloads(): DownloadsState {
|
||||
@@ -17,6 +17,7 @@ export function useDownloadItem(trackId: string): DownloadItem | undefined {
|
||||
|
||||
/** Кнопка «скачать / скачано» у трека: повторное нажатие удаляет загрузку. */
|
||||
export function DownloadButton({ track, size = 26 }: { track: Track; size?: number }) {
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
const item = useDownloadItem(track.id);
|
||||
const busy = item?.status === 'queued' || item?.status === 'downloading';
|
||||
|
||||
+80
-29
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Image,
|
||||
Modal,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
@@ -20,15 +21,17 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useSessionCtx } from '../lib/session';
|
||||
import { downloads } from '../offline/downloads';
|
||||
import { absolute } from '../player/controller';
|
||||
import { colors, radius } from '../theme';
|
||||
import { radius, useColors, type Colors } from '../theme';
|
||||
|
||||
export type IconName = ComponentProps<typeof MaterialIcons>['name'];
|
||||
|
||||
export function Icon({ name, size = 22, color = colors.text2 }: { name: IconName; size?: number; color?: string }) {
|
||||
return <MaterialIcons name={name} size={size} color={color} />;
|
||||
export function Icon({ name, size = 22, color }: { name: IconName; size?: number; color?: string }) {
|
||||
const colors = useColors();
|
||||
return <MaterialIcons name={name} size={size} color={color ?? colors.text2} />;
|
||||
}
|
||||
|
||||
export function Screen({ children, scroll = false, style }: { children: ReactNode; scroll?: boolean; style?: StyleProp<ViewStyle> }) {
|
||||
const s = useStyles();
|
||||
void scroll;
|
||||
return (
|
||||
<SafeAreaView style={[s.screen, style]} edges={['top', 'left', 'right']}>
|
||||
@@ -38,10 +41,12 @@ export function Screen({ children, scroll = false, style }: { children: ReactNod
|
||||
}
|
||||
|
||||
export function H1({ children }: { children: ReactNode }) {
|
||||
const s = useStyles();
|
||||
return <Text style={s.h1}>{children}</Text>;
|
||||
}
|
||||
|
||||
export function Muted({ children, style }: { children: ReactNode; style?: StyleProp<ViewStyle> }) {
|
||||
const s = useStyles();
|
||||
return <Text style={[s.muted, style as object]}>{children}</Text>;
|
||||
}
|
||||
|
||||
@@ -52,6 +57,8 @@ export function Field({
|
||||
secure,
|
||||
...rest
|
||||
}: TextInputProps & { label: string; error?: string; hint?: string; secure?: boolean }) {
|
||||
const s = useStyles();
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
const [shown, setShown] = useState(false);
|
||||
return (
|
||||
@@ -98,6 +105,8 @@ export function Button({
|
||||
disabled?: boolean;
|
||||
icon?: IconName;
|
||||
}) {
|
||||
const s = useStyles();
|
||||
const colors = useColors();
|
||||
const bg = { primary: colors.accent, secondary: colors.surface3, ghost: 'transparent', danger: colors['danger-soft'] }[variant];
|
||||
const fg = { primary: '#fff', secondary: colors.text, ghost: colors.text2, danger: colors.danger }[variant];
|
||||
return (
|
||||
@@ -115,6 +124,8 @@ export function Button({
|
||||
}
|
||||
|
||||
export function ErrorBox({ children }: { children?: ReactNode }) {
|
||||
const s = useStyles();
|
||||
const colors = useColors();
|
||||
if (!children) return null;
|
||||
return (
|
||||
<View style={s.error} accessibilityRole="alert">
|
||||
@@ -143,6 +154,7 @@ export function Cover({
|
||||
size: number;
|
||||
round?: boolean;
|
||||
}) {
|
||||
const colors = useColors();
|
||||
const { server } = useSessionCtx();
|
||||
const remote = server ? absolute(server, album.cover_url) : undefined;
|
||||
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
|
||||
@@ -183,6 +195,8 @@ export function TrackRow({
|
||||
meta?: string;
|
||||
trailing?: ReactNode;
|
||||
}) {
|
||||
const s = useStyles();
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
const liked = track.reaction === 'like';
|
||||
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
|
||||
@@ -225,6 +239,30 @@ export function TrackRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** Нижний лист: затемнение, заголовок, содержимое; закрывается кнопкой «назад» и тапом по фону. */
|
||||
export function Sheet({ visible, title, onClose, children }: { visible: boolean; title: string; onClose: () => void; children: ReactNode }) {
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Modal transparent animationType="slide" visible={visible} onRequestClose={onClose}>
|
||||
<Pressable style={{ flex: 1, backgroundColor: colors.overlay }} onPress={onClose} accessibilityLabel={title} />
|
||||
<SafeAreaView edges={['bottom']} style={{ backgroundColor: colors.surface, borderTopLeftRadius: radius.lg, borderTopRightRadius: radius.lg }}>
|
||||
<View style={{ padding: 20, gap: 16 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={{ color: colors.text, fontSize: 18, fontWeight: '700' }} accessibilityRole="header">
|
||||
{title}
|
||||
</Text>
|
||||
<Pressable onPress={onClose} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('form.close')}>
|
||||
<Icon name="close" color={colors.text2} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function Segmented<T extends string>({
|
||||
value,
|
||||
options,
|
||||
@@ -234,6 +272,8 @@ export function Segmented<T extends string>({
|
||||
options: { value: T; label: string }[];
|
||||
onChange: (v: T) => void;
|
||||
}) {
|
||||
const s = useStyles();
|
||||
const colors = useColors();
|
||||
return (
|
||||
<View style={s.segmented} accessibilityRole="tablist">
|
||||
{options.map((o) => (
|
||||
@@ -251,29 +291,40 @@ export function Segmented<T extends string>({
|
||||
);
|
||||
}
|
||||
|
||||
export const s = StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: colors.bg },
|
||||
h1: { color: colors.text, fontSize: 26, fontWeight: '700', letterSpacing: -0.3 },
|
||||
muted: { color: colors.text2, lineHeight: 21 },
|
||||
label: { color: colors.text2, fontSize: 13, fontWeight: '600' },
|
||||
input: {
|
||||
height: 48,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.surface2,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.line2,
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
},
|
||||
reveal: { position: 'absolute', right: 6, top: 6, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
|
||||
button: { height: 48, borderRadius: radius.md, flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 18 },
|
||||
error: { padding: 12, borderRadius: 10, backgroundColor: colors['danger-soft'] },
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 8, minHeight: 60 },
|
||||
num: { width: 44, textAlign: 'center', color: colors.text3, fontVariant: ['tabular-nums'] },
|
||||
rowTitle: { color: colors.text, fontSize: 15, fontWeight: '500' },
|
||||
rowSub: { color: colors.text2, fontSize: 13, marginTop: 2 },
|
||||
duration: { color: colors.text3, fontSize: 12, fontVariant: ['tabular-nums'] },
|
||||
segmented: { flexDirection: 'row', gap: 2, padding: 3, borderRadius: 10, backgroundColor: colors.surface2, alignSelf: 'flex-start' },
|
||||
segment: { paddingHorizontal: 14, height: 32, borderRadius: 8, alignItems: 'center', justifyContent: 'center' },
|
||||
});
|
||||
const styles = new Map<Colors, ReturnType<typeof makeStyles>>();
|
||||
|
||||
/** Стили кита под активную тему (создаются один раз на тему). */
|
||||
function useStyles() {
|
||||
const colors = useColors();
|
||||
let st = styles.get(colors);
|
||||
if (!st) styles.set(colors, (st = makeStyles(colors)));
|
||||
return st;
|
||||
}
|
||||
|
||||
const makeStyles = (colors: Colors) =>
|
||||
StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: colors.bg },
|
||||
h1: { color: colors.text, fontSize: 26, fontWeight: '700', letterSpacing: -0.3 },
|
||||
muted: { color: colors.text2, lineHeight: 21 },
|
||||
label: { color: colors.text2, fontSize: 13, fontWeight: '600' },
|
||||
input: {
|
||||
height: 48,
|
||||
paddingHorizontal: 14,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: colors.surface2,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.line2,
|
||||
color: colors.text,
|
||||
fontSize: 15,
|
||||
},
|
||||
reveal: { position: 'absolute', right: 6, top: 6, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
|
||||
button: { height: 48, borderRadius: radius.md, flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 18 },
|
||||
error: { padding: 12, borderRadius: 10, backgroundColor: colors['danger-soft'] },
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 8, minHeight: 60 },
|
||||
num: { width: 44, textAlign: 'center', color: colors.text3, fontVariant: ['tabular-nums'] },
|
||||
rowTitle: { color: colors.text, fontSize: 15, fontWeight: '500' },
|
||||
rowSub: { color: colors.text2, fontSize: 13, marginTop: 2 },
|
||||
duration: { color: colors.text3, fontSize: 12, fontVariant: ['tabular-nums'] },
|
||||
segmented: { flexDirection: 'row', gap: 2, padding: 3, borderRadius: 10, backgroundColor: colors.surface2, alignSelf: 'flex-start' },
|
||||
segment: { paddingHorizontal: 14, height: 32, borderRadius: 8, alignItems: 'center', justifyContent: 'center' },
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { ApiError, unwrap } from '@pipu/core';
|
||||
import { useState, useSyncExternalStore } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text, View } from 'react-native';
|
||||
|
||||
import { QUALITIES, qualityPrefs, type Quality, type QualityPrefs } from '../lib/prefs';
|
||||
import { useSession } from '../lib/session';
|
||||
import { useColors } from '../theme';
|
||||
import { Button, ErrorBox, Field, Segmented, Sheet, useErrorText } from './kit';
|
||||
|
||||
export function useQualityPrefs(): QualityPrefs {
|
||||
return useSyncExternalStore(qualityPrefs.subscribe, qualityPrefs.get);
|
||||
}
|
||||
|
||||
/** Лист «Качество звука»: Wi-Fi / сотовая / загрузки. */
|
||||
export function QualitySheet({ visible, onClose }: { visible: boolean; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
const prefs = useQualityPrefs();
|
||||
const row = (key: keyof QualityPrefs) => (
|
||||
<View style={{ gap: 8 }}>
|
||||
<Text style={{ color: colors.text2, fontSize: 13, fontWeight: '600' }}>{t(`mobile.quality_${key}`)}</Text>
|
||||
<Segmented<Quality>
|
||||
value={prefs[key]}
|
||||
options={QUALITIES.map((q) => ({ value: q, label: t(`mobile.q_${q}`) }))}
|
||||
onChange={(q) => void qualityPrefs.set({ [key]: q })}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
return (
|
||||
<Sheet visible={visible} title={t('mobile.quality')} onClose={onClose}>
|
||||
{row('wifi')}
|
||||
{row('cellular')}
|
||||
{row('download')}
|
||||
<Text style={{ color: colors.text3, fontSize: 12 }}>{t('mobile.quality_hint')}</Text>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
/** Лист смены пароля: после смены сервер выдаёт новую пару токенов, остальные сессии завершаются. */
|
||||
export function PasswordSheet({ visible, onClose }: { visible: boolean; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const colors = useColors();
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const [current, setCurrent] = useState('');
|
||||
const [next, setNext] = useState('');
|
||||
const [repeat, setRepeat] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
const mismatch = repeat !== '' && repeat !== next;
|
||||
const fieldErr = (f: string) => (error instanceof ApiError && error.fieldCode(f) ? errorText(error) : undefined);
|
||||
|
||||
const close = () => {
|
||||
setCurrent('');
|
||||
setNext('');
|
||||
setRepeat('');
|
||||
setError(null);
|
||||
setDone(false);
|
||||
onClose();
|
||||
};
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const tokens = unwrap(await session.client.POST('/api/v1/me/password', { body: { current_password: current, new_password: next } }));
|
||||
await session.apply(tokens);
|
||||
setDone(true);
|
||||
setCurrent('');
|
||||
setNext('');
|
||||
setRepeat('');
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet visible={visible} title={t('settings.password')} onClose={close}>
|
||||
{done ? (
|
||||
<>
|
||||
<Text style={{ color: colors.ok }}>{t('settings.password_changed')}</Text>
|
||||
<Button title={t('form.close')} onPress={close} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Field label={t('settings.current_password')} value={current} onChangeText={setCurrent} secure autoComplete="current-password" error={fieldErr('current_password')} />
|
||||
<Field label={t('settings.new_password')} value={next} onChangeText={setNext} secure autoComplete="new-password" error={fieldErr('new_password')} />
|
||||
<Field
|
||||
label={t('settings.repeat_password')}
|
||||
value={repeat}
|
||||
onChangeText={setRepeat}
|
||||
secure
|
||||
autoComplete="new-password"
|
||||
error={mismatch ? t('form.passwords_mismatch') : undefined}
|
||||
/>
|
||||
<ErrorBox>{error && !fieldErr('current_password') && !fieldErr('new_password') ? errorText(error) : null}</ErrorBox>
|
||||
<Button
|
||||
title={t('settings.change_password')}
|
||||
busy={busy}
|
||||
disabled={!current || !next || mismatch || repeat === ''}
|
||||
onPress={() => void submit()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Отрисовка «Моей волны» на Skia — тот же алгоритм, что Canvas 2D в вебе (apps/web/src/widgets/wave/draw.ts).
|
||||
// Функции — worklet'ы: кадр собирается в UI-потоке Reanimated, JS-поток не участвует.
|
||||
import { mix, rgb, rgba, type Rgb, type WaveState } from '@pipu/core';
|
||||
import { BlendMode, ClipOp, PaintStyle, Skia, TileMode, type SkCanvas, type SkPaint } from '@shopify/react-native-skia';
|
||||
|
||||
function radial(cx: number, cy: number, r0: number, r1: number, colors: string[], pos: number[]): SkPaint {
|
||||
'worklet';
|
||||
const p = Skia.Paint();
|
||||
p.setAntiAlias(true);
|
||||
p.setShader(
|
||||
Skia.Shader.MakeTwoPointConicalGradient(
|
||||
{ x: cx, y: cy },
|
||||
r0,
|
||||
{ x: cx, y: cy },
|
||||
r1,
|
||||
colors.map((c) => Skia.Color(c)),
|
||||
pos,
|
||||
TileMode.Clamp,
|
||||
),
|
||||
);
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Градиент со смещённым центром (аналог createRadialGradient(x0, y0, 0, x1, y1, r)). */
|
||||
function conical(x0: number, y0: number, x1: number, y1: number, r: number, colors: string[]): SkPaint {
|
||||
'worklet';
|
||||
const p = Skia.Paint();
|
||||
p.setAntiAlias(true);
|
||||
p.setShader(Skia.Shader.MakeTwoPointConicalGradient({ x: x0, y: y0 }, 0, { x: x1, y: y1 }, r, colors.map((c) => Skia.Color(c)), [0, 1], TileMode.Clamp));
|
||||
return p;
|
||||
}
|
||||
|
||||
export function drawOrb(canvas: SkCanvas, cx: number, cy: number, r: number, s: WaveState) {
|
||||
'worklet';
|
||||
const t = s.t;
|
||||
const c1 = rgb(s.colors[0]);
|
||||
const c2 = rgb(s.colors[1]);
|
||||
const c3 = rgb(s.colors[2]);
|
||||
canvas.drawCircle(
|
||||
cx,
|
||||
cy,
|
||||
r * 1.65,
|
||||
radial(cx, cy, r * 0.6, r * 1.65, [rgba(c1, 0.32 + s.pulse * 0.35 + s.hover * 0.12), rgba(c2, 0.1 + s.pulse * 0.12), rgba(c1, 0)], [0, 0.5, 1]),
|
||||
);
|
||||
|
||||
canvas.save();
|
||||
const clip = Skia.Path.Make();
|
||||
clip.addCircle(cx, cy, r);
|
||||
canvas.clipPath(clip, ClipOp.Intersect, true);
|
||||
const bg = Skia.Paint();
|
||||
bg.setColor(Skia.Color(s.dark ? '#060B1C' : '#0B1533'));
|
||||
const box = Skia.XYWHRect(cx - r, cy - r, r * 2, r * 2);
|
||||
canvas.drawRect(box, bg);
|
||||
|
||||
const blobs: Rgb[] = [c1, c2, c3, c1, c2];
|
||||
for (let i = 0; i < blobs.length; i++) {
|
||||
const c = blobs[i]!;
|
||||
const a = t * (0.25 + i * 0.07) + i * 1.7;
|
||||
const bx = cx + Math.cos(a) * r * (0.35 + 0.12 * Math.sin(t * 0.3 + i)) + s.mx * r * 0.18 * s.hover;
|
||||
const by = cy + Math.sin(a * 1.3) * r * 0.38 + s.my * r * 0.18 * s.hover;
|
||||
const br = r * (0.75 + 0.15 * Math.sin(t * 0.7 + i) + s.pulse * 0.2);
|
||||
const g = radial(bx, by, 0, br, [rgba(c, 0.55), rgba(c, 0)], [0, 1]);
|
||||
g.setBlendMode(BlendMode.Plus);
|
||||
canvas.drawRect(box, g);
|
||||
}
|
||||
|
||||
const lines = 9;
|
||||
for (let l = 0; l < lines; l++) {
|
||||
const yy = cy - r * 0.75 + (l / (lines - 1)) * r * 1.5;
|
||||
const amp = r * (0.03 + 0.05 * s.amp + 0.09 * s.pulse + 0.03 * s.hover);
|
||||
const path = Skia.Path.Make();
|
||||
for (let x = -r; x <= r; x += 4) {
|
||||
const y = yy + Math.sin((x / r) * (3 + l * 0.4) + t * (1.2 + l * 0.15) + l) * amp * Math.cos((x / r) * 1.4);
|
||||
if (x === -r) path.moveTo(cx + x, y);
|
||||
else path.lineTo(cx + x, y);
|
||||
}
|
||||
const stroke = Skia.Paint();
|
||||
stroke.setAntiAlias(true);
|
||||
stroke.setStyle(PaintStyle.Stroke);
|
||||
stroke.setStrokeWidth(1.5);
|
||||
stroke.setColor(Skia.Color(`rgba(255,255,255,${0.05 + 0.05 * s.amp + (l % 3 === 0 ? 0.05 : 0)})`));
|
||||
stroke.setBlendMode(BlendMode.Plus);
|
||||
canvas.drawPath(path, stroke);
|
||||
}
|
||||
|
||||
canvas.drawRect(box, conical(cx - r * 0.35, cy - r * 0.45, cx - r * 0.35, cy - r * 0.45, r * 0.9, ['rgba(255,255,255,0.18)', 'rgba(255,255,255,0)']));
|
||||
canvas.restore();
|
||||
|
||||
const ring = Skia.Paint();
|
||||
ring.setAntiAlias(true);
|
||||
ring.setStyle(PaintStyle.Stroke);
|
||||
ring.setStrokeWidth(1);
|
||||
ring.setColor(Skia.Color(rgba(mix(c1, [255, 255, 255], 0.4), 0.35 + s.pulse * 0.3)));
|
||||
canvas.drawCircle(cx, cy, r - 0.5, ring);
|
||||
}
|
||||
|
||||
export function drawLiquid(canvas: SkCanvas, cx: number, cy: number, r: number, s: WaveState) {
|
||||
'worklet';
|
||||
const t = s.t;
|
||||
const cols: Rgb[] = [rgb(s.colors[0]), rgb(s.colors[1]), rgb(s.colors[2])];
|
||||
canvas.drawCircle(cx, cy, r * 1.6, radial(cx, cy, r * 0.4, r * 1.6, [rgba(cols[0]!, 0.3 + s.pulse * 0.3), rgba(cols[0]!, 0)], [0, 1]));
|
||||
for (let L = 0; L < 3; L++) {
|
||||
const path = Skia.Path.Make();
|
||||
const P = 140;
|
||||
for (let j = 0; j <= P; j++) {
|
||||
const th = (j / P) * Math.PI * 2;
|
||||
const w =
|
||||
1 +
|
||||
0.06 * Math.sin(3 * th + t * (0.9 + L * 0.2) + L * 2) +
|
||||
0.04 * Math.sin(5 * th - t * (1.3 + L * 0.1) + L) +
|
||||
0.03 * Math.sin(7 * th + t * 1.7) * (0.5 + s.amp) +
|
||||
0.08 * s.pulse * Math.sin(2 * th + L) +
|
||||
0.16 * s.hover * Math.pow(Math.max(0, Math.cos(th - s.ang)), 3);
|
||||
const rr = r * (0.86 - L * 0.07) * w;
|
||||
const x = cx + Math.cos(th) * rr;
|
||||
const y = cy + Math.sin(th) * rr;
|
||||
if (j) path.lineTo(x, y);
|
||||
else path.moveTo(x, y);
|
||||
}
|
||||
path.close();
|
||||
const ox = Math.cos(t * 0.4 + L * 2) * r * 0.3;
|
||||
const oy = Math.sin(t * 0.5 + L) * r * 0.3;
|
||||
const c = cols[L]!;
|
||||
const g = conical(cx + ox, cy + oy, cx, cy, r * 1.1, [rgba(mix(c, [255, 255, 255], 0.25), 0.85), rgba(c, 0.25)]);
|
||||
g.setAlphaf(s.dark ? 0.6 : 0.75);
|
||||
g.setBlendMode(s.dark ? BlendMode.Plus : BlendMode.SrcOver);
|
||||
canvas.drawPath(path, g);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user