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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user