mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05: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
131 lines
3.7 KiB
TypeScript
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 };
|
|
}
|