import React, { useEffect, useState, useCallback, useRef } from 'react'; import AlbumCard from '../components/AlbumCard'; import GenreFilterBar from '../components/GenreFilterBar'; import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic'; import { useTranslation } from 'react-i18next'; 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/toast'; import { useZipDownloadStore } from '../store/zipDownloadStore'; import { X, CheckSquare2, Download, HardDriveDownload } from 'lucide-react'; type SortType = 'alphabeticalByName' | 'alphabeticalByArtist'; const PAGE_SIZE = 30; const CURRENT_YEAR = new Date().getFullYear(); 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))); const seen = new Set(); return results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; }); } export default function Albums() { const { t } = useTranslation(); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const auth = useAuthStore(); const serverId = useAuthStore(s => s.activeServerId ?? ''); 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 observerTarget = useRef(null); // ── Multi-selection ────────────────────────────────────────────────────── const [selectionMode, setSelectionMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const toggleSelectionMode = () => { setSelectionMode(v => !v); setSelectedIds(new Set()); }; const toggleSelect = useCallback((id: string) => { setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); }; 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 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 load = useCallback(async ( sortType: SortType, offset: number, append = false, yearFilter?: { from: number; to: number }, ) => { setLoading(true); try { const extra = yearFilter ? { fromYear: yearFilter.from, toYear: yearFilter.to } : {}; const type = yearFilter ? 'byYear' : sortType; const 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]); const loadFiltered = useCallback(async (genres: string[], sortType: SortType) => { setLoading(true); try { const data = await fetchByGenres(genres); const sorted = [...data].sort((a, b) => sortType === 'alphabeticalByArtist' ? a.artist.localeCompare(b.artist) : a.name.localeCompare(b.name) ); setAlbums(sorted); setHasMore(false); } finally { setLoading(false); } }, [musicLibraryFilterVersion]); useEffect(() => { setPage(0); if (genreFiltered) { loadFiltered(selectedGenres, sort); } else if (yearActive) { load(sort, 0, false, { from: fromNum, to: toNum }); } else { load(sort, 0); } }, [sort, genreFiltered, selectedGenres, yearActive, fromNum, toNum, 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); }, [loading, hasMore, page, sort, load, genreFiltered, yearActive, fromNum, toNum]); useEffect(() => { const observer = new IntersectionObserver( entries => { if (entries[0].isIntersecting) loadMore(); }, { rootMargin: '200px' } ); if (observerTarget.current) observer.observe(observerTarget.current); return () => observer.disconnect(); }, [loadMore]); const clearYear = () => { setYearFrom(''); setYearTo(''); }; const sortOptions: { value: SortType; label: string }[] = [ { value: 'alphabeticalByName', label: t('albums.sortByName') }, { value: 'alphabeticalByArtist', label: t('albums.sortByArtist') }, ]; return (

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

{selectionMode && selectedIds.size > 0 ? ( <> ) : ( <> {!yearActive && sortOptions.map(o => ( ))}
{t('albums.yearFilterLabel')} setYearFrom(e.target.value)} style={{ width: 76, padding: 'var(--space-2) var(--space-2)' }} /> setYearTo(e.target.value)} style={{ width: 76, padding: 'var(--space-2) var(--space-2)' }} /> {yearActive && ( )}
)}
{loading && albums.length === 0 ? (
) : ( <>
{albums.map(a => ( ))}
{!genreFiltered && (
{loading && hasMore &&
}
)} )}
); }