mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(offline): local-bytes browse when server is unreachable (#1017)
* feat(offline): local-bytes browse for artists and albums Make Artists, All Albums, and artist/album detail pages work offline from the library index limited to on-disk library and favorite-auto tracks. Add a DEV header toggle to simulate offline browse for testing. * feat(offline): reactive DEV offline toggle with full disconnect simulation Subscribe nav and browse/detail hooks to useOfflineBrowseActive so UI refreshes on toggle. DEV force-offline now blocks server probes, reports disconnected status, and gates Subsonic like real offline for player parity. * feat(offline): bytes-first favorites when offline browse is active Load Favorites from local playback bytes, filter starred tracks client-side, and restrict album-level star queries to local album ids. Drop interim perf attempts (lean SQL, progressive load, connection singleton, prefetch UX). * feat(offline): tracks, help, player stats; suspend library picker offline - Offline browse for Tracks hub from local bytes; sidebar nav for tracks/help/statistics - Statistics redirects to player-stats offline; server/Last.fm tabs skip network fetches - Hide music-library picker offline; save filter and restore on reconnect (all libraries while disconnected) - Unified isOfflineSidebarNavAllowed for library + system entries * feat(offline): fork disconnect navigation by offline browse capability When the server drops: stay on the page if nothing is browsable offline; reload in place on offline-capable routes; otherwise redirect to All Albums instead of the old /offline or /favorites bounce. * feat(offline): browse cached playlists when the server is down List and open manually pinned regular playlists from local library-tier bytes offline, with sidebar/nav routing and read-only playlist UI. * feat(offline): read-only artist detail and local play-all paths Hide favorites and discography offline actions when browse is offline; load Play All, Shuffle, and top-track continuation from local album bytes. * feat(offline): read-only album detail and enqueue from local bytes Hide favorites, download, and cache-offline actions on album pages when offline browse is active. Favorites album cards enqueue via the same resolveAlbumForServer path as play, including local playback bytes. * chore: remove unused import in AlbumCard after enqueue refactor * feat(offline): unify browse integration contract across the app Add useOfflineBrowseContext, offlineMediaResolve, and offlineActionPolicy; wire shell nav to a single capability source; migrate play/enqueue and context-menu paths off raw getAlbum; replace readOnly with action policy on detail surfaces. Tests updated for the media-resolve facade. * feat(offline): close browse contract gaps and fix offline Home feed Split offline browse modules, align favorites capability across servers, wire action policy on context menus, migrate hooks to useOfflineBrowseContext, and preserve stale Home feed cache when offline so the UI does not empty. * fix(offline): block playbar stars, close audit gaps, trim dead exports Hide star rating and favorite in PlayerBar when offline browse is active via offlineActionPolicy playerBar surface. Wire stay-reload token into browse hooks, migrate hooks to context.active, guard rating prefetch network calls, and route playlist load through resolvePlaylist. * docs: add CHANGELOG and credits for offline browse PR #1017 * fix(offline): stop DEV connection probe regression in tests React to devForceOffline transitions only in useConnectionStatus so mount does not double-fire check() or ignore disableBackgroundPolling. Add pingWithCredentials to PlayerBar test mock and DEV-toggle unit tests.
This commit is contained in:
@@ -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<SubsonicAlbum[]>([]);
|
||||
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) {
|
||||
|
||||
@@ -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<ReturnType<typeof getAlbum>>;
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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<SubsonicArtist | null>(null);
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
@@ -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;
|
||||
|
||||
@@ -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<SubsonicArtist[]>([]);
|
||||
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,
|
||||
|
||||
@@ -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<SubsonicAlbum[] | null>(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 };
|
||||
|
||||
@@ -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<SubsonicArtist[] | null>(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 };
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ConnectionStatus>('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<ServerEndpointKind | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const prevDevForceOfflineRef = useRef<boolean | null>(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);
|
||||
|
||||
@@ -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<TopFavoriteArtist[]>(() => {
|
||||
const counts = new Map<string, TopFavoriteArtist>();
|
||||
for (const s of songs) {
|
||||
|
||||
@@ -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<Location, 'pathname' | 'search' | 'state'>,
|
||||
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<string, unknown>
|
||||
: {}),
|
||||
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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<boolean | null>(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]);
|
||||
}
|
||||
@@ -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<string, number>;
|
||||
@@ -20,6 +21,7 @@ export function usePlaylistsLibraryScopeCounts(
|
||||
): PlaylistsLibraryScopeCountsResult {
|
||||
const [filteredSongCountByPlaylist, setFilteredSongCountByPlaylist] = useState<Record<string, number>>({});
|
||||
const [filteredDurationByPlaylist, setFilteredDurationByPlaylist] = useState<Record<string, number>>({});
|
||||
const offlineBrowseActive = useOfflineBrowseContext().active;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -31,6 +33,19 @@ export function usePlaylistsLibraryScopeCounts(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (offlineBrowseActive) {
|
||||
const next: Record<string, number> = {};
|
||||
const nextDuration: Record<string, number> = {};
|
||||
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<string, number> = {};
|
||||
const nextDuration: Record<string, number> = {};
|
||||
@@ -60,7 +75,7 @@ export function usePlaylistsLibraryScopeCounts(
|
||||
};
|
||||
run();
|
||||
return () => { cancelled = true; };
|
||||
}, [playlists, musicLibraryFilterVersion]);
|
||||
}, [playlists, musicLibraryFilterVersion, offlineBrowseActive]);
|
||||
|
||||
return { filteredSongCountByPlaylist, filteredDurationByPlaylist };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SubsonicSong[]> => {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user