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