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
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:
1 parent
765345e0f4
commit
be34ee5361
22 files changed
+881
-387
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}` });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 без ответа сервера
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "Скачивайте треки, альбомы и лайки — они будут доступны без сети"
|
||||
}
|
||||
}
|
||||
Generated
+22
@@ -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
|
||||
|
||||
Reference in new issue
Block a user