mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
feat(genres): local index genre browse with Subsonic fallback (#937)
* feat(genres): genre detail browse via local index with aligned counts Move genre detail albums/play/shuffle onto the local library index with Albums-style in-page scroll, session restore, and genre-scoped stash. Unify genre album totals between the cloud and detail pages via library_get_genre_album_counts, and fix grouped browse totals to count distinct albums rather than matching tracks. * perf(genres): local genre browse with scoped counts cache Add dedicated Rust genre album pagination and indexes, slice-mode grid loading, library-filter-aware counts, and a long-lived in-memory catalog cache invalidated on sync so genre pages avoid repeated full-library SQL. * fix(genres): restore scroll after album back; play hold-to-shuffle Pin restore display count in refs so clearing the return stash no longer reloads the genre grid mid-restore. Load the first SQL page only (60 rows), use long-press on Play for shuffle, and add genre play tooltips. * fix(genres): fall back to Subsonic byGenre when local index unavailable Genre detail album grid now matches All Albums: try library_list_albums_by_genre first, then getAlbumsByGenre when the index is off, not ready, or errors. * docs: add CHANGELOG and credits for PR #937
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigationType, type NavigationType } from 'react-router-dom';
|
||||
import {
|
||||
clearGenreDetailReturnStash,
|
||||
peekAlbumBrowseScrollRestore,
|
||||
peekGenreDetailScrollRestore,
|
||||
type AlbumBrowseSurface,
|
||||
useAlbumBrowseSessionStore,
|
||||
} from '../store/albumBrowseSessionStore';
|
||||
@@ -14,7 +16,10 @@ type PendingScroll = {
|
||||
|
||||
export type UseAlbumBrowseScrollRestoreArgs = {
|
||||
serverId: string;
|
||||
surface: AlbumBrowseSurface;
|
||||
/** Album grid browse surface (All Albums, New Releases, Random Albums). */
|
||||
surface?: AlbumBrowseSurface;
|
||||
/** Genre detail page — uses genre-scoped stash instead of `surface`. */
|
||||
genreName?: string;
|
||||
scrollBodyEl: HTMLElement | null;
|
||||
displayAlbumsLength: number;
|
||||
loading: boolean;
|
||||
@@ -30,12 +35,29 @@ export type UseAlbumBrowseScrollRestoreResult = {
|
||||
|
||||
function readPendingScrollRestore(
|
||||
serverId: string,
|
||||
surface: AlbumBrowseSurface,
|
||||
surface: AlbumBrowseSurface | undefined,
|
||||
genreName: string | undefined,
|
||||
navigationType: NavigationType,
|
||||
locationState: unknown,
|
||||
): PendingScroll | null {
|
||||
if (!shouldRestoreAlbumBrowseSession(navigationType, locationState) || !serverId) return null;
|
||||
return peekAlbumBrowseScrollRestore(serverId, surface);
|
||||
if (genreName) return peekGenreDetailScrollRestore(serverId, genreName);
|
||||
if (surface) return peekAlbumBrowseScrollRestore(serverId, surface);
|
||||
return null;
|
||||
}
|
||||
|
||||
function clearScrollRestoreStash(
|
||||
serverId: string,
|
||||
surface: AlbumBrowseSurface | undefined,
|
||||
genreName: string | undefined,
|
||||
): void {
|
||||
if (genreName) {
|
||||
clearGenreDetailReturnStash(serverId, genreName);
|
||||
return;
|
||||
}
|
||||
if (surface) {
|
||||
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,6 +67,7 @@ function readPendingScrollRestore(
|
||||
export function useAlbumBrowseScrollRestore({
|
||||
serverId,
|
||||
surface,
|
||||
genreName,
|
||||
scrollBodyEl,
|
||||
displayAlbumsLength,
|
||||
loading,
|
||||
@@ -60,11 +83,17 @@ export function useAlbumBrowseScrollRestore({
|
||||
|
||||
if (!initRef.current) {
|
||||
initRef.current = true;
|
||||
pendingRef.current = readPendingScrollRestore(serverId, surface, navigationType, location.state);
|
||||
pendingRef.current = readPendingScrollRestore(
|
||||
serverId,
|
||||
surface,
|
||||
genreName,
|
||||
navigationType,
|
||||
location.state,
|
||||
);
|
||||
}
|
||||
|
||||
const [isScrollRestorePending, setIsScrollRestorePending] = useState(
|
||||
() => readPendingScrollRestore(serverId, surface, navigationType, location.state) !== null,
|
||||
() => readPendingScrollRestore(serverId, surface, genreName, navigationType, location.state) !== null,
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -84,7 +113,7 @@ export function useAlbumBrowseScrollRestore({
|
||||
pendingRef.current = null;
|
||||
doneRef.current = true;
|
||||
setIsScrollRestorePending(false);
|
||||
useAlbumBrowseSessionStore.getState().clearReturnStash(serverId, surface);
|
||||
clearScrollRestoreStash(serverId, surface, genreName);
|
||||
}, [
|
||||
scrollBodyEl,
|
||||
displayAlbumsLength,
|
||||
@@ -94,6 +123,7 @@ export function useAlbumBrowseScrollRestore({
|
||||
loadMore,
|
||||
serverId,
|
||||
surface,
|
||||
genreName,
|
||||
]);
|
||||
|
||||
return { isScrollRestorePending };
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { dedupeById } from '../utils/dedupeById';
|
||||
import type { AlbumBrowseSort } from '../utils/library/albumBrowseSort';
|
||||
import {
|
||||
fetchGenreAlbumPage,
|
||||
GENRE_ALBUM_CATALOG_CHUNK,
|
||||
GENRE_ALBUM_FIRST_PAGE,
|
||||
} from '../utils/library/genreAlbumBrowse';
|
||||
import { useClientSliceInfiniteScroll } from './useClientSliceInfiniteScroll';
|
||||
import { useInpageScrollSentinel } from './useInpageScrollSentinel';
|
||||
|
||||
const CLIENT_SLICE_PAGE_SIZE = GENRE_ALBUM_FIRST_PAGE;
|
||||
|
||||
function initialSqlPageSize(restoreDisplayCount?: number): number {
|
||||
if (restoreDisplayCount != null && restoreDisplayCount > CLIENT_SLICE_PAGE_SIZE) {
|
||||
return Math.min(restoreDisplayCount, GENRE_ALBUM_CATALOG_CHUNK);
|
||||
}
|
||||
return CLIENT_SLICE_PAGE_SIZE;
|
||||
}
|
||||
|
||||
export function useGenreAlbumBrowse(
|
||||
serverId: string,
|
||||
genre: string,
|
||||
indexEnabled: boolean,
|
||||
sort: AlbumBrowseSort,
|
||||
musicLibraryFilterVersion: number,
|
||||
getScrollRoot?: () => HTMLElement | null,
|
||||
scrollRootEl?: HTMLElement | null,
|
||||
restoreDisplayCount?: number,
|
||||
) {
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [catalogLoadingMore, setCatalogLoadingMore] = useState(false);
|
||||
const [catalogHasMore, setCatalogHasMore] = useState(false);
|
||||
const catalogOffsetRef = useRef(0);
|
||||
const catalogLoadingRef = useRef(false);
|
||||
const loadGenerationRef = useRef(0);
|
||||
const loadingRef = useRef(false);
|
||||
const loadPendingRef = useRef(false);
|
||||
const loadMoreRef = useRef<() => void>(() => {});
|
||||
const browseSessionRef = useRef({ key: '', restoreDisplayCount: undefined as number | undefined });
|
||||
const browseKey = `${serverId}:${genre}`;
|
||||
if (browseSessionRef.current.key !== browseKey) {
|
||||
browseSessionRef.current = {
|
||||
key: browseKey,
|
||||
restoreDisplayCount: restoreDisplayCount,
|
||||
};
|
||||
}
|
||||
const sessionRestoreDisplayCount = browseSessionRef.current.restoreDisplayCount;
|
||||
|
||||
const {
|
||||
visibleCount,
|
||||
loadingMore: sliceLoadingMore,
|
||||
loadMore: sliceLoadMore,
|
||||
} = useClientSliceInfiniteScroll({
|
||||
pageSize: CLIENT_SLICE_PAGE_SIZE,
|
||||
resetDeps: [
|
||||
sort,
|
||||
genre,
|
||||
musicLibraryFilterVersion,
|
||||
serverId,
|
||||
indexEnabled,
|
||||
],
|
||||
getScrollRoot,
|
||||
scrollRootEl,
|
||||
restoreDisplayCount: sessionRestoreDisplayCount,
|
||||
});
|
||||
|
||||
const displayAlbums = useMemo(
|
||||
() => albums.slice(0, visibleCount),
|
||||
[albums, visibleCount],
|
||||
);
|
||||
|
||||
const hasMore = visibleCount < albums.length || catalogHasMore;
|
||||
const loadingMore = sliceLoadingMore || catalogLoadingMore;
|
||||
|
||||
const loadCatalogChunk = useCallback(async (
|
||||
offset: number,
|
||||
append: boolean,
|
||||
pageSize: number = GENRE_ALBUM_CATALOG_CHUNK,
|
||||
) => {
|
||||
if (catalogLoadingRef.current || !genre) return;
|
||||
const generation = loadGenerationRef.current;
|
||||
catalogLoadingRef.current = true;
|
||||
setCatalogLoadingMore(true);
|
||||
try {
|
||||
const chunk = await fetchGenreAlbumPage(
|
||||
serverId,
|
||||
genre,
|
||||
indexEnabled,
|
||||
offset,
|
||||
pageSize,
|
||||
sort,
|
||||
);
|
||||
if (generation !== loadGenerationRef.current) 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;
|
||||
}
|
||||
setCatalogHasMore(chunk.hasMore);
|
||||
} finally {
|
||||
catalogLoadingRef.current = false;
|
||||
if (generation === loadGenerationRef.current) {
|
||||
setCatalogLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}, [serverId, genre, indexEnabled, sort]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!genre) {
|
||||
setAlbums([]);
|
||||
setCatalogHasMore(false);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
loadGenerationRef.current += 1;
|
||||
const generation = loadGenerationRef.current;
|
||||
catalogOffsetRef.current = 0;
|
||||
catalogLoadingRef.current = false;
|
||||
loadingRef.current = true;
|
||||
loadPendingRef.current = true;
|
||||
setLoading(true);
|
||||
setCatalogLoadingMore(false);
|
||||
setCatalogHasMore(false);
|
||||
setAlbums([]);
|
||||
|
||||
const firstPageSize = initialSqlPageSize(sessionRestoreDisplayCount);
|
||||
void loadCatalogChunk(0, false, firstPageSize).finally(() => {
|
||||
if (cancelled || generation !== loadGenerationRef.current) return;
|
||||
loadingRef.current = false;
|
||||
loadPendingRef.current = false;
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [serverId, genre, indexEnabled, sort, musicLibraryFilterVersion, loadCatalogChunk]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!genre || loadingRef.current || loadPendingRef.current) return;
|
||||
if (visibleCount < albums.length) {
|
||||
sliceLoadMore();
|
||||
return;
|
||||
}
|
||||
if (catalogHasMore && !catalogLoadingRef.current) {
|
||||
void loadCatalogChunk(catalogOffsetRef.current, true);
|
||||
}
|
||||
}, [genre, visibleCount, albums.length, catalogHasMore, sliceLoadMore, loadCatalogChunk]);
|
||||
|
||||
loadMoreRef.current = loadMore;
|
||||
|
||||
const bindLoadMoreSentinel = useInpageScrollSentinel({
|
||||
active: hasMore,
|
||||
getScrollRoot,
|
||||
scrollRootEl,
|
||||
onIntersect: () => loadMoreRef.current(),
|
||||
drainSignal: loadingMore,
|
||||
});
|
||||
|
||||
return {
|
||||
albums,
|
||||
displayAlbums,
|
||||
loading,
|
||||
loadingMore,
|
||||
hasMore,
|
||||
loadMore,
|
||||
bindLoadMoreSentinel,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useRef, type RefObject } from 'react';
|
||||
import { useLocation, useNavigationType } from 'react-router-dom';
|
||||
import { GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID, readInpageScrollTop } from '../constants/appScroll';
|
||||
import {
|
||||
DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
|
||||
albumBrowseSortForServer,
|
||||
clearGenreDetailReturnStash,
|
||||
genreDetailGenreFromPath,
|
||||
isAlbumDetailPath,
|
||||
isGenreDetailPath,
|
||||
peekGenreDetailScrollRestore,
|
||||
stashGenreDetailReturnFilters,
|
||||
useAlbumBrowseSessionStore,
|
||||
} from '../store/albumBrowseSessionStore';
|
||||
import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation';
|
||||
import type { AlbumBrowseScrollSnapshot } from './useAlbumBrowseFilters';
|
||||
|
||||
/** Genre detail: locked genre filter + leave/restore session (same contract as All Albums). */
|
||||
export function useGenreDetailBrowse(
|
||||
serverId: string,
|
||||
genreName: string,
|
||||
scrollSnapshotRef?: RefObject<AlbumBrowseScrollSnapshot>,
|
||||
) {
|
||||
const navigationType = useNavigationType();
|
||||
const location = useLocation();
|
||||
const sort = useAlbumBrowseSessionStore(s => albumBrowseSortForServer(s.sortByServer, serverId));
|
||||
const restoredFromStashRef = useRef(false);
|
||||
const restoreKeyRef = useRef('');
|
||||
const restoreDisplayCountRef = useRef<number | undefined>(undefined);
|
||||
const restoreKey = `${serverId}:${genreName}`;
|
||||
if (restoreKeyRef.current !== restoreKey) {
|
||||
restoreKeyRef.current = restoreKey;
|
||||
restoreDisplayCountRef.current = peekGenreDetailScrollRestore(serverId, genreName)?.displayCount;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
restoredFromStashRef.current = false;
|
||||
}, [serverId, genreName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverId || !genreName) return;
|
||||
|
||||
if (shouldRestoreAlbumBrowseSession(navigationType, location.state)) {
|
||||
restoredFromStashRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (restoredFromStashRef.current) return;
|
||||
|
||||
clearGenreDetailReturnStash(serverId, genreName);
|
||||
}, [serverId, genreName, navigationType, location.state]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!serverId || !genreName) return;
|
||||
const path = window.location.pathname;
|
||||
if (isAlbumDetailPath(path)) {
|
||||
const snapshot = scrollSnapshotRef?.current;
|
||||
const scrollTop = Math.max(
|
||||
readInpageScrollTop(GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID),
|
||||
snapshot?.scrollTop ?? 0,
|
||||
);
|
||||
stashGenreDetailReturnFilters(serverId, genreName, {
|
||||
...DEFAULT_ALBUM_BROWSE_RETURN_FILTERS,
|
||||
selectedGenres: [genreName],
|
||||
scrollTop,
|
||||
displayCount: snapshot?.displayCount,
|
||||
});
|
||||
} else if (!isGenreDetailPath(path) || genreDetailGenreFromPath(path) !== genreName) {
|
||||
clearGenreDetailReturnStash(serverId, genreName);
|
||||
}
|
||||
};
|
||||
}, [serverId, genreName, scrollSnapshotRef]);
|
||||
|
||||
return {
|
||||
sort,
|
||||
restoreDisplayCount: restoreDisplayCountRef.current,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user