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

+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();
}