import { buildDownloadUrl } from '../api/subsonicStreamUrl'; import { getAlbumsByGenre } from '../api/subsonicGenres'; import { getAlbumList, getAlbum } from '../api/subsonicLibrary'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import { dedupeById } from '../utils/dedupeById'; import { useEffect, useLayoutEffect, useState, useCallback, useRef, useMemo } from 'react'; import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react'; import AlbumCard from '../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 '../store/offlineStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; 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 { useRangeSelection } from '../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 '../components/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 '../hooks/useAlbumGridBrowseFilters'; import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore'; import { useAlbumBrowseScrollReset } from '../hooks/useAlbumBrowseScrollReset'; import { useBrowseAlbumTextSearch } from '../hooks/useBrowseAlbumTextSearch'; import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters'; import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation'; import { useLibraryIndexStore } from '../store/libraryIndexStore'; import { filterAlbumsByGenres } from '../utils/library/albumBrowseFilters'; import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore'; 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()).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({ scrollTop: 0, displayCount: 0 }); const gridSnapshotRef = useRef({ 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(() => 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); 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 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(); }; 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]); 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 (

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

{selectionMode && selectedIds.size > 0 ? ( <> ) : ( )}
{loadingGrid && displayAlbums.length === 0 ? (
) : !loadingGrid && displayAlbums.length === 0 && !genreFiltered && !scopedSearchQuery ? (
{t('common.libraryEmpty')}
) : !loadingGrid && textSearchActive && displayAlbums.length === 0 ? (
{t('albums.noMatchingFilters')}
) : (
a.id} rowVariant="album" disableVirtualization={albumBrowsePlainLayout} layoutSignal={displayAlbums.length} scrollRootId={NEW_RELEASES_INPAGE_SCROLL_VIEWPORT_ID} warmGridCovers={albumGridWarmCovers()} renderItem={a => ( )} /> {gridHasMore && ( )}
{isScrollRestorePending && (
)}
)}
); }