mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(album): co-locate NewReleases + MostPlayed browse pages into features/album
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
import { getAlbumList } from '@/lib/api/subsonicLibrary';
|
||||
import { resolveAlbum } from '@/features/offline';
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
import { songToTrack } from '@/lib/media/songToTrack';
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowUpDown, ArrowDown, ArrowUp, TrendingUp, UsersRound, Play, ListPlus } from 'lucide-react';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
|
||||
import { ArtistCoverArtImage } from '@/cover/ArtistCoverArtImage';
|
||||
import { playAlbum, playAlbumShuffled } from '@/features/playback/utils/playback/playAlbum';
|
||||
import { useLongPressAction } from '@/lib/hooks/useLongPressAction';
|
||||
import { LongPressWaveOverlay } from '@/components/LongPressWaveOverlay';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeaderArtistRefs';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
interface ArtistEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
coverArt?: string;
|
||||
totalPlays: number;
|
||||
}
|
||||
|
||||
const COMPILATION_NAMES = new Set([
|
||||
'various artists', 'various', 'va', 'v.a.', 'v.a',
|
||||
'diverse artister', 'diversos artistas', 'artistes variés',
|
||||
'vários artistas', 'verschiedene künstler', 'verscheidene artiesten',
|
||||
'compilations', 'soundtrack', 'original soundtrack', 'ost',
|
||||
'original motion picture soundtrack', 'original score',
|
||||
]);
|
||||
|
||||
function isCompilation(name: string): boolean {
|
||||
return COMPILATION_NAMES.has(name.toLowerCase().trim());
|
||||
}
|
||||
|
||||
function deriveTopArtists(albums: SubsonicAlbum[], filterCompilations: boolean): ArtistEntry[] {
|
||||
const map = new Map<string, ArtistEntry>();
|
||||
for (const a of albums) {
|
||||
const plays = a.playCount ?? 0;
|
||||
if (plays === 0) continue;
|
||||
if (filterCompilations && isCompilation(a.artist ?? '')) continue;
|
||||
const entry = map.get(a.artistId);
|
||||
if (entry) {
|
||||
entry.totalPlays += plays;
|
||||
if (!entry.coverArt && a.coverArt) entry.coverArt = a.coverArt;
|
||||
} else {
|
||||
map.set(a.artistId, { id: a.artistId, name: a.artist, coverArt: a.coverArt, totalPlays: plays });
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) => b.totalPlays - a.totalPlays);
|
||||
}
|
||||
|
||||
function formatPlays(n: number, t: ReturnType<typeof import('react-i18next').useTranslation>['t']): string {
|
||||
return t('mostPlayed.plays', { n: n.toLocaleString() }) as string;
|
||||
}
|
||||
|
||||
/** Most-played list row cover layout px. */
|
||||
const MOST_PLAYED_COVER_CSS_PX = 80;
|
||||
|
||||
function MostPlayedPlayButton({ albumId }: { albumId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const { isHolding, pressBind } = useLongPressAction({
|
||||
onShortPress: () => playAlbum(albumId),
|
||||
onLongPress: () => playAlbumShuffled(albumId),
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="mp-album-action-btn long-press-play-btn"
|
||||
{...pressBind}
|
||||
data-tooltip={t('hero.playAlbumTooltip')}
|
||||
data-tooltip-pos="top"
|
||||
aria-label={t('hero.playAlbumTooltip')}
|
||||
>
|
||||
<LongPressWaveOverlay active={isHolding} size="compact" />
|
||||
<span className="long-press-play-btn__icon">
|
||||
<Play size={14} fill="currentColor" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default function MostPlayed() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const openContextMenu = usePlayerStore(s => s.openContextMenu);
|
||||
const enqueue = usePlayerStore(s => s.enqueue);
|
||||
|
||||
const handleEnqueueAlbum = useCallback(async (albumId: string) => {
|
||||
if (!activeServerId) return;
|
||||
try {
|
||||
const data = await resolveAlbum(activeServerId, albumId);
|
||||
if (!data) return;
|
||||
enqueue(data.songs.map(songToTrack));
|
||||
} catch {
|
||||
// Network failure — silent (toast would be too noisy for a hover action).
|
||||
}
|
||||
}, [activeServerId, enqueue]);
|
||||
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [sortAsc, setSortAsc] = useState(false); // false = most plays first
|
||||
const [filterCompilations, setFilterCompilations] = useState(false);
|
||||
|
||||
const topArtists = deriveTopArtists(albums, filterCompilations).slice(0, 10);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setAlbums([]);
|
||||
setHasMore(true);
|
||||
try {
|
||||
const result = await getAlbumList('frequent', PAGE_SIZE, 0);
|
||||
setAlbums(result);
|
||||
setHasMore(result.length === PAGE_SIZE);
|
||||
} catch { /* ignore: best-effort */ }
|
||||
setLoading(false);
|
||||
// musicLibraryFilterVersion is an intentional re-create trigger: getAlbumList
|
||||
// reads the active library filter internally, so `load` must refresh (and the
|
||||
// mount effect re-run) when that version bumps even though it is unused here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const result = await getAlbumList('frequent', PAGE_SIZE, albums.length);
|
||||
setAlbums(prev => [...prev, ...result]);
|
||||
setHasMore(result.length === PAGE_SIZE);
|
||||
} catch { /* ignore: best-effort */ }
|
||||
setLoadingMore(false);
|
||||
};
|
||||
|
||||
const sorted = sortAsc ? [...albums].reverse() : albums;
|
||||
const withPlays = sorted.filter(a => (a.playCount ?? 0) > 0);
|
||||
|
||||
return (
|
||||
<div className="content-body animate-fade-in">
|
||||
<div className="mp-header">
|
||||
<div className="mp-header-left">
|
||||
<TrendingUp size={22} className="mp-header-icon" />
|
||||
<h1 className="mp-title">{t('mostPlayed.title')}</h1>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-surface mp-sort-btn"
|
||||
onClick={() => setSortAsc(v => !v)}
|
||||
aria-label={sortAsc ? t('mostPlayed.sortLeast') : t('mostPlayed.sortMost')}
|
||||
data-tooltip={sortAsc ? t('mostPlayed.sortMost') : t('mostPlayed.sortLeast')}
|
||||
>
|
||||
{sortAsc ? <ArrowUp size={14} /> : <ArrowDown size={14} />}
|
||||
<span className="compact-btn-label">{sortAsc ? t('mostPlayed.sortLeast') : t('mostPlayed.sortMost')}</span>
|
||||
<ArrowUpDown size={12} style={{ opacity: 0.45 }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* ── Top Artists ── */}
|
||||
{!loading && (
|
||||
<section className="mp-section">
|
||||
<div className="mp-section-header">
|
||||
<h2 className="mp-section-title">{t('mostPlayed.topArtists')}</h2>
|
||||
<button
|
||||
className={`btn btn-surface mp-filter-btn${filterCompilations ? ' mp-filter-btn--active' : ''}`}
|
||||
onClick={() => setFilterCompilations(v => !v)}
|
||||
aria-label={t('mostPlayed.filterCompilations')}
|
||||
data-tooltip={t('mostPlayed.filterCompilations')}
|
||||
data-tooltip-pos="left"
|
||||
>
|
||||
<UsersRound size={14} />
|
||||
<span className="compact-btn-label">{t('mostPlayed.filterCompilationsShort')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{topArtists.length === 0 && (
|
||||
<div className="empty-state" style={{ padding: '12px 0' }}>{t('mostPlayed.noArtists')}</div>
|
||||
)}
|
||||
<div className="mp-artist-grid">
|
||||
{topArtists.map((artist, i) => (
|
||||
<button
|
||||
key={artist.id}
|
||||
className="mp-artist-card"
|
||||
onClick={() => navigate(`/artist/${artist.id}`)}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, artist, 'artist');
|
||||
}}
|
||||
>
|
||||
<span className="mp-rank">{i + 1}</span>
|
||||
{artist.coverArt ? (
|
||||
<ArtistCoverArtImage
|
||||
artistId={artist.id}
|
||||
coverArt={artist.coverArt}
|
||||
displayCssPx={MOST_PLAYED_COVER_CSS_PX}
|
||||
surface="dense"
|
||||
alt=""
|
||||
className="mp-artist-avatar"
|
||||
/>
|
||||
) : (
|
||||
<div className="mp-artist-avatar mp-artist-avatar--placeholder" />
|
||||
)}
|
||||
<div className="mp-artist-info">
|
||||
<span className="mp-artist-name truncate">{artist.name}</span>
|
||||
<span className="mp-artist-plays">{formatPlays(artist.totalPlays, t)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Top Albums ── */}
|
||||
<section className="mp-section">
|
||||
<h2 className="mp-section-title">{t('mostPlayed.topAlbums')}</h2>
|
||||
|
||||
{loading ? (
|
||||
<div className="mp-loading"><div className="spinner" /></div>
|
||||
) : withPlays.length === 0 ? (
|
||||
<div className="empty-state">{t('mostPlayed.noData')}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mp-album-list">
|
||||
{withPlays.map((album, i) => (
|
||||
<div
|
||||
key={album.id}
|
||||
className="mp-album-row"
|
||||
onClick={() => navigate(`/album/${album.id}`)}
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
openContextMenu(e.clientX, e.clientY, album, 'album');
|
||||
}}
|
||||
>
|
||||
<span className="mp-album-rank">{sortAsc ? withPlays.length - i : i + 1}</span>
|
||||
{album.coverArt ? (
|
||||
<AlbumCoverArtImage
|
||||
albumId={album.id}
|
||||
coverArt={album.coverArt}
|
||||
displayCssPx={MOST_PLAYED_COVER_CSS_PX}
|
||||
surface="dense"
|
||||
alt=""
|
||||
className="mp-album-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="mp-album-cover mp-album-cover--placeholder" />
|
||||
)}
|
||||
<div className="mp-album-meta">
|
||||
<div className="mp-album-name-row">
|
||||
<span className="mp-album-name truncate">{album.name}</span>
|
||||
<span className="mp-album-plays-pill">
|
||||
<Play size={11} fill="currentColor" />
|
||||
{t('mostPlayed.plays', { n: (album.playCount ?? 0).toLocaleString() })}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="mp-album-artist truncate track-artist-link"
|
||||
onClick={e => { e.stopPropagation(); navigate(`/artist/${album.artistId}`); }}
|
||||
>
|
||||
{albumArtistDisplayName(album)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mp-album-actions">
|
||||
<MostPlayedPlayButton albumId={album.id} />
|
||||
<button
|
||||
className="mp-album-action-btn"
|
||||
onClick={e => { e.stopPropagation(); void handleEnqueueAlbum(album.id); }}
|
||||
data-tooltip={t('contextMenu.enqueueAlbum')}
|
||||
data-tooltip-pos="top"
|
||||
aria-label={t('contextMenu.enqueueAlbum')}
|
||||
>
|
||||
<ListPlus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
{album.year && <span className="mp-album-year">{album.year}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
className="btn btn-ghost mp-load-more"
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
{loadingMore ? <div className="spinner" style={{ width: 14, height: 14, borderTopColor: 'currentColor' }} /> : null}
|
||||
{t('mostPlayed.loadMore')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
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 { useEffect, useLayoutEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { Download, HardDriveDownload } from 'lucide-react';
|
||||
import SelectionToggleButton from '@/components/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 { 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 { useMainstageInpageHeaderTight } from '@/hooks/useMainstageInpageHeaderTight';
|
||||
import { albumGridWarmCovers } from '@/cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '@/components/VirtualCardGrid';
|
||||
import OverlayScrollArea from '@/ui/OverlayScrollArea';
|
||||
import { NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID } from '@/constants/appScroll';
|
||||
import { useAsyncInpagePagination } from '@/hooks/useAsyncInpagePagination';
|
||||
import { useInpageScrollSentinel } from '@/hooks/useInpageScrollSentinel';
|
||||
import { useInpageScrollViewport } from '@/hooks/useInpageScrollViewport';
|
||||
import InpageScrollSentinel from '@/components/InpageScrollSentinel';
|
||||
import { useAlbumGridBrowseFilters, type AlbumGridBrowseSnapshot } from '@/features/album/hooks/useAlbumGridBrowseFilters';
|
||||
import { useAlbumBrowseScrollRestore } from '@/features/album/hooks/useAlbumBrowseScrollRestore';
|
||||
import { useAlbumBrowseScrollReset } from '@/features/album/hooks/useAlbumBrowseScrollReset';
|
||||
import { useBrowseAlbumTextSearch } from '@/features/album/hooks/useBrowseAlbumTextSearch';
|
||||
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '@/features/album/hooks/useAlbumBrowseFilters';
|
||||
import { readAlbumBrowseRestore } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeaderArtistRefs';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import { filterAlbumsByGenres } from '@/lib/library/albumBrowseFilters';
|
||||
import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore';
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
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)));
|
||||
return dedupeById(results.flat()).sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
|
||||
}
|
||||
|
||||
export default function NewReleases() {
|
||||
const { t } = useTranslation();
|
||||
const perfFlags = usePerfProbeFlags();
|
||||
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
|
||||
const auth = useAuthStore();
|
||||
const serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const indexEnabled = useLibraryIndexStore(s => s.isIndexEnabled(serverId));
|
||||
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: true });
|
||||
const {
|
||||
selectedGenres,
|
||||
setSelectedGenres,
|
||||
initialAlbums,
|
||||
initialHasMore,
|
||||
} = useAlbumGridBrowseFilters(serverId, 'new-releases', scrollSnapshotRef, gridSnapshotRef);
|
||||
const restoringSessionRef = useRef(initialAlbums != null);
|
||||
|
||||
const newReleasesSearchQuery = useScopedBrowseSearchQuery('newReleases');
|
||||
const { textSearchAlbums, textSearchLoading } = useBrowseAlbumTextSearch(
|
||||
newReleasesSearchQuery,
|
||||
indexEnabled,
|
||||
serverId,
|
||||
);
|
||||
const textSearchActive = textSearchAlbums != null;
|
||||
const scopedSearchQuery = newReleasesSearchQuery.trim();
|
||||
const albumBrowsePlainLayout =
|
||||
perfFlags.disableMainstageVirtualLists
|
||||
|| textSearchActive
|
||||
|| scopedSearchQuery.length > 0;
|
||||
|
||||
const [albums, setAlbums] = useState<SubsonicAlbum[]>(() => initialAlbums ?? []);
|
||||
const [hasMore, setHasMore] = useState(() => initialHasMore ?? true);
|
||||
const {
|
||||
scrollBodyEl,
|
||||
bindScrollBody: bindNewReleasesScrollBody,
|
||||
getScrollRoot,
|
||||
} = useInpageScrollViewport();
|
||||
const {
|
||||
loading,
|
||||
setLoading,
|
||||
resetPage,
|
||||
runLoad,
|
||||
requestNextPage,
|
||||
isBlocked,
|
||||
} = useAsyncInpagePagination(PAGE_SIZE, { initialLoading: initialAlbums == null });
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const genreFiltered = selectedGenres.length > 0;
|
||||
|
||||
const displayAlbums = useMemo(() => {
|
||||
if (textSearchActive && textSearchAlbums) {
|
||||
return genreFiltered
|
||||
? filterAlbumsByGenres(textSearchAlbums, selectedGenres)
|
||||
: textSearchAlbums;
|
||||
}
|
||||
return albums;
|
||||
}, [textSearchActive, textSearchAlbums, albums, genreFiltered, selectedGenres]);
|
||||
|
||||
const loadingGrid = textSearchActive ? textSearchLoading : loading;
|
||||
const gridHasMore = textSearchActive ? false : (!genreFiltered && hasMore);
|
||||
|
||||
// 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: displayAlbums, hasMore: gridHasMore };
|
||||
useAlbumBrowseScrollSnapshotSync(scrollSnapshotRef, scrollBodyEl, displayAlbums.length);
|
||||
|
||||
const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [
|
||||
newReleasesSearchQuery,
|
||||
genreFiltered,
|
||||
selectionMode,
|
||||
selectedGenres,
|
||||
]);
|
||||
|
||||
const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(displayAlbums);
|
||||
|
||||
const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
|
||||
const clearSelection = () => { setSelectionMode(false); resetSelection(); };
|
||||
const selectedAlbums = displayAlbums.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 (offset: number, append = false) => {
|
||||
await runLoad(async () => {
|
||||
const data = await getAlbumList('newest', PAGE_SIZE, offset);
|
||||
if (append) setAlbums(prev => [...prev, ...data]);
|
||||
else setAlbums(data);
|
||||
setHasMore(data.length === PAGE_SIZE);
|
||||
});
|
||||
}, [runLoad]);
|
||||
|
||||
const loadFiltered = useCallback(async (genres: string[]) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setAlbums(await fetchByGenres(genres));
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// musicLibraryFilterVersion is an intentional re-create trigger (fetchByGenres
|
||||
// reads the active library filter internally); the setters are stable. The
|
||||
// loader must refresh when that version bumps even though it is unused here.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [musicLibraryFilterVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (restoringSessionRef.current || scopedSearchQuery) return;
|
||||
if (genreFiltered) loadFiltered(selectedGenres);
|
||||
else {
|
||||
resetPage();
|
||||
void load(0);
|
||||
}
|
||||
}, [genreFiltered, selectedGenres, load, loadFiltered, resetPage, scopedSearchQuery]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!gridHasMore || genreFiltered || textSearchActive || isBlocked()) return;
|
||||
requestNextPage(offset => load(offset, true));
|
||||
}, [gridHasMore, genreFiltered, textSearchActive, isBlocked, requestNextPage, load]);
|
||||
|
||||
const bindLoadMoreSentinel = useInpageScrollSentinel({
|
||||
active: gridHasMore,
|
||||
getScrollRoot,
|
||||
scrollRootEl: scrollBodyEl,
|
||||
onIntersect: loadMore,
|
||||
});
|
||||
|
||||
const { isScrollRestorePending } = useAlbumBrowseScrollRestore({
|
||||
serverId,
|
||||
surface: 'new-releases',
|
||||
scrollBodyEl,
|
||||
displayAlbumsLength: displayAlbums.length,
|
||||
loading: loadingGrid,
|
||||
loadingMore: loadingGrid,
|
||||
hasMore: gridHasMore,
|
||||
loadMore,
|
||||
});
|
||||
|
||||
useAlbumBrowseScrollReset({
|
||||
scrollSnapshotRef,
|
||||
getScrollRoot,
|
||||
isScrollRestorePending,
|
||||
resetKey: [newReleasesSearchQuery, selectedGenres.join('\u0001'), serverId].join('|'),
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
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('sidebar.newReleases')}
|
||||
</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}>
|
||||
<HardDriveDownload size={15} />
|
||||
{t('albums.addOffline')}
|
||||
</button>
|
||||
<button className="btn btn-surface albums-selection-action-btn" onClick={handleDownloadZips}>
|
||||
<Download size={15} />
|
||||
{t('albums.downloadZips')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<GenreFilterBar selected={selectedGenres} onSelectionChange={setSelectedGenres} />
|
||||
)}
|
||||
<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={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
viewportRef={bindNewReleasesScrollBody}
|
||||
railInset="panel"
|
||||
measureDeps={[
|
||||
loadingGrid,
|
||||
displayAlbums.length,
|
||||
genreFiltered,
|
||||
gridHasMore,
|
||||
selectionMode,
|
||||
newReleasesSearchQuery,
|
||||
albumBrowsePlainLayout,
|
||||
]}
|
||||
>
|
||||
{loadingGrid && displayAlbums.length === 0 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
|
||||
<div className="spinner" />
|
||||
</div>
|
||||
) : !loadingGrid && displayAlbums.length === 0 && !genreFiltered && !scopedSearchQuery ? (
|
||||
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
||||
{t('common.libraryEmpty')}
|
||||
</div>
|
||||
) : !loadingGrid && textSearchActive && displayAlbums.length === 0 ? (
|
||||
<div className="empty-state" style={{ padding: '3rem 1rem', textAlign: 'center' }}>
|
||||
{t('albums.noMatchingFilters')}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ visibility: isScrollRestorePending ? 'hidden' : 'visible' }}>
|
||||
<VirtualCardGrid
|
||||
items={displayAlbums}
|
||||
itemKey={(a, _i) => a.id}
|
||||
rowVariant="album"
|
||||
disableVirtualization={albumBrowsePlainLayout}
|
||||
layoutSignal={displayAlbums.length}
|
||||
scrollRootId={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
warmGridCovers={albumGridWarmCovers()}
|
||||
renderItem={a => (
|
||||
<AlbumCard
|
||||
album={a}
|
||||
observeScrollRootId={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID}
|
||||
selectionMode={selectionMode}
|
||||
selected={selectedIds.has(a.id)}
|
||||
onToggleSelect={toggleSelect}
|
||||
selectedAlbums={selectedAlbums}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{gridHasMore && (
|
||||
<InpageScrollSentinel bindSentinel={bindLoadMoreSentinel} loading={loadingGrid} />
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user