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 {error && {t('desktop.download_failed')}} ; } 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

{t('desktop.offline')}

{t('desktop.cache_hint')}

{formatBytes(offline.bytes, i18n.language)} {track && }
{error ? t('desktop.storage_failed') : null} {tracks.length > 0 ? } /> :

{t('desktop.offline_empty')}

}
; } export function DesktopDownloadsPage() { const { t } = useTranslation(); return

{t('desktop.offline')}

; } export function DesktopOfflineBanner() { const state = useAuthState(); const desktop = useDesktop(); const { t } = useTranslation(); return desktop && state.status === 'signed_in' && state.offline ?

{t('mobile.offline')}

: 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('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

{t('desktop.updates')}

{state.status === 'latest' &&

{t('mobile.update_latest')}

} {state.status === 'error' && !state.release && {t('desktop.check_failed')}}
; } 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 }> {notes &&

{notes}

}

{t('mobile.update_size', { size: (release.build.size / 1048576).toFixed(1) })}

{busy &&

{t('mobile.update_downloading', { percent: Math.round((state.progress ?? 0) * 100) })}

} {state.status === 'ready' &&

{t('desktop.installer_open')}

} {state.status === 'error' && {t('mobile.update_failed')}}
; }