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

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

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

No files matched your search

+3
View File
@@ -113,7 +113,10 @@ jobs:
cd android
gradle=(./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m")
# Без New Architecture (app.config.ts) CMake приложения не собирается — отдельный codegen не нужен.
if grep -q '^newArchEnabled=true' gradle.properties; then
"${gradle[@]}" generateCodegenArtifactsFromSchema
fi
"${gradle[@]}" assembleRelease -x lintVitalRelease
./gradlew --stop
+7 -1
View File
@@ -12,7 +12,9 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
version,
orientation: 'portrait',
userInterfaceStyle: 'automatic',
newArchEnabled: true,
// react-native-track-player 4 не поддерживает New Architecture (React Native Directory) — фоновое
// воспроизведение и уведомление важнее; вернём, когда плеер её поддержит (docs/mobile.md).
newArchEnabled: false,
backgroundColor: '#05070D',
android: {
package: 'app.pipu.music',
@@ -23,9 +25,13 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
},
platforms: ['android'],
plugins: [
// Маршруты — файлы в src/app (docs/mobile.md).
['expo-router', { root: './src/app' }],
'expo-secure-store',
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
// Android по умолчанию запрещает незашифрованный HTTP.
['expo-build-properties', { android: { usesCleartextTraffic: true } }],
],
extra: { versionCode },
experiments: { typedRoutes: false },
});
+7 -3
View File
@@ -1,5 +1,9 @@
import { registerRootComponent } from 'expo';
// Фоновый сервис плеера регистрируется до запуска UI: кнопки уведомления и гарнитуры
// работают, даже когда интерфейс выгружен (docs/mobile.md#воспроизведение).
import TrackPlayer from 'react-native-track-player';
import { App } from './src/App';
import { PlaybackService } from './src/player/service';
registerRootComponent(App);
TrackPlayer.registerPlaybackService(() => PlaybackService);
import 'expo-router/entry';
+23 -1
View File
@@ -10,20 +10,42 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@expo/vector-icons": "^14.1.0",
"@pipu/api-client": "workspace:*",
"@pipu/core": "workspace:*",
"@pipu/design-tokens": "workspace:*",
"@pipu/i18n": "workspace:*",
"@react-native-community/netinfo": "11.4.1",
"@tanstack/react-query": "^5.104.4",
"expo": "~53.0.20",
"expo-build-properties": "~0.14.8",
"expo-constants": "~17.1.7",
"expo-font": "~13.3.2",
"expo-linking": "~7.1.7",
"expo-router": "~5.1.11",
"expo-secure-store": "~14.2.4",
"expo-status-bar": "~2.2.3",
"i18next": "^25.2.1",
"query-string": "^7.1.3",
"react": "19.0.0",
"react-i18next": "^15.5.3",
"react-native": "0.79.6"
"react-native": "0.79.6",
"react-native-safe-area-context": "5.4.0",
"react-native-screens": "~4.11.1",
"react-native-track-player": "^4.1.2"
},
"devDependencies": {
"@types/node": "^22.20.5",
"@types/react": "~19.0.10"
},
"expo": {
"doctor": {
"reactNativeDirectoryCheck": {
"exclude": [
"react-native-track-player"
],
"listUnknownPackages": false
}
}
}
}
-33
View File
@@ -1,33 +0,0 @@
import { createI18n, pickLocale } from '@pipu/i18n';
import { StatusBar } from 'expo-status-bar';
import type { i18n as I18n } from 'i18next';
import { useEffect, useState } from 'react';
import { I18nextProvider } from 'react-i18next';
import { ActivityIndicator, View } from 'react-native';
import { ConnectScreen } from './ConnectScreen';
import { colors } from './theme';
export function App() {
const [i18n, setI18n] = useState<I18n | null>(null);
useEffect(() => {
const deviceLocale = Intl.DateTimeFormat().resolvedOptions().locale;
void createI18n(pickLocale([deviceLocale])).then(setI18n);
}, []);
if (!i18n) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg }}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
return (
<I18nextProvider i18n={i18n}>
<StatusBar style="light" />
<ConnectScreen />
</I18nextProvider>
);
}
-122
View File
@@ -1,122 +0,0 @@
import { createApiClient } from '@pipu/api-client';
import { checkServer, normalizeServerUrl, type ServerCheck } from '@pipu/core';
import Constants from 'expo-constants';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
import { colors, radius } from './theme';
const appVersionCode = Number(Constants.expoConfig?.extra?.versionCode ?? 0);
type State = { kind: 'idle' } | { kind: 'checking' } | { kind: 'invalid' } | ({ kind: 'done' } & ServerCheck);
/** Экран подключения к серверу (docs/mobile.md). В M0 — только проверка /server-info. */
export function ConnectScreen() {
const { t } = useTranslation();
const [address, setAddress] = useState('');
const [state, setState] = useState<State>({ kind: 'idle' });
const checking = state.kind === 'checking';
async function onCheck() {
if (checking) return;
const baseUrl = normalizeServerUrl(address);
if (!baseUrl) {
setState({ kind: 'invalid' });
return;
}
setState({ kind: 'checking' });
const res = await checkServer(createApiClient({ baseUrl }), appVersionCode);
setState({ kind: 'done', ...res });
}
return (
<View style={styles.screen}>
<View style={styles.card}>
<Text style={styles.title}>{t('app.name')}</Text>
<Text style={styles.muted}>{t('app.tagline')}</Text>
<Text style={styles.label}>{t('server.address')}</Text>
<TextInput
value={address}
onChangeText={setAddress}
onSubmitEditing={onCheck}
editable={!checking}
placeholder="https://music.home.lan"
placeholderTextColor={colors.text3}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
style={styles.input}
/>
{state.kind === 'checking' && (
<View style={[styles.status, { backgroundColor: colors.surface2 }]}>
<ActivityIndicator color={colors.text2} />
<Text style={{ color: colors.text2 }}>{t('server.checking')}</Text>
</View>
)}
{state.kind === 'invalid' && <Status ok={false} text={t('server.invalid_address')} />}
{state.kind === 'done' && state.status === 'ok' && (
<Status ok text={t('server.online', { version: state.info.version })} />
)}
{state.kind === 'done' && state.status === 'unsupported_app' && (
<Status ok={false} text={t('server.unsupported_app')} />
)}
{state.kind === 'done' && state.status === 'offline' && <Status ok={false} text={t('server.offline')} />}
<Pressable
onPress={onCheck}
disabled={checking}
accessibilityState={{ disabled: checking, busy: checking }}
style={({ pressed }) => [styles.button, (pressed || checking) && { opacity: 0.6 }]}
>
<Text style={styles.buttonText}>{t('server.check')}</Text>
</Pressable>
</View>
</View>
);
}
function Status({ ok, text }: { ok: boolean; text: string }) {
return (
<View style={[styles.status, { backgroundColor: ok ? colors['ok-soft'] : colors['danger-soft'] }]}>
<Text style={{ color: ok ? colors.ok : colors.danger, fontWeight: '500' }}>{text}</Text>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: colors.bg, justifyContent: 'center', padding: 16 },
card: {
gap: 12,
padding: 24,
borderRadius: radius.xl,
backgroundColor: colors.surface,
borderWidth: 1,
borderColor: colors.line2,
},
title: { color: colors.text, fontSize: 26, fontWeight: '600' },
muted: { color: colors.text2, lineHeight: 21 },
label: { color: colors.text2, fontSize: 13, fontWeight: '500', marginTop: 8 },
input: {
height: 48,
paddingHorizontal: 14,
borderRadius: radius.md,
backgroundColor: colors.surface2,
borderWidth: 1,
borderColor: colors.line2,
color: colors.text,
fontSize: 15,
},
status: { flexDirection: 'row', alignItems: 'center', gap: 8, padding: 12, borderRadius: 10 },
button: {
height: 48,
borderRadius: radius.md,
backgroundColor: colors.accent,
alignItems: 'center',
justifyContent: 'center',
},
buttonText: { color: '#fff', fontWeight: '600', fontSize: 15 },
});
+126
View File
@@ -0,0 +1,126 @@
import { Tabs, useRouter } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { Pressable, Text, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks';
import { colors } from '../../theme';
import { Cover, Icon, type IconName } from '../../ui/kit';
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' };
export default function TabsLayout() {
const { t } = useTranslation();
const labels: Record<string, string> = {
index: t('nav.home'),
search: t('mobile.search'),
collection: t('nav.collection'),
profile: t('mobile.profile'),
};
return (
<Tabs
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
tabBar={({ state, navigation }) => (
<View>
<MiniPlayer />
<TabBar
routes={state.routes.map((r) => r.name)}
active={state.index}
labels={labels}
onPress={(name) => navigation.navigate(name)}
/>
</View>
)}
>
<Tabs.Screen name="index" />
<Tabs.Screen name="search" />
<Tabs.Screen name="collection" />
<Tabs.Screen name="profile" />
</Tabs>
);
}
function TabBar({
routes,
active,
labels,
onPress,
}: {
routes: string[];
active: number;
labels: Record<string, string>;
onPress: (name: string) => void;
}) {
const insets = useSafeAreaInsets();
return (
<View
accessibilityRole="tablist"
style={{
flexDirection: 'row',
paddingBottom: insets.bottom,
backgroundColor: colors.bg2,
borderTopWidth: 1,
borderTopColor: colors.line,
}}
>
{routes.map((name, i) => {
const on = i === active;
return (
<Pressable
key={name}
onPress={() => onPress(name)}
accessibilityRole="tab"
accessibilityState={{ selected: on }}
accessibilityLabel={labels[name]}
style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }}
>
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
<Text style={{ fontSize: 11, color: on ? colors.text : colors.text3, fontWeight: on ? '600' : '400' }}>
{labels[name]}
</Text>
</Pressable>
);
})}
</View>
);
}
/** Мини-плеер над навигацией; нажатие — полноэкранный плеер. */
function MiniPlayer() {
const { t } = useTranslation();
const router = useRouter();
const st = usePlayerState();
const track = st.track;
if (!track) return null;
const progress = st.durationMs ? Math.min(st.positionMs / st.durationMs, 1) : 0;
return (
<Pressable
onPress={() => router.push('/player')}
accessibilityRole="button"
accessibilityLabel={t('mobile.open_player')}
style={{ backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.line }}
>
<View style={{ height: 2, backgroundColor: colors.surface3 }}>
<View style={{ height: 2, width: `${progress * 100}%`, backgroundColor: colors.accent2 }} />
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 12, paddingVertical: 8 }}>
<Cover album={track.album} size={42} />
<View style={{ flex: 1, minWidth: 0 }}>
<Text numberOfLines={1} style={{ color: colors.text, fontWeight: '600' }}>
{track.title}
</Text>
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 12 }}>
{track.artists.map((a) => a.name).join(', ')}
</Text>
</View>
<Pressable onPress={() => player.toggle()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}>
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={32} color={colors.text} />
</Pressable>
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
<Icon name="skip-next" size={28} color={colors.text2} />
</Pressable>
</View>
</Pressable>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { unwrap, type Track } from '@pipu/core';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, Text, View } from 'react-native';
import { useSession } from '../../lib/session';
import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks';
import { colors } from '../../theme';
import { Button, H1, Screen, Segmented, TrackRow } from '../../ui/kit';
type Tab = 'history' | 'likes' | 'dislikes';
export default function CollectionScreen() {
const { t, i18n } = useTranslation();
const session = useSession();
const current = usePlayerState().track;
const [tab, setTab] = useState<Tab>('likes');
const list = useInfiniteQuery({
queryKey: ['m-collection', tab],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) => {
const query = { query: { cursor: pageParam, limit: 100 } };
if (tab === 'history') {
const page = unwrap(await session.client.GET('/api/v1/me/history', { params: query }));
return { items: page.items.map((i) => ({ track: i.track, at: i.played_at })), next: page.next_cursor };
}
const path = tab === 'likes' ? '/api/v1/me/likes' : '/api/v1/me/dislikes';
const page = unwrap(await session.client.GET(path, { params: query }));
return { items: page.items.map((i) => ({ track: i.track, at: i.created_at })), next: page.next_cursor };
},
getNextPageParam: (p) => p.next ?? undefined,
});
const items = list.data?.pages.flatMap((p) => p.items) ?? [];
const tracks: Track[] = items.map((i) => i.track);
const time = new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
const context = tab === 'history' ? ({ type: 'history' } as const) : ({ type: 'collection', id: tab } as const);
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
return (
<Screen>
<FlatList
data={items}
keyExtractor={(it, i) => `${it.track.id}-${i}`}
onEndReached={() => list.hasNextPage && void list.fetchNextPage()}
onRefresh={() => void list.refetch()}
refreshing={list.isRefetching}
ListHeaderComponent={
<View style={{ padding: 16, gap: 14 }}>
<H1>{t('collection.title')}</H1>
<Segmented<Tab>
value={tab}
onChange={setTab}
options={[
{ value: 'likes', label: t('collection.likes') },
{ value: 'history', label: t('collection.history') },
{ value: 'dislikes', label: t('collection.dislikes') },
]}
/>
{tab === 'likes' && tracks.length > 0 && (
<Button title={t('collection.play_all')} icon="play-arrow" onPress={() => void player.playList(tracks, 0, context)} />
)}
</View>
}
ListEmptyComponent={list.isSuccess ? <Text style={{ color: colors.text3, textAlign: 'center', padding: 24 }}>{t(empty[tab])}</Text> : null}
renderItem={({ item, index }) => (
<TrackRow
track={item.track}
current={current?.id === item.track.id}
meta={tab === 'history' ? time.format(new Date(item.at)) : undefined}
onPress={() => void player.playList(tracks, index, context)}
onLike={tab === 'dislikes' ? undefined : () => void player.react(item.track, item.track.reaction === 'like' ? null : 'like').then(() => list.refetch())}
/>
)}
/>
</Screen>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AccessibilityInfo, Animated, Easing, Pressable, ScrollView, Text, View } from 'react-native';
import { useSession } from '../../lib/session';
import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks';
import { colors } from '../../theme';
import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */
function iconFor(name: string): IconName {
const map: Record<string, string> = { rainy: 'umbrella', sentiment_satisfied: 'sentiment-satisfied' };
return (map[name] ?? name.replace(/_/g, '-')) as IconName;
}
/**
* Главная: кнопка «Моя волна» и выбор занятия / настроения. На Android спектр недоступен,
* поэтому пульс берётся из BPM и energy трека (docs/mobile.md). Полный порт Canvas-анимации на Skia — отдельно.
*/
export default function HomeScreen() {
const { t } = useTranslation();
const tr = t as unknown as (k: string) => string;
const session = useSession();
const st = usePlayerState();
const errorText = useErrorText();
const [error, setError] = useState<unknown>(null);
const [activity, setActivity] = useState<string | null>(st.wave?.activity ?? null);
const [mood, setMood] = useState<string | null>(st.wave?.mood ?? null);
const options = useQuery({
queryKey: ['wave-options'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/wave/options')),
staleTime: Infinity,
});
const waveOn = st.wave !== null;
const playing = waveOn && st.playing;
const run = (fn: () => Promise<void>) => {
setError(null);
fn().catch(setError);
};
const onWave = () => (waveOn ? player.toggle() : run(() => player.startWave(activity, mood)));
const choose = (kind: 'activity' | 'mood', key: string) => {
const a = kind === 'activity' ? (activity === key ? null : key) : activity;
const m = kind === 'mood' ? (mood === key ? null : key) : mood;
setActivity(a);
setMood(m);
if (waveOn) run(() => player.setWaveContext(a, m));
};
const ctx = [activity && tr(`wave.activities.${activity}`), mood && tr(`wave.moods.${mood}`)].filter(Boolean).join(' · ') || t('wave.for_you');
const colorsWave = waveOn && st.track?.album.cover_colors.length === 3 ? st.track.album.cover_colors : ['#2F80FF', '#7C5CFF', '#35D3FF'];
return (
<Screen>
<ScrollView contentContainerStyle={{ alignItems: 'center', paddingVertical: 32, gap: 24, paddingHorizontal: 16 }}>
<WaveCircle
playing={playing}
bpm={st.track?.features?.bpm ?? 100}
energy={st.track?.features?.energy ?? 0.6}
colors={colorsWave}
onPress={onWave}
label={t(playing ? 'wave.pause' : 'wave.start')}
>
<Icon name={playing ? 'pause' : 'play-arrow'} size={44} color="#fff" />
<Text style={{ color: '#fff', fontSize: 22, fontWeight: '700' }}>{t('wave.title')}</Text>
<Text style={{ color: 'rgba(255,255,255,0.85)', fontSize: 13 }}>{ctx}</Text>
</WaveCircle>
{st.wave?.fallback && <Text style={{ color: colors.text2, fontSize: 13 }}>{t('wave.fallback')}</Text>}
<ErrorBox>{error ? errorText(error) : null}</ErrorBox>
{options.data && (
<>
<Chips title={t('wave.activity_label')} options={options.data.activities} value={activity} label={(k) => tr(`wave.activities.${k}`)} onPick={(k) => choose('activity', k)} />
<Chips title={t('wave.mood_label')} options={options.data.moods} value={mood} label={(k) => tr(`wave.moods.${k}`)} onPick={(k) => choose('mood', k)} />
</>
)}
</ScrollView>
</Screen>
);
}
function WaveCircle({
playing,
bpm,
energy,
colors: palette,
onPress,
label,
children,
}: {
playing: boolean;
bpm: number;
energy: number;
colors: string[];
onPress: () => void;
label: string;
children: React.ReactNode;
}) {
const scale = useRef(new Animated.Value(1)).current;
const [reduced, setReduced] = useState(false);
useEffect(() => {
void AccessibilityInfo.isReduceMotionEnabled().then(setReduced);
}, []);
useEffect(() => {
if (reduced) return;
// Играет — удар в такт BPM с силой от energy; в покое — медленное «дыхание».
const beat = playing ? 60000 / Math.max(bpm, 40) : 3000;
const peak = playing ? 1 + 0.03 + energy * 0.05 : 1.03;
const anim = Animated.loop(
Animated.sequence([
Animated.timing(scale, { toValue: peak, duration: playing ? beat * 0.15 : beat / 2, easing: Easing.out(Easing.quad), useNativeDriver: true }),
Animated.timing(scale, { toValue: 1, duration: playing ? beat * 0.85 : beat / 2, easing: Easing.inOut(Easing.quad), useNativeDriver: true }),
]),
);
anim.start();
return () => anim.stop();
}, [playing, bpm, energy, reduced, scale]);
return (
<Pressable onPress={onPress} accessibilityRole="button" accessibilityLabel={label} accessibilityState={{ selected: playing }}>
<Animated.View
style={{
width: 260,
height: 260,
borderRadius: 130,
backgroundColor: palette[0],
alignItems: 'center',
justifyContent: 'center',
gap: 4,
overflow: 'hidden',
shadowColor: palette[0],
shadowOpacity: 0.6,
shadowRadius: 40,
elevation: 16,
transform: [{ scale }],
}}
>
<View style={{ position: 'absolute', width: 200, height: 200, borderRadius: 100, backgroundColor: palette[1], opacity: 0.55, top: 90, left: 70 }} />
<View style={{ position: 'absolute', width: 140, height: 140, borderRadius: 70, backgroundColor: palette[2], opacity: 0.45, top: 10, left: 20 }} />
{children}
</Animated.View>
</Pressable>
);
}
function Chips({
title,
options,
value,
label,
onPick,
}: {
title: string;
options: { key: string; icon: string }[];
value: string | null;
label: (k: string) => string;
onPick: (k: string) => void;
}) {
return (
<View style={{ alignItems: 'center', gap: 10, width: '100%' }}>
<Text style={{ color: colors.text3, fontSize: 11, fontWeight: '700', letterSpacing: 0.8, textTransform: 'uppercase' }}>{title}</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 8 }}>
{options.map((o) => {
const on = value === o.key;
return (
<Pressable
key={o.key}
onPress={() => onPick(o.key)}
accessibilityRole="button"
accessibilityState={{ selected: on }}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 6,
height: 38,
paddingHorizontal: 14,
borderRadius: 999,
backgroundColor: on ? colors.accent : colors.surface2,
borderWidth: 1,
borderColor: on ? colors.accent : colors.line,
}}
>
<Icon name={iconFor(o.icon)} size={18} color={on ? '#fff' : colors.text2} />
<Text style={{ color: on ? '#fff' : colors.text, fontWeight: '500' }}>{label(o.key)}</Text>
</Pressable>
);
})}
</View>
</View>
);
}
+87
View File
@@ -0,0 +1,87 @@
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 { useTranslation } from 'react-i18next';
import { Pressable, ScrollView, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { player } from '../../player/controller';
import { colors, radius } from '../../theme';
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
export default function ProfileScreen() {
const { t, i18n } = useTranslation();
const session = useSession();
const user = useUser();
const { server, disconnect } = useSessionCtx();
const staff = user.role !== 'user';
const setLocale = async (l: Locale) => {
await i18n.changeLanguage(l);
try {
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { locale: l } })));
} catch {
// язык уже сменился локально
}
};
const logout = async () => {
await player.reset();
await session.logout();
};
return (
<Screen>
<ScrollView contentContainerStyle={{ padding: 16, gap: 16 }}>
<H1>{user.display_name ?? user.username}</H1>
<Muted>
@{user.username} · {t(`roles.${user.role}`)}
</Muted>
<Section>
<Row label={t('language.label')}>
<Segmented<Locale>
value={(SUPPORTED_LOCALES as readonly string[]).includes(i18n.language) ? (i18n.language as Locale) : 'en'}
options={SUPPORTED_LOCALES.map((l) => ({ value: l, label: l.toUpperCase() }))}
onChange={(l) => void setLocale(l)}
/>
</Row>
</Section>
<Section>
<Row label={t('mobile.server')}>
<Text style={{ color: colors.text2 }} numberOfLines={1}>
{server?.replace(/^https?:\/\//, '')}
</Text>
</Row>
{staff && server && (
<Pressable onPress={() => void Linking.openURL(`${server}/admin/users`)} accessibilityRole="link" style={{ flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 12 }}>
<Icon name="open-in-new" size={20} color={colors.accent2} />
<Text style={{ color: colors.accent2, fontWeight: '500' }}>{t('mobile.open_admin')}</Text>
</Pressable>
)}
</Section>
<Button title={t('profile.logout')} variant="danger" icon="logout" onPress={() => void logout()} />
<Button title={t('mobile.change_server')} variant="ghost" onPress={() => void logout().then(disconnect)} />
<Text style={{ color: colors.text3, textAlign: 'center', fontSize: 12 }}>
{t('mobile.version', { version: Constants.expoConfig?.version ?? '0.0.0' })}
</Text>
</ScrollView>
</Screen>
);
}
function Section({ children }: { children: React.ReactNode }) {
return <View style={{ padding: 16, borderRadius: radius.lg, backgroundColor: colors.surface, gap: 8 }}>{children}</View>;
}
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<Text style={{ color: colors.text }}>{label}</Text>
<View style={{ flexShrink: 1 }}>{children}</View>
</View>
);
}
+141
View File
@@ -0,0 +1,141 @@
import { unwrap } from '@pipu/core';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import * as SecureStore from 'expo-secure-store';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
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 { Cover, Icon, Screen, TrackRow, useErrorText } from '../../ui/kit';
const RECENT_KEY = 'pipu.recent_searches';
/** Поиск: подсказки по мере ввода (debounce 250 мс), недавние запросы — локально. */
export default function SearchScreen() {
const { t } = useTranslation();
const session = useSession();
const router = useRouter();
const current = usePlayerState().track;
const errorText = useErrorText();
const [q, setQ] = useState('');
const [debounced, setDebounced] = useState('');
const [recent, setRecent] = useState<string[]>([]);
useEffect(() => {
void SecureStore.getItemAsync(RECENT_KEY).then((v) => setRecent(v ? (JSON.parse(v) as string[]) : []));
}, []);
useEffect(() => {
const id = setTimeout(() => setDebounced(q.trim()), 250);
return () => clearTimeout(id);
}, [q]);
const remember = (query: string) => {
const next = [query, ...recent.filter((r) => r !== query)].slice(0, 10);
setRecent(next);
void SecureStore.setItemAsync(RECENT_KEY, JSON.stringify(next));
};
const res = useQuery({
queryKey: ['m-search', debounced],
enabled: debounced !== '',
placeholderData: keepPreviousData,
queryFn: async ({ signal }) =>
unwrap(await session.client.GET('/api/v1/search', { params: { query: { q: debounced } }, signal })),
});
const data = debounced ? res.data : undefined;
return (
<Screen>
<View style={{ padding: 16, gap: 8 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8, height: 46, paddingHorizontal: 12, borderRadius: radius.md, backgroundColor: colors.surface2 }}>
<Icon name="search" color={colors.text3} />
<TextInput
value={q}
onChangeText={setQ}
onSubmitEditing={() => q.trim() && remember(q.trim())}
placeholder={t('search.placeholder')}
placeholderTextColor={colors.text3}
autoCorrect={false}
autoCapitalize="none"
accessibilityLabel={t('search.label')}
style={{ flex: 1, color: colors.text, fontSize: 16 }}
returnKeyType="search"
/>
{!!q && (
<Pressable onPress={() => setQ('')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('form.close')}>
<Icon name="close" color={colors.text3} size={20} />
</Pressable>
)}
</View>
{data?.corrected_query && <Text style={{ color: colors.warn, fontSize: 13 }}>{t('search.corrected', { query: data.corrected_query })}</Text>}
{res.error && <Text style={{ color: colors.danger }}>{errorText(res.error)}</Text>}
</View>
{!debounced ? (
<View style={{ paddingHorizontal: 16, gap: 4 }}>
{recent.length > 0 && (
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 }}>
<Text style={{ color: colors.text3, fontSize: 12, fontWeight: '700', textTransform: 'uppercase' }}>{t('mobile.recent_searches')}</Text>
<Pressable onPress={() => { setRecent([]); void SecureStore.deleteItemAsync(RECENT_KEY); }} accessibilityRole="button">
<Text style={{ color: colors.accent2 }}>{t('mobile.clear_recent')}</Text>
</Pressable>
</View>
)}
{recent.map((r) => (
<Pressable key={r} onPress={() => setQ(r)} style={{ flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 10 }}>
<Icon name="history" size={20} color={colors.text3} />
<Text style={{ color: colors.text }}>{r}</Text>
</Pressable>
))}
</View>
) : (
<FlatList
data={data?.tracks ?? []}
keyExtractor={(tr) => tr.id}
ListHeaderComponent={
<View style={{ gap: 4 }}>
{data?.artists.map((a) => (
<Pressable key={a.id} onPress={() => { remember(debounced); router.push(`/artist/${a.id}`); }} style={{ flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 8 }}>
<Cover album={{ id: a.id }} size={44} round />
<View>
<Text style={{ color: colors.text, fontWeight: '600' }}>{a.name}</Text>
<Text style={{ color: colors.text3, fontSize: 12 }}>{t('search.artist_kind')}</Text>
</View>
</Pressable>
))}
{data?.albums.map((al) => (
<Pressable key={al.id} onPress={() => { remember(debounced); router.push(`/album/${al.id}`); }} style={{ flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 8 }}>
<Cover album={al} size={44} />
<View style={{ flex: 1 }}>
<Text numberOfLines={1} style={{ color: colors.text, fontWeight: '600' }}>{al.title}</Text>
<Text style={{ color: colors.text3, fontSize: 12 }}>{[al.artist.name, al.year].filter(Boolean).join(' · ')}</Text>
</View>
</Pressable>
))}
</View>
}
ListEmptyComponent={
res.isSuccess && !data?.artists.length && !data?.albums.length ? (
<Text style={{ color: colors.text3, textAlign: 'center', marginTop: 24 }}>{t('search.nothing')}</Text>
) : null
}
renderItem={({ item, index }) => (
<TrackRow
track={item}
current={current?.id === item.id}
onPress={() => {
remember(debounced);
void player.playList(data?.tracks ?? [], index, { type: 'search', id: debounced });
}}
onLike={() => void player.react(item, item.reaction === 'like' ? null : 'like').then(() => res.refetch())}
/>
)}
/>
)}
</Screen>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { createI18n, pickLocale, SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack, useRouter, useSegments } from 'expo-router';
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 { SafeAreaProvider } from 'react-native-safe-area-context';
import { SessionProvider, useAuthState, useSessionCtx } from '../lib/session';
import { player } from '../player/controller';
import { colors } from '../theme';
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, staleTime: 30_000 } } });
export default function RootLayout() {
const [i18n, setI18n] = useState<I18n | null>(null);
useEffect(() => {
const deviceLocale = Intl.DateTimeFormat().resolvedOptions().locale;
void createI18n(pickLocale([deviceLocale])).then(setI18n);
}, []);
if (!i18n) return <Splash />;
return (
<SafeAreaProvider>
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}>
<SessionProvider>
<StatusBar style="light" />
<Gate />
</SessionProvider>
</QueryClientProvider>
</I18nextProvider>
</SafeAreaProvider>
);
}
function Splash() {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.bg }}>
<ActivityIndicator color={colors.accent} />
</View>
);
}
const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly string[]).includes(l);
/**
* Куда пускать: без сервера — подключение, без входа — логин, с обязательной сменой пароля — setup,
* иначе — вкладки. Те же правила, что у веба (docs/auth-and-roles.md).
*/
function Gate() {
const { server, session, ready } = useSessionCtx();
const auth = useAuthState();
const segments = useSegments();
const router = useRouter();
const { i18n } = useTranslation();
const user = auth.status === 'signed_in' ? auth.user : null;
useEffect(() => {
player.attach(user ? session : null, server ?? '');
if (user) void player.restore();
}, [user?.id, session, server]);
useEffect(() => {
if (user && isLocale(user.locale) && user.locale !== i18n.language) void i18n.changeLanguage(user.locale);
}, [user?.locale, i18n]);
// Свёрнутое приложение досылает события и позицию.
useEffect(() => {
const sub = AppState.addEventListener('change', (st) => st !== 'active' && player.flush());
return () => sub.remove();
}, []);
useEffect(() => {
if (!ready) return;
const first = segments[0] as string | undefined;
let target: string | null = null;
if (!server) target = first === 'connect' ? null : '/connect';
else if (!user) target = first === 'login' || first === 'connect' ? null : '/login';
else if (user.must_change_credentials || user.must_change_password) target = first === 'setup' ? null : '/setup';
else if (first === 'connect' || first === 'login' || first === 'setup') target = '/';
if (target) router.replace(target as never);
}, [ready, server, user?.id, user?.must_change_credentials, user?.must_change_password, segments]);
if (!ready) return <Splash />;
return (
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
</Stack>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { FlatList, Pressable, Text, View } from 'react-native';
import { useSession } from '../../lib/session';
import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks';
import { colors } from '../../theme';
import { Button, Cover, Icon, Screen, TrackRow, formatDuration, useErrorText } from '../../ui/kit';
export default function AlbumScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { t } = useTranslation();
const router = useRouter();
const session = useSession();
const current = usePlayerState().track;
const errorText = useErrorText();
const album = useQuery({
queryKey: ['album', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
});
const a = album.data;
const ctx = { type: 'album' as const, id };
return (
<Screen>
<FlatList
data={a?.tracks ?? []}
keyExtractor={(tr) => tr.id}
ListHeaderComponent={
<View style={{ padding: 16, gap: 12, alignItems: 'center' }}>
<Pressable onPress={() => router.back()} style={{ alignSelf: 'flex-start' }} accessibilityRole="button" accessibilityLabel={t('form.close')}>
<Icon name="arrow-back" color={colors.text} />
</Pressable>
{album.error ? <Text style={{ color: colors.danger }}>{errorText(album.error)}</Text> : null}
{a && (
<>
<Cover album={a} size={220} />
<Text style={{ color: colors.text, fontSize: 22, fontWeight: '700', textAlign: 'center' }}>{a.title}</Text>
<Pressable onPress={() => router.push(`/artist/${a.artist.id}`)}>
<Text style={{ color: colors.text2 }}>
{a.artist.name}
{a.year ? ` · ${a.year}` : ''} · {formatDuration(a.duration_ms)}
</Text>
</Pressable>
<Button title={t('player.play')} icon="play-arrow" onPress={() => void player.playList(a.tracks, 0, ctx)} />
</>
)}
</View>
}
renderItem={({ item, index }) => (
<TrackRow
track={item}
numbered={item.track_no ?? index + 1}
current={current?.id === item.id}
onPress={() => void player.playList(a?.tracks ?? [], index, ctx)}
onLike={() => void player.react(item, item.reaction === 'like' ? null : 'like').then(() => album.refetch())}
/>
)}
/>
</Screen>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useTranslation } from 'react-i18next';
import { FlatList, Pressable, Text, View } from 'react-native';
import { useSession } from '../../lib/session';
import { colors } from '../../theme';
import { Cover, Icon, Screen } from '../../ui/kit';
export default function ArtistScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const { t } = useTranslation();
const router = useRouter();
const session = useSession();
const artist = useQuery({
queryKey: ['artist', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}', { params: { path: { id } } })),
});
const albums = useQuery({
queryKey: ['artist-albums', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
});
return (
<Screen>
<FlatList
data={albums.data ?? []}
keyExtractor={(a) => a.id}
numColumns={2}
columnWrapperStyle={{ gap: 12, paddingHorizontal: 16 }}
contentContainerStyle={{ gap: 16, paddingBottom: 24 }}
ListHeaderComponent={
<View style={{ padding: 16, gap: 10, alignItems: 'center' }}>
<Pressable onPress={() => router.back()} style={{ alignSelf: 'flex-start' }} accessibilityRole="button" accessibilityLabel={t('form.close')}>
<Icon name="arrow-back" color={colors.text} />
</Pressable>
<Cover album={{ id }} size={140} round />
<Text style={{ color: colors.text, fontSize: 24, fontWeight: '700' }}>{artist.data?.name ?? '…'}</Text>
{artist.data && (
<Text style={{ color: colors.text2 }}>
{t('catalog.album_count', { count: artist.data.album_count })} · {t('catalog.track_count', { count: artist.data.track_count })}
</Text>
)}
</View>
}
renderItem={({ item }) => (
<Pressable onPress={() => router.push(`/album/${item.id}`)} style={{ flex: 1, gap: 6 }} accessibilityRole="button">
<Cover album={item} size={160} />
<Text numberOfLines={1} style={{ color: colors.text, fontWeight: '600' }}>{item.title}</Text>
<Text style={{ color: colors.text3, fontSize: 12 }}>{item.year ?? ''}</Text>
</Pressable>
)}
/>
</Screen>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { createApiClient } from '@pipu/api-client';
import { checkServer, normalizeServerUrl, type ServerCheck } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import Constants from 'expo-constants';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
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 { 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 { t } = useTranslation();
const { connect } = useSessionCtx();
const [address, setAddress] = useState('');
const [checking, setChecking] = useState(false);
const [result, setResult] = useState<ServerCheck | 'invalid' | null>(null);
const servers = useQuery({ queryKey: ['saved-servers'], queryFn: loadServers });
const onConnect = async (raw = address) => {
const baseUrl = normalizeServerUrl(raw);
if (!baseUrl) return setResult('invalid');
setChecking(true);
const res = await checkServer(createApiClient({ baseUrl }), appVersionCode);
setChecking(false);
setResult(res);
if (res.status === 'ok') await connect(baseUrl, res.info.version);
};
return (
<Screen>
<ScrollView contentContainerStyle={{ padding: 20, gap: 14, flexGrow: 1, justifyContent: 'center' }} keyboardShouldPersistTaps="handled">
<H1>{t('app.name')}</H1>
<Muted>{t('app.tagline')}</Muted>
<Field
label={t('server.address')}
value={address}
onChangeText={setAddress}
onSubmitEditing={() => void onConnect()}
placeholder="https://music.home.lan"
keyboardType="url"
editable={!checking}
/>
{result === 'invalid' && <ErrorBox>{t('server.invalid_address')}</ErrorBox>}
{result !== null && result !== 'invalid' && result.status === 'unsupported_app' && (
<ErrorBox>{t('server.unsupported_app')}</ErrorBox>
)}
{result !== null && result !== 'invalid' && result.status === 'offline' && <ErrorBox>{t('server.offline')}</ErrorBox>}
<Button title={t('mobile.connect')} onPress={() => void onConnect()} busy={checking} disabled={!address.trim()} />
{!!servers.data?.length && (
<View style={{ gap: 8, marginTop: 16 }}>
<Text style={{ color: colors.text3, fontSize: 12, fontWeight: '700', textTransform: 'uppercase' }}>
{t('mobile.servers')}
</Text>
{servers.data.map((sv) => (
<View key={sv.url} style={{ flexDirection: 'row', alignItems: 'center', gap: 10, padding: 12, borderRadius: radius.md, backgroundColor: colors.surface }}>
<Pressable style={{ flex: 1 }} onPress={() => void onConnect(sv.url)} accessibilityRole="button">
<Text style={{ color: colors.text, fontWeight: '600' }}>{sv.url.replace(/^https?:\/\//, '')}</Text>
<Text style={{ color: colors.text3, fontSize: 12 }}>pipu {sv.version}</Text>
</Pressable>
<Pressable
onPress={() => void forgetServer(sv.url).then(() => servers.refetch())}
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={t('mobile.forget_server')}
>
<Icon name="close" size={20} color={colors.text3} />
</Pressable>
</View>
))}
</View>
)}
</ScrollView>
</Screen>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView } from 'react-native';
import { useSessionCtx } from '../lib/session';
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../ui/kit';
export default function LoginScreen() {
const { t } = useTranslation(['auth', 'common']);
const { server, session, disconnect } = useSessionCtx();
const errorText = useErrorText();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<unknown>(null);
const submit = async () => {
if (!session) return;
setBusy(true);
setError(null);
try {
await session.login(username.trim(), password); // переход сделает Gate
} catch (e) {
setError(e);
setPassword('');
} finally {
setBusy(false);
}
};
return (
<Screen>
<ScrollView contentContainerStyle={{ padding: 20, gap: 14, flexGrow: 1, justifyContent: 'center' }} keyboardShouldPersistTaps="handled">
<H1>{t('login.title')}</H1>
<Muted>{server?.replace(/^https?:\/\//, '')}</Muted>
<Field label={t('login.username')} value={username} onChangeText={setUsername} autoComplete="username" />
<Field
label={t('login.password')}
value={password}
onChangeText={setPassword}
secure
autoComplete="password"
onSubmitEditing={() => void submit()}
/>
<ErrorBox>{error ? errorText(error) : null}</ErrorBox>
<Button title={t('login.submit')} onPress={() => void submit()} busy={busy} disabled={!username.trim() || !password} />
<Button title={t('common:mobile.change_server')} variant="ghost" onPress={() => void disconnect()} />
</ScrollView>
</Screen>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
import { player } from '../player/controller';
import { usePlayerState } from '../player/hooks';
import { colors } from '../theme';
import { Cover, Icon, Screen, formatDuration } from '../ui/kit';
/** Полноэкранный плеер (вид cover): обложка, прогресс с перемоткой, лайк / дизлайк, управление. */
export default function PlayerScreen() {
const { t } = useTranslation();
const router = useRouter();
const st = usePlayerState();
const [barWidth, setBarWidth] = useState(1);
const track = st.track;
if (!track) {
return (
<Screen>
<Pressable onPress={() => router.back()} style={{ padding: 16 }} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
</Pressable>
</Screen>
);
}
const duration = st.durationMs || track.duration_ms;
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
return (
<Screen>
<View style={{ flex: 1, padding: 24, gap: 24, justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Pressable onPress={() => router.back()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
</Pressable>
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
<View style={{ width: 32 }} />
</View>
<View style={{ alignItems: 'center' }}>
<Cover album={track.album} size={300} />
</View>
<View style={{ gap: 4 }}>
<Text numberOfLines={2} style={{ color: colors.text, fontSize: 22, fontWeight: '700' }}>
{track.title}
</Text>
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
{track.artists.map((a) => a.name).join(', ')}
</Text>
</View>
<View style={{ gap: 6 }}>
<Pressable
onLayout={(e: LayoutChangeEvent) => setBarWidth(e.nativeEvent.layout.width || 1)}
onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)}
accessibilityRole="adjustable"
accessibilityLabel={t('player.progress')}
accessibilityValue={{ min: 0, max: Math.round(duration / 1000), now: Math.round(st.positionMs / 1000) }}
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
onAccessibilityAction={(e) => player.seek(st.positionMs + (e.nativeEvent.actionName === 'increment' ? 10_000 : -10_000))}
style={{ height: 24, justifyContent: 'center' }}
>
<View style={{ height: 4, borderRadius: 2, backgroundColor: colors.surface3 }}>
<View style={{ height: 4, borderRadius: 2, width: `${progress * 100}%`, backgroundColor: colors.accent2 }} />
</View>
</Pressable>
<View style={{ flexDirection: 'row', justifyContent: 'space-between' }}>
<Text style={{ color: colors.text3, fontSize: 12 }}>{formatDuration(st.positionMs)}</Text>
<Text style={{ color: colors.text3, fontSize: 12 }}>{formatDuration(duration)}</Text>
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Pressable onPress={() => react('dislike')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}>
<Icon name="heart-broken" size={28} color={track.reaction === 'dislike' ? colors.danger : colors.text2} />
</Pressable>
<Pressable onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
<Icon name="skip-previous" size={40} color={colors.text} />
</Pressable>
<Pressable
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' }}
>
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={40} color="#05070D" />
</Pressable>
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
<Icon name="skip-next" size={40} color={colors.text} />
</Pressable>
<Pressable onPress={() => react('like')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}>
<Icon name={track.reaction === 'like' ? 'favorite' : 'favorite-border'} size={28} color={track.reaction === 'like' ? colors.accent2 : colors.text2} />
</Pressable>
</View>
</View>
</Screen>
);
}
+72
View File
@@ -0,0 +1,72 @@
import { ApiError, unwrap } from '@pipu/core';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ScrollView } from 'react-native';
import { useSession, useUser } from '../lib/session';
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../ui/kit';
/** Обязательный шаг: новые логин и пароль админа после первого запуска или замена временного пароля. */
export default function SetupScreen() {
const { t } = useTranslation(['auth', 'common']);
const session = useSession();
const user = useUser();
const errorText = useErrorText();
const admin = user.must_change_credentials;
const min = user.role === 'user' ? 8 : 12;
const [username, setUsername] = useState(user.username);
const [password, setPassword] = useState('');
const [repeat, setRepeat] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<unknown>(null);
const mismatch = repeat !== '' && repeat !== password;
const fieldErr = (f: string) => (error instanceof ApiError && error.fieldCode(f) ? errorText(error) : undefined);
const submit = async () => {
setBusy(true);
setError(null);
try {
const res = admin
? await session.client.POST('/api/v1/me/bootstrap-credentials', { body: { new_username: username.trim(), new_password: password } })
: await session.client.POST('/api/v1/me/password', { body: { new_password: password } });
await session.apply(unwrap(res));
} catch (e) {
setError(e);
} finally {
setBusy(false);
}
};
return (
<Screen>
<ScrollView contentContainerStyle={{ padding: 20, gap: 14, flexGrow: 1, justifyContent: 'center' }} keyboardShouldPersistTaps="handled">
<H1>{t(admin ? 'setup.admin_title' : 'setup.password_title')}</H1>
<Muted>{t(admin ? 'setup.admin_text' : 'setup.password_text')}</Muted>
{admin && <Field label={t('setup.new_username')} value={username} onChangeText={setUsername} error={fieldErr('new_username')} />}
<Field
label={t('setup.new_password')}
value={password}
onChangeText={setPassword}
secure
hint={t('setup.min_length', { min })}
error={fieldErr('new_password')}
/>
<Field
label={t('setup.repeat_password')}
value={repeat}
onChangeText={setRepeat}
secure
error={mismatch ? t('common:form.passwords_mismatch') : undefined}
/>
<ErrorBox>{error && !fieldErr('new_password') && !fieldErr('new_username') ? errorText(error) : null}</ErrorBox>
<Button
title={t('setup.submit')}
onPress={() => void submit()}
busy={busy}
disabled={!password || password !== repeat || (admin && !username.trim())}
/>
<Button title={t('setup.logout')} variant="ghost" onPress={() => void session.logout()} />
</ScrollView>
</Screen>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { AuthSession, type AuthState } from '@pipu/core';
import Constants from 'expo-constants';
import { createContext, useCallback, useContext, useEffect, useMemo, useState, useSyncExternalStore, type ReactNode } from 'react';
import { currentServer, refreshStoreFor, rememberServer, setCurrentServer } from './storage';
interface SessionCtx {
/** Адрес сервера; null — сервер не выбран (экран подключения). */
server: string | null;
session: AuthSession | null;
ready: boolean;
connect: (url: string, version: string) => Promise<void>;
disconnect: () => Promise<void>;
}
const Ctx = createContext<SessionCtx | null>(null);
function deviceName(): string {
return Constants.deviceName ?? 'Android';
}
function makeSession(url: string) {
return new AuthSession({ baseUrl: url, clientType: 'android', deviceName: deviceName(), refreshStore: refreshStoreFor(url) });
}
/** Текущий сервер и сессия на нём; при запуске восстанавливает вход по сохранённому refresh-токену. */
export function SessionProvider({ children }: { children: ReactNode }) {
const [server, setServer] = useState<string | null>(null);
const [session, setSession] = useState<AuthSession | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
void (async () => {
const url = await currentServer();
if (url) {
const s = makeSession(url);
await s.restore();
setServer(url);
setSession(s);
}
setReady(true);
})();
}, []);
const connect = useCallback(async (url: string, version: string) => {
await rememberServer({ url, version });
await setCurrentServer(url);
const s = makeSession(url);
await s.restore(); // на этом сервере уже могли войти раньше
setServer(url);
setSession(s);
}, []);
const disconnect = useCallback(async () => {
await setCurrentServer(null);
setServer(null);
setSession(null);
}, []);
const value = useMemo(() => ({ server, session, ready, connect, disconnect }), [server, session, ready, connect, disconnect]);
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useSessionCtx(): SessionCtx {
const c = useContext(Ctx);
if (!c) throw new Error('useSessionCtx outside SessionProvider');
return c;
}
/** Сессия вошедшего пользователя (экраны внутри приложения). */
export function useSession(): AuthSession {
const { session } = useSessionCtx();
if (!session) throw new Error('no session');
return session;
}
const UNKNOWN: AuthState = { status: 'unknown' };
export function useAuthState(): AuthState {
const { session } = useSessionCtx();
return useSyncExternalStore(
(fn) => session?.subscribe(fn) ?? (() => undefined),
() => session?.state ?? UNKNOWN,
);
}
export function useUser() {
const state = useAuthState();
if (state.status !== 'signed_in') throw new Error('not signed in');
return state.user;
}
+61
View File
@@ -0,0 +1,61 @@
import type { RefreshStore } from '@pipu/core';
import * as SecureStore from 'expo-secure-store';
// Всё храним в SecureStore (Android Keystore): список серверов маленький, а refresh-токен — секрет.
const SERVERS_KEY = 'pipu.servers';
const CURRENT_KEY = 'pipu.server';
export interface SavedServer {
url: string;
version: string;
/** Последний вошедший логин — подставляется в форму входа. */
username?: string;
}
/** Ключи SecureStore — только [A-Za-z0-9._-]: адрес сервера превращаем в безопасный суффикс. */
function keyFor(url: string): string {
let h = 0;
for (const ch of url) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
return `pipu.rt.${h.toString(36)}`;
}
export async function loadServers(): Promise<SavedServer[]> {
try {
const raw = await SecureStore.getItemAsync(SERVERS_KEY);
return raw ? (JSON.parse(raw) as SavedServer[]) : [];
} catch {
return [];
}
}
/** Сохраняет сервер наверх списка (последний использованный — первый). */
export async function rememberServer(server: SavedServer): Promise<void> {
const list = (await loadServers()).filter((s) => s.url !== server.url);
await SecureStore.setItemAsync(SERVERS_KEY, JSON.stringify([server, ...list].slice(0, 10)));
}
export async function forgetServer(url: string): Promise<void> {
const list = (await loadServers()).filter((s) => s.url !== url);
await SecureStore.setItemAsync(SERVERS_KEY, JSON.stringify(list));
await SecureStore.deleteItemAsync(keyFor(url));
if ((await currentServer()) === url) await SecureStore.deleteItemAsync(CURRENT_KEY);
}
export async function currentServer(): Promise<string | null> {
return SecureStore.getItemAsync(CURRENT_KEY);
}
export async function setCurrentServer(url: string | null): Promise<void> {
if (url) await SecureStore.setItemAsync(CURRENT_KEY, url);
else await SecureStore.deleteItemAsync(CURRENT_KEY);
}
/** Refresh-токен сервера: у каждого сервера — свой. */
export function refreshStoreFor(url: string): RefreshStore {
const key = keyFor(url);
return {
get: () => SecureStore.getItemAsync(key),
set: (token) => SecureStore.setItemAsync(key, token),
clear: () => SecureStore.deleteItemAsync(key),
};
}
+395
View File
@@ -0,0 +1,395 @@
import NetInfo from '@react-native-community/netinfo';
import {
EventBuffer,
PlayAttempt,
unwrap,
type AuthSession,
type PlayContext,
type Track,
} from '@pipu/core';
import TrackPlayer, {
AppKilledPlaybackBehavior,
Capability,
Event,
State,
type Track as RntpTrack,
} from 'react-native-track-player';
export interface WaveInfo {
sessionId: string;
activity: string | null;
mood: string | null;
fallback: boolean;
}
export interface PlayerState {
ready: boolean;
track: Track | null;
queue: Track[];
index: number;
playing: boolean;
buffering: boolean;
positionMs: number;
durationMs: number;
context: PlayContext | null;
wave: WaveInfo | null;
}
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
const WAVE_REFILL_AT = 2;
const EARLY_SKIP_MS = 30_000;
const SAVE_EVERY_MS = 10_000;
/** Ссылки API относительные — для приложения нужен полный адрес сервера. */
export function absolute(server: string, path: string | null | undefined): string | undefined {
if (!path) return undefined;
return /^https?:/.test(path) ? path : server + path;
}
/** Качество по сети (docs/playback.md#качество): Wi-Fi — high, сотовая — medium. */
async function quality(): Promise<'high' | 'medium'> {
try {
const net = await NetInfo.fetch();
return net.type === 'cellular' ? 'medium' : 'high';
} catch {
return 'high';
}
}
/**
* Плеер Android. Очередь живёт в ExoPlayer (react-native-track-player) — играет и с выгруженным UI;
* здесь — зеркало очереди с DTO треков, учёт прослушиваний (PlayAttempt из @pipu/core), события,
* реакции и бесконечная волна. Один экземпляр на процесс: им пользуются и экраны, и фоновый сервис.
*/
class Controller {
private session: AuthSession | null = null;
private server = '';
private events: EventBuffer | null = null;
private attempt: PlayAttempt | null = null;
private lastPositionMs = 0;
private lastSave = 0;
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
private waveLoading = false;
private setupDone: Promise<void> | null = null;
private listeners = new Set<() => void>();
private state: PlayerState = {
ready: false, track: null, queue: [], index: 0, playing: false, buffering: false, positionMs: 0, durationMs: 0,
context: null, wave: null,
};
subscribe = (fn: () => void) => {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
};
getState = () => this.state;
private update(patch: Partial<PlayerState>) {
this.state = { ...this.state, ...patch };
for (const fn of this.listeners) fn();
}
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер. */
attach(session: AuthSession | null, server: string) {
if (this.session === session) return;
void this.events?.flush();
this.session = session;
this.server = server;
this.events = session
? new EventBuffer({
send: async (events) => {
unwrap(await session.client.POST('/api/v1/events', { body: { events } }));
},
})
: null;
if (!session) void this.reset();
}
setup(): Promise<void> {
this.setupDone ??= (async () => {
try {
// 500 МБ кэша ExoPlayer для недавних стримов (значение — в КБ).
await TrackPlayer.setupPlayer({ maxCacheSize: 500 * 1024, autoHandleInterruptions: true });
} catch {
// уже настроен (повторный запуск JS при живом сервисе)
}
await TrackPlayer.updateOptions({
capabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext, Capability.SkipToPrevious, Capability.SeekTo],
compactCapabilities: [Capability.Play, Capability.Pause, Capability.SkipToNext],
progressUpdateEventInterval: 1,
android: { appKilledPlaybackBehavior: AppKilledPlaybackBehavior.StopPlaybackAndRemoveNotification },
});
this.bind();
this.update({ ready: true });
})();
return this.setupDone;
}
private bind() {
TrackPlayer.addEventListener(Event.PlaybackState, ({ state }) => {
this.update({ playing: state === State.Playing, buffering: state === State.Buffering || state === State.Loading });
if (state === State.Paused) this.save();
});
TrackPlayer.addEventListener(Event.PlaybackProgressUpdated, ({ position, duration }) => {
const ms = Math.round(position * 1000);
this.attempt?.tick(ms);
this.lastPositionMs = ms;
this.update({ positionMs: ms, durationMs: Math.round(duration * 1000) || this.state.durationMs });
if (this.state.playing && Date.now() - this.lastSave > SAVE_EVERY_MS) this.save();
});
TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, ({ index, lastPosition }) => {
this.finishAttempt(lastPosition);
if (index === undefined || index === null) return;
const track = this.state.queue[index];
if (!track) return;
this.attempt = new PlayAttempt(track, 0);
this.update({ track, index, positionMs: 0, durationMs: track.duration_ms });
this.events?.push({
type: 'play_start', track_id: track.id, position_ms: 0, context_type: this.state.context?.type,
context_id: this.state.context?.id,
});
this.save();
void this.refillWave();
});
TrackPlayer.addEventListener(Event.PlaybackQueueEnded, () => {
this.finishAttempt(this.lastPositionMs / 1000, true);
void this.events?.flush();
});
TrackPlayer.addEventListener(Event.PlaybackError, () => {
// Ссылка могла устареть или трек пропал — переходим дальше, ExoPlayer не застрянет на ошибке.
void TrackPlayer.skipToNext().catch(() => undefined);
});
}
/** Завершение попытки: доиграно (позиция у конца) или пропущено. */
private finishAttempt(lastPositionS: number, ended = false) {
const a = this.attempt;
if (!a) return;
this.attempt = null;
const pos = Math.round(lastPositionS * 1000);
const kind = ended ? 'play_end' : a.outcome(pos);
this.events?.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events?.flush();
if (kind === 'play_end') this.noteWave(a.track.id, 'complete');
else {
const early = a.playedMs < EARLY_SKIP_MS;
this.noteWave(a.track.id, early ? 'skip_early' : 'skip');
if (early) void this.refillWave(true);
}
}
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
const q = await quality();
return tracks.map((t) => ({
id: t.id,
url: `${absolute(this.server, t.stream_url)}&q=${q}`,
title: t.title,
artist: t.artists.map((a) => a.name).join(', '),
album: t.album.title,
artwork: t.album.cover_url ? `${absolute(this.server, t.album.cover_url)}&size=300` : undefined,
duration: t.duration_ms / 1000,
}));
}
async playList(tracks: Track[], index: number, context: PlayContext) {
const playable = tracks.filter((t) => t.available && t.stream_url);
const target = tracks[index];
if (!target || !playable.length) return;
await this.setup();
this.leaveWave();
this.finishAttempt(this.lastPositionMs / 1000);
const start = Math.max(playable.indexOf(target), 0);
this.update({ queue: playable, context, index: start, track: playable[start] ?? null });
await TrackPlayer.reset();
await TrackPlayer.add(await this.toRntp(playable));
await TrackPlayer.skip(start);
await TrackPlayer.play();
}
toggle() {
void (this.state.playing ? TrackPlayer.pause() : TrackPlayer.play());
}
next() {
void TrackPlayer.skipToNext().catch(() => undefined);
}
async prev() {
const { position } = await TrackPlayer.getProgress();
if (position > 3) await TrackPlayer.seekTo(0);
else await TrackPlayer.skipToPrevious().catch(() => TrackPlayer.seekTo(0));
}
seek(ms: number) {
const t = this.state.track;
if (t) this.events?.push({ type: 'seek', track_id: t.id, from_ms: this.lastPositionMs, position_ms: ms });
this.attempt?.seek(ms);
this.lastPositionMs = ms;
void TrackPlayer.seekTo(ms / 1000);
}
async react(track: Track, reaction: 'like' | 'dislike' | null) {
if (!this.session) return;
const prev = track.reaction;
const apply = (r: Track['reaction']) => {
this.update({
queue: this.state.queue.map((t) => (t.id === track.id ? { ...t, reaction: r } : t)),
track: this.state.track?.id === track.id ? { ...this.state.track, reaction: r } : this.state.track,
});
};
apply(reaction);
this.events?.push({ type: reaction ?? (prev === 'like' ? 'unlike' : 'undislike'), track_id: track.id });
try {
const params = { params: { path: { id: track.id } } };
if (reaction) unwrap(await this.session.client.PUT('/api/v1/me/reactions/tracks/{id}', { ...params, body: { reaction } }));
else unwrap(await this.session.client.DELETE('/api/v1/me/reactions/tracks/{id}', params));
} catch (e) {
apply(prev);
throw e;
}
if (reaction) {
this.noteWave(track.id, reaction);
void this.refillWave(true);
}
if (reaction === 'dislike' && this.state.track?.id === track.id) this.next();
}
// --- «Моя волна» ---
async startWave(activity: string | null, mood: string | null) {
if (!this.session) return;
await this.setup();
this.leaveWave();
const res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
const tracks = res.tracks.filter((t) => t.available && t.stream_url);
if (!tracks.length) return;
this.finishAttempt(this.lastPositionMs / 1000);
this.update({
queue: tracks, index: 0, track: tracks[0] ?? null, context: { type: 'wave', id: res.session_id },
wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback },
});
await TrackPlayer.reset();
await TrackPlayer.add(await this.toRntp(tracks));
await TrackPlayer.play();
}
async setWaveContext(activity: string | null, mood: string | null) {
const wave = this.state.wave;
if (!wave || !this.session) return this.startWave(activity, mood);
const res = unwrap(
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
params: { path: { id: wave.sessionId } },
body: { activity, mood },
}),
);
await this.replaceUpcoming(res.tracks);
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
}
private leaveWave() {
const wave = this.state.wave;
if (!wave) return;
this.waveFeedback = [];
this.update({ wave: null });
void this.session?.client
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
.catch(() => undefined);
}
private noteWave(trackId: string, outcome: WaveOutcome) {
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome });
}
/** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */
private async replaceUpcoming(tracks: Track[]) {
const keep = this.state.queue.slice(0, this.state.index + 1);
await TrackPlayer.removeUpcomingTracks();
const add = tracks.filter((t) => t.available && t.stream_url);
await TrackPlayer.add(await this.toRntp(add));
this.update({ queue: [...keep, ...add] });
}
private async refillWave(refresh = false) {
const wave = this.state.wave;
if (!wave || !this.session || this.waveLoading) return;
const remaining = this.state.queue.length - this.state.index - 1;
if (!refresh && remaining > WAVE_REFILL_AT) return;
this.waveLoading = true;
const feedback = this.waveFeedback;
this.waveFeedback = [];
try {
const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id: wave.sessionId } },
body: { feedback },
}),
);
if (this.state.wave?.sessionId !== wave.sessionId) return;
if (refresh) await this.replaceUpcoming(res.tracks);
else {
const add = res.tracks.filter((t) => t.available && t.stream_url);
await TrackPlayer.add(await this.toRntp(add));
this.update({ queue: [...this.state.queue, ...add] });
}
this.update({ wave: { ...wave, fallback: res.fallback } });
} catch {
this.waveFeedback = [...feedback, ...this.waveFeedback];
} finally {
this.waveLoading = false;
}
}
// --- состояние ---
private save() {
const t = this.state.track;
if (!t || !this.session) return;
this.lastSave = Date.now();
void this.session.client
.PUT('/api/v1/me/playback-state', {
body: {
track_id: t.id,
position_ms: this.lastPositionMs,
queue: this.state.queue.slice(0, 500).map((x) => x.id),
queue_index: Math.min(this.state.index, 499),
context: this.state.context ? { type: this.state.context.type, id: this.state.context.id } : undefined,
device_id: 'android',
},
})
.catch(() => undefined);
}
/** Восстановить последний трек и позицию (на паузе, без автозапуска). */
async restore() {
if (!this.session || this.state.track) return;
await this.setup();
try {
const st = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
if (!st.track_id || !st.queue.length) return;
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
const tracks = res.items.filter((t) => t.available && t.stream_url);
const idx = Math.max(tracks.findIndex((t) => t.id === st.track_id), 0);
if (!tracks.length) return;
this.update({ queue: tracks, index: idx, track: tracks[idx] ?? null, context: (st.context as PlayContext | null) ?? null });
await TrackPlayer.reset();
await TrackPlayer.add(await this.toRntp(tracks));
await TrackPlayer.skip(idx, tracks[idx]?.id === st.track_id ? st.position_ms / 1000 : 0);
} catch {
// офлайн или нет сохранённого состояния
}
}
async reset() {
this.attempt = null;
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
if (this.setupDone) await TrackPlayer.reset().catch(() => undefined);
}
flush() {
this.save();
void this.events?.flush();
}
}
export const player = new Controller();
+9
View File
@@ -0,0 +1,9 @@
import { useSyncExternalStore } from 'react';
import { player, type PlayerState } from './controller';
export function usePlayerState(): PlayerState {
return useSyncExternalStore(player.subscribe, player.getState);
}
export { player };
+21
View File
@@ -0,0 +1,21 @@
import TrackPlayer, { Event } from 'react-native-track-player';
import { player } from './controller';
/**
* Фоновый сервис: кнопки уведомления, экрана блокировки и гарнитуры. Работает и без UI —
* команды уходят прямо в ExoPlayer, а учёт прослушиваний ведёт тот же контроллер.
*/
export async function PlaybackService(): Promise<void> {
TrackPlayer.addEventListener(Event.RemotePlay, () => void TrackPlayer.play());
TrackPlayer.addEventListener(Event.RemotePause, () => void TrackPlayer.pause());
TrackPlayer.addEventListener(Event.RemoteStop, () => void TrackPlayer.stop());
TrackPlayer.addEventListener(Event.RemoteNext, () => player.next());
TrackPlayer.addEventListener(Event.RemotePrevious, () => void player.prev());
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => player.seek(position * 1000));
// Звонок / голосовой ассистент: приостанавливаем и продолжаем сами (autoHandleInterruptions — для фокуса).
TrackPlayer.addEventListener(Event.RemoteDuck, ({ paused, permanent }) => {
if (permanent) void TrackPlayer.pause();
else if (paused) void TrackPlayer.pause();
});
}
+270
View File
@@ -0,0 +1,270 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { ApiError, type Track } from '@pipu/core';
import type { ComponentProps, ReactNode } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
Image,
Pressable,
StyleSheet,
Text,
TextInput,
View,
type StyleProp,
type TextInputProps,
type ViewStyle,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useSessionCtx } from '../lib/session';
import { absolute } from '../player/controller';
import { colors, radius } 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 Screen({ children, scroll = false, style }: { children: ReactNode; scroll?: boolean; style?: StyleProp<ViewStyle> }) {
void scroll;
return (
<SafeAreaView style={[s.screen, style]} edges={['top', 'left', 'right']}>
{children}
</SafeAreaView>
);
}
export function H1({ children }: { children: ReactNode }) {
return <Text style={s.h1}>{children}</Text>;
}
export function Muted({ children, style }: { children: ReactNode; style?: StyleProp<ViewStyle> }) {
return <Text style={[s.muted, style as object]}>{children}</Text>;
}
export function Field({
label,
error,
hint,
secure,
...rest
}: TextInputProps & { label: string; error?: string; hint?: string; secure?: boolean }) {
const { t } = useTranslation();
const [shown, setShown] = useState(false);
return (
<View style={{ gap: 6 }}>
<Text style={s.label}>{label}</Text>
<View>
<TextInput
placeholderTextColor={colors.text3}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry={secure && !shown}
accessibilityLabel={label}
style={[s.input, error ? { borderColor: colors.danger } : null, secure ? { paddingRight: 48 } : null]}
{...rest}
/>
{secure && (
<Pressable
onPress={() => setShown((v) => !v)}
style={s.reveal}
accessibilityRole="button"
accessibilityLabel={shown ? t('form.hide_password') : t('form.show_password')}
>
<Icon name={shown ? 'visibility-off' : 'visibility'} size={20} color={colors.text3} />
</Pressable>
)}
</View>
{(error ?? hint) && <Text style={{ color: error ? colors.danger : colors.text3, fontSize: 12 }}>{error ?? hint}</Text>}
</View>
);
}
export function Button({
title,
onPress,
variant = 'primary',
busy,
disabled,
icon,
}: {
title: string;
onPress: () => void;
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
busy?: boolean;
disabled?: boolean;
icon?: IconName;
}) {
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 (
<Pressable
onPress={onPress}
disabled={disabled || busy}
accessibilityRole="button"
accessibilityState={{ disabled: disabled || busy, busy }}
style={({ pressed }) => [s.button, { backgroundColor: bg, opacity: disabled ? 0.5 : pressed ? 0.75 : 1 }]}
>
{busy ? <ActivityIndicator color={fg} /> : icon ? <Icon name={icon} size={20} color={fg} /> : null}
<Text style={{ color: fg, fontWeight: '600', fontSize: 15 }}>{title}</Text>
</Pressable>
);
}
export function ErrorBox({ children }: { children?: ReactNode }) {
if (!children) return null;
return (
<View style={s.error} accessibilityRole="alert">
<Text style={{ color: colors.danger, fontWeight: '500' }}>{children}</Text>
</View>
);
}
/** Текст ошибки API: перевод errors:<code>. */
export function useErrorText(): (err: unknown) => string {
const { t, i18n } = useTranslation();
const tr = t as unknown as (k: string, o?: Record<string, unknown>) => string;
return (err) => {
const code = err instanceof ApiError ? err.code : 'common.internal';
const key = `errors:${code}`;
return i18n.exists(key) ? tr(key, err instanceof ApiError ? err.params : {}) : tr('errors:common.internal');
};
}
export function Cover({
album,
size,
round,
}: {
album: { id: string; cover_url?: string | null; cover_colors?: string[] };
size: number;
round?: boolean;
}) {
const { server } = useSessionCtx();
const uri = server ? absolute(server, album.cover_url) : undefined;
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
const bg = album.cover_colors?.[0] ?? colors.surface3;
return (
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
{uri ? (
<Image source={{ uri: `${uri}&size=${px}` }} style={{ width: size, height: size }} />
) : (
<Icon name={round ? 'person' : 'album'} size={size * 0.45} color="rgba(255,255,255,0.8)" />
)}
</View>
);
}
export function formatDuration(ms: number): string {
const sec = Math.max(0, Math.round(ms / 1000));
return `${Math.floor(sec / 60)}:${String(sec % 60).padStart(2, '0')}`;
}
export function TrackRow({
track,
current,
onPress,
onLike,
numbered,
meta,
}: {
track: Track;
current?: boolean;
onPress: () => void;
onLike?: () => void;
numbered?: number;
meta?: string;
}) {
const { t } = useTranslation();
const liked = track.reaction === 'like';
return (
<Pressable
onPress={onPress}
disabled={!track.available}
accessibilityRole="button"
accessibilityLabel={`${track.title}, ${track.artists.map((a) => a.name).join(', ')}`}
style={({ pressed }) => [s.row, current && { backgroundColor: colors['accent-soft'] }, pressed && { backgroundColor: colors.hover }, !track.available && { opacity: 0.4 }]}
>
{numbered !== undefined ? (
<Text style={s.num}>{numbered}</Text>
) : (
<Cover album={track.album} size={44} />
)}
<View style={{ flex: 1, minWidth: 0 }}>
<Text numberOfLines={1} style={[s.rowTitle, current && { color: colors.accent2 }]}>
{track.title}
</Text>
<Text numberOfLines={1} style={s.rowSub}>
{track.artists.map((a) => a.name).join(', ')}
{meta ? ` · ${meta}` : ''}
</Text>
</View>
{onLike && (
<Pressable
onPress={onLike}
hitSlop={10}
accessibilityRole="button"
accessibilityLabel={t(liked ? 'player.unlike' : 'player.like')}
>
<Icon name={liked ? 'favorite' : 'favorite-border'} size={20} color={liked ? colors.accent2 : colors.text3} />
</Pressable>
)}
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
</Pressable>
);
}
export function Segmented<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: { value: T; label: string }[];
onChange: (v: T) => void;
}) {
return (
<View style={s.segmented} accessibilityRole="tablist">
{options.map((o) => (
<Pressable
key={o.value}
onPress={() => onChange(o.value)}
accessibilityRole="tab"
accessibilityState={{ selected: value === o.value }}
style={[s.segment, value === o.value && { backgroundColor: colors.accent }]}
>
<Text style={{ color: value === o.value ? '#fff' : colors.text2, fontWeight: '600', fontSize: 13 }}>{o.label}</Text>
</Pressable>
))}
</View>
);
}
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' },
});
+3 -3
View File
@@ -4,13 +4,13 @@
| Задача | Выбор |
|---|---|
| Фреймворк | React Native (New Architecture) + Expo SDK, **prebuild / dev client** (нативный код под контролем) |
| Фреймворк | React Native + Expo SDK, **prebuild / dev client** (нативный код под контролем). New Architecture выключена, пока `react-native-track-player` её не поддерживает |
| Навигация | `expo-router` (файловые маршруты) |
| Аудио | `react-native-track-player` v4 — ExoPlayer, MediaSession, уведомление, экран блокировки, Android Auto-совместимость |
| Анимация волны | `@shopify/react-native-skia` (порт `wave.js`, общие формулы в `packages/core/wave-anim`) + `react-native-reanimated` |
| Анимация волны | Сейчас — `Animated` (пульс по BPM / energy, палитра обложки); дальше — `@shopify/react-native-skia` (порт `wave.js`) |
| Серверное состояние | TanStack Query (+ persist в MMKV) |
| Клиентское состояние | Zustand (общие сторы из `packages/core`) |
| Хранилище | `react-native-mmkv` (настройки, кэш), `expo-sqlite` (офлайн-каталог, очередь событий), `expo-secure-store` (refresh-токен) |
| Хранилище | `expo-secure-store` (refresh-токены, список серверов, недавние запросы); `expo-sqlite` — офлайн-каталог и очередь событий (вместе с загрузками) |
| Загрузки | `expo-file-system` (фоновые загрузки, resumable) |
| Сеть | `@react-native-community/netinfo` (Wi-Fi / сотовая → качество) |
| i18n | i18next, словари из `packages/i18n` |
+18
View File
@@ -184,5 +184,23 @@
"calm": "Calm",
"sad": "Sad"
}
},
"mobile": {
"connect": "Connect",
"servers": "Recent servers",
"change_server": "Another server",
"server": "Server",
"open_admin": "Admin — opens in the browser",
"version": "App version {{version}}",
"offline": "No connection to the server",
"profile": "Profile",
"search": "Search",
"recent_searches": "Recent searches",
"clear_recent": "Clear",
"now_playing": "Now playing",
"open_player": "Open the player",
"close_player": "Collapse the player",
"queue": "Queue",
"forget_server": "Forget server"
}
}
+18
View File
@@ -184,5 +184,23 @@
"calm": "Спокойное",
"sad": "Грустное"
}
},
"mobile": {
"connect": "Подключиться",
"servers": "Недавние серверы",
"change_server": "Другой сервер",
"server": "Сервер",
"open_admin": "Админка — откроется в браузере",
"version": "Версия приложения {{version}}",
"offline": "Нет связи с сервером",
"profile": "Профиль",
"search": "Поиск",
"recent_searches": "Недавние запросы",
"clear_recent": "Очистить",
"now_playing": "Сейчас играет",
"open_player": "Открыть плеер",
"close_player": "Свернуть плеер",
"queue": "Очередь",
"forget_server": "Забыть сервер"
}
}
+514 -33
View File
@@ -17,6 +17,9 @@ importers:
apps/mobile:
dependencies:
'@expo/vector-icons':
specifier: ^14.1.0
version: 14.1.0(expo-font@13.3.2(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@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)
'@pipu/api-client':
specifier: workspace:*
version: link:../../packages/api-client
@@ -29,21 +32,42 @@ importers:
'@pipu/i18n':
specifier: workspace:*
version: link:../../packages/i18n
'@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))
'@tanstack/react-query':
specifier: ^5.104.4
version: 5.104.4(@types/react@19.0.14)(react@19.0.0)
expo:
specifier: ~53.0.20
version: 53.0.27(@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)(supports-color@10.2.2)
version: 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)
expo-build-properties:
specifier: ~0.14.8
version: 0.14.8(expo@53.0.27(@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)(supports-color@10.2.2))
version: 0.14.8(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))
expo-constants:
specifier: ~17.1.7
version: 17.1.8(expo@53.0.27(@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)(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))
version: 17.1.8(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))
expo-font:
specifier: ~13.3.2
version: 13.3.2(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@19.0.0)
expo-linking:
specifier: ~7.1.7
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)
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))
expo-status-bar:
specifier: ~2.2.3
version: 2.2.3(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)
i18next:
specifier: ^25.2.1
version: 25.10.10(typescript@5.8.3)
query-string:
specifier: ^7.1.3
version: 7.1.3
react:
specifier: 19.0.0
version: 19.0.0
@@ -53,7 +77,19 @@ 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-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)
react-native-screens:
specifier: ~4.11.1
version: 4.11.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)
react-native-track-player:
specifier: ^4.1.2
version: 4.1.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)
devDependencies:
'@types/node':
specifier: ^22.20.5
version: 22.20.5
'@types/react':
specifier: ~19.0.10
version: 19.0.14
@@ -123,13 +159,13 @@ importers:
version: 4.7.0(vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2))
jsdom:
specifier: ^26.1.0
version: 26.1.0(supports-color@10.2.2)
version: 26.1.0
vite:
specifier: ^6.3.5
version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)
vitest:
specifier: ^3.2.4
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
packages/api-client:
dependencies:
@@ -149,7 +185,7 @@ importers:
devDependencies:
vitest:
specifier: ^3.2.4
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
packages/design-tokens: {}
@@ -888,6 +924,11 @@ packages:
'@expo/metro-config@0.20.18':
resolution: {integrity: sha512-qPYq3Cq61KQO1CppqtmxA1NGKpzFOmdiL7WxwLhEVnz73LPSgneW7dV/3RZwVFkjThzjA41qB4a9pxDqtpepPg==}
'@expo/metro-runtime@5.0.5':
resolution: {integrity: sha512-P8UFTi+YsmiD1BmdTdiIQITzDMcZgronsA3RTQ4QKJjHM3bas11oGzLQOnFaIZnlEV8Rrr3m1m+RHxvnpL+t/A==}
peerDependencies:
react-native: '*'
'@expo/osascript@2.8.1':
resolution: {integrity: sha512-SCi+6q6uOkGhiAn6AIP/OPFgaIq/iKD2oYpnAOg2rShHStJ8ey7wyT127RbOaZXmlcY/62rHOX1WiiMnD8u/BA==}
engines: {node: '>=12'}
@@ -907,6 +948,9 @@ packages:
'@expo/sdk-runtime-versions@1.0.1':
resolution: {integrity: sha512-kvisefkhxu8Fm/4M8G6DEqUaigrXfrSOm2lFw7hsk/nq0zchcp6B4wSk8rlheHY8FRi5iHxKtoVMGY/3M6h04A==}
'@expo/server@0.6.3':
resolution: {integrity: sha512-Ea7NJn9Xk1fe4YeJ86rObHSv/bm3u/6WiQPXEqXJ2GrfYpVab2Swoh9/PnSM3KjR64JAgKjArDn1HiPjITCfHA==}
'@expo/spawn-async@1.8.0':
resolution: {integrity: sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==}
engines: {node: '>=12'}
@@ -1014,6 +1058,29 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-slot@1.2.0':
resolution: {integrity: sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@react-native-community/netinfo@11.4.1':
resolution: {integrity: sha512-B0BYAkghz3Q2V09BF88RA601XursIEA111tnc2JOaN7axJWmNefmfjZqw/KdSxKZp7CZUuPpjBmz/WCR9uaHYg==}
peerDependencies:
react-native: '>=0.59'
'@react-native/assets-registry@0.79.6':
resolution: {integrity: sha512-UVSP1224PWg0X+mRlZNftV5xQwZGfawhivuW8fGgxNK9MS/U84xZ+16lkqcPh1ank6MOt239lIWHQ1S33CHgqA==}
engines: {node: '>=18'}
@@ -1073,6 +1140,50 @@ packages:
'@types/react':
optional: true
'@react-navigation/bottom-tabs@7.21.0':
resolution: {integrity: sha512-G3P88TPiHw9rxGhstwdeVgaBj3YQl8emiF8zLgyfXOF2AIk31myIrtDQeu2Mg5Ad7YWMs4W8zkXuD78xChlTHQ==}
peerDependencies:
'@react-navigation/native': ^7.5.1
react: '>= 18.2.0'
react-native: '*'
react-native-safe-area-context: '>= 4.0.0'
react-native-screens: '>= 4.0.0'
'@react-navigation/core@7.24.0':
resolution: {integrity: sha512-PPMHGSgLB65eqWOBHeD/nGyzGJV3rcZ5sZbsCXlaC37HnLmwTEjB4CaOQn8CBVVv/Sc0Kl/W5dpPLls9Stk2WA==}
peerDependencies:
react: '>= 18.2.0'
'@react-navigation/elements@2.9.45':
resolution: {integrity: sha512-r64qCgjA6asQo4Zlv2lAl68ARkfPbV+BNRm3n9JlMoFr4Ma2A+92Z+sWT1coJRoSXkAwVNb70+LjpM6fo6/D4A==}
peerDependencies:
'@react-native-masked-view/masked-view': '>= 0.2.0'
'@react-navigation/native': ^7.5.1
react: '>= 18.2.0'
react-native: '*'
react-native-safe-area-context: '>= 4.0.0'
peerDependenciesMeta:
'@react-native-masked-view/masked-view':
optional: true
'@react-navigation/native-stack@7.21.0':
resolution: {integrity: sha512-bYrNW87nDtbMXAJsosYwu59xwTqeuaurfCDA+62MQ6hY4+2Iv71+Sik3ulW92fOqg8s2SZinOJosHEWoi9Ayqw==}
peerDependencies:
'@react-navigation/native': ^7.5.1
react: '>= 18.2.0'
react-native: '*'
react-native-safe-area-context: '>= 4.0.0'
react-native-screens: '>= 4.0.0'
'@react-navigation/native@7.5.1':
resolution: {integrity: sha512-p9xOvhG8q8rq0FPAoh0JC22ALbyRZt7U2uH8XrHRDjvqOEFrumME7szM6LaWW2WctolAVeZAfOTV+pUKULI/kQ==}
peerDependencies:
react: '>= 18.2.0'
react-native: '*'
'@react-navigation/routers@7.6.5':
resolution: {integrity: sha512-hpvpVFt6wu3U014F/0Qy45VZCErlUvIV8kN1L6HovN5P9OpbXAzFTaRQmG7VW6czg2tSkUL5S3dZA0HIu5QirQ==}
'@redocly/ajv@8.11.2':
resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==}
@@ -1334,6 +1445,9 @@ packages:
'@types/istanbul-reports@3.0.4':
resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==}
'@types/node@22.20.5':
resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==}
'@types/node@26.6.5':
resolution: {integrity: sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==}
@@ -1684,6 +1798,9 @@ packages:
resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==}
engines: {node: '>=6'}
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -1705,6 +1822,13 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-string@1.9.1:
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
color@4.2.3:
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
engines: {node: '>=12.5.0'}
colorette@1.4.0:
resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==}
@@ -1799,6 +1923,10 @@ packages:
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
decode-uri-component@0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
@@ -1973,6 +2101,12 @@ packages:
expo: '*'
react: '*'
expo-linking@7.1.7:
resolution: {integrity: sha512-ZJaH1RIch2G/M3hx2QJdlrKbYFUTOjVVW4g39hfxrE5bPX9xhZUYXqxqQtzMNl1ylAevw9JkgEfWbBWddbZ3UA==}
peerDependencies:
react: '*'
react-native: '*'
expo-modules-autolinking@2.1.15:
resolution: {integrity: sha512-IUITUERdkgooXjr9bXsX0PmhrZUIGTMyP6NtmQpAxN5+qtf/I7ewbwLx1/rX7tgiAOzaYme+PZOp/o6yqIhFsw==}
hasBin: true
@@ -1980,6 +2114,33 @@ packages:
expo-modules-core@2.5.0:
resolution: {integrity: sha512-aIbQxZE2vdCKsolQUl6Q9Farlf8tjh/ROR4hfN1qT7QBGPl1XrJGnaOKkcgYaGrlzCPg/7IBe0Np67GzKMZKKQ==}
expo-router@5.1.11:
resolution: {integrity: sha512-6YQGqQM2rviVSiU6++hrJDPMByHZ7Oiux4XmgoSaGdaHku5QOn9911f2puEUZh2H9ALKBipw5v3ZkrECBd6Zbw==}
peerDependencies:
'@react-navigation/drawer': ^7.3.9
'@testing-library/jest-native': '*'
expo: '*'
expo-constants: '*'
expo-linking: '*'
react-native-reanimated: '*'
react-native-safe-area-context: '*'
react-native-screens: '*'
react-server-dom-webpack: ~19.0.4 || ~19.1.5 || ~19.2.4
peerDependenciesMeta:
'@react-navigation/drawer':
optional: true
'@testing-library/jest-native':
optional: true
react-native-reanimated:
optional: true
react-server-dom-webpack:
optional: true
expo-secure-store@14.2.4:
resolution: {integrity: sha512-ePaz4fnTitJJZjAiybaVYGfLWWyaEtepZC+vs9ZBMhQMfG5HUotIcVsDaSo3FnwpHmgwsLVPY2qFeryI6AtULw==}
peerDependencies:
expo: '*'
expo-status-bar@2.2.3:
resolution: {integrity: sha512-+c8R3AESBoduunxTJ8353SqKAKpxL6DvcD8VKBuh81zzJyUUbfB4CVjr1GufSJEKsMzNPXZU+HJwXx7Xh7lx8Q==}
peerDependencies:
@@ -2031,6 +2192,10 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
filter-obj@1.1.0:
resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==}
engines: {node: '>=0.10.0'}
finalhandler@1.1.2:
resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==}
engines: {node: '>= 0.8'}
@@ -2203,6 +2368,9 @@ packages:
is-arrayish@0.2.1:
resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==}
is-arrayish@0.3.4:
resolution: {integrity: sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==}
is-core-module@2.17.0:
resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==}
engines: {node: '>= 0.4'}
@@ -2823,6 +2991,10 @@ packages:
resolution: {integrity: sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ==}
hasBin: true
query-string@7.1.3:
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
engines: {node: '>=6'}
queue@6.0.2:
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
@@ -2842,6 +3014,15 @@ packages:
peerDependencies:
react: ^19.3.1
react-fast-compare@3.2.2:
resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==}
react-freeze@1.0.4:
resolution: {integrity: sha512-r4F0Sec0BLxWicc7HEyo2x3/2icUTrRmDjaaRyzzn+7aDyFZliszMDOgLVwSnQnYENOlL1o569Ze2HZefk8clA==}
engines: {node: '>=10'}
peerDependencies:
react: '>=17.0.0'
react-i18next@15.7.4:
resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==}
peerDependencies:
@@ -2864,6 +3045,9 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-is@19.3.1:
resolution: {integrity: sha512-RRieF6IyxQBkcK6pP8xOHprLTPW6QjRkbGdImtqNme+/hYVwCZkLQLdlZGOhUqXwkS9IEynaGvPJtv6XB/cz0Q==}
react-native-edge-to-edge@1.6.0:
resolution: {integrity: sha512-2WCNdE3Qd6Fwg9+4BpbATUxCLcouF6YRY7K+J36KJ4l3y+tWN6XCqAC4DuoGblAAbb2sLkhEDp4FOlbOIot2Og==}
deprecated: This package is deprecated. Set edgeToEdgeEnabled=true in android/gradle.properties instead.
@@ -2877,6 +3061,31 @@ packages:
react: '*'
react-native: '*'
react-native-safe-area-context@5.4.0:
resolution: {integrity: sha512-JaEThVyJcLhA+vU0NU8bZ0a1ih6GiF4faZ+ArZLqpYbL6j7R3caRqj+mE3lEtKCuHgwjLg3bCxLL1GPUJZVqUA==}
peerDependencies:
react: '*'
react-native: '*'
react-native-screens@4.11.1:
resolution: {integrity: sha512-F0zOzRVa3ptZfLpD0J8ROdo+y1fEPw+VBFq1MTY/iyDu08al7qFUO5hLMd+EYMda5VXGaTFCa8q7bOppUszhJw==}
peerDependencies:
react: '*'
react-native: '*'
react-native-track-player@4.1.2:
resolution: {integrity: sha512-cIgMlqVJx/95hirUWPRW8CHxiBFj9Rjl/voKHh2jF/2URYMTQyt76t/m2FKvjeYUW2doKv4QSCBIOUmtyDLtJw==}
peerDependencies:
react: '>=16.8.6'
react-native: '>=0.60.0-rc.2'
react-native-windows: '>=0.63.0'
shaka-player: ^4.7.9
peerDependenciesMeta:
react-native-windows:
optional: true
shaka-player:
optional: true
react-native@0.79.6:
resolution: {integrity: sha512-kvIWSmf4QPfY41HC25TR285N7Fv0Pyn3DAEK8qRL9dA35usSaxsJkHfw+VqnonqJjXOaoKCEanwudRAJ60TBGA==}
engines: {node: '>=18'}
@@ -3005,6 +3214,11 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.6.3:
resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==}
engines: {node: '>=10'}
hasBin: true
semver@7.8.5:
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
engines: {node: '>=10'}
@@ -3022,9 +3236,19 @@ packages:
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
engines: {node: '>= 0.8.0'}
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
sf-symbols-typescript@2.2.0:
resolution: {integrity: sha512-TPbeg0b7ylrswdGCji8FRGFAKuqbpQlLbL8SOle3j1iHSs5Ob5mhvMAxWN2UItOjgALAB5Zp3fmMfj8mbWvXKw==}
engines: {node: '>=10'}
shallowequal@1.1.0:
resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@@ -3050,6 +3274,9 @@ packages:
simple-plist@1.3.1:
resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==}
simple-swizzle@0.2.4:
resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@@ -3076,6 +3303,10 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
split-on-first@1.1.0:
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
engines: {node: '>=6'}
sprintf-js@1.0.3:
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
@@ -3093,6 +3324,11 @@ packages:
resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==}
engines: {node: '>=6'}
standard-navigation@0.0.8:
resolution: {integrity: sha512-TyVbo7INUDWtsUWDFn8RR7kwR87U0S4xHfLfbbnyeC581TmmyqQ+eM+nPw8rQTSD8QitRVcYfPaSHr/QJiUy1g==}
peerDependencies:
react: '*'
statuses@1.5.0:
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
engines: {node: '>= 0.6'}
@@ -3108,6 +3344,10 @@ packages:
resolution: {integrity: sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==}
engines: {node: '>= 0.10.0'}
strict-uri-encode@2.0.0:
resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
engines: {node: '>=4'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@@ -3317,6 +3557,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
undici-types@8.9.0:
resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==}
@@ -3357,6 +3600,11 @@ packages:
uri-js-replace@1.0.1:
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
use-latest-callback@0.2.6:
resolution: {integrity: sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==}
peerDependencies:
react: '>=16.8'
use-sync-external-store@1.7.0:
resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
peerDependencies:
@@ -3466,6 +3714,9 @@ packages:
walker@1.0.8:
resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==}
warn-once@0.1.1:
resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==}
wcwidth@1.0.1:
resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==}
@@ -4511,6 +4762,10 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@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))':
dependencies:
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/osascript@2.8.1':
dependencies:
'@expo/spawn-async': 1.8.0
@@ -4549,15 +4804,24 @@ snapshots:
'@expo/sdk-runtime-versions@1.0.1': {}
'@expo/server@0.6.3(supports-color@10.2.2)':
dependencies:
abort-controller: 3.0.0
debug: 4.4.3(supports-color@10.2.2)
source-map-support: 0.5.21
undici: 6.29.1
transitivePeerDependencies:
- supports-color
'@expo/spawn-async@1.8.0':
dependencies:
cross-spawn: 7.0.6
'@expo/sudo-prompt@9.3.2': {}
'@expo/vector-icons@14.1.0(expo-font@13.3.2(expo@53.0.27(@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)(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)':
'@expo/vector-icons@14.1.0(expo-font@13.3.2(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@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:
expo-font: 13.3.2(expo@53.0.27(@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)(supports-color@10.2.2))(react@19.0.0)
expo-font: 13.3.2(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@19.0.0)
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)
@@ -4685,6 +4949,23 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.0.14)(react@19.0.0)':
dependencies:
react: 19.0.0
optionalDependencies:
'@types/react': 19.0.14
'@radix-ui/react-slot@1.2.0(@types/react@19.0.14)(react@19.0.0)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.14)(react@19.0.0)
react: 19.0.0
optionalDependencies:
'@types/react': 19.0.14
'@react-native-community/netinfo@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))':
dependencies:
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/assets-registry@0.79.6': {}
'@react-native/babel-plugin-codegen@0.79.6(@babel/core@7.29.7)':
@@ -4805,6 +5086,68 @@ snapshots:
optionalDependencies:
'@types/react': 19.0.14
'@react-navigation/bottom-tabs@7.21.0(@react-navigation/native@7.5.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))(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))(react-native-screens@4.11.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))(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-navigation/elements': 2.9.45(@react-navigation/native@7.5.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))(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))(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-navigation/native': 7.5.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)
color: 4.2.3
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-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)
react-native-screens: 4.11.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)
sf-symbols-typescript: 2.2.0
transitivePeerDependencies:
- '@react-native-masked-view/masked-view'
'@react-navigation/core@7.24.0(react@19.0.0)':
dependencies:
'@react-navigation/routers': 7.6.5
escape-string-regexp: 4.0.0
fast-deep-equal: 3.1.3
react: 19.0.0
react-is: 19.3.1
use-latest-callback: 0.2.6(react@19.0.0)
use-sync-external-store: 1.7.0(react@19.0.0)
'@react-navigation/elements@2.9.45(@react-navigation/native@7.5.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))(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))(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-navigation/native': 7.5.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)
color: 4.2.3
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-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)
use-latest-callback: 0.2.6(react@19.0.0)
use-sync-external-store: 1.7.0(react@19.0.0)
'@react-navigation/native-stack@7.21.0(@react-navigation/native@7.5.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))(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))(react-native-screens@4.11.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))(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-navigation/elements': 2.9.45(@react-navigation/native@7.5.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))(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))(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-navigation/native': 7.5.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)
color: 4.2.3
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-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)
react-native-screens: 4.11.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)
sf-symbols-typescript: 2.2.0
warn-once: 0.1.1
transitivePeerDependencies:
- '@react-native-masked-view/masked-view'
'@react-navigation/native@7.5.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-navigation/core': 7.24.0(react@19.0.0)
escape-string-regexp: 4.0.0
fast-deep-equal: 3.1.3
nanoid: 3.3.20
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)
standard-navigation: 0.0.8(react@19.0.0)
use-latest-callback: 0.2.6(react@19.0.0)
'@react-navigation/routers@7.6.5':
dependencies:
nanoid: 3.3.20
'@redocly/ajv@8.11.2':
dependencies:
fast-deep-equal: 3.1.3
@@ -4919,6 +5262,13 @@ snapshots:
'@tanstack/query-core@5.104.4': {}
'@tanstack/react-query@5.104.4(@types/react@19.0.14)(react@19.0.0)':
dependencies:
'@tanstack/query-core': 5.104.4
react: 19.0.0
optionalDependencies:
'@types/react': 19.0.14
'@tanstack/react-query@5.104.4(@types/react@19.3.0)(react@19.3.1)':
dependencies:
'@tanstack/query-core': 5.104.4
@@ -5032,6 +5382,10 @@ snapshots:
dependencies:
'@types/istanbul-lib-report': 3.0.3
'@types/node@22.20.5':
dependencies:
undici-types: 6.21.0
'@types/node@26.6.5':
dependencies:
undici-types: 8.9.0
@@ -5446,6 +5800,8 @@ snapshots:
cli-spinners@2.9.2: {}
client-only@0.0.1: {}
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -5466,6 +5822,16 @@ snapshots:
color-name@1.1.4: {}
color-string@1.9.1:
dependencies:
color-name: 1.1.4
simple-swizzle: 0.2.4
color@4.2.3:
dependencies:
color-convert: 2.0.1
color-string: 1.9.1
colorette@1.4.0: {}
commander@12.1.0: {}
@@ -5555,6 +5921,8 @@ snapshots:
decimal.js@10.6.0: {}
decode-uri-component@0.2.2: {}
deep-eql@5.0.2: {}
deep-extend@0.6.0: {}
@@ -5666,47 +6034,57 @@ snapshots:
expect-type@1.4.0: {}
expo-asset@11.1.7(expo@53.0.27(@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)(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-asset@11.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):
dependencies:
'@expo/image-utils': 0.7.6
expo: 53.0.27(@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)(supports-color@10.2.2)
expo-constants: 17.1.8(expo@53.0.27(@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)(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))
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)
expo-constants: 17.1.8(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
react-native: 0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2)
transitivePeerDependencies:
- supports-color
expo-build-properties@0.14.8(expo@53.0.27(@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)(supports-color@10.2.2)):
expo-build-properties@0.14.8(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)):
dependencies:
ajv: 8.20.0
expo: 53.0.27(@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)(supports-color@10.2.2)
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)
semver: 7.8.5
expo-constants@17.1.8(expo@53.0.27(@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)(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)):
expo-constants@17.1.8(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)):
dependencies:
'@expo/config': 11.0.13
'@expo/env': 1.0.7
expo: 53.0.27(@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)(supports-color@10.2.2)
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)
transitivePeerDependencies:
- supports-color
expo-file-system@18.1.11(expo@53.0.27(@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)(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)):
expo-file-system@18.1.11(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)):
dependencies:
expo: 53.0.27(@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)(supports-color@10.2.2)
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)
expo-font@13.3.2(expo@53.0.27(@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)(supports-color@10.2.2))(react@19.0.0):
expo-font@13.3.2(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@19.0.0):
dependencies:
expo: 53.0.27(@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)(supports-color@10.2.2)
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)
fontfaceobserver: 2.3.0
react: 19.0.0
expo-keep-awake@14.1.4(expo@53.0.27(@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)(supports-color@10.2.2))(react@19.0.0):
expo-keep-awake@14.1.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))(react@19.0.0):
dependencies:
expo: 53.0.27(@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)(supports-color@10.2.2)
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: 19.0.0
expo-linking@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):
dependencies:
expo-constants: 17.1.8(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))
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)
transitivePeerDependencies:
- expo
- supports-color
expo-modules-autolinking@2.1.15:
dependencies:
'@expo/spawn-async': 1.8.0
@@ -5721,6 +6099,38 @@ snapshots:
dependencies:
invariant: 2.2.4
expo-router@5.1.11(9756619e19b25962ba8852217f9e63a2):
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
'@expo/server': 0.6.3(supports-color@10.2.2)
'@radix-ui/react-slot': 1.2.0(@types/react@19.0.14)(react@19.0.0)
'@react-navigation/bottom-tabs': 7.21.0(@react-navigation/native@7.5.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))(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))(react-native-screens@4.11.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))(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-navigation/native': 7.5.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)
'@react-navigation/native-stack': 7.21.0(@react-navigation/native@7.5.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))(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))(react-native-screens@4.11.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))(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)
client-only: 0.0.1
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)
expo-constants: 17.1.8(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))
expo-linking: 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)
invariant: 2.2.4
react-fast-compare: 3.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)
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)
react-native-screens: 4.11.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)
semver: 7.6.3
server-only: 0.0.1
shallowequal: 1.1.0
transitivePeerDependencies:
- '@react-native-masked-view/masked-view'
- '@types/react'
- react
- react-native
- supports-color
expo-secure-store@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)):
dependencies:
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)
expo-status-bar@2.2.3(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
@@ -5728,7 +6138,7 @@ snapshots:
react-native-edge-to-edge: 1.6.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)
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)
expo@53.0.27(@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)(supports-color@10.2.2):
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):
dependencies:
'@babel/runtime': 7.29.10
'@expo/cli': 0.24.24(supports-color@10.2.2)
@@ -5736,19 +6146,21 @@ snapshots:
'@expo/config-plugins': 10.1.2
'@expo/fingerprint': 0.13.4(supports-color@10.2.2)
'@expo/metro-config': 0.20.18
'@expo/vector-icons': 14.1.0(expo-font@13.3.2(expo@53.0.27(@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)(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)
'@expo/vector-icons': 14.1.0(expo-font@13.3.2(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@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)
babel-preset-expo: 13.2.5(@babel/core@7.29.7)(supports-color@10.2.2)
expo-asset: 11.1.7(expo@53.0.27(@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)(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-constants: 17.1.8(expo@53.0.27(@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)(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))
expo-file-system: 18.1.11(expo@53.0.27(@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)(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))
expo-font: 13.3.2(expo@53.0.27(@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)(supports-color@10.2.2))(react@19.0.0)
expo-keep-awake: 14.1.4(expo@53.0.27(@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)(supports-color@10.2.2))(react@19.0.0)
expo-asset: 11.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-constants: 17.1.8(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))
expo-file-system: 18.1.11(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))
expo-font: 13.3.2(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@19.0.0)
expo-keep-awake: 14.1.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))(react@19.0.0)
expo-modules-autolinking: 2.1.15
expo-modules-core: 2.5.0
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-edge-to-edge: 1.6.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)
whatwg-url-without-unicode: 8.0.0-3
optionalDependencies:
'@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))
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-react-compiler
@@ -5777,6 +6189,8 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
filter-obj@1.1.0: {}
finalhandler@1.1.2:
dependencies:
debug: 2.6.9
@@ -5887,7 +6301,7 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
http-proxy-agent@7.0.2(supports-color@10.2.2):
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@10.2.2)
@@ -5945,6 +6359,8 @@ snapshots:
is-arrayish@0.2.1: {}
is-arrayish@0.3.4: {}
is-core-module@2.17.0:
dependencies:
hasown: 2.0.4
@@ -6074,13 +6490,13 @@ snapshots:
jsc-safe-url@0.2.4: {}
jsdom@26.1.0(supports-color@10.2.2):
jsdom@26.1.0:
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2(supports-color@10.2.2)
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6(supports-color@10.2.2)
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.28
@@ -6647,6 +7063,13 @@ snapshots:
qrcode-terminal@0.11.0: {}
query-string@7.1.3:
dependencies:
decode-uri-component: 0.2.2
filter-obj: 1.1.0
split-on-first: 1.1.0
strict-uri-encode: 2.0.0
queue@6.0.2:
dependencies:
inherits: 2.0.4
@@ -6673,6 +7096,12 @@ snapshots:
react: 19.3.1
scheduler: 0.28.0
react-fast-compare@3.2.2: {}
react-freeze@1.0.4(react@19.0.0):
dependencies:
react: 19.0.0
react-i18next@15.7.4(i18next@25.10.10(typescript@5.8.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@5.8.3):
dependencies:
'@babel/runtime': 7.29.10
@@ -6697,6 +7126,8 @@ snapshots:
react-is@18.3.1: {}
react-is@19.3.1: {}
react-native-edge-to-edge@1.6.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
@@ -6707,6 +7138,24 @@ 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-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
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-screens@4.11.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-freeze: 1.0.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.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)
warn-once: 0.1.1
react-native-track-player@4.1.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):
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@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2):
dependencies:
'@jest/create-cache-key-function': 29.7.0
@@ -6879,6 +7328,8 @@ snapshots:
semver@6.3.1: {}
semver@7.6.3: {}
semver@7.8.5: {}
send@0.19.2:
@@ -6910,8 +7361,14 @@ snapshots:
transitivePeerDependencies:
- supports-color
server-only@0.0.1: {}
setprototypeof@1.2.0: {}
sf-symbols-typescript@2.2.0: {}
shallowequal@1.1.0: {}
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
@@ -6932,6 +7389,10 @@ snapshots:
bplist-parser: 0.3.1
plist: 3.1.1
simple-swizzle@0.2.4:
dependencies:
is-arrayish: 0.3.4
sisteransi@1.0.5: {}
slash@3.0.0: {}
@@ -6949,6 +7410,8 @@ snapshots:
source-map@0.6.1: {}
split-on-first@1.1.0: {}
sprintf-js@1.0.3: {}
stack-utils@2.0.6:
@@ -6963,6 +7426,10 @@ snapshots:
dependencies:
type-fest: 0.7.1
standard-navigation@0.0.8(react@19.0.0):
dependencies:
react: 19.0.0
statuses@1.5.0: {}
statuses@2.0.2: {}
@@ -6971,6 +7438,8 @@ snapshots:
stream-buffers@2.2.0: {}
strict-uri-encode@2.0.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@@ -7158,6 +7627,8 @@ snapshots:
typescript@5.8.3: {}
undici-types@6.21.0: {}
undici-types@8.9.0: {}
undici@6.29.1: {}
@@ -7187,6 +7658,14 @@ snapshots:
uri-js-replace@1.0.1: {}
use-latest-callback@0.2.6(react@19.0.0):
dependencies:
react: 19.0.0
use-sync-external-store@1.7.0(react@19.0.0):
dependencies:
react: 19.0.0
use-sync-external-store@1.7.0(react@19.3.1):
dependencies:
react: 19.3.1
@@ -7234,7 +7713,7 @@ snapshots:
lightningcss: 1.27.0
terser: 5.51.2
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.7
@@ -7261,7 +7740,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.6.5
jsdom: 26.1.0(supports-color@10.2.2)
jsdom: 26.1.0
transitivePeerDependencies:
- jiti
- less
@@ -7288,6 +7767,8 @@ snapshots:
dependencies:
makeerror: 1.0.12
warn-once@0.1.1: {}
wcwidth@1.0.1:
dependencies:
defaults: 1.0.4