diff --git a/src/features/storage/StoragePage.tsx b/src/features/storage/StoragePage.tsx index 6ef4c16..e5a631f 100644 --- a/src/features/storage/StoragePage.tsx +++ b/src/features/storage/StoragePage.tsx @@ -1,11 +1,20 @@ import type { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { Window, Card, Badge } from '@olly/modern-sk'; +import { Window, Card, Badge, Callout } from '@olly/modern-sk'; import { useGetStorageStatsQuery } from '../../api/endpoints/storage'; import { LoadingSkeleton } from '../../components/common/LoadingSkeleton'; import { EmptyState } from '../../components/common/EmptyState'; import { ErrorState } from '../../components/common/ErrorState'; import { Icon, type IconName } from '../../components/common/Icon'; +import { useAppSelector } from '../../hooks/useAppDispatch'; +import { useIsOffline } from '../../hooks/useConnectionStatus'; +import { useAudioCacheStats } from '../../hooks/useAudioCacheStats'; +import { + selectLocalTracks, + selectLocalAlbums, + selectLocalArtists, +} from '../../store/selectors/localLibrary'; +import type { AudioCacheStats } from '../../lib/sw'; import { formatFileSize, formatCount, @@ -26,6 +35,13 @@ const STATUS_VARIANT: Record = export function StoragePage() { const { t } = useTranslation(); const { data, isLoading, isError, refetch } = useGetStorageStatsQuery(); + const offline = useIsOffline(); + + // Tier-3 audio cache + the locally-cached library metadata = "this device". + const audio = useAudioCacheStats(); + const localTracks = useAppSelector(selectLocalTracks); + const localAlbums = useAppSelector(selectLocalAlbums); + const localArtists = useAppSelector(selectLocalArtists); return (
@@ -34,23 +50,170 @@ export function StoragePage() { {t('storage.subtitle')}

- {isLoading && } - {isError && ( - refetch()} /> - )} - {data && data.totalTracks === 0 && ( - } - title={t('storage.emptyTitle')} - description={t('storage.emptyDesc')} - /> - )} - {data && data.totalTracks > 0 && } +
+ {/* ── On this device (local + cached) ───────────────────────── */} +
+ + {t('storage.device')} + +
+ +
+
+ + {/* ── On the server (remote) ────────────────────────────────── */} +
+ {t('storage.server')} +
+ {isLoading && } + {isError && offline && ( + + {t('storage.serverUnreachable')} + + )} + {isError && !offline && ( + refetch()} + /> + )} + {data && data.totalTracks === 0 && ( + } + title={t('storage.emptyTitle')} + description={t('storage.emptyDesc')} + /> + )} + {data && data.totalTracks > 0 && ( + + )} +
+
+
); } +/** "On this device": the SW audio cache (downloaded audio) + the offline + * library metadata we can browse without the server. */ +function LocalStoragePanel({ + audio, + trackCount, + albumCount, + artistCount, +}: { + audio: AudioCacheStats | null; + trackCount: number; + albumCount: number; + artistCount: number; +}) { + const { t } = useTranslation(); + return ( +
+ + + {t('storage.audioCache')} + + {audio ? ( + <> +
+
0 ? Math.min((audio.bytes / audio.maxBytes) * 100, 100) : 0}%`, + height: '100%', + background: 'var(--color-accent)', + transition: 'width 0.5s ease', + }} + /> +
+
+ {t('storage.audioCacheUsage', { + used: formatFileSize(audio.bytes), + max: formatFileSize(audio.maxBytes), + })} +
+
+ {t('storage.cachedTracks', { n: audio.count })} +
+ + ) : ( +

+ {t('storage.audioCacheUnavailable')} +

+ )} + + + + + {t('storage.offlineLibrary')} + +

+ {formatCount(trackCount)} +

+
+ {t('storage.offlineLibraryMeta', { + tracks: formatCount(trackCount), + albums: formatCount(albumCount), + artists: formatCount(artistCount), + })} +
+
+
+ ); +} + function StorageDashboard({ stats }: { stats: StorageStats }) { const { t } = useTranslation(); const avgSize = Math.round(stats.totalSize / Math.max(stats.totalTracks, 1)); diff --git a/src/hooks/useAudioCacheStats.ts b/src/hooks/useAudioCacheStats.ts new file mode 100644 index 0000000..a1fb521 --- /dev/null +++ b/src/hooks/useAudioCacheStats.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; +import { getAudioCacheStats, type AudioCacheStats } from '../lib/sw'; + +/** + * Loads the service-worker audio offline cache stats (Tier 3 — the audio + * actually stored on *this device*). Returns `null` until resolved, or when no + * controlling service worker is present (insecure origin, first load, …). + * `bump` forces a re-read after the cache is mutated (e.g. cleared). + */ +export function useAudioCacheStats(bump = 0): AudioCacheStats | null { + const [stats, setStats] = useState(null); + useEffect(() => { + let cancelled = false; + void getAudioCacheStats().then((s) => { + if (!cancelled) setStats(s); + }); + return () => { + cancelled = true; + }; + }, [bump]); + return stats; +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e4e7648..87c5677 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -241,6 +241,17 @@ const en = { }, storage: { subtitle: 'Everything this instance has tucked away', + device: 'On this device', + server: 'On the server', + audioCache: 'Cached audio', + audioCacheUsage: '{{used}} of {{max}} used', + cachedTracks: '{{n}} tracks cached for offline', + audioCacheUnavailable: + 'Offline audio cache unavailable (service worker not active).', + offlineLibrary: 'Offline library', + offlineLibraryMeta: + '{{tracks}} tracks · {{albums}} albums · {{artists}} artists browsable offline', + serverUnreachable: 'Server unreachable — showing this device only.', emptyTitle: 'Nothing stored yet', emptyDesc: 'Download or upload some music and your library stats will appear here.', diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index 92dbe35..07883e0 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -244,6 +244,17 @@ const ru: Translations = { }, storage: { subtitle: 'Всё, что хранит этот инстанс', + device: 'На этом устройстве', + server: 'На сервере', + audioCache: 'Кэш аудио', + audioCacheUsage: 'Занято {{used}} из {{max}}', + cachedTracks: '{{n}} треков сохранено офлайн', + audioCacheUnavailable: + 'Офлайн-кэш аудио недоступен (service worker не активен).', + offlineLibrary: 'Офлайн-библиотека', + offlineLibraryMeta: + '{{tracks}} треков · {{albums}} альбомов · {{artists}} исполнителей доступно офлайн', + serverUnreachable: 'Сервер недоступен — показано только это устройство.', emptyTitle: 'Пока ничего не сохранено', emptyDesc: 'Загрузите немного музыки — и здесь появится статистика вашей библиотеки.',