mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
419 lines
18 KiB
TypeScript
419 lines
18 KiB
TypeScript
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
|
|
import { getAlbumsByGenre } from '@/lib/api/subsonicGenres';
|
|
import { getAlbumList } from '@/lib/api/subsonicLibrary';
|
|
import { resolveAlbum } from '@/features/offline';
|
|
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
|
import { dedupeById } from '@/lib/util/dedupeById';
|
|
import { shuffleArray } from '@/lib/util/shuffleArray';
|
|
import React, { useEffect, useLayoutEffect, useState, useCallback, useRef } from 'react';
|
|
import { RefreshCw, Download, HardDriveDownload } from 'lucide-react';
|
|
import SelectionToggleButton from '@/ui/SelectionToggleButton';
|
|
import AlbumCard from '@/features/album/components/AlbumCard';
|
|
import GenreFilterBar from '@/components/GenreFilterBar';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useLocation, useNavigate } from 'react-router-dom';
|
|
import { useAuthStore } from '@/store/authStore';
|
|
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
|
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/utils/mix/mixRatingFilter';
|
|
import { runLocalRandomAlbums, runLocalAlbumsByGenres } from '@/lib/library/browseTextSearch';
|
|
import { useOfflineStore } from '@/features/offline';
|
|
import { useDownloadModalStore } from '@/features/offline';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { join } from '@tauri-apps/api/path';
|
|
import { showToast } from '@/utils/ui/toast';
|
|
import { useZipDownloadStore } from '@/features/offline';
|
|
import { useRangeSelection } from '@/lib/hooks/useRangeSelection';
|
|
import { usePerfProbeFlags } from '@/utils/perf/perfFlags';
|
|
import { albumGridWarmCovers, COVER_DENSE_GRID_MIN_CELL_CSS_PX } from '@/cover/layoutSizes';
|
|
import {
|
|
primeAlbumCoversForDisplay,
|
|
} from '@/cover/warmDiskPeek';
|
|
import { VirtualCardGrid } from '@/components/VirtualCardGrid';
|
|
import OverlayScrollArea from '@/ui/OverlayScrollArea';
|
|
import { RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
|
|
import { useMainstageInpageHeaderTight } from '@/hooks/useMainstageInpageHeaderTight';
|
|
import { useInpageScrollViewport } from '@/hooks/useInpageScrollViewport';
|
|
import { useAlbumGridBrowseFilters, type AlbumGridBrowseSnapshot } from '@/features/album/hooks/useAlbumGridBrowseFilters';
|
|
import { useAlbumBrowseScrollRestore } from '@/features/album/hooks/useAlbumBrowseScrollRestore';
|
|
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '@/features/album/hooks/useAlbumBrowseFilters';
|
|
import { readAlbumBrowseRestore } from '@/utils/navigation/albumDetailNavigation';
|
|
import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeaderArtistRefs';
|
|
|
|
const ALBUM_COUNT = 30;
|
|
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
|
|
const ALBUM_FETCH_OVERSHOOT = 100;
|
|
/** Cap genre-union size before rating prefetch (avoids hundreds of `getArtist` calls). */
|
|
const GENRE_UNION_PREFILTER_CAP = 250;
|
|
|
|
function sanitizeFilename(name: string): string {
|
|
// eslint-disable-next-line no-control-regex -- intentional: strip control chars for safe download filenames
|
|
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download';
|
|
}
|
|
|
|
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
|
|
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
|
|
const pool = shuffleArray(dedupeById(results.flat())).slice(0, GENRE_UNION_PREFILTER_CAP);
|
|
const filtered = await filterAlbumsByMixRatings(pool, getMixMinRatingsConfigFromAuth());
|
|
return filtered.slice(0, ALBUM_COUNT);
|
|
}
|
|
|
|
/** Shared fetch logic — used by both `load` and the background reserve fill. */
|
|
async function doFetchRandomAlbums(genres: string[]): Promise<SubsonicAlbum[]> {
|
|
const mixCfg = getMixMinRatingsConfigFromAuth();
|
|
const albumMixActive = mixCfg.enabled && (mixCfg.minAlbum > 0 || mixCfg.minArtist > 0);
|
|
const randomSize = albumMixActive ? Math.max(ALBUM_COUNT * 3, ALBUM_FETCH_OVERSHOOT) : ALBUM_COUNT;
|
|
|
|
const serverId = useAuthStore.getState().activeServerId ?? '';
|
|
const indexEnabled = useLibraryIndexStore.getState().isIndexEnabled(serverId);
|
|
|
|
if (genres.length === 0 && indexEnabled && serverId) {
|
|
// Local path: SQLite ORDER BY RANDOM() LIMIT N — no network, effectively instant.
|
|
const local = await runLocalRandomAlbums(serverId, randomSize);
|
|
if (local && local.length > 0) {
|
|
return (await filterAlbumsByMixRatings(local, mixCfg)).slice(0, ALBUM_COUNT);
|
|
}
|
|
}
|
|
|
|
if (genres.length > 0 && indexEnabled && serverId) {
|
|
// Genre path: local index union + JS shuffle (avoids per-genre network requests).
|
|
const allLocal = await runLocalAlbumsByGenres(serverId, genres, 'alphabeticalByName', GENRE_UNION_PREFILTER_CAP);
|
|
if (allLocal && allLocal.length > 0) {
|
|
const pool = shuffleArray(dedupeById(allLocal)).slice(0, GENRE_UNION_PREFILTER_CAP);
|
|
return (await filterAlbumsByMixRatings(pool, mixCfg)).slice(0, ALBUM_COUNT);
|
|
}
|
|
}
|
|
|
|
// Network fallback when local index is unavailable or returned nothing.
|
|
return genres.length > 0
|
|
? fetchByGenres(genres)
|
|
: (await filterAlbumsByMixRatings(await getAlbumList('random', randomSize), mixCfg)).slice(0, ALBUM_COUNT);
|
|
}
|
|
|
|
// ── Module-level reserve: next batch pre-fetched after each Refresh ──────────
|
|
type AlbumReserve = { filterId: string; albums: SubsonicAlbum[] };
|
|
let _nextReserve: AlbumReserve | null = null;
|
|
let _reserveFilling = false;
|
|
|
|
function makeFilterId(
|
|
libraryVersion: number,
|
|
mixEnabled: boolean,
|
|
minAlbum: number,
|
|
minArtist: number,
|
|
genres: string[],
|
|
): string {
|
|
return `${libraryVersion}:${mixEnabled}:${minAlbum}:${minArtist}:${genres.join('\x01')}`;
|
|
}
|
|
|
|
/** Consume the pre-fetched reserve if the filter matches, otherwise discard it. */
|
|
function takeReserve(filterId: string): SubsonicAlbum[] | null {
|
|
if (_nextReserve?.filterId === filterId) {
|
|
const albums = _nextReserve.albums;
|
|
_nextReserve = null;
|
|
return albums;
|
|
}
|
|
_nextReserve = null;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Fire-and-forget: fetch the next batch in the background so it's ready for
|
|
* the next Refresh. Covers are NOT pre-warmed here — doing so would call
|
|
* bumpDiskSrcCache() for every reserve cover, which re-renders all useCoverArt
|
|
* subscribers on the current page and causes a visible flash ~1.5 s after load.
|
|
* Covers are warmed lazily via primeAlbumCoversForDisplay when the reserve is
|
|
* actually consumed.
|
|
*/
|
|
async function fillReserve(filterId: string, genres: string[]): Promise<void> {
|
|
if (_reserveFilling) return;
|
|
_reserveFilling = true;
|
|
try {
|
|
const albums = await doFetchRandomAlbums(genres);
|
|
_nextReserve = { filterId, albums };
|
|
} catch {
|
|
// Network or cache failure — next Refresh falls back to a fresh fetch.
|
|
} finally {
|
|
_reserveFilling = false;
|
|
}
|
|
}
|
|
|
|
export default function RandomAlbums() {
|
|
const { t } = useTranslation();
|
|
const perfFlags = usePerfProbeFlags();
|
|
const auth = useAuthStore();
|
|
const musicLibraryFilterVersion = auth.musicLibraryFilterVersion;
|
|
const mixMinRatingFilterEnabled = auth.mixMinRatingFilterEnabled;
|
|
const mixMinRatingAlbum = auth.mixMinRatingAlbum;
|
|
const mixMinRatingArtist = auth.mixMinRatingArtist;
|
|
const serverId = auth.activeServerId ?? '';
|
|
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
|
|
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
|
|
const scrollSnapshotRef = useRef<AlbumBrowseScrollSnapshot>({ scrollTop: 0, displayCount: 0 });
|
|
const gridSnapshotRef = useRef<AlbumGridBrowseSnapshot>({ albums: [], hasMore: false });
|
|
const {
|
|
selectedGenres,
|
|
setSelectedGenres,
|
|
initialAlbums,
|
|
} = useAlbumGridBrowseFilters(serverId, 'random-albums', scrollSnapshotRef, gridSnapshotRef);
|
|
const restoringSessionRef = useRef(initialAlbums != null);
|
|
|
|
const [albums, setAlbums] = useState<SubsonicAlbum[]>(() => initialAlbums ?? []);
|
|
const [loading, setLoading] = useState(() => initialAlbums == null);
|
|
const loadingRef = useRef(false);
|
|
const filtered = selectedGenres.length > 0;
|
|
const {
|
|
scrollBodyEl,
|
|
bindScrollBody: bindRandomAlbumsScrollBody,
|
|
} = useInpageScrollViewport();
|
|
|
|
const [selectionMode, setSelectionMode] = useState(false);
|
|
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums);
|
|
|
|
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
|
|
const clearSelection = () => { setSelectionMode(false); resetSelection(); };
|
|
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
|
|
|
|
const handleDownloadZips = async () => {
|
|
if (selectedAlbums.length === 0) return;
|
|
const folder = auth.downloadFolder || await requestDownloadFolder();
|
|
if (!folder) return;
|
|
const { start, complete, fail } = useZipDownloadStore.getState();
|
|
clearSelection();
|
|
for (const album of selectedAlbums) {
|
|
const downloadId = crypto.randomUUID();
|
|
const filename = `${sanitizeFilename(album.name)}.zip`;
|
|
const destPath = await join(folder, filename);
|
|
const url = buildDownloadUrl(album.id);
|
|
start(downloadId, filename);
|
|
try {
|
|
await invoke('download_zip', { id: downloadId, url, destPath });
|
|
complete(downloadId);
|
|
} catch (e) {
|
|
fail(downloadId);
|
|
console.error('ZIP download failed for', album.name, e);
|
|
showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleAddOffline = async () => {
|
|
if (selectedAlbums.length === 0) return;
|
|
let queued = 0;
|
|
for (const album of selectedAlbums) {
|
|
try {
|
|
const detail = await resolveAlbum(serverId, album.id);
|
|
if (!detail) throw new Error('album unavailable');
|
|
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId);
|
|
queued++;
|
|
} catch {
|
|
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
|
}
|
|
}
|
|
if (queued > 0) showToast(t('albums.offlineQueuing', { count: queued }), 3000, 'info');
|
|
clearSelection();
|
|
};
|
|
|
|
const load = useCallback(async (genres: string[]) => {
|
|
if (loadingRef.current) return;
|
|
loadingRef.current = true;
|
|
setLoading(true);
|
|
try {
|
|
const filterId = makeFilterId(
|
|
musicLibraryFilterVersion, mixMinRatingFilterEnabled,
|
|
mixMinRatingAlbum, mixMinRatingArtist, genres,
|
|
);
|
|
const reserved = takeReserve(filterId);
|
|
if (reserved) {
|
|
await primeAlbumCoversForDisplay(reserved, COVER_DENSE_GRID_MIN_CELL_CSS_PX);
|
|
setAlbums(reserved);
|
|
} else {
|
|
const data = await doFetchRandomAlbums(genres);
|
|
await primeAlbumCoversForDisplay(data, COVER_DENSE_GRID_MIN_CELL_CSS_PX);
|
|
setAlbums(data);
|
|
}
|
|
// Pre-fetch + disk-warm the next batch so the next Refresh is instant.
|
|
void fillReserve(filterId, genres);
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
loadingRef.current = false;
|
|
setLoading(false);
|
|
}
|
|
}, [
|
|
musicLibraryFilterVersion,
|
|
mixMinRatingFilterEnabled,
|
|
mixMinRatingAlbum,
|
|
mixMinRatingArtist,
|
|
]);
|
|
|
|
const loadRef = useRef(load);
|
|
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
|
// eslint-disable-next-line react-hooks/refs
|
|
loadRef.current = load;
|
|
useEffect(() => {
|
|
if (restoringSessionRef.current) return;
|
|
loadRef.current(selectedGenres);
|
|
}, [selectedGenres]);
|
|
|
|
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
|
|
// eslint-disable-next-line react-hooks/immutability
|
|
const handleRefresh = useCallback(() => {
|
|
if (scrollBodyEl) {
|
|
// React Compiler immutability rule: intentional imperative mutation of an external/DOM target inside an effect.
|
|
// eslint-disable-next-line react-hooks/immutability
|
|
scrollBodyEl.scrollTop = 0;
|
|
scrollBodyEl.dispatchEvent(new Event('scroll', { bubbles: false }));
|
|
}
|
|
scrollSnapshotRef.current.scrollTop = 0;
|
|
load(selectedGenres);
|
|
}, [scrollBodyEl, load, selectedGenres]);
|
|
|
|
// React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data.
|
|
// eslint-disable-next-line react-hooks/refs
|
|
gridSnapshotRef.current = { albums, hasMore: false };
|
|
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, albums.length);
|
|
|
|
const { isScrollRestorePending } = useAlbumBrowseScrollRestore({
|
|
serverId,
|
|
surface: 'random-albums',
|
|
scrollBodyEl,
|
|
displayAlbumsLength: albums.length,
|
|
loading,
|
|
loadingMore: false,
|
|
hasMore: false,
|
|
loadMore: () => {},
|
|
});
|
|
|
|
useLayoutEffect(() => {
|
|
if (!isScrollRestorePending && restoringSessionRef.current) {
|
|
restoringSessionRef.current = false;
|
|
}
|
|
}, [isScrollRestorePending]);
|
|
|
|
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 mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
|
|
filtered,
|
|
selectionMode,
|
|
selectedGenres,
|
|
]);
|
|
|
|
return (
|
|
<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">
|
|
<h1 className="page-title" style={{ marginBottom: 0 }}>
|
|
{selectionMode && selectedIds.size > 0
|
|
? t('albums.selectionCount', { count: selectedIds.size })
|
|
: t('randomAlbums.title')}
|
|
</h1>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
{selectionMode && selectedIds.size > 0 ? (
|
|
<>
|
|
<button className="btn btn-surface albums-selection-action-btn" onClick={handleAddOffline} aria-label={t('albums.addOffline')} data-tooltip={t('albums.addOffline')}>
|
|
<HardDriveDownload size={15} />
|
|
<span className="toolbar-btn-label">{t('albums.addOffline')}</span>
|
|
</button>
|
|
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips} aria-label={t('albums.downloadZips')} data-tooltip={t('albums.downloadZips')}>
|
|
<Download size={15} />
|
|
<span className="toolbar-btn-label">{t('albums.downloadZips')}</span>
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
|
<button
|
|
className="btn btn-surface"
|
|
onClick={handleRefresh}
|
|
disabled={loading}
|
|
aria-label={t('randomAlbums.refresh')}
|
|
data-tooltip={t('randomAlbums.refresh')}
|
|
>
|
|
<RefreshCw size={15} className={loading ? 'animate-spin' : ''} />
|
|
<span className="toolbar-btn-label">{t('randomAlbums.refresh')}</span>
|
|
</button>
|
|
</>
|
|
)}
|
|
<SelectionToggleButton
|
|
active={selectionMode}
|
|
onToggle={toggleSelectionMode}
|
|
selectLabel={t('albums.select')}
|
|
cancelLabel={t('albums.cancelSelect')}
|
|
startTooltip={t('albums.startSelect')}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<OverlayScrollArea
|
|
className="mainstage-inpage-scroll"
|
|
viewportClassName="mainstage-inpage-scroll__viewport"
|
|
viewportId={RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
|
|
viewportRef={bindRandomAlbumsScrollBody}
|
|
railInset="panel"
|
|
measureDeps={[
|
|
loading,
|
|
albums.length,
|
|
filtered,
|
|
selectionMode,
|
|
perfFlags.disableMainstageVirtualLists,
|
|
]}
|
|
>
|
|
{loading && albums.length === 0 ? (
|
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
|
<div className="spinner" />
|
|
</div>
|
|
) : !loading && albums.length === 0 ? (
|
|
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
|
{t('common.libraryEmpty')}
|
|
</div>
|
|
) : (
|
|
<div style={{ position: 'relative' }}>
|
|
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
|
|
<VirtualCardGrid
|
|
items={albums}
|
|
itemKey={(a, _i) => a.id}
|
|
rowVariant="album"
|
|
disableVirtualization={perfFlags.disableMainstageVirtualLists}
|
|
layoutSignal={albums.length}
|
|
scrollRootId={RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
|
|
warmGridCovers={albumGridWarmCovers()}
|
|
renderItem={a => (
|
|
<AlbumCard
|
|
album={a}
|
|
observeScrollRootId={RANDOM_ALBUMS_INPAGE_SCROLL_VIEWPORT_ID}
|
|
selectionMode={selectionMode}
|
|
selected={selectedIds.has(a.id)}
|
|
onToggleSelect={toggleSelect}
|
|
selectedAlbums={selectedAlbums}
|
|
ensurePriority="high"
|
|
/>
|
|
)}
|
|
/>
|
|
</div>
|
|
{isScrollRestorePending && (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
display: 'flex',
|
|
justifyContent: 'center',
|
|
paddingTop: '3rem',
|
|
background: 'var(--bg-app)',
|
|
}}
|
|
>
|
|
<div className="spinner" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</OverlayScrollArea>
|
|
</div>
|
|
);
|
|
}
|