import { buildDownloadUrl } from '../api/subsonicStreamUrl'; import { getAlbumsByGenre } from '../api/subsonicGenres'; import { getAlbumList, getAlbum } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { songToTrack } from '../utils/playback/songToTrack'; import { dedupeById } from '../utils/dedupeById'; import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from 'react'; import AlbumCard from '../components/AlbumCard'; import { albumGridWarmCovers, coverDisplayCssPxForAlbumGrid } from '../cover/layoutSizes'; import { coverPrefetchRegister } from '../cover/prefetchRegistry'; import { coverArtRef } from '../cover/ref'; import { useAuthStore } from '../store/authStore'; import { clampLibraryGridMaxColumns } from '../store/authStoreHelpers'; import { computeCardGridColumnCount } from '../utils/cardGridLayout'; import GenreFilterBar from '../components/GenreFilterBar'; import YearFilterButton from '../components/YearFilterButton'; import StarFilterButton from '../components/StarFilterButton'; import LosslessFilterButton from '../components/LosslessFilterButton'; import SortDropdown from '../components/SortDropdown'; import { useTranslation } from 'react-i18next'; import { useOfflineStore } from '../store/offlineStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; import { usePlayerStore } from '../store/playerStore'; import { invoke } from '@tauri-apps/api/core'; import { join } from '@tauri-apps/api/path'; import { showToast } from '../utils/ui/toast'; import { useZipDownloadStore } from '../store/zipDownloadStore'; import { CheckSquare2, Download, HardDriveDownload, Disc3, ListPlus } from 'lucide-react'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { useRangeSelection } from '../hooks/useRangeSelection'; import { useMainstageInpageHeaderTight } from '../hooks/useMainstageInpageHeaderTight'; import { VirtualCardGrid } from '../components/VirtualCardGrid'; import OverlayScrollArea from '../components/OverlayScrollArea'; import { ALBUMS_INPAGE_SCROLL_VIEWPORT_ID } from '../constants/appScroll'; import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { runLocalAlbumBrowsePage, runLocalAlbumsByGenres, runLocalLosslessAlbums, type AlbumBrowseSort, } from '../utils/library/browseTextSearch'; import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode'; type SortType = AlbumBrowseSort; type CompFilter = 'all' | 'only' | 'hide'; const PAGE_SIZE = 30; function sanitizeFilename(name: string): string { return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim() || 'download'; } async function fetchByGenres(genres: string[]): Promise { const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0))); return dedupeById(results.flat()); } export default function Albums() { const perfFlags = usePerfProbeFlags(); const { t } = useTranslation(); 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 [albums, setAlbums] = useState([]); const [sort, setSort] = useState('alphabeticalByName'); const [loading, setLoading] = useState(true); const [page, setPage] = useState(0); const [hasMore, setHasMore] = useState(true); const [selectedGenres, setSelectedGenres] = useState([]); const [yearFrom, setYearFrom] = useState(''); const [yearTo, setYearTo] = useState(''); const [compFilter, setCompFilter] = useState('all'); const [starredOnly, setStarredOnly] = useState(false); const [losslessOnly, setLosslessOnly] = useState(false); const observerTarget = useRef(null); const gridMeasureRef = useRef(null); const maxGridCols = useAuthStore(s => clampLibraryGridMaxColumns(s.libraryGridMaxColumns)); const [albumCellDisplayCssPx, setAlbumCellDisplayCssPx] = useState(140); const [albumGridCols, setAlbumGridCols] = useState(4); const scrollBodyRef = useRef(null); const [scrollBodyEl, setScrollBodyEl] = useState(null); const bindAlbumsScrollBody = useCallback((el: HTMLDivElement | null) => { scrollBodyRef.current = el; setScrollBodyEl(el); }, []); // ── Multi-selection ────────────────────────────────────────────────────── // selectedIds + toggleSelect come from useRangeSelection (declared after // `visibleAlbums` so Shift-click range expansion follows the visible order). const [selectionMode, setSelectionMode] = useState(false); const starredOverrides = usePlayerStore(s => s.starredOverrides); const clientFilterActive = starredOnly || compFilter !== 'all'; const visibleAlbums = useMemo(() => { let out = albums; if (compFilter === 'only') out = out.filter(a => a.isCompilation); else if (compFilter === 'hide') out = out.filter(a => !a.isCompilation); if (starredOnly) { out = out.filter(a => a.id in starredOverrides ? starredOverrides[a.id] : !!a.starred); } return out; }, [albums, compFilter, starredOnly, starredOverrides]); const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(visibleAlbums); const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); }; const clearSelection = () => { setSelectionMode(false); resetSelection(); }; const selectedAlbums = visibleAlbums.filter(a => selectedIds.has(a.id)); const enqueue = usePlayerStore(state => state.enqueue); const handleEnqueueSelected = async () => { if (selectedAlbums.length === 0) return; try { // Parallel — Navidrome handles concurrent getAlbum requests fine. const results = await Promise.all(selectedAlbums.map(a => getAlbum(a.id).catch(() => null))); const tracks = results.flatMap(r => r ? r.songs.map(songToTrack) : []); if (tracks.length > 0) { enqueue(tracks); showToast(t('albums.enqueueQueued', { count: selectedAlbums.length }), 2500, 'info'); } } finally { clearSelection(); } }; const cycleCompFilter = () => { setCompFilter(v => v === 'all' ? 'only' : v === 'only' ? 'hide' : 'all'); }; 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 getAlbum(album.id); downloadAlbum(album.id, album.name, album.artist, 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(); }; // ── Data loading ───────────────────────────────────────────────────────── const genreFiltered = selectedGenres.length > 0; const fromNum = parseInt(yearFrom, 10); const toNum = parseInt(yearTo, 10); const yearActive = !isNaN(fromNum) && !isNaN(toNum) && fromNum >= 1 && toNum >= 1; const pendingClientFilterMatch = clientFilterActive && visibleAlbums.length === 0 && hasMore && !genreFiltered; const visibleEmptyMessage = useMemo(() => { if (starredOnly) return t('albums.noFavorites'); if (compFilter === 'only') return t('albums.noCompilations'); return t('albums.noMatchingFilters'); }, [starredOnly, compFilter, t]); useLayoutEffect(() => { const el = gridMeasureRef.current; if (!el) return; const update = () => { const w = el.clientWidth; const cols = computeCardGridColumnCount(w, maxGridCols); setAlbumGridCols(cols); setAlbumCellDisplayCssPx(coverDisplayCssPxForAlbumGrid(w, maxGridCols)); }; update(); const ro = new ResizeObserver(update); ro.observe(el); return () => ro.disconnect(); }, [maxGridCols, visibleAlbums.length]); useEffect(() => { const viewportBudget = Math.max(albumGridCols * 3, albumGridCols); const refs = visibleAlbums .slice(0, viewportBudget) .flatMap(a => (a.coverArt ? [coverArtRef(a.coverArt)] : [])); return coverPrefetchRegister(refs, { surface: 'dense', priority: 'high' }); }, [visibleAlbums, albumGridCols]); const mainstageHeaderTight = useMainstageInpageHeaderTight(scrollBodyEl, [ sort, genreFiltered, yearActive, yearFrom, yearTo, compFilter, starredOnly, losslessOnly, selectionMode, selectedGenres, ]); const load = useCallback(async ( sortType: SortType, offset: number, append = false, yearFilter?: { from: number; to: number }, lossless = false, ) => { setLoading(true); try { if (lossless) { if (!indexEnabled || !serverId) { setAlbums([]); setHasMore(false); return; } if (!yearFilter) { const page = await runLocalLosslessAlbums(serverId, PAGE_SIZE, offset); if (!page) { setAlbums([]); setHasMore(false); return; } if (append) setAlbums(prev => dedupeById([...prev, ...page.albums])); else setAlbums(page.albums); setHasMore(page.hasMore); return; } const data = await runLocalAlbumBrowsePage( serverId, sortType, offset, PAGE_SIZE, yearFilter, true, ); if (data == null) { setAlbums([]); setHasMore(false); return; } if (append) setAlbums(prev => [...prev, ...data]); else setAlbums(data); setHasMore(data.length === PAGE_SIZE); return; } let data: SubsonicAlbum[] | null = null; if (indexEnabled && serverId) { data = await runLocalAlbumBrowsePage( serverId, sortType, offset, PAGE_SIZE, yearFilter, false, ); } if (data == null) { const extra = yearFilter ? { fromYear: yearFilter.from, toYear: yearFilter.to } : {}; const type = yearFilter ? 'byYear' : sortType; data = await getAlbumList(type, PAGE_SIZE, offset, extra); } if (append) setAlbums(prev => [...prev, ...data]); else setAlbums(data); setHasMore(data.length === PAGE_SIZE); } finally { setLoading(false); } }, [musicLibraryFilterVersion, indexEnabled, serverId]); const loadFiltered = useCallback(async ( genres: string[], sortType: SortType, lossless: boolean, ) => { setLoading(true); try { if (lossless) { if (!indexEnabled || !serverId) { setAlbums([]); setHasMore(false); return; } const data = await runLocalAlbumsByGenres(serverId, genres, sortType, undefined, true); setAlbums(data ?? []); setHasMore(false); return; } let data: SubsonicAlbum[] | null = null; if (indexEnabled && serverId) { data = await runLocalAlbumsByGenres(serverId, genres, sortType); } if (data == null) { data = await fetchByGenres(genres); data = [...data].sort((a, b) => sortType === 'alphabeticalByArtist' ? a.artist.localeCompare(b.artist) : a.name.localeCompare(b.name), ); } setAlbums(data); setHasMore(false); } finally { setLoading(false); } }, [musicLibraryFilterVersion, indexEnabled, serverId]); useEffect(() => { setPage(0); if (genreFiltered) { loadFiltered(selectedGenres, sort, losslessOnly); } else if (yearActive) { load(sort, 0, false, { from: fromNum, to: toNum }, losslessOnly); } else { load(sort, 0, false, undefined, losslessOnly); } }, [sort, genreFiltered, selectedGenres, yearActive, fromNum, toNum, losslessOnly, load, loadFiltered]); const loadMore = useCallback(() => { if (loading || !hasMore || genreFiltered) return; const next = page + 1; setPage(next); const yf = yearActive ? { from: fromNum, to: toNum } : undefined; load(sort, next * PAGE_SIZE, true, yf, losslessOnly); }, [ loading, hasMore, page, sort, load, genreFiltered, yearActive, fromNum, toNum, losslessOnly, ]); useEffect(() => { if (!indexEnabled && losslessOnly) setLosslessOnly(false); }, [indexEnabled, losslessOnly]); useEffect(() => { const node = observerTarget.current; if (!node) return; const root = scrollBodyRef.current; const observer = new IntersectionObserver( entries => { if (entries[0]?.isIntersecting) loadMore(); }, { root: root instanceof HTMLElement ? root : null, rootMargin: '1500px', }, ); observer.observe(node); return () => observer.disconnect(); }, [loadMore, scrollBodyEl]); useEffect(() => { if (!pendingClientFilterMatch || loading) return; loadMore(); }, [pendingClientFilterMatch, loading, loadMore]); const sortOptions: { value: SortType; label: string }[] = [ { value: 'alphabeticalByName', label: t('albums.sortByName') }, { value: 'alphabeticalByArtist', label: t('albums.sortByArtist') }, ]; return (
{!perfFlags.disableMainstageStickyHeader && (

{selectionMode && selectedIds.size > 0 ? t('albums.selectionCount', { count: selectedIds.size }) : t('albums.title')}

{selectionMode && selectedIds.size > 0 ? ( <> ) : ( <> {!yearActive && ( )} { setYearFrom(from); setYearTo(to); }} /> {indexEnabled && ( )} )}
)} {(loading && albums.length === 0) || pendingClientFilterMatch ? (
) : !loading && albums.length === 0 && !genreFiltered && !yearActive && !clientFilterActive && !losslessOnly ? (
{t('common.libraryEmpty')}
) : !loading && albums.length === 0 && losslessOnly ? (
{t('losslessAlbums.empty')}
) : !loading && visibleAlbums.length === 0 && clientFilterActive ? (
{visibleEmptyMessage}
) : ( <> {!perfFlags.disableMainstageGridCards && (
a.id} rowVariant="album" disableVirtualization={perfFlags.disableMainstageVirtualLists} layoutSignal={visibleAlbums.length} scrollRootId={ALBUMS_INPAGE_SCROLL_VIEWPORT_ID} warmGridCovers={albumGridWarmCovers( albumCellDisplayCssPx, Math.min(visibleAlbums.length, Math.max(albumGridCols * 6, 48)), )} renderItem={a => ( )} />
)} {!genreFiltered && (
{loading && hasMore &&
}
)} )}
); }