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:
cucadmuh
2026-06-01 04:20:18 +03:00
committed by GitHub
parent d3e5a6b704
commit ddf10ee01d
42 changed files with 1952 additions and 139 deletions
+36 -6
View File
@@ -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 };
+179
View File
@@ -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,
};
}
+79
View File
@@ -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,
};
}