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
+237 -117
View File
@@ -1,24 +1,36 @@
import { getAlbumsByGenre, fetchAllSongsByGenre } from '../api/subsonicGenres';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import React, { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ArrowLeft, Disc3, Play, Shuffle, ListPlus, Loader2 } from 'lucide-react';
import { useAuthStore } from '../store/authStore';
import { usePlayerStore } from '../store/playerStore';
import { ArrowLeft, Disc3, Play, ListPlus, Loader2 } from 'lucide-react';
import AlbumCard from '../components/AlbumCard';
import { songToTrack } from '../utils/playback/songToTrack';
import { runBulkPlayAll, runBulkShuffle, runBulkEnqueue } from '../utils/playback/runBulkPlay';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { albumGridWarmCovers } from '../cover/layoutSizes';
import { LongPressWaveOverlay } from '../components/LongPressWaveOverlay';
import InpageScrollSentinel from '../components/InpageScrollSentinel';
import OverlayScrollArea from '../components/OverlayScrollArea';
import { VirtualCardGrid } from '../components/VirtualCardGrid';
const PAGE_SIZE = 50;
// Bulk play/shuffle pulls a bounded slice of the genre. The queue resolver
// (queueTrackResolver) holds a 500-entry LRU; seeding a larger queue evicts the
// earliest tracks, which then render as "…"/0:00 placeholders until lazily
// re-resolved. Keep the slice within that budget so the whole queue stays warm.
const GENRE_QUEUE_CAP = 500;
import { GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { albumGridWarmCovers } from '../cover/layoutSizes';
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
import { useGenreAlbumBrowse } from '../hooks/useGenreAlbumBrowse';
import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore';
import { useGenreDetailBrowse } from '../hooks/useGenreDetailBrowse';
import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
import { useLongPressAction } from '../hooks/useLongPressAction';
import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight';
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { usePlayerStore } from '../store/playerStore';
import {
fetchGenreAlbumCount,
fetchGenreTracksForPlayback,
} from '../utils/library/genreBrowsePlayback';
import { lookupGenreAlbumCount } from '../utils/library/genreCatalogCountsCache';
import { libraryScopeForServer } from '../api/subsonicClient';
import {
readAlbumBrowseRestore,
readAlbumDetailReturnTo,
} from '../utils/navigation/albumDetailNavigation';
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { runBulkEnqueue, runBulkPlayAll, runBulkShuffle } from '../utils/playback/runBulkPlay';
export default function GenreDetail() {
const { name } = useParams<{ name: string }>();
@@ -26,125 +38,233 @@ export default function GenreDetail() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const navigate = useNavigate();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [offset, setOffset] = useState(0);
const [bulkLoading, setBulkLoading] = useState(false);
const location = useLocation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const fetchGenreTracks = useCallback(
() => fetchAllSongsByGenre(genre, GENRE_QUEUE_CAP).then(songs => songs.map(songToTrack)),
[genre],
const scrollSnapshotRef = useRef<AlbumBrowseScrollSnapshot>({ scrollTop: 0, displayCount: 0 });
const { sort, restoreDisplayCount } = useGenreDetailBrowse(serverId, genre, scrollSnapshotRef);
const {
scrollBodyEl,
bindScrollBody: bindGenreDetailScrollBody,
getScrollRoot,
} = useInpageScrollViewport();
const {
albums,
loading,
loadingMore,
hasMore,
displayAlbums,
bindLoadMoreSentinel,
loadMore,
} = useGenreAlbumBrowse(
serverId,
genre,
indexEnabled,
sort,
musicLibraryFilterVersion,
getScrollRoot,
scrollBodyEl,
restoreDisplayCount,
);
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, displayAlbums.length);
const { isScrollRestorePending } = useAlbumBrowseScrollRestore({
serverId,
genreName: genre,
scrollBodyEl,
displayAlbumsLength: displayAlbums.length,
loading,
loadingMore,
hasMore,
loadMore,
});
useEffect(() => {
if (isScrollRestorePending || !readAlbumBrowseRestore(location.state)) return;
navigate(`${location.pathname}${location.search}${location.hash}`, { replace: true, state: null });
}, [isScrollRestorePending, location.pathname, location.search, location.hash, location.state, navigate]);
const [albumCount, setAlbumCount] = useState<number | null>(null);
const [bulkLoading, setBulkLoading] = useState(false);
useEffect(() => {
if (!genre || !serverId) return;
const cached = lookupGenreAlbumCount(serverId, genre, libraryScopeForServer(serverId));
if (cached != null) setAlbumCount(cached);
}, [serverId, genre, musicLibraryFilterVersion]);
useEffect(() => {
if (!genre || loading) return;
const cached = lookupGenreAlbumCount(serverId, genre, libraryScopeForServer(serverId));
if (cached != null) return;
let cancelled = false;
const timer = window.setTimeout(() => {
void fetchGenreAlbumCount(serverId, genre, indexEnabled, sort).then(count => {
if (!cancelled) setAlbumCount(count);
});
}, 0);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [serverId, genre, indexEnabled, sort, musicLibraryFilterVersion, loading]);
const fetchGenreTracks = useCallback(
(shuffle?: boolean) => fetchGenreTracksForPlayback(serverId, genre, {
shuffle,
indexEnabled,
}),
[serverId, genre, indexEnabled],
);
const handlePlayAll = useCallback(
() => runBulkPlayAll({ fetchTracks: fetchGenreTracks, setLoading: setBulkLoading, playTrack }),
() => runBulkPlayAll({ fetchTracks: () => fetchGenreTracks(false), setLoading: setBulkLoading, playTrack }),
[fetchGenreTracks, playTrack],
);
const handleShuffleAll = useCallback(
() => runBulkShuffle({ fetchTracks: fetchGenreTracks, setLoading: setBulkLoading, playTrack }),
() => runBulkShuffle({ fetchTracks: () => fetchGenreTracks(true), setLoading: setBulkLoading, playTrack }),
[fetchGenreTracks, playTrack],
);
const handleEnqueueAll = useCallback(
() => runBulkEnqueue({ fetchTracks: fetchGenreTracks, setLoading: setBulkLoading, enqueue }),
() => runBulkEnqueue({ fetchTracks: () => fetchGenreTracks(false), setLoading: setBulkLoading, enqueue }),
[fetchGenreTracks, enqueue],
);
useEffect(() => {
setAlbums([]);
setOffset(0);
setHasMore(true);
setLoading(true);
getAlbumsByGenre(genre, PAGE_SIZE, 0)
.then(data => {
setAlbums(data);
setHasMore(data.length === PAGE_SIZE);
setOffset(PAGE_SIZE);
})
.finally(() => setLoading(false));
}, [genre]);
const { isHolding, pressBind } = useLongPressAction({
onShortPress: handlePlayAll,
onLongPress: handleShuffleAll,
});
const loadMore = useCallback(() => {
if (loadingMore || !hasMore) return;
setLoadingMore(true);
getAlbumsByGenre(genre, PAGE_SIZE, offset)
.then(data => {
setAlbums(prev => [...prev, ...data]);
setHasMore(data.length === PAGE_SIZE);
setOffset(prev => prev + PAGE_SIZE);
})
.finally(() => setLoadingMore(false));
}, [genre, offset, loadingMore, hasMore]);
const handleBack = useCallback(() => {
navigate(readAlbumDetailReturnTo(location.state) ?? '/genres');
}, [navigate, location.state]);
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [genre, albumCount, bulkLoading]);
const headerCount = useMemo(() => {
if (!loading && !hasMore && albums.length > 0) return albums.length;
if (albumCount != null) return albumCount;
if (loading) return null;
return displayAlbums.length > 0 ? displayAlbums.length : null;
}, [loading, hasMore, albums.length, albumCount, displayAlbums.length]);
const showPlayback = !loading && (displayAlbums.length > 0 || (albumCount ?? 0) > 0);
return (
<div className="content-body animate-fade-in">
<button
className="btn btn-ghost"
onClick={() => navigate(-1)}
style={{ marginBottom: '1.5rem', display: 'flex', alignItems: 'center', gap: '0.5rem' }}
>
<ArrowLeft size={16} />
<span>{t('genres.back')}</span>
</button>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem', flexWrap: 'wrap' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{genre}</h1>
{!loading && albums.length > 0 && (
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--text-secondary)', fontSize: '0.9rem', fontWeight: 500 }}>
<Disc3 size={14} style={{ color: 'var(--accent)' }} />
{t('genres.albumCount', { count: albums.length })}{hasMore ? '+' : ''}
</span>
)}
{!loading && albums.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: 'auto' }}>
<button className="btn btn-primary" onClick={handlePlayAll} disabled={bulkLoading}>
{bulkLoading ? <Loader2 size={15} className="spin" /> : <Play size={15} />} {t('common.play')}
</button>
<button
className="btn btn-surface"
onClick={handleShuffleAll}
disabled={bulkLoading}
data-tooltip={t('genres.shuffle')}
>
<Shuffle size={16} />
</button>
<button
className="btn btn-surface"
onClick={handleEnqueueAll}
disabled={bulkLoading}
data-tooltip={t('genres.addToQueue')}
>
<ListPlus size={16} />
</button>
</div>
)}
<div className={`content-body animate-fade-in mainstage-inpage-split${mainstageHeaderTight ? ' mainstage-inpage--header-tight' : ''}`}>
<div className="mainstage-inpage-toolbar">
<div className="page-sticky-header mainstage-inpage-toolbar-row">
<button
className="btn btn-ghost"
onClick={handleBack}
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginRight: '0.25rem' }}
>
<ArrowLeft size={16} />
<span>{t('genres.back')}</span>
</button>
<h1 className="page-title" style={{ marginBottom: 0 }}>{genre}</h1>
{headerCount != null && headerCount > 0 && (
<span style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', color: 'var(--text-secondary)', fontSize: '0.9rem', fontWeight: 500 }}>
<Disc3 size={14} style={{ color: 'var(--accent)' }} />
{t('genres.albumCount', { count: headerCount })}
</span>
)}
{showPlayback && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: 'auto' }}>
<button
type="button"
className="btn btn-primary long-press-play-btn"
{...pressBind}
disabled={bulkLoading}
data-tooltip={t('genres.playTooltip')}
>
<LongPressWaveOverlay active={isHolding} size="compact" />
<span className="long-press-play-btn__icon" style={{ gap: '0.35rem' }}>
{bulkLoading ? <Loader2 size={15} className="spin" /> : <Play size={15} fill="currentColor" />}
{t('common.play')}
</span>
</button>
<button
className="btn btn-surface"
onClick={handleEnqueueAll}
disabled={bulkLoading}
data-tooltip={t('genres.addToQueue')}
>
<ListPlus size={16} />
</button>
</div>
)}
</div>
</div>
{loading && <p className="loading-text">{t('genres.albumsLoading')}</p>}
{!loading && albums.length === 0 && <p className="loading-text">{t('genres.albumsEmpty')}</p>}
{albums.length > 0 && (
<VirtualCardGrid
items={albums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length}
warmGridCovers={albumGridWarmCovers()}
renderItem={album => <AlbumCard album={album} />}
/>
)}
{hasMore && !loading && (
<div style={{ display: 'flex', justifyContent: 'center', padding: '2rem 0' }}>
<button className="btn btn-surface" onClick={loadMore} disabled={loadingMore}>
{loadingMore ? t('common.loadingMore') : t('genres.loadMore')}
</button>
</div>
)}
<OverlayScrollArea
className="mainstage-inpage-scroll"
viewportClassName="mainstage-inpage-scroll__viewport"
viewportId={GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID}
viewportRef={bindGenreDetailScrollBody}
railInset="panel"
measureDeps={[
loading,
displayAlbums.length,
hasMore,
genre,
perfFlags.disableMainstageVirtualLists,
]}
>
{loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
<div className="spinner" />
</div>
) : !loading && displayAlbums.length === 0 ? (
<p className="loading-text" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
{t('genres.albumsEmpty')}
</p>
) : (
<div style={{ position: 'relative' }}>
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
<VirtualCardGrid
items={displayAlbums}
itemKey={(a, _i) => a.id}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={displayAlbums.length}
scrollRootId={GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID}
warmGridCovers={albumGridWarmCovers()}
renderItem={album => (
<AlbumCard
album={album}
observeScrollRootId={GENRE_DETAIL_INPAGE_SCROLL_VIEWPORT_ID}
/>
)}
/>
{hasMore && (
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loadingMore} />
)}
</div>
{isScrollRestorePending && (
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
justifyContent: 'center',
paddingTop: '3rem',
background: 'var(--ctp-base)',
}}
>
<div className="spinner" />
</div>
)}
</div>
)}
</OverlayScrollArea>
</div>
);
}
+33 -8
View File
@@ -1,10 +1,14 @@
import { getGenres } from '../api/subsonicGenres';
import type { SubsonicGenre } from '../api/subsonicTypes';
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Tags } from 'lucide-react';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useAuthStore } from '../store/authStore';
import { useLibraryIndexStore } from '../store/libraryIndexStore';
import { fetchGenreCatalog } from '../utils/library/genreBrowsePlayback';
import { libraryScopeForServer } from '../api/subsonicClient';
import { peekGenreCatalogCache } from '../utils/library/genreCatalogCountsCache';
const CTP_COLORS = [
'var(--ctp-rosewater)', 'var(--ctp-flamingo)', 'var(--ctp-pink)', 'var(--ctp-mauve)',
@@ -26,14 +30,35 @@ const FONT_MAX_REM = 1.7;
export default function Genres() {
const { t } = useTranslation();
const navigate = useNavigate();
const [rawGenres, setRawGenres] = useState<SubsonicGenre[]>([]);
const [loading, setLoading] = useState(true);
const serverId = useAuthStore(s => s.activeServerId ?? '');
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const libraryScope = libraryScopeForServer(serverId);
const cachedGenres = serverId ? peekGenreCatalogCache(serverId, libraryScope, true) : null;
const [rawGenres, setRawGenres] = useState<SubsonicGenre[]>(cachedGenres ?? []);
const [loading, setLoading] = useState(!cachedGenres);
useEffect(() => {
getGenres()
.then(data => setRawGenres(data))
.finally(() => setLoading(false));
}, []);
let cancelled = false;
const scope = libraryScopeForServer(serverId);
const cached = serverId ? peekGenreCatalogCache(serverId, scope, true) : null;
if (cached) {
setRawGenres(cached);
setLoading(false);
} else {
setLoading(true);
}
void fetchGenreCatalog(serverId, indexEnabled)
.then(data => {
if (!cancelled) setRawGenres(data);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [serverId, indexEnabled, musicLibraryFilterVersion]);
const genres = useMemo(
() => [...rawGenres].sort((a, b) => b.albumCount - a.albumCount),
@@ -62,7 +87,7 @@ export default function Genres() {
const handleGenreClick = (genreValue: string) => {
const el = document.getElementById(APP_MAIN_SCROLL_VIEWPORT_ID);
if (el) sessionStorage.setItem(SCROLL_KEY, String(el.scrollTop));
navigate(`/genres/${encodeURIComponent(genreValue)}`);
navigate(`/genres/${encodeURIComponent(genreValue)}`, { state: { returnTo: '/genres' } });
};
return (