Files
psysonic/src/hooks/useGenreDetailBrowse.ts
T
cucadmuh ddf10ee01d 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
2026-06-01 04:20:18 +03:00

80 lines
2.8 KiB
TypeScript

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,
};
}