diff --git a/CHANGELOG.md b/CHANGELOG.md index 970d418d..7faa937a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -174,6 +174,13 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa * **One-time migration:** if you had previously opened the per-tab Advanced group or customised any of the three sub-sections, Advanced Mode is auto-enabled on first launch — your existing tweaks stay visible. * i18n: `settings.advancedMode`, `settings.advancedModeTooltip`, `settings.advancedBadge`, and `settings.playlistLayout*` across all 9 locales. +### Offline Library — show cached albums from all servers + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#719](https://github.com/Psychotoxical/psysonic/pull/719)** + +* **Offline Library** lists every cached album across all saved servers, not only the currently active one. Cover art loads from each album's source server; play and enqueue switch to that server when needed. +* Sidebar entry, mobile **More** menu, disconnect auto-navigation, and the offline banner treat **any** cached content as available offline. With multiple servers, cards show which server the album came from. + ## Changed ### Backend — Cargo workspace with 5 domain crates (Rust refactor) diff --git a/src/app/AppShell.tsx b/src/app/AppShell.tsx index 1e8fd9c6..d23adf18 100644 --- a/src/app/AppShell.tsx +++ b/src/app/AppShell.tsx @@ -33,6 +33,7 @@ import { useOrbitHost } from '../hooks/useOrbitHost'; import { useOrbitGuest } from '../hooks/useOrbitGuest'; import { useOrbitBodyAttrs } from '../hooks/useOrbitBodyAttrs'; import { usePlatformShellSetup } from '../hooks/usePlatformShellSetup'; +import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers'; import { useWindowFullscreenState } from '../hooks/useWindowFullscreenState'; import { useNowPlayingTrayTitle } from '../hooks/useNowPlayingTrayTitle'; import { useTrayMenuI18n } from '../hooks/useTrayMenuI18n'; @@ -86,10 +87,9 @@ export function AppShell() { const { status: connStatus, isRetrying: connRetrying, retry: connRetry, isLan, serverName } = useConnectionStatus(); const navigate = useNavigate(); const location = useLocation(); - const serverId = useAuthStore(s => s.activeServerId ?? ''); const useCustomTitlebar = useAuthStore(s => s.useCustomTitlebar); const offlineAlbums = useOfflineStore(s => s.albums); - const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); + const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums); const floatingPlayerBar = useThemeStore(s => s.floatingPlayerBar); const perfFlags = usePerfProbeFlags(); diff --git a/src/components/MobileMoreOverlay.tsx b/src/components/MobileMoreOverlay.tsx index 850006a5..b2f8f890 100644 --- a/src/components/MobileMoreOverlay.tsx +++ b/src/components/MobileMoreOverlay.tsx @@ -7,6 +7,7 @@ import { useAuthStore } from '../store/authStore'; import { useOfflineStore } from '../store/offlineStore'; import { ALL_NAV_ITEMS } from '../config/navItems'; import { useLuckyMixAvailable } from '../hooks/useLuckyMixAvailable'; +import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers'; const BOTTOM_NAV_ROUTES = new Set(['/', '/albums', '/now-playing']); @@ -14,9 +15,8 @@ export default function MobileMoreOverlay({ onClose }: { onClose: () => void }) const { t } = useTranslation(); const sidebarItems = useSidebarStore(s => s.items); const randomNavMode = useAuthStore(s => s.randomNavMode); - const serverId = useAuthStore(s => s.activeServerId ?? ''); const offlineAlbums = useOfflineStore(s => s.albums); - const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); + const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums); const luckyMixBase = useLuckyMixAvailable(); const luckyMixAvailable = luckyMixBase && randomNavMode === 'separate'; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index b3523107..cf8a24a3 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -23,6 +23,7 @@ import { useSidebarNewReleasesUnread } from '../hooks/useSidebarNewReleasesUnrea import { useSidebarNavDnd } from '../hooks/useSidebarNavDnd'; import { useSidebarLibraryDropdown } from '../hooks/useSidebarLibraryDropdown'; import { useSidebarScrollVisible } from '../hooks/useSidebarScrollVisible'; +import { hasAnyOfflineAlbums } from '../utils/offline/offlineLibraryHelpers'; import { useSidebarPerfProbe } from '../hooks/useSidebarPerfProbe'; import SidebarPerfProbeModal from './sidebar/SidebarPerfProbeModal'; import SidebarNavBody from './sidebar/SidebarNavBody'; @@ -60,7 +61,7 @@ export default function Sidebar({ const setNormalizationEngine = useAuthStore(s => s.setNormalizationEngine); const loggingMode = useAuthStore(s => s.loggingMode); const setLoggingMode = useAuthStore(s => s.setLoggingMode); - const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId); + const hasOfflineContent = hasAnyOfflineAlbums(offlineAlbums); const sidebarItems = useSidebarStore(s => s.items); const setSidebarItems = useSidebarStore(s => s.setItems); const randomNavMode = useAuthStore(s => s.randomNavMode); diff --git a/src/locales/de/connection.ts b/src/locales/de/connection.ts index eb6e32a8..7ec70bae 100644 --- a/src/locales/de/connection.ts +++ b/src/locales/de/connection.ts @@ -11,6 +11,7 @@ export const connection = { offlineNoCacheBanner: 'Keine Serververbindung — {{server}} nicht erreichbar', offlineLibraryTitle: 'Offline-Bibliothek', offlineLibraryEmpty: 'Noch keine Alben gecacht. Online gehen, Album öffnen und "Offline verfügbar machen" klicken.', + offlineCachedOnServer: 'Auf {{server}}', offlineAlbumCount: '{{n}} Album', offlineAlbumCount_plural: '{{n}} Alben', offlineFilterAll: 'Alle', diff --git a/src/locales/en/connection.ts b/src/locales/en/connection.ts index 6f4856e9..0466ef33 100644 --- a/src/locales/en/connection.ts +++ b/src/locales/en/connection.ts @@ -11,6 +11,7 @@ export const connection = { offlineNoCacheBanner: 'No server connection — cannot reach {{server}}', offlineLibraryTitle: 'Offline Library', offlineLibraryEmpty: 'No albums cached yet. Go online, open an album and click "Make available offline".', + offlineCachedOnServer: 'On {{server}}', offlineAlbumCount: '{{n}} album', offlineAlbumCount_plural: '{{n}} albums', offlineFilterAll: 'All', diff --git a/src/locales/es/connection.ts b/src/locales/es/connection.ts index d403c9a6..6e8a580c 100644 --- a/src/locales/es/connection.ts +++ b/src/locales/es/connection.ts @@ -11,6 +11,7 @@ export const connection = { offlineNoCacheBanner: 'Sin conexión al servidor — no se puede acceder a {{server}}', offlineLibraryTitle: 'Biblioteca Offline', offlineLibraryEmpty: 'No hay álbumes en caché aún. Conéctate, abre un álbum y click en "Disponible offline".', + offlineCachedOnServer: 'En {{server}}', offlineAlbumCount: '{{n}} álbum', offlineAlbumCount_plural: '{{n}} álbumes', offlineFilterAll: 'Todos', diff --git a/src/locales/fr/connection.ts b/src/locales/fr/connection.ts index 1463ddc9..75fb2e95 100644 --- a/src/locales/fr/connection.ts +++ b/src/locales/fr/connection.ts @@ -11,6 +11,7 @@ export const connection = { offlineNoCacheBanner: 'Pas de connexion au serveur — {{server}} inaccessible', offlineLibraryTitle: 'Bibliothèque hors ligne', offlineLibraryEmpty: 'Aucun album en cache. Connectez-vous, ouvrez un album et cliquez sur "Rendre disponible hors ligne".', + offlineCachedOnServer: 'Sur {{server}}', offlineAlbumCount: '{{n}} album', offlineAlbumCount_plural: '{{n}} albums', offlineFilterAll: 'Tout', diff --git a/src/locales/nb/connection.ts b/src/locales/nb/connection.ts index 7890fe8f..10bff357 100644 --- a/src/locales/nb/connection.ts +++ b/src/locales/nb/connection.ts @@ -11,6 +11,7 @@ export const connection = { offlineNoCacheBanner: 'Ingen servertilkobling — kan ikke nå {{server}}', offlineLibraryTitle: 'Frakoblet bibliotek', offlineLibraryEmpty: 'Ingen album bufret ennå. Kobl deg til nettverket, åpne et album og klikk "Gjør tilgjengelig frakoblet".', + offlineCachedOnServer: 'På {{server}}', offlineAlbumCount: '{{n}} album', offlineAlbumCount_plural: '{{n}} album', offlineFilterAll: 'Alle', diff --git a/src/locales/nl/connection.ts b/src/locales/nl/connection.ts index 0b7737b0..d33e4355 100644 --- a/src/locales/nl/connection.ts +++ b/src/locales/nl/connection.ts @@ -11,6 +11,7 @@ export const connection = { offlineNoCacheBanner: 'Geen serververbinding — {{server}} niet bereikbaar', offlineLibraryTitle: 'Offline bibliotheek', offlineLibraryEmpty: 'Nog geen albums gecached. Ga online, open een album en klik op "Offline beschikbaar maken".', + offlineCachedOnServer: 'Op {{server}}', offlineAlbumCount: '{{n}} album', offlineAlbumCount_plural: '{{n}} albums', offlineFilterAll: 'Alles', diff --git a/src/locales/ro/connection.ts b/src/locales/ro/connection.ts index 2b12944f..e96617d5 100644 --- a/src/locales/ro/connection.ts +++ b/src/locales/ro/connection.ts @@ -11,6 +11,7 @@ export const connection = { offlineNoCacheBanner: 'Nicio conexiune la server — nu s-a putut ajunge la {{server}}', offlineLibraryTitle: 'Librărie offline', offlineLibraryEmpty: 'Niciun album adăugat în cache. Conectează-te, deschide un album și apasă "Fă disponibil offline".', + offlineCachedOnServer: 'Pe {{server}}', offlineAlbumCount: '{{n}} album', offlineAlbumCount_plural: '{{n}} albume', offlineFilterAll: 'Toate', diff --git a/src/locales/ru/connection.ts b/src/locales/ru/connection.ts index a1b7ce56..3089587b 100644 --- a/src/locales/ru/connection.ts +++ b/src/locales/ru/connection.ts @@ -12,6 +12,7 @@ export const connection = { offlineLibraryTitle: 'Офлайн-библиотека', offlineLibraryEmpty: 'Пока ничего не сохранено. Подключитесь к сети, откройте альбом и нажмите «Сохранить офлайн».', + offlineCachedOnServer: 'С сервера {{server}}', offlineAlbumCount_one: '{{n}} альбом', offlineAlbumCount_few: '{{n}} альбома', offlineAlbumCount_many: '{{n}} альбомов', diff --git a/src/locales/zh/connection.ts b/src/locales/zh/connection.ts index f2f7673a..bb24f68c 100644 --- a/src/locales/zh/connection.ts +++ b/src/locales/zh/connection.ts @@ -11,6 +11,7 @@ export const connection = { offlineNoCacheBanner: '无服务器连接 — 无法访问 {{server}}', offlineLibraryTitle: '离线音乐库', offlineLibraryEmpty: '尚未缓存任何专辑。请联网,打开专辑并点击"设为离线可用"。', + offlineCachedOnServer: '来自 {{server}}', offlineAlbumCount_one: '{{n}} 张专辑', offlineAlbumCount_plural: '{{n}} 张专辑', retry: '重试', diff --git a/src/pages/OfflineLibrary.tsx b/src/pages/OfflineLibrary.tsx index 5217b578..1d607ae8 100644 --- a/src/pages/OfflineLibrary.tsx +++ b/src/pages/OfflineLibrary.tsx @@ -1,20 +1,31 @@ -import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonicStreamUrl'; -import React, { useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Play, HardDriveDownload, Trash2, ListPlus } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { useOfflineStore } from '../store/offlineStore'; +import { useOfflineStore, type OfflineAlbumMeta } from '../store/offlineStore'; import { useAuthStore } from '../store/authStore'; import { usePlayerStore } from '../store/playerStore'; import CachedImage from '../components/CachedImage'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { VirtualCardGrid } from '../components/VirtualCardGrid'; +import { + buildOfflineTracksForAlbum, + ensureServerForOfflineAlbum, + offlineAlbumCoverArt, + offlineTrackCount, +} from '../utils/offline/offlineLibraryHelpers'; +import { showToast } from '../utils/ui/toast'; type FilterType = 'all' | 'album' | 'playlist' | 'artist'; export default function OfflineLibrary() { const { t } = useTranslation(); const perfFlags = usePerfProbeFlags(); - const serverId = useAuthStore(s => s.activeServerId ?? ''); + const servers = useAuthStore(s => s.servers); + const serverNames = useMemo( + () => Object.fromEntries(servers.map(s => [s.id, s.name])), + [servers], + ); + const showServerLabels = servers.length > 1; const offlineAlbums = useOfflineStore(s => s.albums); const offlineTracks = useOfflineStore(s => s.tracks); const deleteAlbum = useOfflineStore(s => s.deleteAlbum); @@ -22,7 +33,10 @@ export default function OfflineLibrary() { const enqueue = usePlayerStore(s => s.enqueue); const [filter, setFilter] = useState('all'); - const albums = Object.values(offlineAlbums).filter(a => a.serverId === serverId); + const albums = useMemo( + () => Object.values(offlineAlbums).sort((a, b) => a.name.localeCompare(b.name)), + [offlineAlbums], + ); const countByType = (type: FilterType) => { if (type === 'all') return albums.length; @@ -33,37 +47,35 @@ export default function OfflineLibrary() { ? albums : albums.filter(a => (a.type ?? 'album') === filter); - const buildTracks = (albumId: string) => { - const meta = offlineAlbums[`${serverId}:${albumId}`]; - if (!meta) return []; - return meta.trackIds.flatMap(tid => { - const t = offlineTracks[`${serverId}:${tid}`]; - if (!t) return []; - return [{ - id: t.id, title: t.title, artist: t.artist, album: t.album, - albumId: t.albumId, artistId: t.artistId, duration: t.duration, - coverArt: t.coverArt, track: undefined, year: t.year, - bitRate: t.bitRate, suffix: t.suffix, genre: t.genre, - replayGainTrackDb: t.replayGainTrackDb, - replayGainAlbumDb: t.replayGainAlbumDb, - replayGainPeak: t.replayGainPeak, - }]; + const runWithAlbumServer = useCallback(async ( + album: OfflineAlbumMeta, + action: () => void, + ) => { + const ok = await ensureServerForOfflineAlbum(album); + if (!ok) { + showToast(t('connection.switchFailed'), 4500, 'error'); + return; + } + action(); + }, [t]); + + const handlePlay = (album: OfflineAlbumMeta) => { + void runWithAlbumServer(album, () => { + const tracks = buildOfflineTracksForAlbum(album, offlineTracks); + if (tracks[0]) playTrack(tracks[0], tracks); }); }; - const handlePlay = (albumId: string) => { - const tracks = buildTracks(albumId); - if (tracks[0]) playTrack(tracks[0], tracks); + const handleEnqueue = (album: OfflineAlbumMeta) => { + void runWithAlbumServer(album, () => { + enqueue(buildOfflineTracksForAlbum(album, offlineTracks)); + }); }; - const handleEnqueue = (albumId: string) => { - enqueue(buildTracks(albumId)); - }; - - const renderCard = (album: typeof albums[0]) => { - const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : ''; - const cacheKey = album.coverArt ? coverArtCacheKey(album.coverArt, 300) : ''; - const trackCount = album.trackIds.filter(tid => !!offlineTracks[`${serverId}:${tid}`]).length; + const renderCard = (album: OfflineAlbumMeta) => { + const { src: coverUrl, cacheKey } = offlineAlbumCoverArt(album, 300); + const trackCount = offlineTrackCount(album, offlineTracks); + const serverLabel = serverNames[album.serverId]; return (
@@ -77,7 +89,7 @@ export default function OfflineLibrary() {