ci / module boundaries / boundaries (push) Successful in 7s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 3m8s
ci / desktop / rust (desktop proxy) (push) Successful in 4m21s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m0s
release / desktop (Linux и Windows в релиз) (push) Successful in 11m49s
release / prune (старые файлы и прогоны) (push) Successful in 5s
Clean release notes in Android and desktop update dialogs. Persist downloaded audio and offline playback events per server and user; add desktop update checks, checksum verification and installer launch.
148 lines
7.7 KiB
TypeScript
148 lines
7.7 KiB
TypeScript
import { releaseNotesText, type Track } from '@pipu/core';
|
|
import { useEffect, useState, useSyncExternalStore } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useAuthState, useSession } from '../../app/session';
|
|
import { desktopInvoke, useDesktop } from '../../desktop';
|
|
import { TrackList } from '../../entities/catalog';
|
|
import { usePlayer, usePlayerState } from '../../player/context';
|
|
import { Button, Dialog, FormError } from '../../shared/ui/ui';
|
|
import { formatBytes } from '../../shared/lib/format';
|
|
import { checkIcon, downloadIcon } from '../../shared/ui/icons';
|
|
import s from '../../pages/page.module.css';
|
|
|
|
export function useOffline() {
|
|
const offline = usePlayer().offline;
|
|
useSyncExternalStore(offline?.subscribe ?? (() => () => undefined), offline?.snapshot ?? (() => 0));
|
|
return offline;
|
|
}
|
|
|
|
export function SaveOfflineButton({ track, compact = false }: { track: Track; compact?: boolean }) {
|
|
const offline = useOffline();
|
|
const { t } = useTranslation();
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState(false);
|
|
if (!offline) return null;
|
|
return <span>
|
|
<Button busy={busy} disabled={!track.available} icon={offline.pinned(track.id) ? checkIcon : downloadIcon}
|
|
aria-label={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}
|
|
title={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')} onClick={() => {
|
|
setBusy(true); setError(false);
|
|
void (offline.pinned(track.id) ? offline.remove(track.id) : offline.save(track, true))
|
|
.catch(() => setError(true)).finally(() => setBusy(false));
|
|
}}>{compact ? null : t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}</Button>
|
|
{error && <span role="alert">{t('desktop.download_failed')}</span>}
|
|
</span>;
|
|
}
|
|
|
|
export function DesktopOfflineSection() {
|
|
const offline = useOffline();
|
|
const { track } = usePlayerState();
|
|
const { t, i18n } = useTranslation();
|
|
const [error, setError] = useState(false);
|
|
useEffect(() => { void offline?.ready.catch(() => setError(true)); }, [offline]);
|
|
if (!offline) return null;
|
|
const tracks = offline.tracks();
|
|
return <section className={s.section}>
|
|
<h2>{t('desktop.offline')}</h2>
|
|
<p className={s.note}>{t('desktop.cache_hint')}</p>
|
|
<div className={s.toolbar}>
|
|
<span>{formatBytes(offline.bytes, i18n.language)}</span>
|
|
<Button onClick={() => void offline.clearRecent().catch(() => setError(true))}>{t('desktop.clear_recent')}</Button>
|
|
{track && <SaveOfflineButton track={track} />}
|
|
</div>
|
|
<FormError>{error ? t('desktop.storage_failed') : null}</FormError>
|
|
{tracks.length > 0 ? <TrackList tracks={tracks} context={{ type: 'collection', id: 'offline' }} actions={(track) => <SaveOfflineButton track={track} compact />} /> : <p>{t('desktop.offline_empty')}</p>}
|
|
</section>;
|
|
}
|
|
|
|
export function DesktopDownloadsPage() {
|
|
const { t } = useTranslation();
|
|
return <div className={s.page}><h1>{t('desktop.offline')}</h1><DesktopOfflineSection /></div>;
|
|
}
|
|
|
|
export function DesktopOfflineBanner() {
|
|
const state = useAuthState();
|
|
const desktop = useDesktop();
|
|
const { t } = useTranslation();
|
|
return desktop && state.status === 'signed_in' && state.offline ? <p role="status">{t('mobile.offline')}</p> : null;
|
|
}
|
|
|
|
type Release = { build: { version: string; size: number }; notes: string };
|
|
type Update = { status: 'idle' | 'checking' | 'latest' | 'error' | 'available' | 'installing' | 'ready'; release?: Release; dismissed?: boolean; progress?: number };
|
|
let update: Update = { status: 'idle' };
|
|
let generation = 0;
|
|
const listeners = new Set<() => void>();
|
|
function setUpdate(value: Update) { update = value; for (const fn of listeners) fn(); }
|
|
const subscribe = (fn: () => void) => { listeners.add(fn); return () => { listeners.delete(fn); }; };
|
|
function useUpdate() { return useSyncExternalStore(subscribe, () => update); }
|
|
async function checkUpdate(server: string, manual = false) {
|
|
if (update.status === 'checking' || update.status === 'installing') return;
|
|
const key = `pipu.update.checked.${server}`;
|
|
if (!manual && Date.now() - Number(localStorage.getItem(key) ?? 0) < 6 * 60 * 60 * 1000) return;
|
|
setUpdate({ status: 'checking' });
|
|
const requestGeneration = generation;
|
|
try {
|
|
const release = await desktopInvoke<Release | null>('update_check');
|
|
if (requestGeneration !== generation) return;
|
|
localStorage.setItem(key, String(Date.now()));
|
|
setUpdate(release ? { status: 'available', release } : { status: manual ? 'latest' : 'idle' });
|
|
} catch { if (requestGeneration === generation) setUpdate({ status: manual ? 'error' : 'idle' }); }
|
|
}
|
|
|
|
export function DesktopUpdateButton() {
|
|
const desktop = useDesktop();
|
|
const state = useUpdate();
|
|
const { t } = useTranslation();
|
|
if (!desktop?.server) return null;
|
|
return <section className={s.section}>
|
|
<h2>{t('desktop.updates')}</h2>
|
|
<Button busy={state.status === 'checking'} disabled={state.status === 'installing'} onClick={() => void checkUpdate(desktop.server!, true)}>{t('mobile.update_check')}</Button>
|
|
{state.status === 'latest' && <p role="status">{t('mobile.update_latest')}</p>}
|
|
{state.status === 'error' && !state.release && <FormError>{t('desktop.check_failed')}</FormError>}
|
|
</section>;
|
|
}
|
|
|
|
export function DesktopUpdateDialog() {
|
|
const desktop = useDesktop();
|
|
const session = useSession();
|
|
const state = useUpdate();
|
|
const { t } = useTranslation();
|
|
useEffect(() => {
|
|
if (!desktop?.server) return;
|
|
const check = () => void checkUpdate(desktop.server!);
|
|
check();
|
|
window.addEventListener('focus', check);
|
|
return () => { window.removeEventListener('focus', check); generation++; setUpdate({ status: 'idle' }); };
|
|
}, [desktop, session]);
|
|
useEffect(() => {
|
|
if (!desktop) return;
|
|
let stop: (() => void) | undefined;
|
|
let disposed = false;
|
|
// The native updater emits progress through the same Tauri event surface as media controls.
|
|
const api = (window as Window & { __TAURI__?: { event: { listen: (name: string, fn: (e: { payload: string }) => void) => Promise<() => void> } } }).__TAURI__;
|
|
void api?.event.listen('update-progress', (e) => {
|
|
const progress = Number(e.payload);
|
|
if (update.status === 'installing' && Number.isFinite(progress)) setUpdate({ ...update, progress: Math.min(1, Math.max(0, progress)) });
|
|
}).then((fn) => { if (disposed) fn(); else stop = fn; });
|
|
return () => { disposed = true; stop?.(); };
|
|
}, [desktop]);
|
|
if (!desktop || !state.release || state.dismissed) return null;
|
|
const release = state.release;
|
|
const busy = state.status === 'installing';
|
|
const close = () => { if (!busy) setUpdate({ ...state, dismissed: true }); };
|
|
const notes = releaseNotesText(release.notes);
|
|
return <Dialog title={t('mobile.update_title', { version: release.build.version })} onClose={close} footer={<>
|
|
<Button disabled={busy} onClick={close}>{t('mobile.update_later')}</Button>
|
|
<Button busy={busy} onClick={() => {
|
|
setUpdate({ status: 'installing', release, progress: 0 });
|
|
void desktopInvoke('update_install').then(() => setUpdate({ status: 'ready', release })).catch(() => setUpdate({ status: 'error', release }));
|
|
}}>{t('mobile.update_install')}</Button>
|
|
</>}>
|
|
{notes && <p style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', maxHeight: '35vh', overflowY: 'auto' }}>{notes}</p>}
|
|
<p>{t('mobile.update_size', { size: (release.build.size / 1048576).toFixed(1) })}</p>
|
|
{busy && <p role="status">{t('mobile.update_downloading', { percent: Math.round((state.progress ?? 0) * 100) })}</p>}
|
|
{state.status === 'ready' && <p role="status">{t('desktop.installer_open')}</p>}
|
|
{state.status === 'error' && <FormError>{t('mobile.update_failed')}</FormError>}
|
|
</Dialog>;
|
|
}
|