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
@@ -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"
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' }} />
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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' },
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 — проверить на устройстве.
|
||||
- **Офлайн-баннер** вверху, когда нет связи с сервером.
|
||||
- Админку в мобильном **не делаем** на первом этапе (ссылка «открыть в браузере»).
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "Очистится при следующем запуске"
|
||||
}
|
||||
}
|
||||
Generated
+104
-2
@@ -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: {}
|
||||
|
||||
Reference in new issue
Block a user