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

- 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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 13:30:21 +03:00
1 parent 756e915ff9
commit ed063d3763
28 files changed
+923 -188

No files matched your search

+15 -74
View File
@@ -1,14 +1,15 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AccessibilityInfo, Animated, Easing, Pressable, ScrollView, Text, View } from 'react-native';
import { Pressable, ScrollView, Text, View } from 'react-native';
import { useSession } from '../../lib/session';
import { useSession, useUser } from '../../lib/session';
import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks';
import { colors } from '../../theme';
import { useColors } from '../../theme';
import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
import { WaveOrb } from '../../ui/wave/WaveOrb';
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */
function iconFor(name: string): IconName {
@@ -17,13 +18,15 @@ function iconFor(name: string): IconName {
}
/**
* Главная: кнопка «Моя волна» и выбор занятия / настроения. На Android спектр недоступен,
* поэтому пульс берётся из BPM и energy трека (docs/mobile.md). Полный порт Canvas-анимации на Skia — отдельно.
* Главная: кнопка «Моя волна» (Skia, вид orb / liquid из профиля) и выбор занятия / настроения.
* На Android спектр недоступен, поэтому пульс берётся из BPM и energy трека (docs/mobile.md).
*/
export default function HomeScreen() {
const colors = useColors();
const { t } = useTranslation();
const tr = t as unknown as (k: string) => string;
const session = useSession();
const user = useUser();
const st = usePlayerState();
const errorText = useErrorText();
const [error, setError] = useState<unknown>(null);
@@ -50,23 +53,24 @@ export default function HomeScreen() {
if (waveOn) run(() => player.setWaveContext(a, m));
};
const ctx = [activity && tr(`wave.activities.${activity}`), mood && tr(`wave.moods.${mood}`)].filter(Boolean).join(' · ') || t('wave.for_you');
const colorsWave = waveOn && st.track?.album.cover_colors.length === 3 ? st.track.album.cover_colors : ['#2F80FF', '#7C5CFF', '#35D3FF'];
return (
<Screen>
<ScrollView contentContainerStyle={{ alignItems: 'center', paddingVertical: 32, gap: 24, paddingHorizontal: 16 }}>
<WaveCircle
<WaveOrb
variant={user.ui_prefs.wave_variant ?? 'orb'}
size={260}
playing={playing}
bpm={st.track?.features?.bpm ?? 100}
energy={st.track?.features?.energy ?? 0.6}
colors={colorsWave}
colors={waveOn ? st.track?.album.cover_colors : undefined}
onPress={onWave}
label={t(playing ? 'wave.pause' : 'wave.start')}
>
<Icon name={playing ? 'pause' : 'play-arrow'} size={44} color="#fff" />
<Text style={{ color: '#fff', fontSize: 22, fontWeight: '700' }}>{t('wave.title')}</Text>
<Text style={{ color: 'rgba(255,255,255,0.85)', fontSize: 13 }}>{ctx}</Text>
</WaveCircle>
</WaveOrb>
{st.wave?.fallback && <Text style={{ color: colors.text2, fontSize: 13 }}>{t('wave.fallback')}</Text>}
<ErrorBox>{error ? errorText(error) : null}</ErrorBox>
{options.data && (
@@ -80,70 +84,6 @@ export default function HomeScreen() {
);
}
function WaveCircle({
playing,
bpm,
energy,
colors: palette,
onPress,
label,
children,
}: {
playing: boolean;
bpm: number;
energy: number;
colors: string[];
onPress: () => void;
label: string;
children: React.ReactNode;
}) {
const scale = useRef(new Animated.Value(1)).current;
const [reduced, setReduced] = useState(false);
useEffect(() => {
void AccessibilityInfo.isReduceMotionEnabled().then(setReduced);
}, []);
useEffect(() => {
if (reduced) return;
// Играет — удар в такт BPM с силой от energy; в покое — медленное «дыхание».
const beat = playing ? 60000 / Math.max(bpm, 40) : 3000;
const peak = playing ? 1 + 0.03 + energy * 0.05 : 1.03;
const anim = Animated.loop(
Animated.sequence([
Animated.timing(scale, { toValue: peak, duration: playing ? beat * 0.15 : beat / 2, easing: Easing.out(Easing.quad), useNativeDriver: true }),
Animated.timing(scale, { toValue: 1, duration: playing ? beat * 0.85 : beat / 2, easing: Easing.inOut(Easing.quad), useNativeDriver: true }),
]),
);
anim.start();
return () => anim.stop();
}, [playing, bpm, energy, reduced, scale]);
return (
<Pressable onPress={onPress} accessibilityRole="button" accessibilityLabel={label} accessibilityState={{ selected: playing }}>
<Animated.View
style={{
width: 260,
height: 260,
borderRadius: 130,
backgroundColor: palette[0],
alignItems: 'center',
justifyContent: 'center',
gap: 4,
overflow: 'hidden',
shadowColor: palette[0],
shadowOpacity: 0.6,
shadowRadius: 40,
elevation: 16,
transform: [{ scale }],
}}
>
<View style={{ position: 'absolute', width: 200, height: 200, borderRadius: 100, backgroundColor: palette[1], opacity: 0.55, top: 90, left: 70 }} />
<View style={{ position: 'absolute', width: 140, height: 140, borderRadius: 70, backgroundColor: palette[2], opacity: 0.45, top: 10, left: 20 }} />
{children}
</Animated.View>
</Pressable>
);
}
function Chips({
title,
options,
@@ -157,6 +97,7 @@ function Chips({
label: (k: string) => string;
onPick: (k: string) => void;
}) {
const colors = useColors();
return (
<View style={{ alignItems: 'center', gap: 10, width: '100%' }}>
<Text style={{ color: colors.text3, fontSize: 11, fontWeight: '700', letterSpacing: 0.8, textTransform: 'uppercase' }}>{title}</Text>