feat(mobile): sign-in, tabs, search, collection and background player (M5)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m39s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 1m5s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 39s

- expo-router app in src/app: connect (server check, recent servers),
  login, mandatory credential / password change, tabs Home / Search /
  Collection / Profile, album and artist pages, full-screen player
- AuthSession per server with the refresh token in expo-secure-store
  (Android Keystore); language from the profile
- player on react-native-track-player: ExoPlayer queue with a
  notification and lock-screen controls via a playback service, 500 MB
  stream cache, quality by network (Wi-Fi high, cellular medium),
  play events with played time, reactions, playback state restore and
  the endless My Wave with feedback
- home: pulsing wave circle driven by BPM / energy in the cover
  palette, activity and mood chips that switch the wave on the fly
- New Architecture is off until react-native-track-player supports it;
  the release build runs codegen only when it is on; query-string is a
  direct dependency because expo-router 5.1 imports it undeclared

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 12:48:55 +03:00
1 parent eaedd1fc62
commit 9fd616da57
28 files changed
+2590 -197

No files matched your search

+192
View File
@@ -0,0 +1,192 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AccessibilityInfo, Animated, Easing, Pressable, ScrollView, Text, View } from 'react-native';
import { useSession } from '../../lib/session';
import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks';
import { colors } from '../../theme';
import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */
function iconFor(name: string): IconName {
const map: Record<string, string> = { rainy: 'umbrella', sentiment_satisfied: 'sentiment-satisfied' };
return (map[name] ?? name.replace(/_/g, '-')) as IconName;
}
/**
* Главная: кнопка «Моя волна» и выбор занятия / настроения. На Android спектр недоступен,
* поэтому пульс берётся из BPM и energy трека (docs/mobile.md). Полный порт Canvas-анимации на Skia — отдельно.
*/
export default function HomeScreen() {
const { t } = useTranslation();
const tr = t as unknown as (k: string) => string;
const session = useSession();
const st = usePlayerState();
const errorText = useErrorText();
const [error, setError] = useState<unknown>(null);
const [activity, setActivity] = useState<string | null>(st.wave?.activity ?? null);
const [mood, setMood] = useState<string | null>(st.wave?.mood ?? null);
const options = useQuery({
queryKey: ['wave-options'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/wave/options')),
staleTime: Infinity,
});
const waveOn = st.wave !== null;
const playing = waveOn && st.playing;
const run = (fn: () => Promise<void>) => {
setError(null);
fn().catch(setError);
};
const onWave = () => (waveOn ? player.toggle() : run(() => player.startWave(activity, mood)));
const choose = (kind: 'activity' | 'mood', key: string) => {
const a = kind === 'activity' ? (activity === key ? null : key) : activity;
const m = kind === 'mood' ? (mood === key ? null : key) : mood;
setActivity(a);
setMood(m);
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
playing={playing}
bpm={st.track?.features?.bpm ?? 100}
energy={st.track?.features?.energy ?? 0.6}
colors={colorsWave}
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>
{st.wave?.fallback && <Text style={{ color: colors.text2, fontSize: 13 }}>{t('wave.fallback')}</Text>}
<ErrorBox>{error ? errorText(error) : null}</ErrorBox>
{options.data && (
<>
<Chips title={t('wave.activity_label')} options={options.data.activities} value={activity} label={(k) => tr(`wave.activities.${k}`)} onPick={(k) => choose('activity', k)} />
<Chips title={t('wave.mood_label')} options={options.data.moods} value={mood} label={(k) => tr(`wave.moods.${k}`)} onPick={(k) => choose('mood', k)} />
</>
)}
</ScrollView>
</Screen>
);
}
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,
value,
label,
onPick,
}: {
title: string;
options: { key: string; icon: string }[];
value: string | null;
label: (k: string) => string;
onPick: (k: string) => void;
}) {
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>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 8 }}>
{options.map((o) => {
const on = value === o.key;
return (
<Pressable
key={o.key}
onPress={() => onPick(o.key)}
accessibilityRole="button"
accessibilityState={{ selected: on }}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 6,
height: 38,
paddingHorizontal: 14,
borderRadius: 999,
backgroundColor: on ? colors.accent : colors.surface2,
borderWidth: 1,
borderColor: on ? colors.accent : colors.line,
}}
>
<Icon name={iconFor(o.icon)} size={18} color={on ? '#fff' : colors.text2} />
<Text style={{ color: on ? '#fff' : colors.text, fontWeight: '500' }}>{label(o.key)}</Text>
</Pressable>
);
})}
</View>
</View>
);
}