diff --git a/apps/mobile/package.json b/apps/mobile/package.json index d5def6c..f0ee198 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -16,6 +16,7 @@ "@pipu/design-tokens": "workspace:*", "@pipu/i18n": "workspace:*", "@react-native-community/netinfo": "11.4.1", + "@shopify/react-native-skia": "2.0.0-next.4", "@tanstack/react-query": "^5.104.4", "expo": "~53.0.20", "expo-build-properties": "~0.14.8", @@ -34,6 +35,7 @@ "react": "19.0.0", "react-i18next": "^15.5.3", "react-native": "0.79.6", + "react-native-reanimated": "~3.17.5", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", "react-native-track-player": "^4.1.2" diff --git a/apps/mobile/src/app/(tabs)/_layout.tsx b/apps/mobile/src/app/(tabs)/_layout.tsx index 623dbd1..c397c69 100644 --- a/apps/mobile/src/app/(tabs)/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/_layout.tsx @@ -6,13 +6,14 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { player } from '../../player/controller'; import { usePlayerState } from '../../player/hooks'; -import { colors } from '../../theme'; +import { useColors } from '../../theme'; import { Cover, Icon, type IconName } from '../../ui/kit'; import { UpdateDialog } from '../../ui/UpdateDialog'; const ICONS: Record = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' }; export default function TabsLayout() { + const colors = useColors(); const { t } = useTranslation(); const labels: Record = { index: t('nav.home'), @@ -59,6 +60,7 @@ function TabBar({ labels: Record; onPress: (name: string) => void; }) { + const colors = useColors(); const insets = useSafeAreaInsets(); return ( diff --git a/apps/mobile/src/app/(tabs)/collection.tsx b/apps/mobile/src/app/(tabs)/collection.tsx index 7741657..95b2c03 100644 --- a/apps/mobile/src/app/(tabs)/collection.tsx +++ b/apps/mobile/src/app/(tabs)/collection.tsx @@ -8,13 +8,14 @@ import { useSession } from '../../lib/session'; import { downloads } from '../../offline/downloads'; import { player } from '../../player/controller'; import { usePlayerState } from '../../player/hooks'; -import { colors } from '../../theme'; +import { useColors } from '../../theme'; import { DownloadButton, useDownloads } from '../../ui/downloads'; import { Button, H1, Muted, Screen, Segmented, TrackRow } from '../../ui/kit'; type Tab = 'history' | 'likes' | 'dislikes' | 'downloads'; export default function CollectionScreen() { + const colors = useColors(); const { t, i18n } = useTranslation(); const session = useSession(); const current = usePlayerState().track; @@ -105,6 +106,7 @@ const MB = 1024 * 1024; /** Коллекция → Скачанное: работает без сети, данные — из SQLite. */ function DownloadsTab({ tabs }: { tabs: React.ReactNode }) { + const colors = useColors(); const { t } = useTranslation(); const dl = useDownloads(); const current = usePlayerState().track; diff --git a/apps/mobile/src/app/(tabs)/index.tsx b/apps/mobile/src/app/(tabs)/index.tsx index 3f46a64..fa5f425 100644 --- a/apps/mobile/src/app/(tabs)/index.tsx +++ b/apps/mobile/src/app/(tabs)/index.tsx @@ -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(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 ( - {t('wave.title')} {ctx} - + {st.wave?.fallback && {t('wave.fallback')}} {error ? errorText(error) : null} {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 ( - - - - - {children} - - - ); -} - function Chips({ title, options, @@ -157,6 +97,7 @@ function Chips({ label: (k: string) => string; onPick: (k: string) => void; }) { + const colors = useColors(); return ( {title} diff --git a/apps/mobile/src/app/(tabs)/profile.tsx b/apps/mobile/src/app/(tabs)/profile.tsx index 767a260..bb27ffe 100644 --- a/apps/mobile/src/app/(tabs)/profile.tsx +++ b/apps/mobile/src/app/(tabs)/profile.tsx @@ -1,20 +1,28 @@ +import type { components } from '@pipu/api-client'; import { unwrap } from '@pipu/core'; import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n'; import Constants from 'expo-constants'; import * as Linking from 'expo-linking'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, ScrollView, Switch, Text, View } from 'react-native'; +import { playerCache } from '../../lib/prefs'; import { useSession, useSessionCtx, useUser } from '../../lib/session'; import { updater } from '../../lib/update'; import { downloads } from '../../offline/downloads'; import { player } from '../../player/controller'; -import { colors, radius } from '../../theme'; +import { radius, useColors } from '../../theme'; import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit'; import { useDownloads } from '../../ui/downloads'; +import { PasswordSheet, QualitySheet, useQualityPrefs } from '../../ui/settings'; import { useUpdateState } from '../../ui/UpdateDialog'; +type UiPrefs = components['schemas']['UiPrefs']; +const MB = 1024 * 1024; + export default function ProfileScreen() { + const colors = useColors(); const { t, i18n } = useTranslation(); const session = useSession(); const user = useUser(); @@ -22,6 +30,27 @@ export default function ProfileScreen() { const staff = user.role !== 'user'; const update = useUpdateState(); const dl = useDownloads(); + const quality = useQualityPrefs(); + const [sheet, setSheet] = useState<'quality' | 'password' | null>(null); + const [cache, setCache] = useState<{ bytes: number; pending: boolean } | null>(null); + + useEffect(() => { + void Promise.all([playerCache.size(), playerCache.clearPending()]).then(([bytes, pending]) => setCache({ bytes, pending })); + }, []); + + // Оптимистично, как в вебе: настройка применяется сразу, сервер подтверждает. + const setPrefs = async (prefs: UiPrefs) => { + session.setUser({ ...user, ui_prefs: { ...user.ui_prefs, ...prefs } }); + try { + session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { ui_prefs: prefs } }))); + } catch { + session.setUser(user); + } + }; + const clearCache = async () => { + await playerCache.requestClear(); + setCache((c) => (c ? { ...c, pending: true } : c)); + }; const setLocale = async (l: Locale) => { await i18n.changeLanguage(l); @@ -56,6 +85,58 @@ export default function ProfileScreen() {
+ + > + value={user.ui_prefs.theme ?? 'system'} + options={[ + { value: 'dark', label: t('settings.theme_dark') }, + { value: 'light', label: t('settings.theme_light') }, + { value: 'system', label: t('settings.theme_system') }, + ]} + onChange={(v) => void setPrefs({ theme: v })} + /> + + + > + value={user.ui_prefs.wave_variant ?? 'orb'} + options={[ + { value: 'orb', label: t('settings.wave_orb') }, + { value: 'liquid', label: t('settings.wave_liquid') }, + ]} + onChange={(v) => void setPrefs({ wave_variant: v })} + /> + + + > + value={user.ui_prefs.mobile_player ?? 'cover'} + options={[ + { value: 'cover', label: t('mobile.player_cover') }, + { value: 'wave', label: t('mobile.player_wave') }, + ]} + onChange={(v) => void setPrefs({ mobile_player: v })} + /> + +
+ +
+ setSheet('quality')} accessibilityRole="button" style={{ paddingVertical: 4 }}> + + + {t(`mobile.q_${quality.wifi}`)} · {t(`mobile.q_${quality.cellular}`)} + + + + + {cache?.pending ? ( + {t('mobile.cache_pending')} + ) : ( + void clearCache()} accessibilityRole="button" hitSlop={8}> + + {t('mobile.cache_clear', { size: ((cache?.bytes ?? 0) / MB).toFixed(0) })} + + + )} + void downloads.setWifiOnly(v)} /> @@ -78,6 +159,7 @@ export default function ProfileScreen() { )}
+