feat(mobile): offline downloads, persistent event queue and offline start (M5)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m7s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Canceled after 4m15s
ci / ts / ts (packages + web) (push) Canceled after 17m42s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 18m52s

- Downloads queue (2 parallel, Wi-Fi only by default) stores files under
  the app's document directory and track metadata in SQLite; Collection
  gets a Downloads tab, album and player screens get download buttons.
- Player plays downloaded files and local covers; My Wave falls back to
  a shuffle of downloaded tracks when the server is unreachable.
- Playback events that fail on the network are kept in SQLite and sent
  when the connection returns.
- AuthSession can open offline with the cached user and fetches an
  access token before the first request once online.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-20 13:02:47 +03:00
1 parent 765345e0f4
commit be34ee5361
22 files changed
+881 -387

No files matched your search

+1
View File
@@ -26,6 +26,7 @@
"expo-linking": "~7.1.7",
"expo-router": "~5.1.11",
"expo-secure-store": "~14.2.4",
"expo-sqlite": "~15.2.14",
"expo-status-bar": "~2.2.3",
"i18next": "^25.2.1",
"js-sha256": "^1.0.0",
+44 -89
View File
@@ -1,36 +1,31 @@
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 { useNetInfo } from '@react-native-community/netinfo';
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";
import { UpdateDialog } from "../../ui/UpdateDialog";
import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks';
import { colors } from '../../theme';
import { Cover, Icon, type IconName } from '../../ui/kit';
import { UpdateDialog } from '../../ui/UpdateDialog';
const ICONS: Record<string, IconName> = {
index: "home",
search: "search",
collection: "library-music",
profile: "person",
};
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"),
index: t('nav.home'),
search: t('mobile.search'),
collection: t('nav.collection'),
profile: t('mobile.profile'),
};
const net = useNetInfo();
return (
<>
{net.isConnected === false && <OfflineBanner text={t('mobile.offline')} />}
<Tabs
screenOptions={{
headerShown: false,
sceneStyle: { backgroundColor: colors.bg },
}}
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
tabBar={({ state, navigation }) => (
<View>
<MiniPlayer />
@@ -69,7 +64,7 @@ function TabBar({
<View
accessibilityRole="tablist"
style={{
flexDirection: "row",
flexDirection: 'row',
paddingBottom: insets.bottom,
backgroundColor: colors.bg2,
borderTopWidth: 1,
@@ -85,24 +80,10 @@ function TabBar({
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",
}}
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>
@@ -119,71 +100,45 @@ function MiniPlayer() {
const st = usePlayerState();
const track = st.track;
if (!track) return null;
const progress = st.durationMs
? Math.min(st.positionMs / st.durationMs, 1)
: 0;
const progress = st.durationMs ? Math.min(st.positionMs / st.durationMs, 1) : 0;
return (
<Pressable
onPress={() => router.push("/player")}
onPress={() => router.push('/player')}
accessibilityRole="button"
accessibilityLabel={t("mobile.open_player")}
style={{
backgroundColor: colors.surface,
borderTopWidth: 1,
borderTopColor: colors.line,
}}
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 style={{ height: 2, width: `${progress * 100}%`, backgroundColor: colors.accent2 }} />
</View>
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 12,
paddingHorizontal: 12,
paddingVertical: 8,
}}
>
<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" }}
>
<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(", ")}
{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 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")}
>
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
<Icon name="skip-next" size={28} color={colors.text2} />
</Pressable>
</View>
</Pressable>
);
}
/** Нет сети: доступны «Скачанное» и офлайн-волна. */
function OfflineBanner({ text }: { text: string }) {
const insets = useSafeAreaInsets();
return (
<View accessibilityRole="alert" style={{ paddingTop: insets.top + 6, paddingBottom: 6, paddingHorizontal: 16, backgroundColor: colors.surface3, flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<Icon name="cloud-off" size={16} color={colors.text2} />
<Text style={{ color: colors.text2, fontSize: 12 }}>{text}</Text>
</View>
);
}
+86 -12
View File
@@ -1,16 +1,18 @@
import { unwrap, type Track } from '@pipu/core';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, Text, View } from 'react-native';
import { useSession } from '../../lib/session';
import { downloads } from '../../offline/downloads';
import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks';
import { colors } from '../../theme';
import { Button, H1, Screen, Segmented, TrackRow } from '../../ui/kit';
import { DownloadButton, useDownloads } from '../../ui/downloads';
import { Button, H1, Muted, Screen, Segmented, TrackRow } from '../../ui/kit';
type Tab = 'history' | 'likes' | 'dislikes';
type Tab = 'history' | 'likes' | 'dislikes' | 'downloads';
export default function CollectionScreen() {
const { t, i18n } = useTranslation();
@@ -20,6 +22,7 @@ export default function CollectionScreen() {
const list = useInfiniteQuery({
queryKey: ['m-collection', tab],
enabled: tab !== 'downloads',
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) => {
const query = { query: { cursor: pageParam, limit: 100 } };
@@ -34,11 +37,35 @@ export default function CollectionScreen() {
getNextPageParam: (p) => p.next ?? undefined,
});
const items = list.data?.pages.flatMap((p) => p.items) ?? [];
/** Все лайки целиком, а не только загруженные страницы списка. */
const downloadAllLikes = async () => {
let cursor: string | undefined;
do {
const page = unwrap(await session.client.GET('/api/v1/me/likes', { params: { query: { cursor, limit: 100 } } }));
await downloads.enqueue(page.items.map((i) => i.track));
cursor = page.next_cursor ?? undefined;
} while (cursor);
};
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;
const tabs = (
<Segmented<Tab>
value={tab}
onChange={setTab}
options={[
{ value: 'likes', label: t('collection.likes') },
{ value: 'history', label: t('collection.history') },
{ value: 'downloads', label: t('mobile.dl_title') },
{ value: 'dislikes', label: t('collection.dislikes') },
]}
/>
);
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
return (
<Screen>
<FlatList
@@ -50,18 +77,13 @@ export default function CollectionScreen() {
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') },
]}
/>
{tabs}
{tab === 'likes' && tracks.length > 0 && (
<Button title={t('collection.play_all')} icon="play-arrow" onPress={() => void player.playList(tracks, 0, context)} />
)}
{tab === 'likes' && tracks.length > 0 && (
<Button title={t('mobile.dl_all_likes')} variant="secondary" icon="download" onPress={() => void downloadAllLikes()} />
)}
</View>
}
ListEmptyComponent={list.isSuccess ? <Text style={{ color: colors.text3, textAlign: 'center', padding: 24 }}>{t(empty[tab])}</Text> : null}
@@ -78,3 +100,55 @@ export default function CollectionScreen() {
</Screen>
);
}
const MB = 1024 * 1024;
/** Коллекция → Скачанное: работает без сети, данные — из SQLite. */
function DownloadsTab({ tabs }: { tabs: React.ReactNode }) {
const { t } = useTranslation();
const dl = useDownloads();
const current = usePlayerState().track;
const [usage, setUsage] = useState<{ used: number; free: number } | null>(null);
const items = [...dl.items.values()];
const done = items.filter((i) => i.status === 'done').map((i) => i.track);
const pending = items.filter((i) => i.status === 'queued' || i.status === 'downloading').length;
const failed = items.some((i) => i.status === 'error');
const context = { type: 'collection', id: 'downloads' } as const;
useEffect(() => {
void downloads.usage().then(setUsage);
}, [done.length]);
return (
<Screen>
<FlatList
data={items}
keyExtractor={(it) => it.track.id}
ListHeaderComponent={
<View style={{ padding: 16, gap: 14 }}>
<H1>{t('collection.title')}</H1>
{tabs}
{usage && <Muted>{t('mobile.dl_usage', { used: (usage.used / MB).toFixed(0), free: (usage.free / MB / 1024).toFixed(1) })}</Muted>}
{pending > 0 && (
<Muted>{t(dl.allowed ? 'mobile.dl_pending' : dl.wifiOnly ? 'mobile.dl_waiting_wifi' : 'mobile.dl_waiting_network', { count: pending })}</Muted>
)}
{failed && <Button title={t('mobile.dl_retry')} variant="secondary" icon="refresh" onPress={() => void downloads.retry()} />}
{done.length > 0 && <Button title={t('collection.play_all')} icon="play-arrow" onPress={() => void player.playList(done, 0, context)} />}
</View>
}
ListEmptyComponent={<Text style={{ color: colors.text3, textAlign: 'center', padding: 24 }}>{t('mobile.dl_empty')}</Text>}
renderItem={({ item }) => (
<TrackRow
track={item.track}
current={current?.id === item.track.id}
meta={item.status === 'downloading' ? `${Math.round(item.progress * 100)}%` : undefined}
onPress={() => {
if (item.status === 'done') void player.playList(done, done.indexOf(item.track), context);
}}
trailing={<DownloadButton track={item.track} size={20} />}
/>
)}
/>
</Screen>
);
}
+43 -96
View File
@@ -1,33 +1,32 @@
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 { 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, Switch, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from "../../lib/session";
import { updater } from "../../lib/update";
import { player } from "../../player/controller";
import { colors, radius } from "../../theme";
import { Button, H1, Icon, Muted, Screen, Segmented } from "../../ui/kit";
import { useUpdateState } from "../../ui/UpdateDialog";
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { updater } from '../../lib/update';
import { downloads } from '../../offline/downloads';
import { player } from '../../player/controller';
import { colors, radius } from '../../theme';
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
import { useDownloads } from '../../ui/downloads';
import { useUpdateState } from '../../ui/UpdateDialog';
export default function ProfileScreen() {
const { t, i18n } = useTranslation();
const session = useSession();
const user = useUser();
const { server, disconnect } = useSessionCtx();
const staff = user.role !== "user";
const staff = user.role !== 'user';
const update = useUpdateState();
const dl = useDownloads();
const setLocale = async (l: Locale) => {
await i18n.changeLanguage(l);
try {
session.setUser(
unwrap(
await session.client.PATCH("/api/v1/me", { body: { locale: l } }),
),
);
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { locale: l } })));
} catch {
// язык уже сменился локально
}
@@ -47,79 +46,51 @@ export default function ProfileScreen() {
</Muted>
<Section>
<Row label={t("language.label")}>
<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(),
}))}
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")}>
<Row label={t('mobile.dl_wifi_only')}>
<Switch value={dl.wifiOnly} onValueChange={(v) => void downloads.setWifiOnly(v)} />
</Row>
<Row label={t('mobile.dl_auto_likes')}>
<Switch value={dl.autoLikes} onValueChange={(v) => void downloads.setAutoLikes(v)} />
</Row>
</Section>
<Section>
<Row label={t('mobile.server')}>
<Text style={{ color: colors.text2 }} numberOfLines={1}>
{server?.replace(/^https?:\/\//, "")}
{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,
}}
>
<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>
<Text style={{ color: colors.accent2, fontWeight: '500' }}>{t('mobile.open_admin')}</Text>
</Pressable>
)}
</Section>
<Button
title={t(
update.status === "checking"
? "mobile.update_checking"
: "mobile.update_check",
)}
title={t(update.status === 'checking' ? 'mobile.update_checking' : 'mobile.update_check')}
variant="secondary"
icon="system-update"
busy={update.status === "checking"}
busy={update.status === 'checking'}
onPress={() => void updater.check(session, true)}
/>
{update.status === "latest" && (
<Muted>{t("mobile.update_latest")}</Muted>
)}
{update.status === 'latest' && <Muted>{t('mobile.update_latest')}</Muted>}
<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",
})}
<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>
@@ -127,36 +98,12 @@ export default function ProfileScreen() {
}
function Section({ children }: { children: React.ReactNode }) {
return (
<View
style={{
padding: 16,
borderRadius: radius.lg,
backgroundColor: colors.surface,
gap: 8,
}}
>
{children}
</View>
);
return <View style={{ padding: 16, borderRadius: radius.lg, backgroundColor: colors.surface, gap: 8 }}>{children}</View>;
}
function Row({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
function Row({ label, children }: { label: string; children: React.ReactNode }) {
return (
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<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>
+3 -1
View File
@@ -9,6 +9,7 @@ import { ActivityIndicator, AppState, View } from 'react-native';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { SessionProvider, useAuthState, useSessionCtx } from '../lib/session';
import { downloads } from '../offline/downloads';
import { player } from '../player/controller';
import { colors } from '../theme';
@@ -59,7 +60,8 @@ function Gate() {
const user = auth.status === 'signed_in' ? auth.user : null;
useEffect(() => {
player.attach(user ? session : null, server ?? '');
player.attach(user ? session : null, server ?? '', user?.id ?? null);
void downloads.attach(user ? session : null, server ?? '', user?.id ?? null);
if (user) void player.restore();
}, [user?.id, session, server]);
+2
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { FlatList, Pressable, Text, View } from 'react-native';
import { useSession } from '../../lib/session';
import { downloads } from '../../offline/downloads';
import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks';
import { colors } from '../../theme';
@@ -46,6 +47,7 @@ export default function AlbumScreen() {
</Text>
</Pressable>
<Button title={t('player.play')} icon="play-arrow" onPress={() => void player.playList(a.tracks, 0, ctx)} />
<Button title={t('mobile.dl_album')} variant="secondary" icon="download" onPress={() => void downloads.enqueue(a.tracks)} />
</>
)}
</View>
+2 -1
View File
@@ -6,6 +6,7 @@ import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
import { player } from '../player/controller';
import { usePlayerState } from '../player/hooks';
import { colors } from '../theme';
import { DownloadButton } from '../ui/downloads';
import { Cover, Icon, Screen, formatDuration } from '../ui/kit';
/** Полноэкранный плеер (вид cover): обложка, прогресс с перемоткой, лайк / дизлайк, управление. */
@@ -37,7 +38,7 @@ export default function PlayerScreen() {
<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 }} />
<DownloadButton track={track} />
</View>
<View style={{ alignItems: 'center' }}>
+24 -4
View File
@@ -1,4 +1,4 @@
import type { RefreshStore } from '@pipu/core';
import type { AuthUser, RefreshStore } from '@pipu/core';
import * as SecureStore from 'expo-secure-store';
// Всё храним в SecureStore (Android Keystore): список серверов маленький, а refresh-токен — секрет.
@@ -37,7 +37,7 @@ export async function rememberServer(server: SavedServer): Promise<void> {
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));
await refreshStoreFor(url).clear();
if ((await currentServer()) === url) await SecureStore.deleteItemAsync(CURRENT_KEY);
}
@@ -50,12 +50,32 @@ export async function setCurrentServer(url: string | null): Promise<void> {
else await SecureStore.deleteItemAsync(CURRENT_KEY);
}
/** Refresh-токен сервера: у каждого сервера — свой. */
/** Refresh-токен сервера (у каждого сервера — свой) и последний пользователь — для запуска без сети. */
export function refreshStoreFor(url: string): RefreshStore {
const key = keyFor(url);
const userKey = key.replace('pipu.rt.', 'pipu.user.');
return {
get: () => SecureStore.getItemAsync(key),
set: (token) => SecureStore.setItemAsync(key, token),
clear: () => SecureStore.deleteItemAsync(key),
clear: async () => {
await SecureStore.deleteItemAsync(key);
await SecureStore.deleteItemAsync(userKey);
},
getUser: async () => {
try {
const raw = await SecureStore.getItemAsync(userKey);
return raw ? (JSON.parse(raw) as AuthUser) : null;
} catch {
return null;
}
},
setUser: (user) => SecureStore.setItemAsync(userKey, JSON.stringify(user)),
};
}
/** Короткий стабильный хэш строки — для имён файлов и ключей. */
export function shortHash(s: string): string {
let h = 0;
for (const ch of s) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
return h.toString(36);
}
+44 -67
View File
@@ -1,54 +1,51 @@
import { unwrap, type AuthSession } from "@pipu/core";
import type { components } from "@pipu/api-client";
import Constants from "expo-constants";
import * as FileSystem from "expo-file-system";
import * as IntentLauncher from "expo-intent-launcher";
import * as SecureStore from "expo-secure-store";
import { sha256 } from "js-sha256";
import { unwrap, type AuthSession } from '@pipu/core';
import type { components } from '@pipu/api-client';
import Constants from 'expo-constants';
import * as FileSystem from 'expo-file-system';
import * as IntentLauncher from 'expo-intent-launcher';
import * as SecureStore from 'expo-secure-store';
import { sha256 } from 'js-sha256';
export type AndroidRelease = components["schemas"]["AndroidRelease"];
export type AndroidRelease = components['schemas']['AndroidRelease'];
/** Состояние самообновления (docs/mobile.md#самообновление). */
export type UpdateState =
| { status: "idle" }
| { status: "checking" }
| { status: "latest" }
| { status: 'idle' }
| { status: 'checking' }
| { status: 'latest' }
| {
status: "available";
status: 'available';
release: AndroidRelease;
required: boolean;
dismissed: boolean;
}
| {
status: "downloading";
status: 'downloading';
release: AndroidRelease;
required: boolean;
progress: number;
}
| { status: "verifying"; release: AndroidRelease; required: boolean }
| { status: 'verifying'; release: AndroidRelease; required: boolean }
| {
status: "ready";
status: 'ready';
release: AndroidRelease;
required: boolean;
file: string;
}
| {
status: "error";
status: 'error';
release: AndroidRelease;
required: boolean;
error: "bad_checksum" | "failed";
error: 'bad_checksum' | 'failed';
};
const CHECK_EVERY_MS = 6 * 60 * 60 * 1000;
const CHECKED_KEY = "pipu.update.checked";
const CHECKED_KEY = 'pipu.update.checked';
const HASH_CHUNK = 1 << 20;
const APK_MIME = "application/vnd.android.package-archive";
const APK_MIME = 'application/vnd.android.package-archive';
export function installedVersionCode(): number {
return Number(
(Constants.expoConfig?.extra as { versionCode?: number } | undefined)
?.versionCode ?? 0,
);
return Number((Constants.expoConfig?.extra as { versionCode?: number } | undefined)?.versionCode ?? 0);
}
/** SHA-256 файла кусками по 1 МБ: целиком APK в память JS не читаем. */
@@ -69,7 +66,7 @@ async function fileSha256(uri: string, size: number): Promise<string> {
}
class Updater {
private current: UpdateState = { status: "idle" };
private current: UpdateState = { status: 'idle' };
private readonly listeners = new Set<() => void>();
private download: FileSystem.DownloadResumable | null = null;
@@ -89,76 +86,59 @@ class Updater {
/** Проверка: автоматически — не чаще раза в 6 часов, вручную (профиль) — всегда. */
async check(session: AuthSession, manual = false): Promise<void> {
if (["checking", "downloading", "verifying"].includes(this.current.status))
return;
if (['checking', 'downloading', 'verifying'].includes(this.current.status)) return;
if (!manual) {
const last = Number((await SecureStore.getItemAsync(CHECKED_KEY)) ?? 0);
if (Date.now() - last < CHECK_EVERY_MS) return;
}
if (manual) this.set({ status: "checking" });
if (manual) this.set({ status: 'checking' });
try {
const release = unwrap(
await session.client.GET("/api/v1/app/android/latest"),
);
const release = unwrap(await session.client.GET('/api/v1/app/android/latest'));
await SecureStore.setItemAsync(CHECKED_KEY, String(Date.now()));
const installed = installedVersionCode();
if (release.version_code > installed) {
this.set({
status: "available",
status: 'available',
release,
required: installed < release.min_supported,
dismissed: false,
});
} else {
this.set(manual ? { status: "latest" } : { status: "idle" });
this.set(manual ? { status: 'latest' } : { status: 'idle' });
}
} catch {
// Нет релиза или сервер без Gitea — молча; при ручной проверке показываем «последняя версия».
this.set(manual ? { status: "latest" } : { status: "idle" });
this.set(manual ? { status: 'latest' } : { status: 'idle' });
}
}
dismiss() {
const s = this.current;
if (s.status === "available" && !s.required)
this.set({ ...s, dismissed: true });
else if (s.status === "latest") this.set({ status: "idle" });
if (s.status === 'available' && !s.required) this.set({ ...s, dismissed: true });
else if (s.status === 'latest') this.set({ status: 'idle' });
}
/** Скачивает APK с сервера (с Bearer), сверяет SHA-256 и запускает установщик. */
async downloadAndInstall(
session: AuthSession,
server: string,
): Promise<void> {
async downloadAndInstall(session: AuthSession, server: string): Promise<void> {
const s = this.current;
if (
s.status !== "available" &&
s.status !== "error" &&
s.status !== "ready"
)
return;
if (s.status !== 'available' && s.status !== 'error' && s.status !== 'ready') return;
const { release, required } = s;
if (s.status === "ready") return this.install(s.file);
if (s.status === 'ready') return this.install(s.file);
// Любой запрос через client обновит access-токен, если он истёк.
await session.client
.GET("/api/v1/app/android/latest")
.catch(() => undefined);
await session.client.GET('/api/v1/app/android/latest').catch(() => undefined);
const file = `${FileSystem.cacheDirectory}pipu-music-${release.version}.apk`;
this.set({ status: "downloading", release, required, progress: 0 });
this.set({ status: 'downloading', release, required, progress: 0 });
try {
await FileSystem.deleteAsync(file, { idempotent: true });
this.download = FileSystem.createDownloadResumable(
server + release.download_url,
file,
{ headers: { Authorization: `Bearer ${session.accessToken ?? ""}` } },
{ headers: { Authorization: `Bearer ${session.accessToken ?? ''}` } },
(p) => {
const total =
p.totalBytesExpectedToWrite > 0
? p.totalBytesExpectedToWrite
: release.size;
const total = p.totalBytesExpectedToWrite > 0 ? p.totalBytesExpectedToWrite : release.size;
this.set({
status: "downloading",
status: 'downloading',
release,
required,
progress: total ? p.totalBytesWritten / total : 0,
@@ -169,25 +149,25 @@ class Updater {
this.download = null;
if (!res || res.status !== 200) throw new Error(`status ${res?.status}`);
this.set({ status: "verifying", release, required });
this.set({ status: 'verifying', release, required });
const info = await FileSystem.getInfoAsync(file);
const size = info.exists ? info.size : 0;
if ((await fileSha256(file, size)) !== release.sha256.toLowerCase()) {
await FileSystem.deleteAsync(file, { idempotent: true });
this.set({ status: "error", release, required, error: "bad_checksum" });
this.set({ status: 'error', release, required, error: 'bad_checksum' });
return;
}
this.set({ status: "ready", release, required, file });
this.set({ status: 'ready', release, required, file });
await this.install(file);
} catch {
this.download = null;
this.set({ status: "error", release, required, error: "failed" });
this.set({ status: 'error', release, required, error: 'failed' });
}
}
private async install(file: string) {
const uri = await FileSystem.getContentUriAsync(file);
await IntentLauncher.startActivityAsync("android.intent.action.VIEW", {
await IntentLauncher.startActivityAsync('android.intent.action.VIEW', {
data: uri,
type: APK_MIME,
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
@@ -196,11 +176,8 @@ class Updater {
/** Экран «Установка неизвестных приложений» для нашего пакета. */
async openInstallSettings() {
const pkg = Constants.expoConfig?.android?.package ?? "app.pipu.music";
await IntentLauncher.startActivityAsync(
"android.settings.MANAGE_UNKNOWN_APP_SOURCES",
{ data: `package:${pkg}` },
);
const pkg = Constants.expoConfig?.android?.package ?? 'app.pipu.music';
await IntentLauncher.startActivityAsync('android.settings.MANAGE_UNKNOWN_APP_SOURCES', { data: `package:${pkg}` });
}
}
+32
View File
@@ -0,0 +1,32 @@
import * as SQLite from 'expo-sqlite';
let db: Promise<SQLite.SQLiteDatabase> | null = null;
/**
* Локальная база офлайн-режима (docs/mobile.md#офлайн). `owner` — хэш «сервер + пользователь»:
* скачанное и неотправленные события одного аккаунта не видны другому.
*/
export function getDb(): Promise<SQLite.SQLiteDatabase> {
db ??= (async () => {
const d = await SQLite.openDatabaseAsync('pipu.db');
await d.execAsync(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS downloads (
owner TEXT NOT NULL,
track_id TEXT NOT NULL,
track TEXT NOT NULL,
status TEXT NOT NULL,
bytes INTEGER NOT NULL DEFAULT 0,
added_at INTEGER NOT NULL,
PRIMARY KEY (owner, track_id)
);
CREATE TABLE IF NOT EXISTS pending_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL,
body TEXT NOT NULL
);
`);
return d;
})();
return db;
}
+277
View File
@@ -0,0 +1,277 @@
import NetInfo, { type NetInfoState } from '@react-native-community/netinfo';
import { unwrap, type AuthSession, type Track } from '@pipu/core';
import * as FileSystem from 'expo-file-system';
import * as SecureStore from 'expo-secure-store';
import { shortHash } from '../lib/storage';
import { getDb } from './db';
export type DownloadStatus = 'queued' | 'downloading' | 'done' | 'error';
export interface DownloadItem {
track: Track;
status: DownloadStatus;
progress: number;
bytes: number;
addedAt: number;
}
export interface DownloadsState {
items: ReadonlyMap<string, DownloadItem>;
wifiOnly: boolean;
autoLikes: boolean;
/** Сеть разрешает загрузку прямо сейчас (с учётом «только Wi-Fi»). */
allowed: boolean;
}
const PARALLEL = 2;
const WIFI_KEY = 'pipu.dl.wifi';
const AUTO_LIKES_KEY = 'pipu.dl.autolikes';
/**
* Загрузки для офлайна: файлы — в documentDirectory/offline/<owner>/, метаданные (DTO трека) — в SQLite.
* Не более двух загрузок параллельно; по умолчанию только по Wi-Fi; прерванные продолжаются при запуске.
*/
class Downloads {
private session: AuthSession | null = null;
private server = '';
private owner = '';
private active = new Map<string, FileSystem.DownloadResumable | null>();
private net: NetInfoState | null = null;
private unsubscribeNet: (() => void) | null = null;
private listeners = new Set<() => void>();
private current: DownloadsState = { items: new Map(), wifiOnly: true, autoLikes: false, allowed: false };
get state(): DownloadsState {
return this.current;
}
subscribe = (fn: () => void) => {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
};
private set(patch: Partial<DownloadsState>) {
this.current = { ...this.current, ...patch };
for (const fn of this.listeners) fn();
}
private setItem(id: string, patch: Partial<DownloadItem> | null) {
const items = new Map(this.current.items);
if (patch === null) items.delete(id);
else {
const prev = items.get(id);
if (!prev && !patch.track) return;
items.set(id, { ...(prev as DownloadItem), ...patch });
}
this.set({ items });
}
private get dir() {
return `${FileSystem.documentDirectory}offline/${this.owner}/`;
}
private file(trackId: string) {
return `${this.dir}${trackId}.opus`;
}
private cover(albumId: string) {
return `${this.dir}covers/${albumId}.webp`;
}
/** Привязка к пользователю: загружает список скачанного и продолжает незавершённые загрузки. */
async attach(session: AuthSession | null, server: string, userId: string | null) {
const owner = session && userId ? shortHash(`${server}|${userId}`) : '';
if (owner === this.owner && session === this.session) return;
for (const d of this.active.values()) void d?.pauseAsync().catch(() => undefined);
this.active.clear();
this.session = session;
this.server = server;
this.owner = owner;
if (!owner) {
this.set({ items: new Map() });
return;
}
const [wifi, autoLikes] = await Promise.all([SecureStore.getItemAsync(WIFI_KEY), SecureStore.getItemAsync(AUTO_LIKES_KEY)]);
await FileSystem.makeDirectoryAsync(`${this.dir}covers/`, { intermediates: true }).catch(() => undefined);
const db = await getDb();
await db.runAsync(`UPDATE downloads SET status = 'queued' WHERE owner = ? AND status = 'downloading'`, owner);
const rows = await db.getAllAsync<{ track_id: string; track: string; status: DownloadStatus; bytes: number; added_at: number }>(
'SELECT track_id, track, status, bytes, added_at FROM downloads WHERE owner = ? ORDER BY added_at',
owner,
);
const items = new Map<string, DownloadItem>();
for (const r of rows) {
items.set(r.track_id, { track: JSON.parse(r.track) as Track, status: r.status, progress: r.status === 'done' ? 1 : 0, bytes: r.bytes, addedAt: r.added_at });
}
this.set({ items, wifiOnly: wifi !== '0', autoLikes: autoLikes === '1' });
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
this.net = st;
this.set({ allowed: this.networkAllowed() });
this.pump();
});
this.net = await NetInfo.fetch();
this.set({ allowed: this.networkAllowed() });
this.pump();
}
private networkAllowed(): boolean {
if (!this.net?.isConnected) return false;
return !this.current.wifiOnly || this.net.type === 'wifi' || this.net.type === 'ethernet';
}
async setWifiOnly(v: boolean) {
await SecureStore.setItemAsync(WIFI_KEY, v ? '1' : '0');
this.set({ wifiOnly: v });
this.set({ allowed: this.networkAllowed() });
this.pump();
}
async setAutoLikes(v: boolean) {
await SecureStore.setItemAsync(AUTO_LIKES_KEY, v ? '1' : '0');
this.set({ autoLikes: v });
}
isDownloaded(trackId: string): boolean {
return this.current.items.get(trackId)?.status === 'done';
}
/** Локальный файл трека, если он скачан. */
localTrack(trackId: string): string | undefined {
return this.isDownloaded(trackId) ? this.file(trackId) : undefined;
}
localCover(track: Track): string | undefined {
return this.isDownloaded(track.id) && track.album.cover_url ? this.cover(track.album.id) : undefined;
}
/** Обложка альбома на устройстве, если из него что-то скачано. */
localAlbumCover(albumId: string): string | undefined {
for (const it of this.current.items.values()) {
if (it.status === 'done' && it.track.album.id === albumId && it.track.album.cover_url) return this.cover(albumId);
}
return undefined;
}
/** Скачанные треки в порядке добавления. */
downloaded(): Track[] {
return [...this.current.items.values()].filter((i) => i.status === 'done').map((i) => i.track);
}
async enqueue(tracks: Track[]) {
if (!this.owner) return;
const db = await getDb();
const now = Date.now();
for (const [n, t] of tracks.entries()) {
if (!t.available || this.current.items.has(t.id)) continue;
const addedAt = now + n;
await db.runAsync(
`INSERT OR IGNORE INTO downloads (owner, track_id, track, status, added_at) VALUES (?, ?, ?, 'queued', ?)`,
this.owner,
t.id,
JSON.stringify(t),
addedAt,
);
this.setItem(t.id, { track: t, status: 'queued', progress: 0, bytes: 0, addedAt });
}
this.pump();
}
async remove(trackIds: string[]) {
const db = await getDb();
for (const id of trackIds) {
const d = this.active.get(id);
this.active.delete(id);
await d?.pauseAsync().catch(() => undefined);
await FileSystem.deleteAsync(this.file(id), { idempotent: true });
await FileSystem.deleteAsync(`${this.file(id)}.part`, { idempotent: true });
await db.runAsync('DELETE FROM downloads WHERE owner = ? AND track_id = ?', this.owner, id);
this.setItem(id, null);
}
this.pump();
}
/** Повторить загрузки с ошибкой. */
async retry() {
const db = await getDb();
await db.runAsync(`UPDATE downloads SET status = 'queued' WHERE owner = ? AND status = 'error'`, this.owner);
for (const [id, it] of this.current.items) if (it.status === 'error') this.setItem(id, { status: 'queued', progress: 0 });
this.pump();
}
/** Занято на устройстве и свободно в телефоне (байты). */
async usage(): Promise<{ used: number; free: number }> {
let used = 0;
for (const it of this.current.items.values()) used += it.bytes;
const free = await FileSystem.getFreeDiskStorageAsync().catch(() => 0);
return { used, free };
}
private pump() {
if (!this.owner || !this.current.allowed) return;
for (const [id, it] of this.current.items) {
if (this.active.size >= PARALLEL) return;
if (it.status !== 'queued' || this.active.has(id)) continue;
this.active.set(id, null);
void this.run(id).finally(() => {
this.active.delete(id);
this.pump();
});
}
}
private async run(id: string) {
const session = this.session;
const owner = this.owner;
if (!session) return;
const db = await getDb();
const status = async (s: DownloadStatus, patch: Partial<DownloadItem> = {}, track?: Track) => {
if (this.owner !== owner) return;
await db.runAsync(
'UPDATE downloads SET status = ?, bytes = ?, track = COALESCE(?, track) WHERE owner = ? AND track_id = ?',
s,
patch.bytes ?? 0,
track ? JSON.stringify(track) : null,
owner,
id,
);
this.setItem(id, { status: s, ...patch, ...(track ? { track } : {}) });
};
try {
await status('downloading', { progress: 0 });
// Подписанные ссылки живут 12 ч — перед загрузкой берём свежий DTO.
const res = unwrap(await session.client.POST('/api/v1/tracks/batch', { body: { ids: [id] } }));
const track = res.items[0];
if (!track?.available || !track.stream_url) {
await status('error');
return;
}
const part = `${this.file(id)}.part`;
const dl = FileSystem.createDownloadResumable(`${this.abs(track.stream_url)}&q=high`, part, {}, (p) => {
if (p.totalBytesExpectedToWrite > 0) this.setItem(id, { progress: p.totalBytesWritten / p.totalBytesExpectedToWrite });
});
this.active.set(id, dl);
const out = await dl.downloadAsync();
if (this.active.get(id) !== dl) return; // отменено (удалили или сменился пользователь)
if (!out || out.status !== 200) throw new Error(`status ${out?.status}`);
await FileSystem.moveAsync({ from: part, to: this.file(id) });
if (track.album.cover_url) {
const cover = this.cover(track.album.id);
const info = await FileSystem.getInfoAsync(cover);
if (!info.exists) await FileSystem.downloadAsync(`${this.abs(track.album.cover_url)}&size=300`, cover).catch(() => undefined);
}
const info = await FileSystem.getInfoAsync(this.file(id));
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
} catch {
await status('error', { progress: 0 });
}
}
private abs(path: string) {
return /^https?:/.test(path) ? path : this.server + path;
}
}
export const downloads = new Downloads();
+72
View File
@@ -0,0 +1,72 @@
import { ApiError, unwrap, type AuthSession, type PlayEvent } from '@pipu/core';
import { shortHash } from '../lib/storage';
import { getDb } from './db';
const BATCH = 500;
/**
* Отправка событий прослушивания с запасом на офлайн: что не ушло из-за сети — в SQLite,
* досылается при появлении сети (`POST /events` идемпотентен по id события).
*/
export class PersistentEvents {
private readonly owner: string;
private draining = false;
constructor(
private readonly session: AuthSession,
server: string,
userId: string,
) {
this.owner = shortHash(`${server}|${userId}`);
}
private async post(events: PlayEvent[]) {
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
}
/** Sink для EventBuffer: при сетевой ошибке сохраняет пачку и «успешно» завершается. */
send = async (events: PlayEvent[]) => {
try {
await this.post(events);
void this.drain();
} catch (e) {
if (!isNetwork(e)) throw e;
const db = await getDb();
await db.runAsync('INSERT INTO pending_events (owner, body) VALUES (?, ?)', this.owner, JSON.stringify(events));
}
};
/** Досылает сохранённые пачки; останавливается на первой сетевой ошибке. */
async drain() {
if (this.draining) return;
this.draining = true;
try {
const db = await getDb();
for (;;) {
const rows = await db.getAllAsync<{ id: number; body: string }>(
'SELECT id, body FROM pending_events WHERE owner = ? ORDER BY id LIMIT 20',
this.owner,
);
if (!rows.length) return;
for (const r of rows) {
const events = JSON.parse(r.body) as PlayEvent[];
try {
for (let i = 0; i < events.length; i += BATCH) await this.post(events.slice(i, i + BATCH));
} catch (e) {
if (isNetwork(e)) return;
// Сервер отверг пачку (4xx) — повтор не поможет, отбрасываем.
}
await db.runAsync('DELETE FROM pending_events WHERE id = ?', r.id);
}
}
} finally {
this.draining = false;
}
}
}
function isNetwork(e: unknown): boolean {
if (e instanceof ApiError) return e.code === 'common.network' || e.status >= 500;
return true; // fetch бросает TypeError без ответа сервера
}
+47 -21
View File
@@ -15,6 +15,9 @@ import TrackPlayer, {
type Track as RntpTrack,
} from 'react-native-track-player';
import { downloads } from '../offline/downloads';
import { PersistentEvents } from '../offline/events';
export interface WaveInfo {
sessionId: string;
activity: string | null;
@@ -41,6 +44,17 @@ const WAVE_REFILL_AT = 2;
const EARLY_SKIP_MS = 30_000;
const SAVE_EVERY_MS = 10_000;
/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */
export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] {
return tracks
.filter((t) => t.reaction !== 'dislike')
.map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) }))
.sort((a, b) => a.key - b.key)
.map((x) => x.t);
}
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
/** Ссылки API относительные — для приложения нужен полный адрес сервера. */
export function absolute(server: string, path: string | null | undefined): string | undefined {
if (!path) return undefined;
@@ -66,6 +80,8 @@ class Controller {
private session: AuthSession | null = null;
private server = '';
private events: EventBuffer | null = null;
private persistent: PersistentEvents | null = null;
private unsubscribeNet: (() => void) | null = null;
private attempt: PlayAttempt | null = null;
private lastPositionMs = 0;
private lastSave = 0;
@@ -90,19 +106,19 @@ class Controller {
for (const fn of this.listeners) fn();
}
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер. */
attach(session: AuthSession | null, server: string) {
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер, без сети — копятся в SQLite. */
attach(session: AuthSession | null, server: string, userId: string | null) {
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;
this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null;
const sink = this.persistent;
this.events = sink ? new EventBuffer({ send: sink.send }) : null;
void sink?.drain();
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
if (st.isConnected) void this.persistent?.drain();
});
if (!session) void this.reset();
}
@@ -181,28 +197,29 @@ class Controller {
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}`,
url: downloads.localTrack(t.id) ?? `${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,
artwork: downloads.localCover(t) ?? (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 list = tracks.filter(playable);
const target = tracks[index];
if (!target || !playable.length) return;
if (!target || !list.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 });
const start = Math.max(list.indexOf(target), 0);
this.update({ queue: list, context, index: start, track: list[start] ?? null });
await TrackPlayer.reset();
await TrackPlayer.add(await this.toRntp(playable));
await TrackPlayer.add(await this.toRntp(list));
await TrackPlayer.skip(start);
await TrackPlayer.play();
}
@@ -248,6 +265,7 @@ class Controller {
apply(prev);
throw e;
}
if (reaction === 'like' && downloads.state.autoLikes) void downloads.enqueue([{ ...track, reaction }]);
if (reaction) {
this.noteWave(track.id, reaction);
void this.refillWave(true);
@@ -261,8 +279,16 @@ class Controller {
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);
let res;
try {
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
} catch (e) {
// Нет связи с сервером — офлайн-волна из скачанного.
const local = offlineWave(downloads.downloaded());
if (!local.length) throw e;
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
}
const tracks = res.tracks.filter(playable);
if (!tracks.length) return;
this.finishAttempt(this.lastPositionMs / 1000);
this.update({
@@ -305,7 +331,7 @@ class Controller {
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);
const add = tracks.filter(playable);
await TrackPlayer.add(await this.toRntp(add));
this.update({ queue: [...keep, ...add] });
}
@@ -328,7 +354,7 @@ class Controller {
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);
const add = res.tracks.filter(playable);
await TrackPlayer.add(await this.toRntp(add));
this.update({ queue: [...this.state.queue, ...add] });
}
@@ -368,7 +394,7 @@ class Controller {
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 tracks = res.items.filter(playable);
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 });
+38 -87
View File
@@ -1,18 +1,11 @@
import { useEffect, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import {
AppState,
Modal,
Pressable,
ScrollView,
Text,
View,
} from "react-native";
import { useEffect, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native';
import { useSession, useSessionCtx } from "../lib/session";
import { updater } from "../lib/update";
import { colors, radius } from "../theme";
import { Button } from "./kit";
import { useSession, useSessionCtx } from '../lib/session';
import { updater } from '../lib/update';
import { colors, radius } from '../theme';
import { Button } from './kit';
export function useUpdateState() {
return useSyncExternalStore(updater.subscribe, () => updater.state);
@@ -27,36 +20,26 @@ export function UpdateDialog() {
useEffect(() => {
void updater.check(session);
const sub = AppState.addEventListener("change", (s) => {
if (s === "active") void updater.check(session);
const sub = AppState.addEventListener('change', (s) => {
if (s === 'active') void updater.check(session);
});
return () => sub.remove();
}, [session]);
if (
st.status === "idle" ||
st.status === "checking" ||
st.status === "latest"
)
return null;
if (st.status === "available" && st.dismissed) return null;
if (st.status === 'idle' || st.status === 'checking' || st.status === 'latest') return null;
if (st.status === 'available' && st.dismissed) return null;
const { release, required } = st;
const busy = st.status === "downloading" || st.status === "verifying";
const action = () => void updater.downloadAndInstall(session, server ?? "");
const busy = st.status === 'downloading' || st.status === 'verifying';
const action = () => void updater.downloadAndInstall(session, server ?? '');
return (
<Modal
transparent
animationType="fade"
visible
onRequestClose={() => updater.dismiss()}
>
<Modal transparent animationType="fade" visible onRequestClose={() => updater.dismiss()}>
<View
style={{
flex: 1,
backgroundColor: "rgba(0,0,0,0.6)",
justifyContent: "center",
backgroundColor: 'rgba(0,0,0,0.6)',
justifyContent: 'center',
padding: 24,
}}
>
@@ -66,31 +49,25 @@ export function UpdateDialog() {
borderRadius: radius.lg,
padding: 20,
gap: 12,
maxHeight: "80%",
maxHeight: '80%',
}}
>
<Text style={{ color: colors.text, fontSize: 18, fontWeight: "700" }}>
{t("mobile.update_title", { version: release.version })}
<Text style={{ color: colors.text, fontSize: 18, fontWeight: '700' }}>
{t('mobile.update_title', { version: release.version })}
</Text>
{required && (
<Text style={{ color: colors.danger }}>
{t("mobile.update_required")}
</Text>
)}
{release.notes_md.trim() !== "" && (
{required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>}
{release.notes_md.trim() !== '' && (
<ScrollView style={{ maxHeight: 240 }}>
<Text style={{ color: colors.text2, lineHeight: 20 }}>
{plainNotes(release.notes_md)}
</Text>
<Text style={{ color: colors.text2, lineHeight: 20 }}>{plainNotes(release.notes_md)}</Text>
</ScrollView>
)}
<Text style={{ color: colors.text3, fontSize: 12 }}>
{t("mobile.update_size", {
{t('mobile.update_size', {
size: (release.size / 1048576).toFixed(1),
})}
</Text>
{st.status === "downloading" && (
{st.status === 'downloading' && (
<View style={{ gap: 6 }}>
<View
style={{
@@ -109,55 +86,29 @@ export function UpdateDialog() {
/>
</View>
<Text style={{ color: colors.text2, fontSize: 12 }}>
{t("mobile.update_downloading", {
{t('mobile.update_downloading', {
percent: Math.round(st.progress * 100),
})}
</Text>
</View>
)}
{st.status === "verifying" && (
<Text style={{ color: colors.text2, fontSize: 12 }}>
{t("mobile.update_verifying")}
</Text>
)}
{st.status === "error" && (
{st.status === 'verifying' && <Text style={{ color: colors.text2, fontSize: 12 }}>{t('mobile.update_verifying')}</Text>}
{st.status === 'error' && (
<Text style={{ color: colors.danger }}>
{t(
st.error === "bad_checksum"
? "mobile.update_bad_checksum"
: "mobile.update_failed",
)}
{t(st.error === 'bad_checksum' ? 'mobile.update_bad_checksum' : 'mobile.update_failed')}
</Text>
)}
{st.status === "ready" && (
{st.status === 'ready' && (
<View style={{ gap: 4 }}>
<Text style={{ color: colors.text2, fontSize: 12 }}>
{t("mobile.update_allow")}
</Text>
<Pressable
onPress={() => void updater.openInstallSettings()}
accessibilityRole="link"
>
<Text style={{ color: colors.accent2, fontWeight: "500" }}>
{t("mobile.update_open_settings")}
</Text>
<Text style={{ color: colors.text2, fontSize: 12 }}>{t('mobile.update_allow')}</Text>
<Pressable onPress={() => void updater.openInstallSettings()} accessibilityRole="link">
<Text style={{ color: colors.accent2, fontWeight: '500' }}>{t('mobile.update_open_settings')}</Text>
</Pressable>
</View>
)}
<Button
title={t("mobile.update_install")}
icon="system-update"
busy={busy}
onPress={action}
/>
{!required && !busy && (
<Button
title={t("mobile.update_later")}
variant="ghost"
onPress={() => updater.dismiss()}
/>
)}
<Button title={t('mobile.update_install')} icon="system-update" busy={busy} onPress={action} />
{!required && !busy && <Button title={t('mobile.update_later')} variant="ghost" onPress={() => updater.dismiss()} />}
</View>
</View>
</Modal>
@@ -167,9 +118,9 @@ export function UpdateDialog() {
/** Заметки релиза — Markdown; в диалоге показываем текстом без разметки заголовков и выделения. */
function plainNotes(md: string): string {
return md
.replace(/^#{1,6}\s*/gm, "")
.replace(/\*\*(.+?)\*\*/g, "$1")
.replace(/^\s*[-*]\s+/gm, "• ")
.replace(/\[(.+?)\]\(.+?\)/g, "$1")
.replace(/^#{1,6}\s*/gm, '')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/^\s*[-*]\s+/gm, '• ')
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
.trim();
}
+43
View File
@@ -0,0 +1,43 @@
import type { Track } from '@pipu/core';
import { useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Pressable } from 'react-native';
import { downloads, type DownloadItem, type DownloadsState } from '../offline/downloads';
import { colors } from '../theme';
import { Icon } from './kit';
export function useDownloads(): DownloadsState {
return useSyncExternalStore(downloads.subscribe, () => downloads.state);
}
export function useDownloadItem(trackId: string): DownloadItem | undefined {
return useSyncExternalStore(downloads.subscribe, () => downloads.state.items.get(trackId));
}
/** Кнопка «скачать / скачано» у трека: повторное нажатие удаляет загрузку. */
export function DownloadButton({ track, size = 26 }: { track: Track; size?: number }) {
const { t } = useTranslation();
const item = useDownloadItem(track.id);
const busy = item?.status === 'queued' || item?.status === 'downloading';
const onPress = () => void (item ? downloads.remove([track.id]) : downloads.enqueue([track]));
return (
<Pressable
onPress={onPress}
hitSlop={10}
disabled={!track.available}
accessibilityRole="button"
accessibilityLabel={t(item ? 'mobile.dl_remove' : 'mobile.dl_download')}
>
{busy ? (
<ActivityIndicator size="small" color={colors.accent2} />
) : (
<Icon
name={item?.status === 'done' ? 'download-done' : item?.status === 'error' ? 'error-outline' : 'download'}
size={size}
color={item?.status === 'done' ? colors.accent2 : item?.status === 'error' ? colors.danger : colors.text2}
/>
)}
</Pressable>
);
}
+12 -3
View File
@@ -1,7 +1,7 @@
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 { useState, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActivityIndicator,
@@ -18,6 +18,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import { useSessionCtx } from '../lib/session';
import { downloads } from '../offline/downloads';
import { absolute } from '../player/controller';
import { colors, radius } from '../theme';
@@ -143,13 +144,16 @@ export function Cover({
round?: boolean;
}) {
const { server } = useSessionCtx();
const uri = server ? absolute(server, album.cover_url) : undefined;
const remote = server ? absolute(server, album.cover_url) : undefined;
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
const uri = local ?? (remote ? `${remote}&size=${px}` : undefined);
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 }} />
<Image source={{ uri }} style={{ width: size, height: size }} />
) : (
<Icon name={round ? 'person' : 'album'} size={size * 0.45} color="rgba(255,255,255,0.8)" />
)}
@@ -169,6 +173,7 @@ export function TrackRow({
onLike,
numbered,
meta,
trailing,
}: {
track: Track;
current?: boolean;
@@ -176,9 +181,11 @@ export function TrackRow({
onLike?: () => void;
numbered?: number;
meta?: string;
trailing?: ReactNode;
}) {
const { t } = useTranslation();
const liked = track.reaction === 'like';
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
return (
<Pressable
onPress={onPress}
@@ -211,6 +218,8 @@ export function TrackRow({
<Icon name={liked ? 'favorite' : 'favorite-border'} size={20} color={liked ? colors.accent2 : colors.text3} />
</Pressable>
)}
{trailing}
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
</Pressable>
);
+10
View File
@@ -62,6 +62,16 @@
- реакции (лайк / дизлайк) — оптимистично локально + очередь мутаций.
- Подписанные ссылки не кэшируются — при восстановлении сети запрашиваются заново.
**Реализация** (`src/offline/`):
- `downloads.ts` — очередь загрузок: таблица `downloads` (DTO трека JSON + статус), файлы в
`documentDirectory/offline/<owner>/`, обложка 300 px рядом; `owner` — хэш «сервер + пользователь».
Перед загрузкой берётся свежий DTO (`POST /tracks/batch`), т.к. подписанная ссылка живёт 12 ч.
- `events.ts` — sink для `EventBuffer`: при сетевой ошибке пачка уходит в `pending_events` и досылается при появлении сети.
- Запуск без сети: `AuthSession.restore()` берёт последнего пользователя из `RefreshStore.getUser` (состояние
`signed_in` с `offline: true`); access-токен получается перед первым запросом, когда сеть вернётся.
- Офлайн-волна: если `POST /wave/sessions` не удался — перемешанные скачанные треки без дизлайков, лайки ближе к началу.
- Пока не сделано: очередь мутаций реакций без сети (лайк офлайн откатывается).
## Самообновление
Приложение распространяется только через Gitea Releases (не Google Play).
+27 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import { ApiError, AuthSession, type AuthTokens, type RefreshStore } from './auth';
import { ApiError, AuthSession, type AuthTokens, type AuthUser, type RefreshStore } from './auth';
const user = {
id: '00000000-0000-0000-0000-000000000001',
@@ -182,6 +182,32 @@ describe('AuthSession', () => {
expect(await auth.refresh()).toBe(false);
});
it('opens offline with the cached user and refreshes on the first request', async () => {
let online = false;
const store = { ...memoryStore('R0'), cached: null as AuthUser | null };
store.getUser = async () => store.cached;
store.setUser = async (u) => {
store.cached = u;
};
store.cached = user;
const s = server({
'POST /api/v1/auth/refresh': () => json(tokens('A1', 'R1')),
'GET /api/v1/me': () => json(user),
});
const fetchImpl = (async (input: RequestInfo | URL) => {
if (!online) throw new TypeError('Network request failed');
return s.fetch(input);
}) as typeof fetch;
const auth = new AuthSession({ baseUrl: 'http://x', fetch: fetchImpl, clientType: 'android', refreshStore: store });
expect(await auth.restore()).toEqual({ status: 'signed_in', user, offline: true });
online = true;
await auth.client.GET('/api/v1/me');
expect(s.log.map((l) => l.route)).toEqual(['POST /api/v1/auth/refresh', 'GET /api/v1/me']);
expect(s.log[1]?.auth).toBe('Bearer A1');
expect(auth.state).toEqual({ status: 'signed_in', user });
});
it('requires a refresh store for android', () => {
expect(() => new AuthSession({ clientType: 'android' })).toThrow(/refreshStore/);
});
+18 -3
View File
@@ -48,12 +48,16 @@ export interface RefreshStore {
get(): Promise<string | null>;
set(token: string): Promise<void>;
clear(): Promise<void>;
/** Последний вошедший пользователь — чтобы открыть приложение без сети (Android, офлайн-режим). */
getUser?(): Promise<AuthUser | null>;
setUser?(user: AuthUser): Promise<void>;
}
export type AuthState =
| { status: 'unknown' }
| { status: 'signed_out'; reason?: string }
| { status: 'signed_in'; user: AuthUser };
/** offline — сервер недоступен при запуске, пользователь взят из RefreshStore; токен обновится при первом запросе. */
| { status: 'signed_in'; user: AuthUser; offline?: boolean };
export interface AuthSessionOptions {
/** Адрес сервера; пусто — тот же origin (веб). */
@@ -84,6 +88,7 @@ export class AuthSession {
private readonly baseFetch: typeof globalThis.fetch;
private access: string | null = null;
private refreshing: Promise<boolean> | null = null;
private networkDown = false;
private current: AuthState = { status: 'unknown' };
private readonly listeners = new Set<(s: AuthState) => void>();
@@ -120,7 +125,9 @@ export class AuthSession {
/** Восстанавливает сессию после перезагрузки по refresh-токену. */
async restore(): Promise<AuthState> {
if (!(await this.refresh())) {
if (this.current.status !== 'signed_out') this.setState({ status: 'signed_out' });
const user = this.networkDown ? await this.opts.refreshStore?.getUser?.() : null;
if (user) this.setState({ status: 'signed_in', user, offline: true });
else if (this.current.status !== 'signed_out') this.setState({ status: 'signed_out' });
}
return this.current;
}
@@ -136,13 +143,17 @@ export class AuthSession {
async apply(tokens: AuthTokens): Promise<AuthUser> {
this.access = tokens.access_token;
if (tokens.refresh_token && this.opts.refreshStore) await this.opts.refreshStore.set(tokens.refresh_token);
await this.opts.refreshStore?.setUser?.(tokens.user);
this.setState({ status: 'signed_in', user: tokens.user });
return tokens.user;
}
/** Обновляет профиль в состоянии (после PATCH /me). */
setUser(user: AuthUser) {
if (this.current.status === 'signed_in') this.setState({ status: 'signed_in', user });
if (this.current.status === 'signed_in') {
this.setState({ ...this.current, user });
void this.opts.refreshStore?.setUser?.(user);
}
}
async logout(all = false): Promise<void> {
@@ -177,7 +188,9 @@ export class AuthSession {
let res;
try {
res = await this.raw.POST('/api/v1/auth/refresh', { body });
this.networkDown = false;
} catch {
this.networkDown = true;
return false; // сеть недоступна: сессию не трогаем, следующий запрос попробует снова
}
if (res.data) {
@@ -195,6 +208,8 @@ export class AuthSession {
}
private async authFetch(request: Request): Promise<Response> {
// Запущены без сети (offline): access-токена ещё нет — получаем его перед первым запросом.
if (!this.access && this.opts.refreshStore && this.current.status === 'signed_in') await this.refresh();
const retry = request.clone();
const sent = this.access;
const res = await this.baseFetch(this.withAuth(request));
+17 -1
View File
@@ -215,6 +215,22 @@
"update_bad_checksum": "The file is corrupted — try again",
"update_failed": "Could not download the update",
"update_allow": "Allow installing apps from this app in Android settings",
"update_open_settings": "Open settings"
"update_open_settings": "Open settings",
"dl_title": "Downloads",
"dl_download": "Download",
"dl_remove": "Remove from device",
"dl_album": "Download album",
"dl_all_likes": "Download all likes",
"dl_wifi_only": "Download over Wi-Fi only",
"dl_auto_likes": "Download new likes",
"dl_usage": "{{used}} MB used · {{free}} GB free on the phone",
"dl_pending_one": "{{count}} track queued",
"dl_pending_other": "{{count}} tracks queued",
"dl_waiting_wifi": "Downloads are waiting for Wi-Fi ({{count}})",
"dl_waiting_network": "Downloads are waiting for a connection ({{count}})",
"dl_retry": "Retry failed",
"dl_empty": "Download tracks, albums and likes to listen without a connection",
"dl_pending_few": "{{count}} tracks queued",
"dl_pending_many": "{{count}} tracks queued"
}
}
+17 -1
View File
@@ -215,6 +215,22 @@
"update_bad_checksum": "Файл повреждён — попробуйте ещё раз",
"update_failed": "Не удалось скачать обновление",
"update_allow": "Разрешите установку из этого приложения в настройках Android",
"update_open_settings": "Открыть настройки"
"update_open_settings": "Открыть настройки",
"dl_title": "Скачанное",
"dl_download": "Скачать",
"dl_remove": "Удалить с устройства",
"dl_album": "Скачать альбом",
"dl_all_likes": "Скачать все лайки",
"dl_wifi_only": "Скачивать только по Wi-Fi",
"dl_auto_likes": "Скачивать новые лайки",
"dl_usage": "Занято {{used}} МБ · свободно в телефоне {{free}} ГБ",
"dl_pending_one": "В очереди {{count}} трек",
"dl_pending_few": "В очереди {{count}} трека",
"dl_pending_many": "В очереди {{count}} треков",
"dl_pending_other": "В очереди {{count}} трека",
"dl_waiting_wifi": "Загрузки ждут Wi-Fi ({{count}})",
"dl_waiting_network": "Загрузки ждут сеть ({{count}})",
"dl_retry": "Повторить неудачные",
"dl_empty": "Скачивайте треки, альбомы и лайки — они будут доступны без сети"
}
}
+22
View File
@@ -65,6 +65,9 @@ importers:
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-sqlite:
specifier: ~15.2.14
version: 15.2.14(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-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)
@@ -1619,6 +1622,9 @@ packages:
async-limiter@1.0.1:
resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==}
await-lock@2.2.2:
resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==}
babel-jest@29.7.0:
resolution: {integrity: sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -2155,6 +2161,13 @@ packages:
peerDependencies:
expo: '*'
expo-sqlite@15.2.14:
resolution: {integrity: sha512-6tWnEE0fcir30/e7eVwjeC7eKdncfVnIgo2JvnKpRndedyiFMXLMyOQWNVGnuhnSrPV2BHvGGjLByS/j5VgH4w==}
peerDependencies:
expo: '*'
react: '*'
react-native: '*'
expo-status-bar@2.2.3:
resolution: {integrity: sha512-+c8R3AESBoduunxTJ8353SqKAKpxL6DvcD8VKBuh81zzJyUUbfB4CVjr1GufSJEKsMzNPXZU+HJwXx7Xh7lx8Q==}
peerDependencies:
@@ -5568,6 +5581,8 @@ snapshots:
async-limiter@1.0.1: {}
await-lock@2.2.2: {}
babel-jest@29.7.0(@babel/core@7.29.7):
dependencies:
'@babel/core': 7.29.7
@@ -6152,6 +6167,13 @@ snapshots:
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-sqlite@15.2.14(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:
await-lock: 2.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
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-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