>;
t: TFunction;
+ actionPolicy?: OfflineActionPolicy;
}
/**
@@ -31,7 +33,9 @@ export function AlbumDetailToolbar({
showPlPicker,
setShowPlPicker,
t,
+ actionPolicy,
}: Props) {
+ const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
return (
@@ -59,22 +63,24 @@ export function AlbumDetailToolbar({
{t('common.bulkSelected', { count: selectedCount })}
-
-
- {showPlPicker && (
-
{ setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
- dropDown
- />
- )}
-
+ {policy.canAddToPlaylist && (
+
+
+ {showPlPicker && (
+
{ setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
+ dropDown
+ />
+ )}
+
+ )}
@@ -168,15 +172,17 @@ export default function ArtistDetailHero({
)}
-
+ {policy.canFavorite && (
+
+ )}
@@ -222,7 +228,7 @@ export default function ArtistDetailHero({
)}
- {albums.length > 0 && (
+ {policy.canCacheDiscography && albums.length > 0 && (
handleAction(async () => {
- const albumData = await getAlbum(album.id);
+ const serverId = resolveMediaServerId(album.serverId);
+ if (!serverId) return;
+ const albumData = await resolveAlbum(serverId, album.id);
+ if (!albumData) return;
const tracks = albumData.songs.map(songToTrack);
if (tracks.length === 0) return;
playNext(tracks);
@@ -48,7 +51,10 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
{t('contextMenu.playNext')}
handleAction(async () => {
- const albumData = await getAlbum(album.id);
+ const serverId = resolveMediaServerId(album.serverId);
+ if (!serverId) return;
+ const albumData = await resolveAlbum(serverId, album.id);
+ if (!albumData) return;
enqueue(albumData.songs.map(songToTrack));
})}>
{t('contextMenu.enqueueAlbum')}
@@ -57,58 +63,66 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
handleAction(() => goLibrary(`/artist/${album.artistId}`))}>
{t('contextMenu.goToArtist')}
-
handleAction(() => {
- const starred = isStarred(album.id, album.starred);
- setStarredOverride(album.id, !starred);
- const meta = {
- serverId: album.serverId,
- name: album.name,
- artist: album.artist,
- artistId: album.artistId,
- coverArtId: album.coverArt,
- year: album.year,
- };
- return starred ? unstar(album.id, 'album', meta) : star(album.id, 'album', meta);
- })}>
-
- {isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
-
-
e.stopPropagation()}
- >
-
- { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }}
- />
-
+ {offlinePolicy.canFavorite && (
+
handleAction(() => {
+ const starred = isStarred(album.id, album.starred);
+ setStarredOverride(album.id, !starred);
+ const meta = {
+ serverId: album.serverId,
+ name: album.name,
+ artist: album.artist,
+ artistId: album.artistId,
+ coverArtId: album.coverArt,
+ year: album.year,
+ };
+ return starred ? unstar(album.id, 'album', meta) : star(album.id, 'album', meta);
+ })}>
+
+ {isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
+
+ )}
+ {offlinePolicy.canRate && (
+
e.stopPropagation()}
+ >
+
+ { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }}
+ />
+
+ )}
handleAction(() => copyShareLink('album', album.id))}>
{t('contextMenu.shareLink')}
-
handleAction(() => downloadAlbum(album.name, album.id))}>
- {t('contextMenu.download')}
-
-
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
- onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
- >
-
{t('contextMenu.addToPlaylist')}
-
- {playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && (
-
{ setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
- )}
-
+ {offlinePolicy.canDownload && (
+
handleAction(() => downloadAlbum(album.name, album.id))}>
+ {t('contextMenu.download')}
+
+ )}
+ {offlinePolicy.canAddToPlaylist && (
+
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`album:${album.id}`]); setPlaylistSubmenuOpen(true); }}
+ onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
+ >
+
{t('contextMenu.addToPlaylist')}
+
+ {playlistSubmenuOpen && playlistSongIds[0] === `album:${album.id}` && (
+
{ setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
+ )}
+
+ )}
>
);
})()}
@@ -131,47 +145,56 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
handleAction(async () => {
- // Parallel — Navidrome handles concurrent getAlbum requests fine.
- const results = await Promise.all(albums.map(a => getAlbum(a.id)));
- const allTracks = results.flatMap(r => r.songs.map(songToTrack));
+ const results = await Promise.all(albums.map(async a => {
+ const serverId = resolveMediaServerId(a.serverId);
+ if (!serverId) return null;
+ return resolveAlbum(serverId, a.id);
+ }));
+ const allTracks = results
+ .filter((r): r is NonNullable => r != null)
+ .flatMap(r => r.songs.map(songToTrack));
enqueue(allTracks);
})}>
{t('contextMenu.enqueueAlbums', { count: albums.length })}
- { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
- onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
- >
- {t('contextMenu.addToPlaylist')}
-
- {playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && (
- { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
- )}
-
- e.stopPropagation()}
- >
-
- {
- setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r });
- for (const a of albums) applyAlbumRating(a, r);
- }}
- />
-
+ {offlinePolicy.canAddToPlaylist && (
+ { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-album:${albumIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
+ onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
+ >
+ {t('contextMenu.addToPlaylist')}
+
+ {playlistSubmenuOpen && playlistSongIds[0] === `multi-album:${albumIds.join(',')}` && (
+ { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
+ )}
+
+ )}
+ {offlinePolicy.canRate && (
+ e.stopPropagation()}
+ >
+
+ {
+ setKeyboardRating({ kind: 'album', id: multiAlbumRatingId, value: r });
+ for (const a of albums) applyAlbumRating(a, r);
+ }}
+ />
+
+ )}
>
);
})()}
diff --git a/src/components/contextMenu/ArtistContextItems.tsx b/src/components/contextMenu/ArtistContextItems.tsx
index cf783467..c12b86b8 100644
--- a/src/components/contextMenu/ArtistContextItems.tsx
+++ b/src/components/contextMenu/ArtistContextItems.tsx
@@ -20,6 +20,7 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
applySongRating, applyAlbumRating, applyArtistRating,
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
+ offlinePolicy,
} = props;
const { t } = useTranslation();
const auth = useAuthStore();
@@ -35,54 +36,64 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
handleAction(() => startRadio(artist.id, artist.name))}>
{t('contextMenu.startRadio')}
- { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }}
- onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
- >
-
{t('contextMenu.addToPlaylist')}
-
- {playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && (
-
{ setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
- )}
-
+ {offlinePolicy.canAddToPlaylist && (
+ { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }}
+ onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
+ >
+
{t('contextMenu.addToPlaylist')}
+
+ {playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && (
+
{ setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
+ )}
+
+ )}
handleAction(() => copyShareLink(shareKindOverride ?? 'artist', artist.id))}>
{t('contextMenu.shareLink')}
-
- handleAction(() => {
- const starred = isStarred(artist.id, artist.starred);
- setStarredOverride(artist.id, !starred);
- const meta = {
- serverId: artist.serverId,
- name: artist.name,
- albumCount: artist.albumCount,
- };
- return starred
- ? unstar(artist.id, 'artist', meta)
- : star(artist.id, 'artist', meta);
- })}>
-
- {isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
-
- e.stopPropagation()}
- >
-
- { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }}
- />
-
+ {(offlinePolicy.canFavorite || offlinePolicy.canRate) && (
+ <>
+
+ {offlinePolicy.canFavorite && (
+ handleAction(() => {
+ const starred = isStarred(artist.id, artist.starred);
+ setStarredOverride(artist.id, !starred);
+ const meta = {
+ serverId: artist.serverId,
+ name: artist.name,
+ albumCount: artist.albumCount,
+ };
+ return starred
+ ? unstar(artist.id, 'artist', meta)
+ : star(artist.id, 'artist', meta);
+ })}>
+
+ {isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
+
+ )}
+ {offlinePolicy.canRate && (
+ e.stopPropagation()}
+ >
+
+ { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }}
+ />
+
+ )}
+ >
+ )}
>
);
})()}
@@ -104,40 +115,44 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
{t('contextMenu.selectedArtists', { count: artists.length })}
- { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
- onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
- >
- {t('contextMenu.addToPlaylist')}
-
- {playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && (
- { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
- )}
-
- e.stopPropagation()}
- >
-
- {
- setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r });
- for (const a of artists) applyArtistRating(a, r);
- }}
- />
-
+ {offlinePolicy.canAddToPlaylist && (
+ { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-artist:${artistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
+ onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
+ >
+ {t('contextMenu.addToPlaylist')}
+
+ {playlistSubmenuOpen && playlistSongIds[0] === `multi-artist:${artistIds.join(',')}` && (
+ { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
+ )}
+
+ )}
+ {offlinePolicy.canRate && (
+ e.stopPropagation()}
+ >
+
+ {
+ setKeyboardRating({ kind: 'artist', id: multiArtistRatingId, value: r });
+ for (const a of artists) applyArtistRating(a, r);
+ }}
+ />
+
+ )}
>
);
})()}
diff --git a/src/components/contextMenu/MultiAlbumToPlaylistSubmenu.tsx b/src/components/contextMenu/MultiAlbumToPlaylistSubmenu.tsx
index 4b812e94..6c7330e7 100644
--- a/src/components/contextMenu/MultiAlbumToPlaylistSubmenu.tsx
+++ b/src/components/contextMenu/MultiAlbumToPlaylistSubmenu.tsx
@@ -1,7 +1,7 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ListMusic, Plus } from 'lucide-react';
-import { getAlbum } from '../../api/subsonicLibrary';
+import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import { usePlaylistStore } from '../../store/playlistStore';
import { showToast } from '../../utils/ui/toast';
@@ -26,7 +26,10 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig
setTotalAlbums(albumIds.length);
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
(async () => {
- const albumSongs = await Promise.all(albumIds.map(id => getAlbum(id).then(r => r.songs).catch(() => [])));
+ const serverId = resolveMediaServerId();
+ const albumSongs = serverId
+ ? await Promise.all(albumIds.map(id => resolveAlbum(serverId, id).then(r => r?.songs ?? []).catch(() => [])))
+ : [];
const allSongs = albumSongs.flat();
setResolvedIds(allSongs.map(s => s.id));
})().catch(() => setResolvedIds([]));
@@ -34,11 +37,15 @@ export function MultiAlbumToPlaylistSubmenu({ albumIds, onDone, triggerId: _trig
}, [albumIds]);
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
- const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
+ const { updatePlaylist } = await import('../../api/subsonicPlaylists');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
- const { songs: existingSongs } = await getPlaylist(pl.id);
+ const serverId = resolveMediaServerId();
+ if (!serverId) return;
+ const resolved = await resolvePlaylist(serverId, pl.id);
+ if (!resolved) return;
+ const { songs: existingSongs } = resolved;
const existingIds = new Set(existingSongs.map((s) => s.id));
const newIds: string[] = [];
diff --git a/src/components/contextMenu/MultiArtistToPlaylistSubmenu.tsx b/src/components/contextMenu/MultiArtistToPlaylistSubmenu.tsx
index 931e1690..14778606 100644
--- a/src/components/contextMenu/MultiArtistToPlaylistSubmenu.tsx
+++ b/src/components/contextMenu/MultiArtistToPlaylistSubmenu.tsx
@@ -1,8 +1,7 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ListMusic, Plus } from 'lucide-react';
-import { getAlbum } from '../../api/subsonicLibrary';
-import { getArtist } from '../../api/subsonicArtists';
+import { resolveAlbum, resolveArtist, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
import { getPlaylists } from '../../api/subsonicPlaylists';
import type { SubsonicPlaylist } from '../../api/subsonicTypes';
import { usePlaylistStore } from '../../store/playlistStore';
@@ -29,10 +28,18 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr
const loadingTimeout = setTimeout(() => setShowLoading(true), 300);
(async () => {
const allSongs: string[] = [];
+ const serverId = resolveMediaServerId();
+ if (!serverId) {
+ setResolvedIds([]);
+ return;
+ }
for (const artistId of artistIds) {
try {
- const { albums } = await getArtist(artistId);
- const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs).catch(() => [])));
+ const artistData = await resolveArtist(serverId, artistId);
+ if (!artistData) continue;
+ const albumSongs = await Promise.all(
+ artistData.albums.map(a => resolveAlbum(serverId, a.id).then(r => r?.songs ?? []).catch(() => [])),
+ );
allSongs.push(...albumSongs.flat().map(s => s.id));
} catch {
// Skip failed artists
@@ -44,11 +51,15 @@ export function MultiArtistToPlaylistSubmenu({ artistIds, onDone, triggerId: _tr
}, [artistIds]);
const handleAddWithToast = async (pl: SubsonicPlaylist, songIds: string[]) => {
- const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
+ const { updatePlaylist } = await import('../../api/subsonicPlaylists');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
- const { songs: existingSongs } = await getPlaylist(pl.id);
+ const serverId = resolveMediaServerId();
+ if (!serverId) return;
+ const resolved = await resolvePlaylist(serverId, pl.id);
+ if (!resolved) return;
+ const { songs: existingSongs } = resolved;
const existingIds = new Set(existingSongs.map((s) => s.id));
const newIds: string[] = [];
diff --git a/src/components/contextMenu/PlaylistContextItems.tsx b/src/components/contextMenu/PlaylistContextItems.tsx
index 727d2cc0..25683996 100644
--- a/src/components/contextMenu/PlaylistContextItems.tsx
+++ b/src/components/contextMenu/PlaylistContextItems.tsx
@@ -18,6 +18,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
applySongRating, applyAlbumRating, applyArtistRating,
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
+ offlinePolicy,
} = props;
const { t } = useTranslation();
const auth = useAuthStore();
@@ -33,18 +34,22 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
{t('contextMenu.playNow')}
- { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
- onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
- >
- {t('contextMenu.addToPlaylist')}
-
- {playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && (
- { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
- )}
-
+ {offlinePolicy.canAddToPlaylist && (
+ { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`playlist:${playlist.id}`]); setPlaylistSubmenuOpen(true); }}
+ onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
+ >
+ {t('contextMenu.addToPlaylist')}
+
+ {playlistSubmenuOpen && playlistSongIds[0] === `playlist:${playlist.id}` && (
+ { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
+ )}
+
+ )}
+ {offlinePolicy.canEditPlaylist && (
+ <>
handleAction(async () => {
const { showToast } = await import('../../utils/ui/toast');
@@ -64,6 +69,8 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
})}>
{t('playlists.deletePlaylist')}
+ >
+ )}
>
);
})()}
@@ -77,18 +84,21 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
{t('contextMenu.selectedPlaylists', { count: selectedPlaylists.length })}
- { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
- onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
- >
- {t('contextMenu.addToPlaylist')}
-
- {playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && (
- { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
- )}
-
+ {offlinePolicy.canAddToPlaylist && (
+ { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([`multi-playlist:${playlistIds.join(',')}`]); setPlaylistSubmenuOpen(true); }}
+ onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
+ >
+ {t('contextMenu.addToPlaylist')}
+
+ {playlistSubmenuOpen && playlistSongIds[0] === `multi-playlist:${playlistIds.join(',')}` && (
+ { setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
+ )}
+
+ )}
+ {offlinePolicy.canEditPlaylist && (
handleAction(async () => {
const { showToast } = await import('../../utils/ui/toast');
const { deletePlaylist } = await import('../../api/subsonicPlaylists');
@@ -113,6 +123,7 @@ export default function PlaylistContextItems(props: ContextMenuItemsProps) {
})}>
{t('playlists.deleteSelected')}
+ )}
>
);
})()}
diff --git a/src/components/contextMenu/SongContextItems.tsx b/src/components/contextMenu/SongContextItems.tsx
index 2aca22eb..efb5784b 100644
--- a/src/components/contextMenu/SongContextItems.tsx
+++ b/src/components/contextMenu/SongContextItems.tsx
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
import { Play, ListPlus, Radio, Heart, ChevronRight, ChevronsRight, User, Disc3, ListMusic, Info, Sparkles, Star, Trash2, HeartCrack, Share2, Orbit as OrbitIcon } from 'lucide-react';
import { useNavigateToAlbum } from '../../hooks/useNavigateToAlbum';
import { useNavigateToArtist } from '../../hooks/useNavigateToArtist';
-import { getAlbum } from '../../api/subsonicLibrary';
+import { resolveAlbum, resolveMediaServerId, resolvePlaylist } from '../../utils/offline/offlineMediaResolve';
import { queueSongStar } from '../../store/pendingStarSync';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../../api/lastfm';
import type { Track } from '../../store/playerStoreTypes';
@@ -27,6 +27,7 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
orbitRole, entityRatingSupport, audiomuseNavidromeEnabled,
applySongRating, applyAlbumRating, applyArtistRating,
handleAction, startRadio, startInstantMix, downloadAlbum, copyShareLink, isStarred,
+ offlinePolicy,
} = props;
const { t } = useTranslation();
const auth = useAuthStore();
@@ -80,21 +81,26 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
{t('orbit.ctxAddToSessionHost')}
)}
- { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
- onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
- >
-
{t('contextMenu.addToPlaylist')}
-
- {playlistSubmenuOpen && playlistSongIds[0] === song.id && (
-
{ setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
- )}
-
+ {offlinePolicy.canAddToPlaylist && (
+ { cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
+ onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
+ >
+
{t('contextMenu.addToPlaylist')}
+
+ {playlistSubmenuOpen && playlistSongIds[0] === song.id && (
+
{ setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
+ )}
+
+ )}
{type === 'album-song' && (
handleAction(async () => {
- const albumData = await getAlbum(song.albumId);
+ const serverId = resolveMediaServerId(song.serverId);
+ if (!serverId || !song.albumId) return;
+ const albumData = await resolveAlbum(serverId, song.albumId);
+ if (!albumData) return;
const tracks = albumData.songs.map(songToTrack);
enqueue(tracks);
})}>
@@ -120,12 +126,14 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
{t('contextMenu.instantMix')}
)}
- handleAction(() => {
- queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId);
- })}>
-
- {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
-
+ {offlinePolicy.canFavorite && (
+ handleAction(() => {
+ queueSongStar(song.id, !isStarred(song.id, song.starred), song.serverId);
+ })}>
+
+ {isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
+
+ )}
{auth.lastfmSessionKey && (() => {
const loveKey = `${song.title}::${song.artist}`;
const loved = lastfmLovedCache[loveKey] ?? false;
@@ -141,22 +149,24 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
);
})()}
- e.stopPropagation()}
- >
-
- { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
- ariaLabel={t('albumDetail.ratingLabel')}
- />
-
+ {offlinePolicy.canRate && (
+ e.stopPropagation()}
+ >
+
+ { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
+ ariaLabel={t('albumDetail.ratingLabel')}
+ />
+
+ )}
handleAction(() => copyShareLink('track', song.id))}>
{t('contextMenu.shareLink')}
@@ -164,13 +174,17 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
handleAction(() => openSongInfo(song.id))}>
{t('contextMenu.songInfo')}
- {playlistId && playlistSongIndex !== undefined && (
+ {offlinePolicy.canEditPlaylist && playlistId && playlistSongIndex !== undefined && (
handleAction(async () => {
- const { getPlaylist, updatePlaylist } = await import('../../api/subsonicPlaylists');
+ const { updatePlaylist } = await import('../../api/subsonicPlaylists');
const { showToast } = await import('../../utils/ui/toast');
const touchPlaylist = usePlaylistStore.getState().touchPlaylist;
try {
- const { songs } = await getPlaylist(playlistId);
+ const serverId = resolveMediaServerId();
+ if (!serverId) return;
+ const resolved = await resolvePlaylist(serverId, playlistId);
+ if (!resolved) return;
+ const { songs } = resolved;
const prevCount = songs.length;
const updatedIds = songs.filter((_, i) => i !== playlistSongIndex).map(s => s.id);
await updatePlaylist(playlistId, updatedIds, prevCount);
@@ -232,18 +246,20 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
{t('orbit.ctxAddToSessionHost')}
)}
-
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
- onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
- >
-
{t('contextMenu.addToPlaylist')}
-
- {playlistSubmenuOpen && playlistSongIds[0] === song.id && (
-
{ setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
- )}
-
+ {offlinePolicy.canAddToPlaylist && (
+
{ cancelPlaylistSubmenuCloseTimer(); setPlaylistSongIds([song.id]); setPlaylistSubmenuOpen(true); }}
+ onMouseLeave={onPlaylistSubmenuTriggerMouseLeave}
+ >
+
{t('contextMenu.addToPlaylist')}
+
+ {playlistSubmenuOpen && playlistSongIds[0] === song.id && (
+
{ setPlaylistSubmenuOpen(false); closeContextMenu(); }} />
+ )}
+
+ )}
{song.albumId && (
handleAction(() => navigateToAlbum(song.albumId!))}>
@@ -278,22 +294,24 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
);
})()}
-
e.stopPropagation()}
- >
-
- { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
- ariaLabel={t('albumDetail.ratingLabel')}
- />
-
+ {offlinePolicy.canRate && (
+
e.stopPropagation()}
+ >
+
+ { setKeyboardRating({ kind: 'song', id: song.id, value: r }); applySongRating(song.id, r); }}
+ ariaLabel={t('albumDetail.ratingLabel')}
+ />
+
+ )}
handleAction(() => copyShareLink('track', song.id))}>
{t('contextMenu.shareLink')}
@@ -301,12 +319,16 @@ export default function SongContextItems(props: ContextMenuItemsProps) {
handleAction(() => openSongInfo(song.id))}>
{t('contextMenu.songInfo')}
-
-
handleAction(() => {
- queueSongStar(song.id, false, song.serverId);
- })}>
- {t('contextMenu.unfavorite')}
-
+ {offlinePolicy.canFavorite && (
+ <>
+
+
handleAction(() => {
+ queueSongStar(song.id, false, song.serverId);
+ })}>
+ {t('contextMenu.unfavorite')}
+
+ >
+ )}
>
);
})()}
diff --git a/src/components/contextMenu/contextMenuItemTypes.ts b/src/components/contextMenu/contextMenuItemTypes.ts
index 3b2a7cc2..9cc63459 100644
--- a/src/components/contextMenu/contextMenuItemTypes.ts
+++ b/src/components/contextMenu/contextMenuItemTypes.ts
@@ -2,6 +2,7 @@ import type React from 'react';
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
import type { QueueItemRef, Track } from '../../store/playerStoreTypes';
import type { EntityShareKind } from '../../utils/share/shareLink';
+import type { OfflineActionPolicy } from '../../utils/offline/offlineActionPolicy';
export type RatingKind = 'song' | 'album' | 'artist';
@@ -56,4 +57,5 @@ export interface ContextMenuItemsProps {
/** When true, album/artist links switch to the queue server before routing. */
pinToPlaybackServer: boolean;
navigateLibrary: (path: string) => void | Promise
;
+ offlinePolicy: OfflineActionPolicy;
}
diff --git a/src/components/playerBar/PlayerTrackInfo.tsx b/src/components/playerBar/PlayerTrackInfo.tsx
index f86f2c61..fcc265e6 100644
--- a/src/components/playerBar/PlayerTrackInfo.tsx
+++ b/src/components/playerBar/PlayerTrackInfo.tsx
@@ -19,6 +19,8 @@ import {
usePlayerBarLayoutStore,
type PlayerBarLayoutItemId,
} from '../../store/playerBarLayoutStore';
+import { useOfflineBrowseContext } from '../../hooks/useOfflineBrowseContext';
+import { offlineActionPolicy } from '../../utils/offline/offlineActionPolicy';
interface Props {
currentTrack: Track | null;
@@ -67,6 +69,8 @@ export function PlayerTrackInfo({
const layoutItems = usePlayerBarLayoutStore(s => s.items);
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
layoutItems.find(i => i.id === id)?.visible !== false;
+ const offlineBrowseActive = useOfflineBrowseContext().active;
+ const playerPolicy = offlineActionPolicy('playerBar', offlineBrowseActive);
return (
@@ -168,7 +172,7 @@ export function PlayerTrackInfo({
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
/>
)}
- {currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && (
+ {currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && playerPolicy.canRate && (
queueSongRating(currentTrack.id, r)}
@@ -182,7 +186,7 @@ export function PlayerTrackInfo({
)}
- {currentTrack && !isRadio && isLayoutVisible('favorite') && (
+ {currentTrack && !isRadio && isLayoutVisible('favorite') && playerPolicy.canFavorite && (
- {isLayoutVisible('addSongs') && (
+ {actionPolicy.canEditPlaylist && isLayoutVisible('addSongs') && (
{ setSearchOpen(v => !v); setSearchQuery(''); setSearchResults([]); setSelectedSearchIds(new Set()); setSearchPlPickerOpen(false); }}
@@ -181,7 +185,7 @@ export default function PlaylistHero({
{t('playlists.addSongs')}
)}
- {isLayoutVisible('importCsv') && (
+ {actionPolicy.canEditPlaylist && isLayoutVisible('importCsv') && (
)}
- {isLayoutVisible('downloadZip') && songs.length > 0 && (
+ {actionPolicy.canDownload && isLayoutVisible('downloadZip') && songs.length > 0 && (
activeZip && !activeZip.done && !activeZip.error ? (
@@ -207,7 +211,7 @@ export default function PlaylistHero({
)
)}
- {isLayoutVisible('offlineCache') && songs.length > 0 && id
+ {actionPolicy.canPinOffline && isLayoutVisible('offlineCache') && songs.length > 0 && id
&& (!isSmartPlaylistName(playlist.name) || offlineStatus !== 'none') && (
>;
setSmartFilters: React.Dispatch>;
setGenreQuery: React.Dispatch>;
+ actionPolicy?: OfflineActionPolicy;
}
export default function PlaylistsHeader({
@@ -32,8 +34,10 @@ export default function PlaylistsHeader({
creating, setCreating, setCreatingSmart,
newName, setNewName, nameInputRef, handleCreate,
isNavidromeServer, setEditingSmartId, setSmartFilters, setGenreQuery,
+ actionPolicy,
}: Props) {
const { t } = useTranslation();
+ const policy = actionPolicy ?? offlineActionPolicy('playlistsHeader', false);
return (
@@ -43,7 +47,7 @@ export default function PlaylistsHeader({
: t('playlists.title')}
- {!(selectionMode && selectedIds.size > 0) && (<>
+ {policy.canEditPlaylist && !(selectionMode && selectedIds.size > 0) && (<>
{creating ? (
<>
- navigate('/statistics')}
- >
- {t('statistics.tabServer')}
-
- navigate('/player-stats')}
- >
- {t('statistics.tabPlayer')}
-
+ {showServerTab && (
+ navigate('/statistics')}
+ >
+ {t('statistics.tabServer')}
+
+ )}
+ {playerStatsEnabled && (
+ navigate('/player-stats')}
+ >
+ {t('statistics.tabPlayer')}
+
+ )}
);
}
diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts
index 6dc90410..3d1f9c14 100644
--- a/src/config/settingsCredits.ts
+++ b/src/config/settingsCredits.ts
@@ -153,6 +153,7 @@ const CONTRIBUTOR_ENTRIES = [
'Cover backfill: follow the smart local/public endpoint switch so off-LAN clients stop fetching covers from the unreachable local address (PR #952)',
'Audio: Symphonia 0.6 migration with libopus adapter 0.3; ranged-stream start latency fix (probe seek-gate) and a probe timeout so a stalled stream no longer hangs playback start (PR #999)',
'Offline experience — unified media layout (cache/library/favorites), localPlaybackStore, library-index Offline Library, favorites auto-sync, cached album/playlist/artist pin reconcile, mixed-server offline queue, and single mediaDir setting (PR #1008)',
+ 'Offline browse — local-bytes catalog when server is down, integration contract (context/policy/resolvers), disconnect nav fork, Home stale-cache feed, read-only context menus, PlayerBar rating/favorite guard (PR #1017)',
],
},
{
diff --git a/src/hooks/useAlbumBrowseData.ts b/src/hooks/useAlbumBrowseData.ts
index a72bc225..1550246c 100644
--- a/src/hooks/useAlbumBrowseData.ts
+++ b/src/hooks/useAlbumBrowseData.ts
@@ -31,6 +31,13 @@ import {
ALBUM_YEAR_FILTER_DEBOUNCE_MS,
resolveAlbumYearBounds,
} from '../utils/library/albumYearFilter';
+import { loadOfflineAlbumBrowseInitial } from '../utils/offline/offlineAlbumBrowseCatalog';
+import { useOfflineBrowseReloadToken } from './useOfflineBrowseReloadToken';
+import {
+ fetchAlbumBrowseCatalogChunk,
+ mergeAlbumCatalogChunk,
+} from '../utils/library/albumBrowseCatalogChunk';
+import { useOfflineBrowseContext } from './useOfflineBrowseContext';
import { useClientSliceInfiniteScroll } from './useClientSliceInfiniteScroll';
import { useDebouncedValue } from './useDebouncedValue';
import { useInpageScrollSentinel } from './useInpageScrollSentinel';
@@ -91,6 +98,8 @@ export function useAlbumBrowseData({
scrollRootEl,
restoreDisplayCount,
}: UseAlbumBrowseDataArgs) {
+ const offlineBrowseActive = useOfflineBrowseContext().active;
+ const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
const [albums, setAlbums] = useState
([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
@@ -232,18 +241,19 @@ export function useAlbumBrowseData({
catalogLoadingRef.current = true;
setCatalogLoadingMore(true);
try {
- const chunk = await fetchLocalAlbumCatalogChunk(serverId, query, offset, CATALOG_CHUNK_SIZE);
+ const chunk = await fetchAlbumBrowseCatalogChunk(
+ serverId,
+ query,
+ offset,
+ CATALOG_CHUNK_SIZE,
+ starredOverrides,
+ );
if (generation !== loadGenerationRef.current || chunk == null) return;
- if (append) {
- setAlbums(prev => {
- const merged = dedupeById([...prev, ...chunk.albums]);
- catalogOffsetRef.current = merged.length;
- return merged;
- });
- } else {
- setAlbums(chunk.albums);
- catalogOffsetRef.current = chunk.albums.length;
- }
+ setAlbums(prev => {
+ const { albums: next, offset: nextOffset } = mergeAlbumCatalogChunk(prev, chunk, append);
+ catalogOffsetRef.current = nextOffset;
+ return next;
+ });
setCatalogHasMore(chunk.hasMore);
} finally {
catalogLoadingRef.current = false;
@@ -251,7 +261,7 @@ export function useAlbumBrowseData({
setCatalogLoadingMore(false);
}
}
- }, [serverId]);
+ }, [offlineBrowseActive, serverId, starredOverrides]);
const loadBrowse = useCallback(async (
query: AlbumBrowseQuery,
@@ -321,6 +331,28 @@ export function useAlbumBrowseData({
setLoading(true);
void (async () => {
+ if (offlineBrowseActive) {
+ const generation = ++loadGenerationRef.current;
+ if (cancelled || generation !== loadGenerationRef.current) return;
+ setBrowseMode('slice');
+ try {
+ const first = await loadOfflineAlbumBrowseInitial(
+ serverId,
+ browseQuery,
+ CATALOG_CHUNK_SIZE,
+ starredOverrides,
+ );
+ if (cancelled || generation !== loadGenerationRef.current) return;
+ setAlbums(first.albums);
+ catalogOffsetRef.current = first.albums.length;
+ setCatalogHasMore(first.hasMore);
+ } catch {
+ setAlbums([]);
+ setCatalogHasMore(false);
+ }
+ setLoading(false);
+ return;
+ }
if (indexEnabled && serverId) {
const generation = ++loadGenerationRef.current;
coverTrafficBeginGridPagination();
@@ -353,7 +385,7 @@ export function useAlbumBrowseData({
return () => {
cancelled = true;
};
- }, [browseQuery, indexEnabled, serverId, loadBrowse, musicLibraryFilterVersion]);
+ }, [browseQuery, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, loadBrowse, musicLibraryFilterVersion]);
useEffect(() => {
if (!genreCatalogActive) {
diff --git a/src/hooks/useAlbumDetailData.ts b/src/hooks/useAlbumDetailData.ts
index 54fb7848..3aed0dcd 100644
--- a/src/hooks/useAlbumDetailData.ts
+++ b/src/hooks/useAlbumDetailData.ts
@@ -1,22 +1,28 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
-import { getAlbum, getAlbumForServer } from '../api/subsonicLibrary';
-import { getArtist, getArtistForServer } from '../api/subsonicArtists';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { useAuthStore } from '../store/authStore';
-import { useConnectionStatus } from './useConnectionStatus';
import {
loadAlbumFromLibraryIndex,
loadArtistFromLibraryIndex,
- resolveAlbumForServer,
-} from '../utils/offline/favoritesOfflineBrowse';
+} from '../utils/offline/offlineLibraryIndexLoad';
+import {
+ resolveAlbum,
+ resolveArtist,
+ type ResolvedAlbum,
+} from '../utils/offline/offlineMediaResolve';
+import { useOfflineBrowseContext } from './useOfflineBrowseContext';
+import {
+ loadArtistFromLocalPlayback,
+ offlineLocalBrowseEnabled,
+} from '../utils/offline/offlineLocalBrowse';
import { readDetailServerId } from '../utils/navigation/detailServerScope';
import {
shouldAttemptSubsonicForActiveServer,
shouldAttemptSubsonicForServer,
} from '../utils/network/subsonicNetworkGuard';
-type AlbumPayload = Awaited>;
+type AlbumPayload = ResolvedAlbum;
interface UseAlbumDetailDataResult {
album: AlbumPayload | null;
@@ -44,7 +50,7 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
const activeServerId = useAuthStore(s => s.activeServerId);
const [searchParams] = useSearchParams();
const detailServerId = readDetailServerId(searchParams, activeServerId);
- const { status: connStatus } = useConnectionStatus();
+ const offlineBrowseActive = useOfflineBrowseContext().active && !!detailServerId;
useEffect(() => {
if (!id) return;
@@ -64,22 +70,25 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
serverId: string | null,
artistId: string | undefined,
useLocalArtist: boolean,
+ localBytesOnly: boolean,
) => {
if (!artistId) return;
try {
if (useLocalArtist && serverId) {
- const artistLocal = await loadArtistFromLibraryIndex(serverId, artistId);
+ const artistLocal = localBytesOnly
+ ? await loadArtistFromLocalPlayback(serverId, artistId)
+ : await loadArtistFromLibraryIndex(serverId, artistId);
if (artistLocal) {
setRelatedAlbums(artistLocal.albums.filter(a => a.id !== id));
return;
}
}
const relatedServerId = serverId ?? detailServerId ?? activeServerId;
- if (!relatedServerId || !shouldAttemptSubsonicForServer(relatedServerId)) return;
- const artistData = detailServerId
- ? await getArtistForServer(detailServerId, artistId)
- : await getArtist(artistId);
- setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
+ if (!relatedServerId) return;
+ const artistData = await resolveArtist(relatedServerId, artistId);
+ if (artistData) {
+ setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
+ }
} catch (e) {
console.error('Failed to fetch related albums', e);
}
@@ -88,12 +97,28 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
const libraryFirst = favoritesOfflineEnabled && !!detailServerId;
void (async () => {
+ if (offlineBrowseActive && detailServerId) {
+ const local = await resolveAlbum(detailServerId, id);
+ if (local) {
+ applyAlbumPayload(local);
+ await loadRelatedAlbums(
+ detailServerId,
+ local.album.artistId,
+ true,
+ offlineLocalBrowseEnabled(detailServerId),
+ );
+ return;
+ }
+ setLoading(false);
+ return;
+ }
+
if (libraryFirst && detailServerId) {
try {
- const local = await resolveAlbumForServer(detailServerId, id);
+ const local = await resolveAlbum(detailServerId, id);
if (local) {
applyAlbumPayload(local);
- await loadRelatedAlbums(detailServerId, local.album.artistId, true);
+ await loadRelatedAlbums(detailServerId, local.album.artistId, true, false);
return;
}
} catch { /* fall through */ }
@@ -106,10 +131,10 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
if (!detailNetworkAllowed) {
if (favoritesOfflineEnabled && detailServerId) {
try {
- const local = await loadAlbumFromLibraryIndex(detailServerId, id);
+ const local = await resolveAlbum(detailServerId, id);
if (local) {
applyAlbumPayload(local);
- await loadRelatedAlbums(detailServerId, local.album.artistId, true);
+ await loadRelatedAlbums(detailServerId, local.album.artistId, true, false);
return;
}
} catch { /* ignore */ }
@@ -119,18 +144,25 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
}
try {
- const data = detailServerId
- ? await getAlbumForServer(detailServerId, id)
- : await getAlbum(id);
+ const sid = detailServerId ?? activeServerId;
+ if (!sid) {
+ setLoading(false);
+ return;
+ }
+ const data = await resolveAlbum(sid, id);
+ if (!data) {
+ setLoading(false);
+ return;
+ }
applyAlbumPayload(data);
- await loadRelatedAlbums(detailServerId, data.album.artistId, false);
+ await loadRelatedAlbums(detailServerId, data.album.artistId, false, false);
} catch {
if (favoritesOfflineEnabled && detailServerId) {
try {
const local = await loadAlbumFromLibraryIndex(detailServerId, id);
if (local) {
applyAlbumPayload(local);
- await loadRelatedAlbums(detailServerId, local.album.artistId, true);
+ await loadRelatedAlbums(detailServerId, local.album.artistId, true, false);
return;
}
} catch { /* ignore */ }
@@ -138,7 +170,7 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
setLoading(false);
}
})();
- }, [id, connStatus, favoritesOfflineEnabled, detailServerId, searchParams]);
+ }, [activeServerId, detailServerId, favoritesOfflineEnabled, id, offlineBrowseActive, searchParams]);
return { album, setAlbum, relatedAlbums, loading, isStarred, setIsStarred, starredSongs, setStarredSongs };
}
diff --git a/src/hooks/useArtistDetailData.ts b/src/hooks/useArtistDetailData.ts
index 07da2b52..883b0e2f 100644
--- a/src/hooks/useArtistDetailData.ts
+++ b/src/hooks/useArtistDetailData.ts
@@ -7,7 +7,9 @@ import type {
} from '../api/subsonicTypes';
import { useAuthStore } from '../store/authStore';
import { useConnectionStatus } from './useConnectionStatus';
-import { loadArtistFromLibraryIndex } from '../utils/offline/favoritesOfflineBrowse';
+import { loadArtistFromLibraryIndex } from '../utils/offline/offlineLibraryIndexLoad';
+import { useOfflineBrowseContext } from './useOfflineBrowseContext';
+import { loadArtistFromLocalPlayback, offlineLocalBrowseEnabled } from '../utils/offline/offlineLocalBrowse';
import { readDetailServerId } from '../utils/navigation/detailServerScope';
import { runLocalArtistLosslessBrowse } from '../utils/library/browseTextSearch';
import { isLosslessSuffix } from '../utils/library/losslessFormats';
@@ -58,9 +60,10 @@ export function useArtistDetailData(
s => !!(serverId && s.audiomuseNavidromeByServer[serverId]),
);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
- const preferLocalArtist = connStatus === 'disconnected'
- && favoritesOfflineEnabled
- && !!serverId;
+ const offlineBrowseActive = useOfflineBrowseContext().active && !!serverId;
+ const preferLocalBytesOnly = offlineBrowseActive && offlineLocalBrowseEnabled(serverId);
+ const preferLocalArtist = preferLocalBytesOnly
+ || (connStatus === 'disconnected' && favoritesOfflineEnabled && !!serverId);
const [artist, setArtist] = useState(null);
const [albums, setAlbums] = useState([]);
@@ -82,8 +85,14 @@ export function useArtistDetailData(
(async () => {
try {
+ if (offlineBrowseActive && !preferLocalBytesOnly) {
+ setLoading(false);
+ return;
+ }
if (preferLocalArtist && serverId && id) {
- const local = await loadArtistFromLibraryIndex(serverId, id);
+ const local = preferLocalBytesOnly
+ ? await loadArtistFromLocalPlayback(serverId, id)
+ : await loadArtistFromLibraryIndex(serverId, id);
if (cancelled) return;
if (local) {
setArtist(local.artist);
@@ -93,6 +102,10 @@ export function useArtistDetailData(
setLoading(false);
return;
}
+ if (preferLocalBytesOnly) {
+ setLoading(false);
+ return;
+ }
}
if (losslessOnly && serverId) {
@@ -135,7 +148,9 @@ export function useArtistDetailData(
if (!cancelled) {
if (preferLocalArtist && serverId && id) {
try {
- const local = await loadArtistFromLibraryIndex(serverId, id);
+ const local = preferLocalBytesOnly
+ ? await loadArtistFromLocalPlayback(serverId, id)
+ : await loadArtistFromLibraryIndex(serverId, id);
if (cancelled) return;
if (local) {
setArtist(local.artist);
@@ -154,7 +169,7 @@ export function useArtistDetailData(
})();
return () => { cancelled = true; };
- }, [id, losslessOnly, serverId, preferLocalArtist, searchParams]);
+ }, [id, losslessOnly, serverId, offlineBrowseActive, preferLocalArtist, preferLocalBytesOnly, searchParams]);
useEffect(() => {
if (!id || preferLocalArtist) return;
diff --git a/src/hooks/useArtistsBrowseCatalog.ts b/src/hooks/useArtistsBrowseCatalog.ts
index 320fb742..36fd82c0 100644
--- a/src/hooks/useArtistsBrowseCatalog.ts
+++ b/src/hooks/useArtistsBrowseCatalog.ts
@@ -6,6 +6,13 @@ import {
fetchLocalArtistCatalogChunk,
fetchNetworkStarredArtists,
} from '../utils/library/browseTextSearch';
+import { useOfflineBrowseContext } from './useOfflineBrowseContext';
+import { useOfflineBrowseReloadToken } from './useOfflineBrowseReloadToken';
+import {
+ fetchOfflineLocalArtistCatalogChunk,
+ fetchOfflineLocalStarredArtists,
+ offlineLocalBrowseEnabled,
+} from '../utils/offline/offlineLocalBrowse';
/** Local-index artist catalog buffer grows by this many rows per background SQL chunk. */
export const ARTIST_CATALOG_CHUNK_SIZE = 200;
@@ -25,6 +32,8 @@ export function useArtistsBrowseCatalog({
starredOnly,
musicLibraryFilterVersion,
}: UseArtistsBrowseCatalogArgs) {
+ const offlineBrowseActive = useOfflineBrowseContext().active;
+ const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
const [catalogArtists, setCatalogArtists] = useState([]);
const [loading, setLoading] = useState(true);
const [catalogHasMore, setCatalogHasMore] = useState(false);
@@ -41,6 +50,27 @@ export function useArtistsBrowseCatalog({
catalogLoadingRef.current = true;
setCatalogLoadingMore(true);
try {
+ if (offlineBrowseActive) {
+ if (!offlineLocalBrowseEnabled(serverId)) return;
+ const chunk = await fetchOfflineLocalArtistCatalogChunk(
+ serverId,
+ catalogOffsetRef.current,
+ ARTIST_CATALOG_CHUNK_SIZE,
+ );
+ if (generation !== loadGenerationRef.current || chunk == null) return;
+ if (append) {
+ setCatalogArtists(prev => {
+ const merged = dedupeById([...prev, ...chunk.artists]);
+ catalogOffsetRef.current = merged.length;
+ return merged;
+ });
+ } else {
+ setCatalogArtists(chunk.artists);
+ catalogOffsetRef.current = chunk.artists.length;
+ }
+ setCatalogHasMore(chunk.hasMore);
+ return;
+ }
const chunk = await fetchLocalArtistCatalogChunk(
serverId,
catalogOffsetRef.current,
@@ -65,7 +95,7 @@ export function useArtistsBrowseCatalog({
setCatalogLoadingMore(false);
}
}
- }, [serverId]);
+ }, [offlineBrowseActive, serverId]);
useEffect(() => {
let cancelled = false;
@@ -80,6 +110,27 @@ export function useArtistsBrowseCatalog({
void (async () => {
try {
+ if (offlineBrowseActive) {
+ if (!cancelled && generation === loadGenerationRef.current) {
+ if (serverId && starredOnly && offlineLocalBrowseEnabled(serverId)) {
+ setCatalogArtists((await fetchOfflineLocalStarredArtists(serverId)) ?? []);
+ } else if (serverId && !starredOnly && offlineLocalBrowseEnabled(serverId)) {
+ const first = await fetchOfflineLocalArtistCatalogChunk(
+ serverId,
+ 0,
+ ARTIST_CATALOG_CHUNK_SIZE,
+ );
+ setCatalogArtists(first?.artists ?? []);
+ catalogOffsetRef.current = first?.artists.length ?? 0;
+ setCatalogHasMore(first?.hasMore ?? false);
+ } else {
+ setCatalogArtists([]);
+ setCatalogHasMore(false);
+ }
+ setBrowseMode('slice');
+ }
+ return;
+ }
if (starredOnly) {
if (!cancelled && generation === loadGenerationRef.current) {
setCatalogArtists(await fetchNetworkStarredArtists());
@@ -116,7 +167,7 @@ export function useArtistsBrowseCatalog({
return () => {
cancelled = true;
};
- }, [musicLibraryFilterVersion, indexEnabled, serverId, starredOnly]);
+ }, [musicLibraryFilterVersion, indexEnabled, offlineBrowseActive, offlineBrowseReloadTs, serverId, starredOnly]);
return {
catalogArtists,
diff --git a/src/hooks/useBrowseAlbumTextSearch.ts b/src/hooks/useBrowseAlbumTextSearch.ts
index a9a99df0..3a2f574f 100644
--- a/src/hooks/useBrowseAlbumTextSearch.ts
+++ b/src/hooks/useBrowseAlbumTextSearch.ts
@@ -8,6 +8,8 @@ import {
runLocalBrowseAlbums,
runNetworkBrowseAlbums,
} from '../utils/library/browseTextSearch';
+import { useOfflineBrowseContext } from './useOfflineBrowseContext';
+import { offlineLocalBrowseEnabled, searchOfflineLocalAlbums } from '../utils/offline/offlineLocalBrowse';
/**
* Debounced album title search with local-vs-network race when the
@@ -19,6 +21,7 @@ export function useBrowseAlbumTextSearch(
serverId: string | null | undefined,
losslessOnly = false,
) {
+ const offlineBrowseActive = useOfflineBrowseContext().active;
const [debouncedFilter, setDebouncedFilter] = useState('');
const [textSearchAlbums, setTextSearchAlbums] = useState(null);
const [textSearchLoading, setTextSearchLoading] = useState(false);
@@ -43,6 +46,15 @@ export function useBrowseAlbumTextSearch(
setTextSearchLoading(true);
void (async () => {
+ if (offlineBrowseActive) {
+ const albums = offlineLocalBrowseEnabled(serverId)
+ ? await searchOfflineLocalAlbums(serverId, q, losslessOnly)
+ : [];
+ if (isStale()) return;
+ setTextSearchAlbums(albums);
+ setTextSearchLoading(false);
+ return;
+ }
if (!indexEnabled) {
const albums = await runNetworkBrowseAlbums(q);
if (isStale()) return;
@@ -66,7 +78,7 @@ export function useBrowseAlbumTextSearch(
setTextSearchAlbums(outcome?.result ?? null);
setTextSearchLoading(false);
})();
- }, [debouncedFilter, indexEnabled, serverId, losslessOnly]);
+ }, [debouncedFilter, indexEnabled, offlineBrowseActive, serverId, losslessOnly]);
const effectiveFilter = textSearchAlbums != null ? '' : filter;
return { textSearchAlbums, textSearchLoading, effectiveFilter };
diff --git a/src/hooks/useBrowseArtistTextSearch.ts b/src/hooks/useBrowseArtistTextSearch.ts
index 362ee1ef..5feeba52 100644
--- a/src/hooks/useBrowseArtistTextSearch.ts
+++ b/src/hooks/useBrowseArtistTextSearch.ts
@@ -9,6 +9,8 @@ import {
runNetworkBrowseArtists,
type LibrarySearchSurface,
} from '../utils/library/browseTextSearch';
+import { useOfflineBrowseContext } from './useOfflineBrowseContext';
+import { offlineLocalBrowseEnabled, searchOfflineLocalArtists } from '../utils/offline/offlineLocalBrowse';
/**
* Debounced artist/composer name search with local-vs-network race when the
@@ -22,6 +24,7 @@ export function useBrowseArtistTextSearch(
serverId: string | null | undefined,
surface: LibrarySearchSurface = 'artists_browse',
) {
+ const offlineBrowseActive = useOfflineBrowseContext().active;
const [debouncedFilter, setDebouncedFilter] = useState('');
const [textSearchArtists, setTextSearchArtists] = useState(null);
const [textSearchLoading, setTextSearchLoading] = useState(false);
@@ -46,6 +49,15 @@ export function useBrowseArtistTextSearch(
setTextSearchLoading(true);
void (async () => {
+ if (offlineBrowseActive) {
+ const artists = offlineLocalBrowseEnabled(serverId)
+ ? await searchOfflineLocalArtists(serverId, q)
+ : [];
+ if (isStale()) return;
+ setTextSearchArtists(artists);
+ setTextSearchLoading(false);
+ return;
+ }
const outcome = await raceBrowseWithLocalFallback(
isStale,
() => runLocalBrowseArtists(serverId, q),
@@ -61,7 +73,7 @@ export function useBrowseArtistTextSearch(
setTextSearchArtists(outcome?.result ?? null);
setTextSearchLoading(false);
})();
- }, [debouncedFilter, indexEnabled, serverId, surface]);
+ }, [debouncedFilter, indexEnabled, offlineBrowseActive, serverId, surface]);
const effectiveFilter = textSearchArtists != null ? '' : filter;
return { textSearchArtists, textSearchLoading, effectiveFilter };
diff --git a/src/hooks/useConnectionStatus.test.ts b/src/hooks/useConnectionStatus.test.ts
index abebee3c..42820af6 100644
--- a/src/hooks/useConnectionStatus.test.ts
+++ b/src/hooks/useConnectionStatus.test.ts
@@ -17,11 +17,13 @@ vi.mock('@/utils/perf/perfFlags', () => ({
}));
import { pingWithCredentials } from '@/api/subsonic';
+import { useDevOfflineBrowseStore } from '@/store/devOfflineBrowseStore';
import { useConnectionStatus } from './useConnectionStatus';
beforeEach(() => {
resetAuthStore();
invalidateReachableEndpointCache();
+ useDevOfflineBrowseStore.getState().setForceOffline(false);
vi.mocked(pingWithCredentials).mockReset();
});
@@ -133,3 +135,39 @@ describe('useConnectionStatus online event', () => {
expect(vi.mocked(pingWithCredentials).mock.calls.length).toBeGreaterThanOrEqual(2);
});
});
+
+describe('useConnectionStatus DEV offline toggle', () => {
+ it('does not probe again on mount beyond the polling effect', async () => {
+ seedDualAddressServer();
+ vi.mocked(pingWithCredentials).mockResolvedValue({
+ ok: true,
+ type: 'navidrome',
+ serverVersion: '0.55.0',
+ openSubsonic: true,
+ });
+
+ renderHook(() => useConnectionStatus());
+ await waitFor(() => expect(vi.mocked(pingWithCredentials).mock.calls.length).toBeGreaterThanOrEqual(1));
+ const callsAfterMount = vi.mocked(pingWithCredentials).mock.calls.length;
+ await new Promise(r => setTimeout(r, 20));
+ expect(vi.mocked(pingWithCredentials).mock.calls.length).toBe(callsAfterMount);
+ });
+
+ it('disconnects on force-offline toggle without an extra probe', async () => {
+ seedDualAddressServer();
+ vi.mocked(pingWithCredentials).mockResolvedValue({
+ ok: true,
+ type: 'navidrome',
+ serverVersion: '0.55.0',
+ openSubsonic: true,
+ });
+
+ const { result } = renderHook(() => useConnectionStatus());
+ await waitFor(() => expect(result.current.status).toBe('connected'));
+ const callsBeforeToggle = vi.mocked(pingWithCredentials).mock.calls.length;
+
+ act(() => useDevOfflineBrowseStore.getState().setForceOffline(true));
+ await waitFor(() => expect(result.current.status).toBe('disconnected'));
+ expect(vi.mocked(pingWithCredentials).mock.calls.length).toBe(callsBeforeToggle);
+ });
+});
diff --git a/src/hooks/useConnectionStatus.ts b/src/hooks/useConnectionStatus.ts
index 6aabf9ab..afbf6deb 100644
--- a/src/hooks/useConnectionStatus.ts
+++ b/src/hooks/useConnectionStatus.ts
@@ -10,6 +10,10 @@ import {
} from '../utils/server/serverEndpoint';
import { setActiveServerReachable } from '../utils/network/activeServerReachability';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
+import {
+ isDevOfflineBrowseForced,
+ useDevOfflineBrowseStore,
+} from '../store/devOfflineBrowseStore';
// Backward-compatible re-export for call sites that still import from the hook.
export { isLanUrl };
@@ -18,6 +22,7 @@ export type ConnectionStatus = 'connected' | 'disconnected' | 'checking';
export function useConnectionStatus() {
const perfFlags = usePerfProbeFlags();
+ const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
const [status, setStatus] = useState('checking');
const [isRetrying, setIsRetrying] = useState(false);
// Tracks the kind of endpoint the last successful probe answered on so the
@@ -26,8 +31,15 @@ export function useConnectionStatus() {
// public alternate must read as 'public', not 'local'.
const [activeEndpointKind, setActiveEndpointKind] = useState(null);
const intervalRef = useRef | null>(null);
+ const prevDevForceOfflineRef = useRef(null);
const check = useCallback(async () => {
+ if (isDevOfflineBrowseForced()) {
+ setActiveServerReachable(false);
+ setStatus('disconnected');
+ return;
+ }
+
const server = useAuthStore.getState().getActiveServer();
if (!server) {
setActiveServerReachable(false);
@@ -75,14 +87,47 @@ export function useConnectionStatus() {
setIsRetrying(false);
}, [check]);
+ // DEV offline toggle: react to transitions only — the polling effect already
+ // probes on mount; an unconditional check() here doubled probes and ignored
+ // disableBackgroundPolling (PlayerBar tests, perf-flagged runs).
+ useEffect(() => {
+ if (!import.meta.env.DEV) return;
+
+ if (prevDevForceOfflineRef.current === null) {
+ prevDevForceOfflineRef.current = devForceOffline;
+ if (devForceOffline) {
+ setActiveServerReachable(false);
+ setStatus('disconnected');
+ }
+ return;
+ }
+
+ if (prevDevForceOfflineRef.current === devForceOffline) return;
+ prevDevForceOfflineRef.current = devForceOffline;
+
+ if (devForceOffline) {
+ setActiveServerReachable(false);
+ setStatus('disconnected');
+ return;
+ }
+ if (!perfFlags.disableBackgroundPolling) {
+ void check();
+ }
+ }, [devForceOffline, check, perfFlags.disableBackgroundPolling]);
+
useEffect(() => {
if (perfFlags.disableBackgroundPolling) {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
- setActiveServerReachable(true);
- setStatus('connected');
+ if (isDevOfflineBrowseForced()) {
+ setActiveServerReachable(false);
+ setStatus('disconnected');
+ } else {
+ setActiveServerReachable(true);
+ setStatus('connected');
+ }
return;
}
check();
@@ -108,7 +153,7 @@ export function useConnectionStatus() {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
- }, [check, perfFlags.disableBackgroundPolling]);
+ }, [check, devForceOffline, perfFlags.disableBackgroundPolling]);
const server = useAuthStore(s => s.getActiveServer());
const servers = useAuthStore(s => s.servers);
diff --git a/src/hooks/useFavoritesData.ts b/src/hooks/useFavoritesData.ts
index f3ffef63..11bd9222 100644
--- a/src/hooks/useFavoritesData.ts
+++ b/src/hooks/useFavoritesData.ts
@@ -9,10 +9,12 @@ import { usePlayerStore } from '../store/playerStore';
import type { TopFavoriteArtist } from '../components/favorites/TopFavoriteArtists';
import { useConnectionStatus } from './useConnectionStatus';
import { isActiveServerReachable } from '../utils/network/activeServerReachability';
+import { useOfflineBrowseContext } from './useOfflineBrowseContext';
+import { useOfflineBrowseReloadToken } from './useOfflineBrowseReloadToken';
import {
loadStarredFromAllLibraryIndexes,
loadStarredFromAllServersOnline,
-} from '../utils/offline/favoritesOfflineBrowse';
+} from '../utils/offline/offlineStarredLoad';
export interface FavoritesDataResult {
albums: SubsonicAlbum[];
@@ -43,6 +45,8 @@ export function useFavoritesData(): FavoritesDataResult {
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const servers = useAuthStore(s => s.servers);
const { status: connStatus } = useConnectionStatus();
+ const offlineBrowseActive = useOfflineBrowseContext().active;
+ const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
const starredOverrides = usePlayerStore(s => s.starredOverrides);
useEffect(() => {
@@ -76,7 +80,7 @@ export function useFavoritesData(): FavoritesDataResult {
if (favoritesOfflineEnabled) {
try {
- applyStarred(await loadStarredFromAllLibraryIndexes());
+ applyStarred(await loadStarredFromAllLibraryIndexes(offlineBrowseActive));
} catch { /* ignore */ }
if (!cancelled) setLoading(false);
@@ -100,9 +104,8 @@ export function useFavoritesData(): FavoritesDataResult {
void loadAll();
return () => { cancelled = true; };
- }, [musicLibraryFilterVersion, connStatus, favoritesOfflineEnabled, servers]);
+ }, [musicLibraryFilterVersion, connStatus, favoritesOfflineEnabled, offlineBrowseActive, offlineBrowseReloadTs, servers]);
- // ── Top Favorite Artists aggregated from favorited songs ─────────────
const topFavoriteArtists = useMemo(() => {
const counts = new Map();
for (const s of songs) {
diff --git a/src/hooks/useOfflineAutoNav.ts b/src/hooks/useOfflineAutoNav.ts
index f6243ef4..c952748a 100644
--- a/src/hooks/useOfflineAutoNav.ts
+++ b/src/hooks/useOfflineAutoNav.ts
@@ -1,24 +1,29 @@
import { useEffect, useRef } from 'react';
-import type { NavigateFunction } from 'react-router-dom';
+import type { Location, NavigateFunction } from 'react-router-dom';
+import { resolveOfflineDisconnectNavAction } from '../utils/offline/offlineBrowseRouting';
type ConnStatus = 'connected' | 'disconnected' | 'connecting' | 'unknown';
+type OfflineAutoNavContext = {
+ favoritesOfflineBrowse: boolean;
+ localLibraryBrowse: boolean;
+ playerStatsBrowse: boolean;
+ playlistsOfflineBrowse: boolean;
+ hasManualOfflineContent: boolean;
+};
+
/**
- * Auto-route the user between offline-capable pages and main pages based on
- * connection status:
- * - Disconnect with manual offline pins → push `/offline`.
- * - Disconnect with favorites offline browse enabled → push `/favorites`.
- * - Reconnect while sitting on `/offline` or `/favorites` → push back to `/`.
+ * On disconnect:
+ * - No offline browse content → stay on the current page (banner only).
+ * - Offline-capable route → stay and bump location state so data hooks reload.
+ * - Otherwise → redirect to All Albums.
*
- * Only fires on transitions (not on every render). Reconnect-bounce is
- * gated on `prev === 'disconnected'` so a user who navigates to `/offline`
- * manually while online stays there.
+ * Only runs on connection transitions, not every render.
*/
export function useOfflineAutoNav(
connStatus: ConnStatus | string,
- hasManualOfflineContent: boolean,
- favoritesOfflineBrowse: boolean,
- pathname: string,
+ ctx: OfflineAutoNavContext,
+ location: Pick,
navigate: NavigateFunction,
): void {
const prevConnStatus = useRef(connStatus);
@@ -26,25 +31,46 @@ export function useOfflineAutoNav(
const prev = prevConnStatus.current;
prevConnStatus.current = connStatus;
- if (connStatus === 'disconnected' && prev !== 'disconnected') {
- if (hasManualOfflineContent) {
- navigate('/offline', { replace: true });
- } else if (favoritesOfflineBrowse) {
- navigate('/favorites', { replace: true });
- }
- }
- if (
- connStatus === 'connected'
- && prev === 'disconnected'
- && (pathname === '/offline' || pathname === '/favorites')
- ) {
- navigate('/', { replace: true });
+ if (connStatus !== 'disconnected' || prev === 'disconnected') return;
+
+ const action = resolveOfflineDisconnectNavAction(
+ location.pathname,
+ ctx.favoritesOfflineBrowse,
+ ctx.localLibraryBrowse,
+ ctx.playerStatsBrowse,
+ ctx.playlistsOfflineBrowse,
+ ctx.hasManualOfflineContent,
+ );
+
+ if (action.kind === 'stay') return;
+
+ if (action.kind === 'stay-reload') {
+ navigate(
+ { pathname: location.pathname, search: location.search },
+ {
+ replace: true,
+ state: {
+ ...(typeof location.state === 'object' && location.state != null
+ ? location.state as Record
+ : {}),
+ offlineBrowseReloadTs: Date.now(),
+ },
+ },
+ );
+ return;
}
+
+ navigate(action.to, { replace: true });
}, [
connStatus,
- hasManualOfflineContent,
- favoritesOfflineBrowse,
- pathname,
+ ctx.favoritesOfflineBrowse,
+ ctx.localLibraryBrowse,
+ ctx.playerStatsBrowse,
+ ctx.playlistsOfflineBrowse,
+ ctx.hasManualOfflineContent,
+ location.pathname,
+ location.search,
+ location.state,
navigate,
]);
}
diff --git a/src/hooks/useOfflineBrowseContext.ts b/src/hooks/useOfflineBrowseContext.ts
new file mode 100644
index 00000000..ad0adb17
--- /dev/null
+++ b/src/hooks/useOfflineBrowseContext.ts
@@ -0,0 +1,36 @@
+import { useAuthStore } from '../store/authStore';
+import { useOfflineStore } from '../store/offlineStore';
+import { useConnectionStatus } from './useConnectionStatus';
+import { usePlayerStatsRecordingEnabled } from './usePlayerStatsRecordingEnabled';
+import { hasOfflineBrowsingContent } from '../utils/offline/favoritesOfflineBrowse';
+import { useOfflineBrowseActive } from '../utils/offline/offlineBrowseMode';
+import {
+ buildOfflineBrowseContext,
+ computeOfflineBrowseCapabilities,
+ type OfflineBrowseContext,
+} from '../utils/offline/offlineBrowseContext';
+
+/** Single subscription for shell and pages: offline browse mode + capabilities. */
+export function useOfflineBrowseContext(): OfflineBrowseContext {
+ const active = useOfflineBrowseActive();
+ const serverId = useAuthStore(s => s.activeServerId);
+ const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
+ const offlineAlbums = useOfflineStore(s => s.albums);
+ const playerStats = usePlayerStatsRecordingEnabled();
+ const { status: connStatus } = useConnectionStatus();
+
+ const capabilities = computeOfflineBrowseCapabilities({
+ activeServerId: serverId,
+ favoritesOfflineEnabled,
+ offlineAlbums,
+ playerStats,
+ });
+
+ return buildOfflineBrowseContext({
+ active,
+ serverId,
+ capabilities,
+ connStatus,
+ hasBrowsingContent: hasOfflineBrowsingContent(offlineAlbums),
+ });
+}
diff --git a/src/hooks/useOfflineBrowseReloadToken.ts b/src/hooks/useOfflineBrowseReloadToken.ts
new file mode 100644
index 00000000..5e7957de
--- /dev/null
+++ b/src/hooks/useOfflineBrowseReloadToken.ts
@@ -0,0 +1,8 @@
+import { useLocation } from 'react-router-dom';
+
+/** Bumps when disconnect fork chooses stay-reload ({@link useOfflineAutoNav}). */
+export function useOfflineBrowseReloadToken(): number | undefined {
+ const location = useLocation();
+ const state = location.state as { offlineBrowseReloadTs?: number } | null;
+ return state?.offlineBrowseReloadTs;
+}
diff --git a/src/hooks/useOfflineLibraryFilterSuspend.ts b/src/hooks/useOfflineLibraryFilterSuspend.ts
new file mode 100644
index 00000000..9c102355
--- /dev/null
+++ b/src/hooks/useOfflineLibraryFilterSuspend.ts
@@ -0,0 +1,27 @@
+import { useEffect, useRef } from 'react';
+import { useOfflineBrowseContext } from './useOfflineBrowseContext';
+import {
+ restoreMusicLibraryFiltersAfterOffline,
+ suspendMusicLibraryFiltersForOffline,
+} from '../utils/offline/offlineLibraryFilterSuspend';
+
+/** Disable scoped library browse offline; restore the picker value when back online. */
+export function useOfflineLibraryFilterSuspend(): void {
+ const offlineBrowseActive = useOfflineBrowseContext().active;
+ const prevOfflineRef = useRef(null);
+
+ useEffect(() => {
+ const prev = prevOfflineRef.current;
+ prevOfflineRef.current = offlineBrowseActive;
+
+ if (prev === null) {
+ if (offlineBrowseActive) suspendMusicLibraryFiltersForOffline();
+ return;
+ }
+ if (offlineBrowseActive && !prev) {
+ suspendMusicLibraryFiltersForOffline();
+ } else if (!offlineBrowseActive && prev) {
+ restoreMusicLibraryFiltersAfterOffline();
+ }
+ }, [offlineBrowseActive]);
+}
diff --git a/src/hooks/usePlaylistsLibraryScopeCounts.ts b/src/hooks/usePlaylistsLibraryScopeCounts.ts
index f6bd0ff7..99c03d03 100644
--- a/src/hooks/usePlaylistsLibraryScopeCounts.ts
+++ b/src/hooks/usePlaylistsLibraryScopeCounts.ts
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { filterSongsToActiveLibrary } from '../api/subsonicLibrary';
import { getPlaylist } from '../api/subsonicPlaylists';
import type { SubsonicPlaylist } from '../api/subsonicTypes';
+import { useOfflineBrowseContext } from './useOfflineBrowseContext';
export interface PlaylistsLibraryScopeCountsResult {
filteredSongCountByPlaylist: Record;
@@ -20,6 +21,7 @@ export function usePlaylistsLibraryScopeCounts(
): PlaylistsLibraryScopeCountsResult {
const [filteredSongCountByPlaylist, setFilteredSongCountByPlaylist] = useState>({});
const [filteredDurationByPlaylist, setFilteredDurationByPlaylist] = useState>({});
+ const offlineBrowseActive = useOfflineBrowseContext().active;
useEffect(() => {
let cancelled = false;
@@ -31,6 +33,19 @@ export function usePlaylistsLibraryScopeCounts(
}
return;
}
+ if (offlineBrowseActive) {
+ const next: Record = {};
+ const nextDuration: Record = {};
+ for (const pl of playlists) {
+ next[pl.id] = pl.songCount;
+ nextDuration[pl.id] = pl.duration;
+ }
+ if (!cancelled) {
+ setFilteredSongCountByPlaylist(next);
+ setFilteredDurationByPlaylist(nextDuration);
+ }
+ return;
+ }
const ids = playlists.map((pl) => pl.id);
const next: Record = {};
const nextDuration: Record = {};
@@ -60,7 +75,7 @@ export function usePlaylistsLibraryScopeCounts(
};
run();
return () => { cancelled = true; };
- }, [playlists, musicLibraryFilterVersion]);
+ }, [playlists, musicLibraryFilterVersion, offlineBrowseActive]);
return { filteredSongCountByPlaylist, filteredDurationByPlaylist };
}
diff --git a/src/hooks/useQueuePanelDrag.ts b/src/hooks/useQueuePanelDrag.ts
index d8e9950b..b708b2e9 100644
--- a/src/hooks/useQueuePanelDrag.ts
+++ b/src/hooks/useQueuePanelDrag.ts
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
-import { getAlbum } from '../api/subsonicLibrary';
+import { resolveAlbum, resolveMediaServerId } from '../utils/offline/offlineMediaResolve';
+import { songToTrack } from '../utils/playback/songToTrack';
import { useDragDrop, registerQueueDragHitTest } from '../contexts/DragDropContext';
import { usePlayerStore } from '../store/playerStore';
import type { Track } from '../store/playerStoreTypes';
@@ -77,13 +78,11 @@ export function useQueuePanelDrag({
} else if (parsedData.type === 'songs') {
enqueueAt(parsedData.tracks as Track[], insertIdx);
} else if (parsedData.type === 'album') {
- const albumData = await getAlbum(parsedData.id);
- const tracks: Track[] = albumData.songs.map((s: any) => ({
- id: s.id, title: s.title, artist: s.artist, album: s.album,
- albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
- year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating, genre: s.genre,
- }));
- enqueueAt(tracks, insertIdx);
+ const serverId = resolveMediaServerId(parsedData.serverId);
+ if (!serverId) return;
+ const albumData = await resolveAlbum(serverId, parsedData.id);
+ if (!albumData) return;
+ enqueueAt(albumData.songs.map(songToTrack), insertIdx);
}
};
diff --git a/src/hooks/useSongBrowseList.test.ts b/src/hooks/useSongBrowseList.test.ts
index 0a649abe..51d30d0b 100644
--- a/src/hooks/useSongBrowseList.test.ts
+++ b/src/hooks/useSongBrowseList.test.ts
@@ -18,6 +18,10 @@ vi.mock('../utils/library/advancedSearchLocal', () => ({
runLocalSongBrowse: vi.fn(async () => []),
}));
+vi.mock('./useOfflineBrowseReloadToken', () => ({
+ useOfflineBrowseReloadToken: () => undefined,
+}));
+
vi.mock('../utils/library/browseTextSearch', () => ({
BROWSE_TEXT_DEBOUNCE_NETWORK_MS: 10,
BROWSE_TEXT_DEBOUNCE_RACE_MS: 10,
diff --git a/src/hooks/useSongBrowseList.ts b/src/hooks/useSongBrowseList.ts
index d635467c..d1179675 100644
--- a/src/hooks/useSongBrowseList.ts
+++ b/src/hooks/useSongBrowseList.ts
@@ -14,6 +14,13 @@ import {
} from '../utils/library/browseTextSearch';
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
+import { useOfflineBrowseContext } from './useOfflineBrowseContext';
+import { useOfflineBrowseReloadToken } from './useOfflineBrowseReloadToken';
+import {
+ fetchOfflineLocalBrowsableSongPage,
+ offlineLocalBrowseEnabled,
+ searchOfflineLocalBrowsableSongs,
+} from '../utils/offline/offlineLocalBrowse';
const PAGE_SIZE = 50;
@@ -50,7 +57,10 @@ type UseSongBrowseListArgs = {
/** Tracks hub song browse — all-library paging or filtered text search. */
export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseSongBrowseListArgs) {
const serverId = useAuthStore(s => s.activeServerId);
+ const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
+ const offlineBrowseActive = useOfflineBrowseContext().active;
+ const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
const [debouncedQuery, setDebouncedQuery] = useState(
() => initialRestore?.query.trim() ?? searchQuery.trim(),
@@ -73,7 +83,6 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
const restoreQueryHoldRef = useRef(
initialRestore?.query.trim() ? initialRestore.query.trim() : null,
);
-
useEffect(() => {
if (!enabled) return;
const incoming = searchQuery.trim();
@@ -88,6 +97,15 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
const fetchSongPage = useCallback(
async (q: string, pageOffset: number, isStale: () => boolean): Promise => {
+ if (offlineBrowseActive && serverId && offlineLocalBrowseEnabled(serverId)) {
+ localSearchModeRef.current = true;
+ if (q === '') {
+ const page = await fetchOfflineLocalBrowsableSongPage(serverId, pageOffset, PAGE_SIZE);
+ return page?.songs ?? [];
+ }
+ return (await searchOfflineLocalBrowsableSongs(serverId, q, pageOffset, PAGE_SIZE)) ?? [];
+ }
+
if (q === '') {
return fetchBrowseAllPage(serverId, pageOffset);
}
@@ -123,7 +141,7 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
return (await runNetworkBrowseSongPage(q, pageOffset, PAGE_SIZE)) ?? [];
},
- [indexEnabled, serverId],
+ [indexEnabled, musicLibraryFilterVersion, offlineBrowseActive, serverId],
);
useEffect(() => {
@@ -171,7 +189,7 @@ export function useSongBrowseList({ enabled, searchQuery, initialRestore }: UseS
return () => {
cancelled = true;
};
- }, [debouncedQuery, searchQuery, fetchSongPage, enabled]);
+ }, [debouncedQuery, searchQuery, fetchSongPage, enabled, musicLibraryFilterVersion, offlineBrowseReloadTs]);
const loadMore = useCallback(async () => {
if (!enabled || loading || !hasMore) return;
diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx
index 073d8b3d..90f6aca7 100644
--- a/src/pages/AlbumDetail.tsx
+++ b/src/pages/AlbumDetail.tsx
@@ -46,6 +46,8 @@ import LosslessModeBanner from '../components/LosslessModeBanner';
import { isLosslessSuffix } from '../utils/library/losslessFormats';
import { isLosslessMode } from '../utils/library/losslessMode';
import { readDetailServerId } from '../utils/navigation/detailServerScope';
+import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
+import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
export default function AlbumDetail() {
const { t } = useTranslation();
@@ -76,6 +78,8 @@ export default function AlbumDetail() {
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown';
+ const offlineCtx = useOfflineBrowseContext();
+ const albumActionPolicy = offlineActionPolicy('albumDetail', offlineCtx.active);
const [albumEntityRating, setAlbumEntityRating] = useState(0);
const [filterText, setFilterText] = useState('');
@@ -369,6 +373,7 @@ const handleShuffleAll = () => {
entityRatingValue={albumEntityRating}
onEntityRatingChange={handleAlbumEntityRating}
entityRatingSupport={albumEntityRatingSupport}
+ actionPolicy={albumActionPolicy}
/>
{losslessOnly && }
@@ -381,6 +386,7 @@ const handleShuffleAll = () => {
showPlPicker={showPlPicker}
setShowPlPicker={setShowPlPicker}
t={t}
+ actionPolicy={albumActionPolicy}
/>
)}
@@ -402,6 +408,7 @@ const handleShuffleAll = () => {
sortKey={sortKey}
sortDir={sortDir}
onSort={handleSort}
+ actionPolicy={albumActionPolicy}
/>
{relatedAlbums.length > 0 && (
diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx
index 4ccdb5c1..a5cdf4c9 100644
--- a/src/pages/Albums.tsx
+++ b/src/pages/Albums.tsx
@@ -1,5 +1,5 @@
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
-import { getAlbum } from '../api/subsonicLibrary';
+import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
import { songToTrack } from '../utils/playback/songToTrack';
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react';
import AlbumCard from '../components/AlbumCard';
@@ -233,8 +233,10 @@ export default function Albums() {
const handleEnqueueSelected = async () => {
if (selectedAlbums.length === 0) return;
try {
- // Parallel — Navidrome handles concurrent getAlbum requests fine.
- const results = await Promise.all(selectedAlbums.map(a => getAlbum(a.id).catch(() => null)));
+ // Parallel album resolves — Navidrome handles concurrent requests fine.
+ const results = await Promise.all(
+ selectedAlbums.map(a => resolveAlbum(serverId, a.id).catch(() => null)),
+ );
const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []);
if (tracks.length > 0) {
enqueue(tracks);
@@ -277,7 +279,8 @@ export default function Albums() {
let queued = 0;
for (const album of selectedAlbums) {
try {
- const detail = await getAlbum(album.id);
+ const detail = await resolveAlbum(serverId, album.id);
+ if (!detail) throw new Error('album unavailable');
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
queued++;
} catch {
diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx
index 063b46a7..82598c84 100644
--- a/src/pages/ArtistDetail.tsx
+++ b/src/pages/ArtistDetail.tsx
@@ -2,7 +2,6 @@ import { uploadArtistImage } from '../api/subsonicPlaylists';
import { useCoverArt } from '../cover/useCoverArt';
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
import { setRating, star, unstar } from '../api/subsonicStarRating';
-import { getAlbum } from '../api/subsonicLibrary';
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
@@ -31,8 +30,11 @@ import {
import { useArtistDetailData } from '../hooks/useArtistDetailData';
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
import {
+ fetchArtistDetailTracks,
runArtistDetailPlayAll, runArtistDetailShuffle, runArtistDetailStartRadio,
} from '../utils/componentHelpers/runArtistDetailPlay';
+import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
+import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
import {
runArtistEntityRating, runArtistToggleStar, runArtistShare, runArtistImageUpload,
} from '../utils/componentHelpers/runArtistDetailActions';
@@ -101,6 +103,8 @@ export default function ArtistDetail() {
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const artistEntityRatingSupport = entityRatingSupportByServer[activeServerId] ?? 'unknown';
+ const offlineCtx = useOfflineBrowseContext();
+ const artistActionPolicy = offlineActionPolicy('artistDetail', offlineCtx.active);
const [artistEntityRating, setArtistEntityRating] = useState(0);
@@ -122,8 +126,12 @@ export default function ArtistDetail() {
const toggleStar = () => runArtistToggleStar({ artist, isStarred, setIsStarred });
- const handlePlayAll = () => runArtistDetailPlayAll({ albums, setPlayAllLoading, playTrack });
- const handleShuffle = () => runArtistDetailShuffle({ albums, setPlayAllLoading, playTrack });
+ const handlePlayAll = () => runArtistDetailPlayAll({
+ albums, serverId: activeServerId, setPlayAllLoading, playTrack,
+ });
+ const handleShuffle = () => runArtistDetailShuffle({
+ albums, serverId: activeServerId, setPlayAllLoading, playTrack,
+ });
const handleStartRadio = () => {
if (!artist) return;
return runArtistDetailStartRadio({ artist, t, setRadioLoading, playTrack, enqueue });
@@ -139,9 +147,7 @@ export default function ArtistDetail() {
setPlayAllLoading(true);
try {
// Get all artist tracks ordered by album and track number
- const results = await Promise.all(albums.map(a => getAlbum(a.id)));
- const sorted = [...results].sort((a, b) => (a.album.year ?? 0) - (b.album.year ?? 0));
- const allTracks = sorted.flatMap(r => [...r.songs].sort((a, b) => (a.track ?? 0) - (b.track ?? 0))).map(songToTrack);
+ const allTracks = await fetchArtistDetailTracks(albums, activeServerId);
// Top songs from clicked index onward
const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack);
@@ -314,6 +320,7 @@ export default function ArtistDetail() {
coverRevision={coverRevision}
headerCoverFailed={headerCoverFailed}
setHeaderCoverFailed={setHeaderCoverFailed}
+ actionPolicy={artistActionPolicy}
/>
{losslessOnly && }
diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx
index 6aacf716..7e27e2ec 100644
--- a/src/pages/Home.tsx
+++ b/src/pages/Home.tsx
@@ -22,10 +22,16 @@ import { useLibraryCoverPrefetch } from '../cover/useLibraryCoverPrefetch';
import { primeAlbumCoversForDisplay, warmHomeMainstageCovers } from '../cover/warmDiskPeek';
import { readBecauseYouLikeCache } from '../store/becauseYouLikeCache';
import {
+ isHomeFeedSnapshotEmpty,
readHomeFeedCache,
+ readHomeFeedCacheStale,
writeHomeFeedCache,
type HomeFeedSnapshot,
} from '../store/homeFeedCache';
+import { useConnectionStatus } from '../hooks/useConnectionStatus';
+import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
+import { useOfflineBrowseReloadToken } from '../hooks/useOfflineBrowseReloadToken';
+import { useDevOfflineBrowseStore } from '../store/devOfflineBrowseStore';
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
const HOME_RANDOM_FETCH = 100;
@@ -52,7 +58,8 @@ const HOME_ARTWORK_VISIBLE_ROW_BUDGET_WHEN_ENABLED = 8;
function getInitialHomeFeed(): HomeFeedSnapshot | null {
const { activeServerId, musicLibraryFilterVersion } = useAuthStore.getState();
if (!activeServerId) return null;
- return readHomeFeedCache(activeServerId, musicLibraryFilterVersion);
+ return readHomeFeedCache(activeServerId, musicLibraryFilterVersion)
+ ?? readHomeFeedCacheStale(activeServerId);
}
export default function Home() {
@@ -63,6 +70,10 @@ export default function Home() {
const homeSections = useHomeStore(s => s.sections);
const activeServerId = useAuthStore(s => s.activeServerId);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
+ const connStatus = useConnectionStatus().status;
+ const devForceOffline = useDevOfflineBrowseStore(s => s.forceOffline);
+ const offlineBrowseActive = useOfflineBrowseContext().active;
+ const offlineBrowseReloadTs = useOfflineBrowseReloadToken();
// Mix-rating deps intentionally NOT subscribed here — they change during Zustand
// rehydration and would trigger a second useEffect fire right after the first,
// showing the cached home feed briefly and then replacing it (~500 ms later)
@@ -119,7 +130,6 @@ export default function Home() {
useEffect(() => {
if (!activeServerId) return;
let cancelled = false;
-
const fetchFreshHomeFeed = async (): Promise => {
const mixCfg = getMixMinRatingsConfigFromAuth();
const albumMix =
@@ -154,7 +164,8 @@ export default function Home() {
};
};
- const cached = readHomeFeedCache(activeServerId, musicLibraryFilterVersion);
+ const cached = readHomeFeedCache(activeServerId, musicLibraryFilterVersion)
+ ?? (offlineBrowseActive ? readHomeFeedCacheStale(activeServerId) : null);
if (cached) {
// When lazy initializers already pre-populated state from this same
// snapshot, re-applying it would only create new array references and
@@ -168,27 +179,37 @@ export default function Home() {
});
// Keep the current visit visually stable, but prepare fresh data so the
// next re-enter opens with a newer snapshot immediately.
- void (async () => {
- try {
- const fresh = await fetchFreshHomeFeed();
- if (!fresh || cancelled) return;
- writeHomeFeedCache(fresh);
- void warmHomeMainstageCovers(fresh);
- } catch {
- /* ignore */
- }
- })();
+ if (!offlineBrowseActive) {
+ void (async () => {
+ try {
+ const fresh = await fetchFreshHomeFeed();
+ if (!fresh || cancelled || isHomeFeedSnapshotEmpty(fresh)) return;
+ writeHomeFeedCache(fresh);
+ void warmHomeMainstageCovers(fresh);
+ } catch {
+ /* ignore */
+ }
+ })();
+ }
return () => {
cancelled = true;
};
}
+ const stale = offlineBrowseActive ? readHomeFeedCacheStale(activeServerId) : null;
+ if (stale) {
+ applyFeedSnapshot(stale);
+ setLoading(false);
+ return () => { cancelled = true; };
+ }
+
setLoading(true);
(async () => {
try {
const snap = await fetchFreshHomeFeed();
if (!snap) return;
if (cancelled) return;
+ if (offlineBrowseActive && isHomeFeedSnapshotEmpty(snap)) return;
writeHomeFeedCache(snap);
applyFeedSnapshot(snap);
if (!cancelled) setLoading(false);
@@ -208,8 +229,20 @@ export default function Home() {
activeServerId,
musicLibraryFilterVersion,
homeSections,
+ offlineBrowseActive,
+ offlineBrowseReloadTs,
]); // eslint-disable-line react-hooks/exhaustive-deps
+ /** When offline toggles without a library-filter bump, re-apply stale cache if the feed was cleared. */
+ useEffect(() => {
+ if (!activeServerId || !offlineBrowseActive) return;
+ const stale = readHomeFeedCacheStale(activeServerId);
+ if (!stale || isHomeFeedSnapshotEmpty(stale)) return;
+ if (recent.length > 0 || random.length > 0 || heroAlbums.length > 0) return;
+ applyFeedSnapshot(stale);
+ setLoading(false);
+ }, [activeServerId, connStatus, devForceOffline, offlineBrowseActive]); // eslint-disable-line react-hooks/exhaustive-deps
+
const loadMore = async (
type: 'starred' | 'newest' | 'random' | 'frequent' | 'recent',
currentList: SubsonicAlbum[],
diff --git a/src/pages/LosslessAlbums.tsx b/src/pages/LosslessAlbums.tsx
index 4c0e558d..1a7ac18e 100644
--- a/src/pages/LosslessAlbums.tsx
+++ b/src/pages/LosslessAlbums.tsx
@@ -1,5 +1,5 @@
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
-import { getAlbum } from '../api/subsonicLibrary';
+import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -232,7 +232,9 @@ export default function LosslessAlbums() {
const handleEnqueueSelected = async () => {
if (selectedAlbums.length === 0) return;
try {
- const results = await Promise.all(selectedAlbums.map(a => getAlbum(a.id).catch(() => null)));
+ const results = await Promise.all(
+ selectedAlbums.map(a => resolveAlbum(serverId, a.id).catch(() => null)),
+ );
const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []);
if (tracks.length > 0) {
enqueue(tracks);
@@ -248,7 +250,8 @@ export default function LosslessAlbums() {
let queued = 0;
for (const album of selectedAlbums) {
try {
- const detail = await getAlbum(album.id);
+ const detail = await resolveAlbum(serverId, album.id);
+ if (!detail) throw new Error('album unavailable');
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
queued++;
} catch {
diff --git a/src/pages/MostPlayed.tsx b/src/pages/MostPlayed.tsx
index eb05bbf5..4100af12 100644
--- a/src/pages/MostPlayed.tsx
+++ b/src/pages/MostPlayed.tsx
@@ -1,4 +1,5 @@
-import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
+import { getAlbumList } from '../api/subsonicLibrary';
+import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
@@ -87,17 +88,20 @@ export default function MostPlayed() {
const { t } = useTranslation();
const navigate = useNavigate();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
+ const activeServerId = useAuthStore(s => s.activeServerId);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const enqueue = usePlayerStore(s => s.enqueue);
const handleEnqueueAlbum = useCallback(async (albumId: string) => {
+ if (!activeServerId) return;
try {
- const data = await getAlbum(albumId);
+ const data = await resolveAlbum(activeServerId, albumId);
+ if (!data) return;
enqueue(data.songs.map(songToTrack));
} catch {
// Network failure — silent (toast would be too noisy for a hover action).
}
- }, [enqueue]);
+ }, [activeServerId, enqueue]);
const [albums, setAlbums] = useState([]);
const [loading, setLoading] = useState(true);
diff --git a/src/pages/NewReleases.tsx b/src/pages/NewReleases.tsx
index 12648d4b..fa360840 100644
--- a/src/pages/NewReleases.tsx
+++ b/src/pages/NewReleases.tsx
@@ -1,6 +1,7 @@
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
import { getAlbumsByGenre } from '../api/subsonicGenres';
-import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
+import { getAlbumList } from '../api/subsonicLibrary';
+import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { dedupeById } from '../utils/dedupeById';
import { useEffect, useLayoutEffect, useState, useCallback, useRef, useMemo } from 'react';
@@ -157,7 +158,8 @@ export default function NewReleases() {
let queued = 0;
for (const album of selectedAlbums) {
try {
- const detail = await getAlbum(album.id);
+ const detail = await resolveAlbum(serverId, album.id);
+ if (!detail) throw new Error('album unavailable');
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
queued++;
} catch {
diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx
index b1a8f94a..15bf5c5b 100644
--- a/src/pages/PlaylistDetail.tsx
+++ b/src/pages/PlaylistDetail.tsx
@@ -54,6 +54,8 @@ import { usePlaylistDerived } from '../hooks/usePlaylistDerived';
import { usePlaylistRouteEffects } from '../hooks/usePlaylistRouteEffects';
import { useBulkPlPickerOutsideClick } from '../hooks/useBulkPlPickerOutsideClick';
import { usePlaylistDnDReorder } from '../hooks/usePlaylistDnDReorder';
+import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
+import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
// ── Column configuration ──────────────────────────────────────────────────────
const PL_COLUMNS: readonly ColDef[] = [
@@ -179,13 +181,15 @@ export default function PlaylistDetail() {
// ── Load ─────────────────────────────────────────────────────
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
+ const { active: offlineBrowseActive } = useOfflineBrowseContext();
+ const actionPolicy = offlineActionPolicy('playlistDetail', offlineBrowseActive);
useEffect(() => {
if (!id) return;
runPlaylistLoad({
id, setLoading, setPlaylist, setSongs, setCustomCoverId, setRatings, setStarredSongs,
});
- }, [id, lastModified]);
+ }, [id, lastModified, offlineBrowseActive]);
// ── Meta edit ─────────────────────────────────────────────────
const handleSaveMeta = async (opts: {
@@ -280,6 +284,7 @@ export default function PlaylistDetail() {
offlineStatus={resolvedOfflineStatus}
offlineProgress={offlineProgress}
activeServerId={activeServerId}
+ actionPolicy={actionPolicy}
setEditingMeta={setEditingMeta}
setSearchOpen={setSearchOpen}
setSearchQuery={setSearchQuery}
diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx
index 37b27116..094f3903 100644
--- a/src/pages/Playlists.tsx
+++ b/src/pages/Playlists.tsx
@@ -1,4 +1,4 @@
-import { getPlaylist } from '../api/subsonicPlaylists';
+import { resolveMediaServerId, resolvePlaylist } from '../utils/offline/offlineMediaResolve';
import { getGenres } from '../api/subsonicGenres';
import { filterSongsToActiveLibrary } from '../api/subsonicLibrary';
import type { SubsonicPlaylist, SubsonicGenre } from '../api/subsonicTypes';
@@ -29,6 +29,8 @@ import PlaylistsHeader from '../components/playlists/PlaylistsHeader';
import PlaylistCard from '../components/playlists/PlaylistCard';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
+import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
+import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
function formatDuration(seconds: number): string {
return formatHumanHoursMinutes(seconds);
@@ -49,6 +51,9 @@ export default function Playlists() {
const activeServerId = useAuthStore(s => s.activeServerId);
const subsonicIdentityByServer = useAuthStore(s => s.subsonicServerIdentityByServer);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
+ const offlineCtx = useOfflineBrowseContext();
+ const offlineBrowseActive = offlineCtx.active;
+ const playlistsActionPolicy = offlineActionPolicy('playlistsHeader', offlineCtx.active);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
@@ -94,8 +99,10 @@ export default function Playlists() {
useEffect(() => {
fetchPlaylists().finally(() => setLoading(false));
- getGenres().then(setGenres).catch(() => {});
- }, [fetchPlaylists]);
+ if (!offlineBrowseActive) {
+ getGenres().then(setGenres).catch(() => {});
+ }
+ }, [fetchPlaylists, offlineBrowseActive]);
useEffect(() => {
if (creating) nameInputRef.current?.focus();
@@ -138,9 +145,14 @@ export default function Playlists() {
if (playingId === pl.id) return;
setPlayingId(pl.id);
try {
- const data = await getPlaylist(pl.id);
- const filteredSongs = await filterSongsToActiveLibrary(data.songs);
- const tracks = filteredSongs.map(songToTrack);
+ const serverId = resolveMediaServerId(activeServerId);
+ if (!serverId) return;
+ const data = await resolvePlaylist(serverId, pl.id);
+ if (!data) return;
+ const songs = offlineBrowseActive
+ ? data.songs
+ : await filterSongsToActiveLibrary(data.songs);
+ const tracks = songs.map(songToTrack);
if (tracks.length > 0) {
touchPlaylist(pl.id);
playTrack(tracks[0], tracks);
@@ -233,6 +245,7 @@ export default function Playlists() {
setEditingSmartId={setEditingSmartId}
setSmartFilters={setSmartFilters}
setGenreQuery={setGenreQuery}
+ actionPolicy={playlistsActionPolicy}
/>
{creatingSmart && (
diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx
index 3d8b31f9..a3dcb9f2 100644
--- a/src/pages/RandomAlbums.tsx
+++ b/src/pages/RandomAlbums.tsx
@@ -1,6 +1,7 @@
import { buildDownloadUrl } from '../api/subsonicStreamUrl';
import { getAlbumsByGenre } from '../api/subsonicGenres';
-import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
+import { getAlbumList } from '../api/subsonicLibrary';
+import { resolveAlbum } from '../utils/offline/offlineMediaResolve';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { dedupeById } from '../utils/dedupeById';
import { shuffleArray } from '../utils/playback/shuffleArray';
@@ -199,7 +200,8 @@ export default function RandomAlbums() {
let queued = 0;
for (const album of selectedAlbums) {
try {
- const detail = await getAlbum(album.id);
+ const detail = await resolveAlbum(serverId, album.id);
+ if (!detail) throw new Error('album unavailable');
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
queued++;
} catch {
diff --git a/src/pages/Statistics.tsx b/src/pages/Statistics.tsx
index 4e3ac266..68b1a5fd 100644
--- a/src/pages/Statistics.tsx
+++ b/src/pages/Statistics.tsx
@@ -2,6 +2,7 @@ import { fetchStatisticsFormatSample, fetchStatisticsLibraryAggregates, fetchSta
import { getAlbumList } from '../api/subsonicLibrary';
import type { SubsonicAlbum, SubsonicGenre } from '../api/subsonicTypes';
import React, { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
import { Share2 } from 'lucide-react';
import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration';
import AlbumRow from '../components/AlbumRow';
@@ -12,6 +13,8 @@ import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { useLocation } from 'react-router-dom';
import { lastfmIsConfigured, lastfmGetTopArtists, lastfmGetTopAlbums, lastfmGetTopTracks, lastfmGetRecentTracks, LastfmPeriod, LastfmTopArtist, LastfmTopAlbum, LastfmTopTrack, LastfmRecentTrack } from '../api/lastfm';
+import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
+import { usePlayerStatsRecordingEnabled } from '../hooks/usePlayerStatsRecordingEnabled';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function relativeTime(timestamp: number, t: (key: string, opts?: any) => string): string {
@@ -34,7 +37,10 @@ const PERIODS: { key: LastfmPeriod; label: string }[] = [
export default function Statistics() {
const { t } = useTranslation();
const location = useLocation();
+ const navigate = useNavigate();
const isPlayerStats = location.pathname === '/player-stats';
+ const offlineBrowseActive = useOfflineBrowseContext().active;
+ const playerStatsEnabled = usePlayerStatsRecordingEnabled();
const { lastfmSessionKey, lastfmUsername } = useAuthStore();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const [recent, setRecent] = useState([]);
@@ -62,6 +68,16 @@ export default function Statistics() {
const [lfmRecentLoading, setLfmRecentLoading] = useState(false);
useEffect(() => {
+ if (offlineBrowseActive && playerStatsEnabled && !isPlayerStats) {
+ navigate('/player-stats', { replace: true });
+ }
+ }, [offlineBrowseActive, playerStatsEnabled, isPlayerStats, navigate]);
+
+ useEffect(() => {
+ if (offlineBrowseActive || isPlayerStats) {
+ setLoading(false);
+ return;
+ }
fetchStatisticsOverview()
.then(d => {
setRecent(d.recent);
@@ -71,10 +87,11 @@ export default function Statistics() {
setLoading(false);
})
.catch(() => setLoading(false));
- }, [musicLibraryFilterVersion]);
+ }, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]);
// Background: playtime, album/song counts, genre insights (cached per server+library like rating prefetch)
useEffect(() => {
+ if (offlineBrowseActive || isPlayerStats) return;
let cancelled = false;
setTotalPlaytime(null);
setTotalAlbums(null);
@@ -101,10 +118,11 @@ export default function Statistics() {
}
})();
return () => { cancelled = true; };
- }, [musicLibraryFilterVersion]);
+ }, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]);
// Background: format distribution (cached random sample, same TTL as other Statistics fetches)
useEffect(() => {
+ if (offlineBrowseActive || isPlayerStats) return;
let cancelled = false;
setFormatData(null);
setFormatSampleSize(0);
@@ -116,17 +134,19 @@ export default function Statistics() {
})
.catch(() => {});
return () => { cancelled = true; };
- }, [musicLibraryFilterVersion]);
+ }, [musicLibraryFilterVersion, offlineBrowseActive, isPlayerStats]);
useEffect(() => {
+ if (offlineBrowseActive || isPlayerStats) return;
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
setLfmRecentLoading(true);
lastfmGetRecentTracks(lastfmUsername, lastfmSessionKey, 20)
.then(tracks => { setLfmRecentTracks(tracks); setLfmRecentLoading(false); })
.catch(() => setLfmRecentLoading(false));
- }, [lastfmSessionKey, lastfmUsername]);
+ }, [lastfmSessionKey, lastfmUsername, offlineBrowseActive, isPlayerStats]);
useEffect(() => {
+ if (offlineBrowseActive || isPlayerStats) return;
if (!lastfmIsConfigured() || !lastfmSessionKey || !lastfmUsername) return;
setLfmLoading(true);
Promise.all([
@@ -139,7 +159,7 @@ export default function Statistics() {
setLfmTopTracks(tracks);
setLfmLoading(false);
}).catch(() => setLfmLoading(false));
- }, [lfmPeriod, lastfmSessionKey, lastfmUsername]);
+ }, [lfmPeriod, lastfmSessionKey, lastfmUsername, offlineBrowseActive, isPlayerStats]);
const loadMore = async (
type: 'frequent' | 'highest',
diff --git a/src/store/devOfflineBrowseStore.ts b/src/store/devOfflineBrowseStore.ts
new file mode 100644
index 00000000..ef6a1850
--- /dev/null
+++ b/src/store/devOfflineBrowseStore.ts
@@ -0,0 +1,19 @@
+import { create } from 'zustand';
+
+/** DEV-only: simulate full offline (no server probes, no Subsonic, local playback only). */
+interface DevOfflineBrowseState {
+ forceOffline: boolean;
+ setForceOffline: (v: boolean) => void;
+ toggleForceOffline: () => void;
+}
+
+export const useDevOfflineBrowseStore = create()((set, get) => ({
+ forceOffline: false,
+ setForceOffline: (v) => set({ forceOffline: v }),
+ toggleForceOffline: () => set({ forceOffline: !get().forceOffline }),
+}));
+
+/** True when DEV mode forces disconnected server + offline player behavior. */
+export function isDevOfflineBrowseForced(): boolean {
+ return import.meta.env.DEV && useDevOfflineBrowseStore.getState().forceOffline;
+}
diff --git a/src/store/homeFeedCache.ts b/src/store/homeFeedCache.ts
index 4b37607f..73d8d708 100644
--- a/src/store/homeFeedCache.ts
+++ b/src/store/homeFeedCache.ts
@@ -28,6 +28,27 @@ export function readHomeFeedCache(
return snapshot;
}
+/** Last good snapshot for this server when filter version changed (e.g. offline filter suspend). */
+export function readHomeFeedCacheStale(
+ serverId: string | null | undefined,
+): HomeFeedSnapshot | null {
+ if (!serverId || !snapshot) return null;
+ if (snapshot.serverId !== serverId) return null;
+ if (Date.now() - snapshot.savedAt > TTL_MS) return null;
+ return snapshot;
+}
+
+export function isHomeFeedSnapshotEmpty(snap: HomeFeedSnapshot): boolean {
+ return snap.heroAlbums.length === 0
+ && snap.recent.length === 0
+ && snap.random.length === 0
+ && snap.starred.length === 0
+ && snap.mostPlayed.length === 0
+ && snap.recentlyPlayed.length === 0
+ && snap.discoverSongs.length === 0
+ && snap.randomArtists.length === 0;
+}
+
export function writeHomeFeedCache(data: Omit): void {
snapshot = { ...data, savedAt: Date.now() };
}
diff --git a/src/store/playlistStore.ts b/src/store/playlistStore.ts
index 2d82ef92..2bbca30d 100644
--- a/src/store/playlistStore.ts
+++ b/src/store/playlistStore.ts
@@ -3,6 +3,9 @@ import type { SubsonicPlaylist } from '../api/subsonicTypes';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { createPlaylist as apiCreatePlaylist } from '../api/subsonicPlaylists';
+import { useAuthStore } from './authStore';
+import { isOfflineBrowseActive } from '../utils/offline/offlineBrowseMode';
+import { fetchOfflineBrowsablePlaylists } from '../utils/offline/offlinePlaylistBrowse';
interface PlaylistStore {
recentIds: string[];
playlists: SubsonicPlaylist[];
@@ -32,6 +35,12 @@ export const usePlaylistStore = create()(
fetchPlaylists: async () => {
set({ playlistsLoading: true });
try {
+ const serverId = useAuthStore.getState().activeServerId;
+ if (isOfflineBrowseActive() && serverId) {
+ const playlists = await fetchOfflineBrowsablePlaylists(serverId);
+ set({ playlists, playlistsLoading: false });
+ return;
+ }
const playlists = await getPlaylists();
set({ playlists, playlistsLoading: false });
} catch {
diff --git a/src/styles/layout/dev-build-chrome.css b/src/styles/layout/dev-build-chrome.css
index 09d7f9a1..b274824e 100644
--- a/src/styles/layout/dev-build-chrome.css
+++ b/src/styles/layout/dev-build-chrome.css
@@ -61,3 +61,24 @@ html[data-dev-build] .app-shell[data-mobile] .dev-build-badge {
html[data-dev-build] .app-shell[data-mobile][data-titlebar] .dev-build-badge {
top: var(--titlebar-height);
}
+
+html[data-dev-build] .dev-network-mode-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35rem;
+ padding: 0.25rem 0.55rem;
+ border-radius: 6px;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ color: var(--text-muted);
+ font-size: 0.72rem;
+ font-weight: 600;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ cursor: pointer;
+}
+
+html[data-dev-build] .dev-network-mode-toggle--offline {
+ color: var(--warning, #e6a700);
+ border-color: color-mix(in srgb, var(--warning, #e6a700) 45%, var(--border));
+}
diff --git a/src/utils/componentHelpers/runArtistDetailPlay.test.ts b/src/utils/componentHelpers/runArtistDetailPlay.test.ts
new file mode 100644
index 00000000..2f73fb07
--- /dev/null
+++ b/src/utils/componentHelpers/runArtistDetailPlay.test.ts
@@ -0,0 +1,46 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import type { SubsonicAlbum } from '../../api/subsonicTypes';
+import * as offlineMediaResolve from '../offline/offlineMediaResolve';
+import { fetchArtistDetailTracks } from './runArtistDetailPlay';
+
+vi.mock('../offline/offlineMediaResolve', () => ({
+ resolveAlbum: vi.fn(),
+ resolveMediaServerId: vi.fn((id?: string | null) => id ?? 'srv-1'),
+}));
+
+const resolveAlbumMock = vi.mocked(offlineMediaResolve.resolveAlbum);
+
+const albums: SubsonicAlbum[] = [
+ { id: 'al-2', name: 'B', artist: 'A', artistId: 'ar-1', songCount: 1, duration: 100, year: 2001 },
+ { id: 'al-1', name: 'A', artist: 'A', artistId: 'ar-1', songCount: 1, duration: 100, year: 2000 },
+];
+
+describe('fetchArtistDetailTracks', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('loads albums via resolveAlbum when serverId is set', async () => {
+ resolveAlbumMock
+ .mockResolvedValueOnce({
+ album: albums[1],
+ songs: [{ id: 't1', title: 'One', artist: 'A', album: 'A', albumId: 'al-1', duration: 100, track: 2 }],
+ })
+ .mockResolvedValueOnce({
+ album: albums[0],
+ songs: [{ id: 't2', title: 'Two', artist: 'A', album: 'B', albumId: 'al-2', duration: 100, track: 1 }],
+ });
+
+ const tracks = await fetchArtistDetailTracks(albums, 'srv-1');
+ expect(tracks.map(t => t.id)).toEqual(['t1', 't2']);
+ expect(resolveAlbumMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('returns empty when no server scope', async () => {
+ vi.mocked(offlineMediaResolve.resolveMediaServerId).mockReturnValueOnce(null);
+
+ const tracks = await fetchArtistDetailTracks(albums, null);
+ expect(tracks).toEqual([]);
+ expect(resolveAlbumMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/utils/componentHelpers/runArtistDetailPlay.ts b/src/utils/componentHelpers/runArtistDetailPlay.ts
index 4ef0927e..e79e4f2c 100644
--- a/src/utils/componentHelpers/runArtistDetailPlay.ts
+++ b/src/utils/componentHelpers/runArtistDetailPlay.ts
@@ -1,33 +1,53 @@
import type { TFunction } from 'i18next';
-import { getAlbum } from '../../api/subsonicLibrary';
import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists';
import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes';
import type { Track } from '../../store/playerStoreTypes';
import { songToTrack } from '../playback/songToTrack';
import { runBulkPlayAll, runBulkShuffle } from '../playback/runBulkPlay';
+import { resolveAlbum, resolveMediaServerId } from '../offline/offlineMediaResolve';
-async function fetchAllTracks(albums: SubsonicAlbum[]): Promise