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

+2
View File
@@ -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"
+5 -1
View File
@@ -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<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' };
export default function TabsLayout() {
const colors = useColors();
const { t } = useTranslation();
const labels: Record<string, string> = {
index: t('nav.home'),
@@ -59,6 +60,7 @@ function TabBar({
labels: Record<string, string>;
onPress: (name: string) => void;
}) {
const colors = useColors();
const insets = useSafeAreaInsets();
return (
<View
@@ -95,6 +97,7 @@ function TabBar({
/** Мини-плеер над навигацией; нажатие — полноэкранный плеер. */
function MiniPlayer() {
const colors = useColors();
const { t } = useTranslation();
const router = useRouter();
const st = usePlayerState();
@@ -134,6 +137,7 @@ function MiniPlayer() {
/** Нет сети: доступны «Скачанное» и офлайн-волна. */
function OfflineBanner({ text }: { text: string }) {
const colors = useColors();
const insets = useSafeAreaInsets();
return (
<View accessibilityRole="alert" style={{ paddingTop: insets.top + 6, paddingBottom: 6, paddingHorizontal: 16, backgroundColor: colors.surface3, flexDirection: 'row', gap: 8, alignItems: 'center' }}>
+3 -1
View File
@@ -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;
+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>
+87 -1
View File
@@ -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() {
</Section>
<Section>
<Row label={t('settings.theme')}>
<Segmented<NonNullable<UiPrefs['theme']>>
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 })}
/>
</Row>
<Row label={t('settings.wave_variant')}>
<Segmented<NonNullable<UiPrefs['wave_variant']>>
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 })}
/>
</Row>
<Row label={t('mobile.player_view')}>
<Segmented<NonNullable<UiPrefs['mobile_player']>>
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 })}
/>
</Row>
</Section>
<Section>
<Pressable onPress={() => setSheet('quality')} accessibilityRole="button" style={{ paddingVertical: 4 }}>
<Row label={t('mobile.quality')}>
<Text style={{ color: colors.text2 }}>
{t(`mobile.q_${quality.wifi}`)} · {t(`mobile.q_${quality.cellular}`)}
</Text>
</Row>
</Pressable>
<Row label={t('mobile.cache')}>
{cache?.pending ? (
<Text style={{ color: colors.text3, fontSize: 12 }}>{t('mobile.cache_pending')}</Text>
) : (
<Pressable onPress={() => void clearCache()} accessibilityRole="button" hitSlop={8}>
<Text style={{ color: colors.accent2, fontWeight: '500' }}>
{t('mobile.cache_clear', { size: ((cache?.bytes ?? 0) / MB).toFixed(0) })}
</Text>
</Pressable>
)}
</Row>
<Row label={t('mobile.dl_wifi_only')}>
<Switch value={dl.wifiOnly} onValueChange={(v) => void downloads.setWifiOnly(v)} />
</Row>
@@ -78,6 +159,7 @@ export default function ProfileScreen() {
)}
</Section>
<Button title={t('settings.change_password')} variant="secondary" icon="lock" onPress={() => setSheet('password')} />
<Button
title={t(update.status === 'checking' ? 'mobile.update_checking' : 'mobile.update_check')}
variant="secondary"
@@ -93,15 +175,19 @@ export default function ProfileScreen() {
{t('mobile.version', { version: Constants.expoConfig?.version ?? '0.0.0' })}
</Text>
</ScrollView>
<QualitySheet visible={sheet === 'quality'} onClose={() => setSheet(null)} />
<PasswordSheet visible={sheet === 'password'} onClose={() => setSheet(null)} />
</Screen>
);
}
function Section({ children }: { children: React.ReactNode }) {
const colors = useColors();
return <View style={{ padding: 16, borderRadius: radius.lg, backgroundColor: colors.surface, gap: 8 }}>{children}</View>;
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
const colors = useColors();
return (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<Text style={{ color: colors.text }}>{label}</Text>
+2 -1
View File
@@ -9,13 +9,14 @@ import { FlatList, Pressable, Text, TextInput, View } from 'react-native';
import { useSession } from '../../lib/session';
import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks';
import { colors, radius } from '../../theme';
import { radius, useColors } from '../../theme';
import { Cover, Icon, Screen, TrackRow, useErrorText } from '../../ui/kit';
const RECENT_KEY = 'pipu.recent_searches';
/** Поиск: подсказки по мере ввода (debounce 250 мс), недавние запросы — локально. */
export default function SearchScreen() {
const colors = useColors();
const { t } = useTranslation();
const session = useSession();
const router = useRouter();
+17 -3
View File
@@ -5,13 +5,13 @@ import { StatusBar } from 'expo-status-bar';
import type { i18n as I18n } from 'i18next';
import { useEffect, useState } from 'react';
import { I18nextProvider, useTranslation } from 'react-i18next';
import { ActivityIndicator, AppState, View } from 'react-native';
import { ActivityIndicator, AppState, useColorScheme, View } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { SessionProvider, useAuthState, useSessionCtx } from '../lib/session';
import { downloads } from '../offline/downloads';
import { player } from '../player/controller';
import { colors } from '../theme';
import { ThemeContext, useColors } from '../theme';
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } } });
@@ -28,7 +28,6 @@ export default function RootLayout() {
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}>
<SessionProvider>
<StatusBar style="light" />
<Gate />
</SessionProvider>
</QueryClientProvider>
@@ -38,6 +37,7 @@ export default function RootLayout() {
}
function Splash() {
const colors = useColors();
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg }}>
<ActivityIndicator color={colors.accent} />
@@ -58,6 +58,10 @@ function Gate() {
const router = useRouter();
const { i18n } = useTranslation();
const user = auth.status === 'signed_in' ? auth.user : null;
const system = useColorScheme();
// Тема из профиля; «system» и экраны до входа — по настройке телефона.
const pref = user?.ui_prefs.theme ?? 'system';
const theme = pref === 'system' ? (system === 'light' ? 'light' : 'dark') : pref;
useEffect(() => {
player.attach(user ? session : null, server ?? '', user?.id ?? null);
@@ -87,6 +91,16 @@ function Gate() {
}, [ready, server, user?.id, user?.must_change_credentials, user?.must_change_password, segments]);
if (!ready) return <Splash />;
return (
<ThemeContext.Provider value={theme}>
<StatusBar style={theme === 'dark' ? 'light' : 'dark'} />
<Screens />
</ThemeContext.Provider>
);
}
function Screens() {
const colors = useColors();
return (
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
+2 -1
View File
@@ -8,10 +8,11 @@ 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 { Button, Cover, Icon, Screen, TrackRow, formatDuration, useErrorText } from '../../ui/kit';
export default function AlbumScreen() {
const colors = useColors();
const { id } = useLocalSearchParams<{ id: string }>();
const { t } = useTranslation();
const router = useRouter();
+2 -1
View File
@@ -5,10 +5,11 @@ import { useTranslation } from 'react-i18next';
import { FlatList, Pressable, Text, View } from 'react-native';
import { useSession } from '../../lib/session';
import { colors } from '../../theme';
import { useColors } from '../../theme';
import { Cover, Icon, Screen } from '../../ui/kit';
export default function ArtistScreen() {
const colors = useColors();
const { id } = useLocalSearchParams<{ id: string }>();
const { t } = useTranslation();
const router = useRouter();
+2 -1
View File
@@ -8,13 +8,14 @@ import { Pressable, ScrollView, Text, View } from 'react-native';
import { useSessionCtx } from '../lib/session';
import { forgetServer, loadServers } from '../lib/storage';
import { colors, radius } from '../theme';
import { radius, useColors } from '../theme';
import { Button, ErrorBox, Field, H1, Icon, Muted, Screen } from '../ui/kit';
const appVersionCode = Number(Constants.expoConfig?.extra?.versionCode ?? 0);
/** Подключение: адрес → проверка /server-info (версия, совместимость) → вход. */
export default function ConnectScreen() {
const colors = useColors();
const { t } = useTranslation();
const { connect } = useSessionCtx();
const [address, setAddress] = useState('');
+25 -5
View File
@@ -3,17 +3,24 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
import { useUser } from '../lib/session';
import { player } from '../player/controller';
import { usePlayerState } from '../player/hooks';
import { colors } from '../theme';
import { useColors } from '../theme';
import { DownloadButton } from '../ui/downloads';
import { Cover, Icon, Screen, formatDuration } from '../ui/kit';
import { WaveOrb } from '../ui/wave/WaveOrb';
/** Полноэкранный плеер (вид cover): обложка, прогресс с перемоткой, лайк / дизлайк, управление. */
/**
* Полноэкранный плеер: вид `cover` (крупная обложка) или `wave` (визуализация волны в цветах обложки) — по профилю;
* прогресс с перемоткой, лайк / дизлайк, скачать, управление.
*/
export default function PlayerScreen() {
const colors = useColors();
const { t } = useTranslation();
const router = useRouter();
const st = usePlayerState();
const prefs = useUser().ui_prefs;
const [barWidth, setBarWidth] = useState(1);
const track = st.track;
@@ -42,7 +49,20 @@ export default function PlayerScreen() {
</View>
<View style={{ alignItems: 'center' }}>
<Cover album={track.album} size={300} />
{prefs.mobile_player === 'wave' ? (
<WaveOrb
variant={prefs.wave_variant ?? 'orb'}
size={280}
playing={st.playing}
bpm={track.features?.bpm ?? 100}
energy={track.features?.energy ?? 0.6}
colors={track.album.cover_colors}
>
<Cover album={track.album} size={96} round />
</WaveOrb>
) : (
<Cover album={track.album} size={300} />
)}
</View>
<View style={{ gap: 4 }}>
@@ -86,9 +106,9 @@ export default function PlayerScreen() {
onPress={() => player.toggle()}
accessibilityRole="button"
accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}
style={{ width: 72, height: 72, borderRadius: 36, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center' }}
style={{ width: 72, height: 72, borderRadius: 36, backgroundColor: colors.text, alignItems: 'center', justifyContent: 'center' }}
>
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={40} color="#05070D" />
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={40} color={colors.bg} />
</Pressable>
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
<Icon name="skip-next" size={40} color={colors.text} />
+67
View File
@@ -0,0 +1,67 @@
import * as FileSystem from 'expo-file-system';
import * as SecureStore from 'expo-secure-store';
export type Quality = 'original' | 'high' | 'medium' | 'low';
export const QUALITIES: readonly Quality[] = ['original', 'high', 'medium', 'low'];
/** Качество звука по типу сети и для загрузок (настройка устройства, на сервер не уходит). */
export interface QualityPrefs {
wifi: Quality;
cellular: Quality;
download: Quality;
}
const QUALITY_KEY = 'pipu.quality';
const CLEAR_CACHE_KEY = 'pipu.cache.clear';
const DEFAULT_QUALITY: QualityPrefs = { wifi: 'high', cellular: 'medium', download: 'high' };
let quality: QualityPrefs = DEFAULT_QUALITY;
let loaded: Promise<void> | null = null;
const listeners = new Set<() => void>();
export const qualityPrefs = {
get: () => quality,
subscribe(fn: () => void) {
listeners.add(fn);
return () => listeners.delete(fn);
},
async load(): Promise<QualityPrefs> {
loaded ??= SecureStore.getItemAsync(QUALITY_KEY)
.then((raw) => {
if (raw) quality = { ...DEFAULT_QUALITY, ...(JSON.parse(raw) as Partial<QualityPrefs>) };
})
.catch(() => undefined);
await loaded;
return quality;
},
async set(patch: Partial<QualityPrefs>) {
quality = { ...quality, ...patch };
for (const fn of listeners) fn();
await SecureStore.setItemAsync(QUALITY_KEY, JSON.stringify(quality));
},
};
/**
* Кэш недавних стримов ExoPlayer (KotlinAudio кладёт его в `cacheDir/TrackPlayer`). Пока сервис плеера жив,
* SimpleCache держит индекс в памяти — поэтому очистка откладывается до следующего холодного запуска.
*/
const PLAYER_CACHE = `${FileSystem.cacheDirectory}TrackPlayer`;
export const playerCache = {
async size(): Promise<number> {
const info = await FileSystem.getInfoAsync(PLAYER_CACHE, { size: true }).catch(() => null);
return info?.exists ? (info.size ?? 0) : 0;
},
async requestClear() {
await SecureStore.setItemAsync(CLEAR_CACHE_KEY, '1');
},
async clearPending(): Promise<boolean> {
return (await SecureStore.getItemAsync(CLEAR_CACHE_KEY)) === '1';
},
/** Вызывается до setupPlayer, когда сервис ещё не запущен. */
async clearIfRequested() {
if (!(await this.clearPending())) return;
await FileSystem.deleteAsync(PLAYER_CACHE, { idempotent: true }).catch(() => undefined);
await SecureStore.deleteItemAsync(CLEAR_CACHE_KEY);
},
};
+3 -1
View File
@@ -3,6 +3,7 @@ import { unwrap, type AuthSession, type Track } from '@pipu/core';
import * as FileSystem from 'expo-file-system';
import * as SecureStore from 'expo-secure-store';
import { qualityPrefs } from '../lib/prefs';
import { shortHash } from '../lib/storage';
import { getDb } from './db';
@@ -258,7 +259,8 @@ class Downloads {
return;
}
const part = `${this.file(id)}.part`;
const dl = FileSystem.createDownloadResumable(`${this.abs(track.stream_url)}&q=high`, part, {}, (p) => {
const q = (await qualityPrefs.load()).download;
const dl = FileSystem.createDownloadResumable(`${this.abs(track.stream_url)}&q=${q}`, part, {}, (p) => {
if (p.totalBytesExpectedToWrite > 0) this.setItem(id, { progress: p.totalBytesWritten / p.totalBytesExpectedToWrite });
});
this.active.set(id, dl);
+8 -3
View File
@@ -15,6 +15,7 @@ import TrackPlayer, {
type Track as RntpTrack,
} from 'react-native-track-player';
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
import { downloads } from '../offline/downloads';
import { PersistentEvents } from '../offline/events';
@@ -62,12 +63,14 @@ export function absolute(server: string, path: string | null | undefined): strin
}
/** Качество по сети (docs/playback.md#качество): Wi-Fi — high, сотовая — medium. */
async function quality(): Promise<'high' | 'medium'> {
/** Качество стрима по типу сети — из настроек профиля. */
async function quality(): Promise<Quality> {
const prefs = await qualityPrefs.load();
try {
const net = await NetInfo.fetch();
return net.type === 'cellular' ? 'medium' : 'high';
return net.type === 'cellular' ? prefs.cellular : prefs.wifi;
} catch {
return 'high';
return prefs.wifi;
}
}
@@ -124,6 +127,8 @@ class Controller {
setup(): Promise<void> {
this.setupDone ??= (async () => {
// Очистка кэша из профиля — только пока сервис плеера не запущен (иначе SimpleCache держит файлы).
if (!(await TrackPlayer.isServiceRunning().catch(() => true))) await playerCache.clearIfRequested();
try {
// 500 МБ кэша ExoPlayer для недавних стримов (значение — в КБ).
await TrackPlayer.setupPlayer({ maxCacheSize: 500 * 1024, autoHandleInterruptions: true });
+17 -3
View File
@@ -1,5 +1,19 @@
import { tokens } from '@pipu/design-tokens';
import { tokens, type ThemeName } from '@pipu/design-tokens';
import { createContext, useContext } from 'react';
// Пока только тёмная тема; переключение тем — вместе с профилем (M5).
export const colors = tokens.color.dark;
export type Colors = Record<keyof typeof tokens.color.dark, string>;
/** Активная тема (dark / light); выбирается в профиле (`ui_prefs.theme`, «system» — по настройке телефона). */
export const ThemeContext = createContext<ThemeName>('dark');
export function useThemeName(): ThemeName {
return useContext(ThemeContext);
}
export function useColors(): Colors {
return tokens.color[useThemeName()];
}
/** Цвета до входа и загрузки настроек (сплэш) — тёмная тема. */
export const darkColors: Colors = tokens.color.dark;
export const radius = tokens.radius;
+2 -1
View File
@@ -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();
+2 -1
View File
@@ -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
View File
@@ -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' },
});
+110
View File
@@ -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>
);
}
+110
View File
@@ -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>
);
}
+129
View File
@@ -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);
}
}
+3 -56
View File
@@ -1,45 +1,9 @@
// Отрисовка кнопки «Моя волна» на Canvas 2D — порт прототипа docs/Разработка дизайна приложения/wave.js
// (варианты orb и liquid; «Кольца» не реализуем — docs/design.md).
// (варианты orb и liquid; «Кольца» не реализуем — docs/design.md). Физика и цвета — в @pipu/core (wave-anim).
export type Rgb = [number, number, number];
import { mix, rgb, rgba, type Rgb, type WaveState } from '@pipu/core';
export interface WaveState {
hover: number;
hoverT: number;
press: number;
pressV: number;
pressT: number;
mx: number;
my: number;
ang: number;
t: number;
amp: number;
pulse: number;
speed: number;
phase: number;
variant: 'orb' | 'liquid';
playing: boolean;
bpm: number;
energy: number;
colors: [string, string, string];
dark: boolean;
reduced: boolean;
size: number;
}
export function rgb(hex: string): Rgb {
const h = hex.replace('#', '');
const full = h.length === 3 ? [...h].map((c) => c + c).join('') : h;
const n = parseInt(full, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const rgba = (c: Rgb, a: number) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
const mix = (a: Rgb, b: Rgb, k: number): Rgb => [
Math.round(a[0] + (b[0] - a[0]) * k),
Math.round(a[1] + (b[1] - a[1]) * k),
Math.round(a[2] + (b[2] - a[2]) * k),
];
export { rgb, step, type Rgb, type WaveState } from '@pipu/core';
export function drawOrb(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, s: WaveState, t: number) {
const [c1, c2, c3] = s.colors.map(rgb) as [Rgb, Rgb, Rgb];
@@ -139,20 +103,3 @@ export function drawLiquid(ctx: CanvasRenderingContext2D, cx: number, cy: number
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
/** Шаг физики: пружины hover / press, амплитуда при игре, пульс в такт BPM (docs/design.md#3). */
export function step(s: WaveState, dt: number) {
s.hover += (s.hoverT - s.hover) * 0.08;
s.pressV += (s.pressT - s.press) * 0.35;
s.pressV *= 0.62;
s.press += s.pressV;
s.amp += ((s.playing ? 1 : 0) - s.amp) * 0.04;
s.speed = s.reduced ? 0.15 : 1 + s.hover * 1.4 + s.amp * 0.7;
s.t += dt * s.speed;
if (s.playing && !s.reduced) {
s.phase = (s.phase + (dt * s.bpm) / 60) % 1;
s.pulse += (Math.pow(1 - s.phase, 4) * (0.35 + s.energy * 0.65) - s.pulse) * 0.5;
} else {
s.pulse += ((s.reduced ? 0 : (Math.sin(s.t * 1.1) * 0.5 + 0.5) * 0.12) - s.pulse) * 0.1;
}
}
+8 -1
View File
@@ -7,7 +7,7 @@
| Фреймворк | React Native + Expo SDK, **prebuild / dev client** (нативный код под контролем). New Architecture выключена, пока `react-native-track-player` её не поддерживает |
| Навигация | `expo-router` (файловые маршруты) |
| Аудио | `react-native-track-player` v4 — ExoPlayer, MediaSession, уведомление, экран блокировки, Android Auto-совместимость |
| Анимация волны | Сейчас — `Animated` (пульс по BPM / energy, палитра обложки); дальше — `@shopify/react-native-skia` (порт `wave.js`) |
| Анимация волны | `@shopify/react-native-skia` + Reanimated: кадр собирается в UI-потоке (`createPicture` в `useDerivedValue`), формулы — общие с вебом (`packages/core/src/wave-anim.ts`, worklet'ы), отрисовка — `src/ui/wave/draw.ts`; вне экрана и в фоне кадры не считаются |
| Серверное состояние | TanStack Query (+ persist в MMKV) |
| Клиентское состояние | Zustand (общие сторы из `packages/core`) |
| Хранилище | `expo-secure-store` (refresh-токены, список серверов, недавние запросы); `expo-sqlite` — офлайн-каталог и очередь событий (вместе с загрузками) |
@@ -35,6 +35,13 @@
- **Профиль**: смена пароля (лист), язык, тема, вид волны, вид плеера, качество звука (лист: Wi-Fi / сотовая / загрузки),
«только Wi-Fi для загрузок», кэш недавних треков (размер + «Очистить»), проверка обновлений (точка, если есть новая версия),
текущий сервер, «Админка — откроется в браузере» (для admin / moderator), выход.
- **Тема**: `ui_prefs.theme` (dark / light / system); цвета берутся через `useColors()` из контекста темы (`src/theme.ts`),
до входа — по настройке телефона.
- **Качество звука** хранится на устройстве (`src/lib/prefs.ts`, SecureStore): Wi-Fi (по умолчанию high), сотовая (medium),
загрузки (high) → параметр `q` стрима.
- **Кэш недавних треков** — кэш ExoPlayer (`cacheDir/TrackPlayer`, 500 МБ). Пока сервис плеера жив, SimpleCache держит
индекс в памяти, поэтому «Очистить» помечает кэш, а удаляется он при следующем холодном запуске до `setupPlayer`.
Путь каталога взят из KotlinAudio — проверить на устройстве.
- **Офлайн-баннер** вверху, когда нет связи с сервером.
- Админку в мобильном **не делаем** на первом этапе (ссылка «открыть в браузере»).
+1
View File
@@ -22,3 +22,4 @@ export {
type Track,
} from './player';
export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server';
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
+85
View File
@@ -0,0 +1,85 @@
// Общие формулы кнопки «Моя волна» (docs/design.md#3): состояние, пружины, пульс в такт BPM и цвета.
// Отрисовка — своя на платформе: Canvas 2D в вебе, Skia на Android. Директива 'worklet' нужна Android,
// где кадр считается в UI-потоке Reanimated; в вебе это просто строка.
export type Rgb = [number, number, number];
export interface WaveState {
hover: number;
hoverT: number;
press: number;
pressV: number;
pressT: number;
mx: number;
my: number;
ang: number;
t: number;
amp: number;
pulse: number;
speed: number;
phase: number;
variant: 'orb' | 'liquid';
playing: boolean;
bpm: number;
energy: number;
colors: [string, string, string];
dark: boolean;
reduced: boolean;
size: number;
}
export const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
export function initialWaveState(size: number): WaveState {
return {
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
speed: 1, phase: 0, variant: 'orb', playing: false, bpm: 100, energy: 0.6, colors: WAVE_COLORS,
dark: true, reduced: false, size,
};
}
export function rgb(hex: string): Rgb {
'worklet';
const h = hex.replace('#', '');
const full = h.length === 3 ? h[0]! + h[0]! + h[1]! + h[1]! + h[2]! + h[2]! : h;
const n = parseInt(full, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
export function rgba(c: Rgb, a: number): string {
'worklet';
return `rgba(${c[0]},${c[1]},${c[2]},${a})`;
}
export function mix(a: Rgb, b: Rgb, k: number): Rgb {
'worklet';
return [
Math.round(a[0] + (b[0] - a[0]) * k),
Math.round(a[1] + (b[1] - a[1]) * k),
Math.round(a[2] + (b[2] - a[2]) * k),
];
}
/** Шаг физики: пружины hover / press, амплитуда при игре, пульс в такт BPM (docs/design.md#3). */
export function step(s: WaveState, dt: number) {
'worklet';
s.hover += (s.hoverT - s.hover) * 0.08;
s.pressV += (s.pressT - s.press) * 0.35;
s.pressV *= 0.62;
s.press += s.pressV;
s.amp += ((s.playing ? 1 : 0) - s.amp) * 0.04;
s.speed = s.reduced ? 0.15 : 1 + s.hover * 1.4 + s.amp * 0.7;
s.t += dt * s.speed;
if (s.playing && !s.reduced) {
s.phase = (s.phase + (dt * s.bpm) / 60) % 1;
s.pulse += (Math.pow(1 - s.phase, 4) * (0.35 + s.energy * 0.65) - s.pulse) * 0.5;
} else {
s.pulse += ((s.reduced ? 0 : (Math.sin(s.t * 1.1) * 0.5 + 0.5) * 0.12) - s.pulse) * 0.1;
}
}
/** Масштаб круга: сжатие при нажатии, лёгкое увеличение при наведении. */
export function waveScale(s: WaveState): number {
'worklet';
return 1 - s.press * 0.07 + s.hover * 0.02;
}
+16 -1
View File
@@ -231,6 +231,21 @@
"dl_retry": "Retry failed",
"dl_empty": "Download tracks, albums and likes to listen without a connection",
"dl_pending_few": "{{count}} tracks queued",
"dl_pending_many": "{{count}} tracks queued"
"dl_pending_many": "{{count}} tracks queued",
"player_view": "Player view",
"player_cover": "Cover",
"player_wave": "Wave",
"quality": "Sound quality",
"quality_wifi": "On Wi-Fi",
"quality_cellular": "On mobile data",
"quality_download": "Downloads",
"quality_hint": "The new quality applies to the next tracks; downloaded tracks are not re-downloaded.",
"q_original": "Original",
"q_high": "High",
"q_medium": "Medium",
"q_low": "Low",
"cache": "Recent tracks cache",
"cache_clear": "Clear ({{size}} MB)",
"cache_pending": "Will be cleared on next launch"
}
}
+16 -1
View File
@@ -231,6 +231,21 @@
"dl_waiting_wifi": "Загрузки ждут Wi-Fi ({{count}})",
"dl_waiting_network": "Загрузки ждут сеть ({{count}})",
"dl_retry": "Повторить неудачные",
"dl_empty": "Скачивайте треки, альбомы и лайки — они будут доступны без сети"
"dl_empty": "Скачивайте треки, альбомы и лайки — они будут доступны без сети",
"player_view": "Вид плеера",
"player_cover": "Обложка",
"player_wave": "Волна",
"quality": "Качество звука",
"quality_wifi": "По Wi-Fi",
"quality_cellular": "По мобильной сети",
"quality_download": "Загрузки",
"quality_hint": "Новое качество применяется к следующим трекам; уже скачанные не перекачиваются.",
"q_original": "Оригинал",
"q_high": "Высокое",
"q_medium": "Среднее",
"q_low": "Низкое",
"cache": "Кэш недавних треков",
"cache_clear": "Очистить ({{size}} МБ)",
"cache_pending": "Очистится при следующем запуске"
}
}
+104 -2
View File
@@ -35,6 +35,9 @@ importers:
'@react-native-community/netinfo':
specifier: 11.4.1
version: 11.4.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))
'@shopify/react-native-skia':
specifier: 2.0.0-next.4
version: 2.0.0-next.4(react-native-reanimated@3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
'@tanstack/react-query':
specifier: ^5.104.4
version: 5.104.4(@types/react@19.0.14)(react@19.0.0)
@@ -61,7 +64,7 @@ importers:
version: 7.1.7(expo@53.0.27(@babel/core@7.29.7)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
expo-router:
specifier: ~5.1.11
version: 5.1.11(9756619e19b25962ba8852217f9e63a2)
version: 5.1.11(99c5a26029bebd7a42e3d109417eed28)
expo-secure-store:
specifier: ~14.2.4
version: 14.2.4(expo@53.0.27(@babel/core@7.29.7)(@expo/metro-runtime@5.0.5(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)(supports-color@10.2.2))
@@ -89,6 +92,9 @@ importers:
react-native:
specifier: 0.79.6
version: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
react-native-reanimated:
specifier: ~3.17.5
version: 3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
react-native-safe-area-context:
specifier: 5.4.0
version: 5.4.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
@@ -674,6 +680,12 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-transform-template-literals@7.29.7':
resolution: {integrity: sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==}
engines: {node: '>=6.9.0'}
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/plugin-transform-typescript@7.29.9':
resolution: {integrity: sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg==}
engines: {node: '>=6.9.0'}
@@ -1347,6 +1359,19 @@ packages:
cpu: [x64]
os: [win32]
'@shopify/react-native-skia@2.0.0-next.4':
resolution: {integrity: sha512-NzvdgryRz6tkKMHgCChCKa3wXfN9TZhlV0/LrfIU/wKLC1uKgGXkoZgNz7Is0wwdhtao1JJJJ81fqHCGHgzk9g==}
hasBin: true
peerDependencies:
react: '>=19.0'
react-native: '>=0.78'
react-native-reanimated: ^3.0
peerDependenciesMeta:
react-native:
optional: true
react-native-reanimated:
optional: true
'@sinclair/typebox@0.27.12':
resolution: {integrity: sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==}
@@ -1526,6 +1551,9 @@ packages:
'@vitest/utils@3.2.7':
resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==}
'@webgpu/types@0.1.21':
resolution: {integrity: sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==}
'@xmldom/xmldom@0.8.15':
resolution: {integrity: sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==}
engines: {node: '>=10.0.0'}
@@ -1767,6 +1795,9 @@ packages:
caniuse-lite@1.0.30001816:
resolution: {integrity: sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==}
canvaskit-wasm@0.40.0:
resolution: {integrity: sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==}
chai@5.3.3:
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
engines: {node: '>=18'}
@@ -3085,12 +3116,25 @@ packages:
react: '*'
react-native: '*'
react-native-is-edge-to-edge@1.1.7:
resolution: {integrity: sha512-EH6i7E8epJGIcu7KpfXYXiV2JFIYITtq+rVS8uEb+92naMRBdxhTuS8Wn2Q7j9sqyO0B+Xbaaf9VdipIAmGW4w==}
peerDependencies:
react: '*'
react-native: '*'
react-native-is-edge-to-edge@1.3.1:
resolution: {integrity: sha512-NIXU/iT5+ORyCc7p0z2nnlkouYKX425vuU1OEm6bMMtWWR9yvb+Xg5AZmImTKoF9abxCPqrKC3rOZsKzUYgYZA==}
peerDependencies:
react: '*'
react-native: '*'
react-native-reanimated@3.17.5:
resolution: {integrity: sha512-SxBK7wQfJ4UoWoJqQnmIC7ZjuNgVb9rcY5Xc67upXAFKftWg0rnkknTw6vgwnjRcvYThrjzUVti66XoZdDJGtw==}
peerDependencies:
'@babel/core': ^7.0.0-0
react: '*'
react-native: '*'
react-native-safe-area-context@5.4.0:
resolution: {integrity: sha512-JaEThVyJcLhA+vU0NU8bZ0a1ih6GiF4faZ+ArZLqpYbL6j7R3caRqj+mE3lEtKCuHgwjLg3bCxLL1GPUJZVqUA==}
peerDependencies:
@@ -3127,6 +3171,12 @@ packages:
'@types/react':
optional: true
react-reconciler@0.31.0:
resolution: {integrity: sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==}
engines: {node: '>=0.10.0'}
peerDependencies:
react: ^19.0.0
react-refresh@0.14.2:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'}
@@ -4436,6 +4486,11 @@ snapshots:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-transform-template-literals@7.29.7(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
'@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-transform-typescript@7.29.9(@babel/core@7.29.7)':
dependencies:
'@babel/core': 7.29.7
@@ -5278,6 +5333,15 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.64.10':
optional: true
'@shopify/react-native-skia@2.0.0-next.4(react-native-reanimated@3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)':
dependencies:
canvaskit-wasm: 0.40.0
react: 19.0.0
react-reconciler: 0.31.0(react@19.0.0)
optionalDependencies:
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
react-native-reanimated: 3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
'@sinclair/typebox@0.27.12': {}
'@sinonjs/commons@3.0.1':
@@ -5506,6 +5570,8 @@ snapshots:
loupe: 3.2.1
tinyrainbow: 2.0.0
'@webgpu/types@0.1.21': {}
'@xmldom/xmldom@0.8.15': {}
'@xmldom/xmldom@0.9.12': {}
@@ -5777,6 +5843,10 @@ snapshots:
caniuse-lite@1.0.30001816: {}
canvaskit-wasm@0.40.0:
dependencies:
'@webgpu/types': 0.1.21
chai@5.3.3:
dependencies:
assertion-error: 2.0.1
@@ -6135,7 +6205,7 @@ snapshots:
dependencies:
invariant: 2.2.4
expo-router@5.1.11(9756619e19b25962ba8852217f9e63a2):
expo-router@5.1.11(99c5a26029bebd7a42e3d109417eed28):
dependencies:
'@expo/metro-runtime': 5.0.5(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))
'@expo/schema-utils': 0.1.9
@@ -6156,6 +6226,8 @@ snapshots:
semver: 7.6.3
server-only: 0.0.1
shallowequal: 1.1.0
optionalDependencies:
react-native-reanimated: 3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
transitivePeerDependencies:
- '@react-native-masked-view/masked-view'
- '@types/react'
@@ -7178,11 +7250,36 @@ snapshots:
react: 19.0.0
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
react-native-is-edge-to-edge@1.1.7(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
dependencies:
react: 19.0.0
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
react-native-is-edge-to-edge@1.3.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
dependencies:
react: 19.0.0
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
react-native-reanimated@3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7)
'@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7)
'@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7)
'@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7)
'@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7)
'@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7)
'@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7)
'@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7)
'@babel/preset-typescript': 7.29.7(@babel/core@7.29.7)
convert-source-map: 2.0.0
invariant: 2.2.4
react: 19.0.0
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
react-native-is-edge-to-edge: 1.1.7(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
transitivePeerDependencies:
- supports-color
react-native-safe-area-context@5.4.0(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0):
dependencies:
react: 19.0.0
@@ -7249,6 +7346,11 @@ snapshots:
- supports-color
- utf-8-validate
react-reconciler@0.31.0(react@19.0.0):
dependencies:
react: 19.0.0
scheduler: 0.25.0
react-refresh@0.14.2: {}
react-refresh@0.17.0: {}