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
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user