mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
ddf10ee01d
* 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
180 lines
5.4 KiB
TypeScript
180 lines
5.4 KiB
TypeScript
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,
|
|
};
|
|
}
|