Files
Psychotoxical-psysonic/src/hooks/useAlbumBrowseScrollRestore.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

131 lines
3.7 KiB
TypeScript

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';
import { shouldRestoreAlbumBrowseSession } from '../utils/navigation/albumDetailNavigation';
type PendingScroll = {
scrollTop: number;
displayCount: number;
};
export type UseAlbumBrowseScrollRestoreArgs = {
serverId: string;
/** 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;
loadingMore: boolean;
hasMore: boolean;
loadMore: () => void;
};
export type UseAlbumBrowseScrollRestoreResult = {
/** True until saved scroll position is applied — hide the grid meanwhile. */
isScrollRestorePending: boolean;
};
function readPendingScrollRestore(
serverId: string,
surface: AlbumBrowseSurface | undefined,
genreName: string | undefined,
navigationType: NavigationType,
locationState: unknown,
): PendingScroll | null {
if (!shouldRestoreAlbumBrowseSession(navigationType, locationState) || !serverId) return null;
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);
}
}
/**
* When returning to an album grid browse surface via browser/app back from album
* detail, restore the in-page grid scroll position saved in `albumBrowseSessionStore`.
*/
export function useAlbumBrowseScrollRestore({
serverId,
surface,
genreName,
scrollBodyEl,
displayAlbumsLength,
loading,
loadingMore,
hasMore,
loadMore,
}: UseAlbumBrowseScrollRestoreArgs): UseAlbumBrowseScrollRestoreResult {
const navigationType = useNavigationType();
const location = useLocation();
const initRef = useRef(false);
const pendingRef = useRef<PendingScroll | null>(null);
const doneRef = useRef(false);
if (!initRef.current) {
initRef.current = true;
pendingRef.current = readPendingScrollRestore(
serverId,
surface,
genreName,
navigationType,
location.state,
);
}
const [isScrollRestorePending, setIsScrollRestorePending] = useState(
() => readPendingScrollRestore(serverId, surface, genreName, navigationType, location.state) !== null,
);
useLayoutEffect(() => {
const pending = pendingRef.current;
if (doneRef.current || !pending) return;
if (!scrollBodyEl || loading) return;
const needsMore = displayAlbumsLength < pending.displayCount && hasMore;
if (needsMore) {
if (!loadingMore) loadMore();
return;
}
if (loadingMore) return;
scrollBodyEl.scrollTop = pending.scrollTop;
scrollBodyEl.dispatchEvent(new Event('scroll', { bubbles: false }));
pendingRef.current = null;
doneRef.current = true;
setIsScrollRestorePending(false);
clearScrollRestoreStash(serverId, surface, genreName);
}, [
scrollBodyEl,
displayAlbumsLength,
loading,
loadingMore,
hasMore,
loadMore,
serverId,
surface,
genreName,
]);
return { isScrollRestorePending };
}