diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx
index 94d8d78f..d768e306 100644
--- a/src/components/AlbumCard.tsx
+++ b/src/components/AlbumCard.tsx
@@ -1,6 +1,6 @@
import React, { memo } from 'react';
import { useNavigate } from 'react-router-dom';
-import { Play, HardDriveDownload } from 'lucide-react';
+import { Play, HardDriveDownload, Check } from 'lucide-react';
import { SubsonicAlbum, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
@@ -11,9 +11,12 @@ import { useDragDrop } from '../contexts/DragDropContext';
interface AlbumCardProps {
album: SubsonicAlbum;
+ selected?: boolean;
+ selectionMode?: boolean;
+ onToggleSelect?: (id: string) => void;
}
-function AlbumCard({ album }: AlbumCardProps) {
+function AlbumCard({ album, selected, selectionMode, onToggleSelect }: AlbumCardProps) {
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const serverId = useAuthStore(s => s.activeServerId ?? '');
@@ -25,20 +28,26 @@ function AlbumCard({ album }: AlbumCardProps) {
const coverUrl = album.coverArt ? buildCoverArtUrl(album.coverArt, 300) : '';
const psyDrag = useDragDrop();
+ const handleClick = () => {
+ if (selectionMode) { onToggleSelect?.(album.id); return; }
+ navigate(`/album/${album.id}`);
+ };
+
return (
navigate(`/album/${album.id}`)}
+ className={`album-card card${selectionMode ? ' album-card--selectable' : ''}${selected ? ' album-card--selected' : ''}`}
+ onClick={handleClick}
role="button"
tabIndex={0}
aria-label={`${album.name} von ${album.artist}`}
- onKeyDown={e => e.key === 'Enter' && navigate(`/album/${album.id}`)}
+ onKeyDown={e => e.key === 'Enter' && handleClick()}
onContextMenu={(e) => {
+ if (selectionMode) { e.preventDefault(); return; }
e.preventDefault();
openContextMenu(e.clientX, e.clientY, album, 'album');
}}
onMouseDown={e => {
- if (e.button !== 0) return;
+ if (selectionMode || e.button !== 0) return;
e.preventDefault();
const sx = e.clientX, sy = e.clientY;
const onMove = (me: MouseEvent) => {
@@ -64,20 +73,27 @@ function AlbumCard({ album }: AlbumCardProps) {
)}
- {isOffline && (
+ {isOffline && !selectionMode && (
{album.name}
diff --git a/src/locales/de.ts b/src/locales/de.ts
index 9a74d3b3..28ee7194 100644
--- a/src/locales/de.ts
+++ b/src/locales/de.ts
@@ -244,6 +244,17 @@ export const deTranslation = {
yearTo: 'Bis',
yearFilterClear: 'Jahresfilter zurücksetzen',
yearFilterLabel: 'Jahr',
+ select: 'Mehrfachauswahl',
+ startSelect: 'Mehrfachauswahl aktivieren',
+ cancelSelect: 'Abbrechen',
+ selectionCount: '{{count}} ausgewählt',
+ downloadZips: 'ZIPs herunterladen',
+ addOffline: 'Offline hinzufügen',
+ downloadingZip: 'Lade {{current}}/{{total}} herunter: {{name}}',
+ downloadZipDone: '{{count}} ZIP(s) heruntergeladen',
+ downloadZipFailed: 'Download fehlgeschlagen: {{name}}',
+ offlineQueuing: '{{count}} Album(s) für Offline einreihen…',
+ offlineFailed: '{{name}} konnte nicht offline hinzugefügt werden',
},
artists: {
title: 'Künstler',
diff --git a/src/locales/en.ts b/src/locales/en.ts
index 5b1fa647..220d11e4 100644
--- a/src/locales/en.ts
+++ b/src/locales/en.ts
@@ -245,6 +245,17 @@ export const enTranslation = {
yearTo: 'To',
yearFilterClear: 'Clear year filter',
yearFilterLabel: 'Year',
+ select: 'Multi-select',
+ startSelect: 'Enable multi-select',
+ cancelSelect: 'Cancel',
+ selectionCount: '{{count}} selected',
+ downloadZips: 'Download ZIPs',
+ addOffline: 'Add Offline',
+ downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
+ downloadZipDone: '{{count}} ZIP(s) downloaded',
+ downloadZipFailed: 'Failed to download {{name}}',
+ offlineQueuing: 'Queuing {{count}} album(s) for offline…',
+ offlineFailed: 'Failed to add {{name}} offline',
},
artists: {
title: 'Artists',
diff --git a/src/locales/fr.ts b/src/locales/fr.ts
index a596e643..efab16e6 100644
--- a/src/locales/fr.ts
+++ b/src/locales/fr.ts
@@ -244,6 +244,17 @@ export const frTranslation = {
yearTo: 'À',
yearFilterClear: 'Effacer le filtre année',
yearFilterLabel: 'Année',
+ select: 'Multi-select',
+ startSelect: 'Enable multi-select',
+ cancelSelect: 'Cancel',
+ selectionCount: '{{count}} selected',
+ downloadZips: 'Download ZIPs',
+ addOffline: 'Add Offline',
+ downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
+ downloadZipDone: '{{count}} ZIP(s) downloaded',
+ downloadZipFailed: 'Failed to download {{name}}',
+ offlineQueuing: 'Queuing {{count}} album(s) for offline…',
+ offlineFailed: 'Failed to add {{name}} offline',
},
artists: {
title: 'Artistes',
diff --git a/src/locales/nb.ts b/src/locales/nb.ts
index a5f8cf5e..11c2ce01 100644
--- a/src/locales/nb.ts
+++ b/src/locales/nb.ts
@@ -243,7 +243,18 @@ export const nbTranslation = {
yearFrom: 'Fra',
yearTo: 'Til',
yearFilterClear: 'Tøm år filteret',
- yearFilterLabel: 'År',
+ yearFilterLabel: 'År',
+ select: 'Multi-select',
+ startSelect: 'Enable multi-select',
+ cancelSelect: 'Cancel',
+ selectionCount: '{{count}} selected',
+ downloadZips: 'Download ZIPs',
+ addOffline: 'Add Offline',
+ downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
+ downloadZipDone: '{{count}} ZIP(s) downloaded',
+ downloadZipFailed: 'Failed to download {{name}}',
+ offlineQueuing: 'Queuing {{count}} album(s) for offline…',
+ offlineFailed: 'Failed to add {{name}} offline',
},
artists: {
title: 'Artister',
diff --git a/src/locales/nl.ts b/src/locales/nl.ts
index 5323109b..099a8e43 100644
--- a/src/locales/nl.ts
+++ b/src/locales/nl.ts
@@ -244,6 +244,17 @@ export const nlTranslation = {
yearTo: 'Tot',
yearFilterClear: 'Jaarfilter wissen',
yearFilterLabel: 'Jaar',
+ select: 'Multi-select',
+ startSelect: 'Enable multi-select',
+ cancelSelect: 'Cancel',
+ selectionCount: '{{count}} selected',
+ downloadZips: 'Download ZIPs',
+ addOffline: 'Add Offline',
+ downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
+ downloadZipDone: '{{count}} ZIP(s) downloaded',
+ downloadZipFailed: 'Failed to download {{name}}',
+ offlineQueuing: 'Queuing {{count}} album(s) for offline…',
+ offlineFailed: 'Failed to add {{name}} offline',
},
artists: {
title: 'Artiesten',
diff --git a/src/locales/ru.ts b/src/locales/ru.ts
index 33c12fc9..ebf6ded5 100644
--- a/src/locales/ru.ts
+++ b/src/locales/ru.ts
@@ -253,6 +253,17 @@ export const ruTranslation = {
yearTo: 'По',
yearFilterClear: 'Сбросить год',
yearFilterLabel: 'Год',
+ select: 'Multi-select',
+ startSelect: 'Enable multi-select',
+ cancelSelect: 'Cancel',
+ selectionCount: '{{count}} selected',
+ downloadZips: 'Download ZIPs',
+ addOffline: 'Add Offline',
+ downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
+ downloadZipDone: '{{count}} ZIP(s) downloaded',
+ downloadZipFailed: 'Failed to download {{name}}',
+ offlineQueuing: 'Queuing {{count}} album(s) for offline…',
+ offlineFailed: 'Failed to add {{name}} offline',
},
artists: {
title: 'Исполнители',
diff --git a/src/locales/zh.ts b/src/locales/zh.ts
index 134ded62..3c8e6785 100644
--- a/src/locales/zh.ts
+++ b/src/locales/zh.ts
@@ -244,6 +244,17 @@ export const zhTranslation = {
yearTo: '到',
yearFilterClear: '清除年份筛选',
yearFilterLabel: '年份',
+ select: 'Multi-select',
+ startSelect: 'Enable multi-select',
+ cancelSelect: 'Cancel',
+ selectionCount: '{{count}} selected',
+ downloadZips: 'Download ZIPs',
+ addOffline: 'Add Offline',
+ downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
+ downloadZipDone: '{{count}} ZIP(s) downloaded',
+ downloadZipFailed: 'Failed to download {{name}}',
+ offlineQueuing: 'Queuing {{count}} album(s) for offline…',
+ offlineFailed: 'Failed to add {{name}} offline',
},
artists: {
title: '艺术家',
diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx
index dcb920ef..d04afb0d 100644
--- a/src/pages/Albums.tsx
+++ b/src/pages/Albums.tsx
@@ -1,16 +1,25 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
-import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
+import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
-import { X } from 'lucide-react';
+import { useOfflineStore } from '../store/offlineStore';
+import { useDownloadModalStore } from '../store/downloadModalStore';
+import { writeFile } from '@tauri-apps/plugin-fs';
+import { join } from '@tauri-apps/api/path';
+import { showToast } from '../utils/toast';
+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();
@@ -20,6 +29,11 @@ async function fetchByGenres(genres: string[]): Promise {
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();
+ const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
+
const [albums, setAlbums] = useState([]);
const [sort, setSort] = useState('alphabeticalByName');
const [loading, setLoading] = useState(true);
@@ -30,6 +44,73 @@ export default function Albums() {
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;
+
+ let done = 0;
+ for (const album of selectedAlbums) {
+ showToast(t('albums.downloadingZip', { current: done + 1, total: selectedAlbums.length, name: album.name }), 8000, 'info');
+ try {
+ const url = buildDownloadUrl(album.id);
+ const response = await fetch(url);
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
+ const blob = await response.blob();
+ const buffer = await blob.arrayBuffer();
+ const path = await join(folder, `${sanitizeFilename(album.name)}.zip`);
+ await writeFile(path, new Uint8Array(buffer));
+ done++;
+ } catch (e) {
+ console.error('ZIP download failed for', album.name, e);
+ showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
+ }
+ }
+ showToast(t('albums.downloadZipDone', { count: done }), 4000, 'info');
+ clearSelection();
+ };
+
+ 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);
@@ -108,58 +189,87 @@ export default function Albums() {
return (
-
{t('albums.title')}
+
+ {selectionMode && selectedIds.size > 0
+ ? t('albums.selectionCount', { count: selectedIds.size })
+ : t('albums.title')}
+
- {!yearActive && sortOptions.map(o => (
-
- ))}
-
- {/* Year range filter */}
-
-
- {t('albums.yearFilterLabel')}
-
- setYearFrom(e.target.value)}
- style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
- />
- –
- setYearTo(e.target.value)}
- style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
- />
- {yearActive && (
-
+
+
+ {t('albums.downloadZips')}
+
+ >
+ ) : (
+ <>
+ {!yearActive && sortOptions.map(o => (
+
setSort(o.value)}
+ style={sort === o.value ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
+ >
+ {o.label}
+
+ ))}
-
+
+
+ {t('albums.yearFilterLabel')}
+
+ setYearFrom(e.target.value)}
+ style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
+ />
+ –
+ setYearTo(e.target.value)}
+ style={{ width: 68, padding: '4px 6px', fontSize: 12 }}
+ />
+ {yearActive && (
+
+
+
+ )}
+
+
+
+ >
+ )}
+
+
+
+ {selectionMode ? t('albums.cancelSelect') : t('albums.select')}
+
@@ -170,7 +280,15 @@ export default function Albums() {
) : (
<>
- {albums.map(a =>
)}
+ {albums.map(a => (
+
+ ))}
{!genreFiltered && (
@@ -179,6 +297,7 @@ export default function Albums() {
)}
>
)}
+
);
}
diff --git a/src/pages/NewReleases.tsx b/src/pages/NewReleases.tsx
index e896a54c..44ce8dde 100644
--- a/src/pages/NewReleases.tsx
+++ b/src/pages/NewReleases.tsx
@@ -1,12 +1,22 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
+import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
-import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
+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 { writeFile } from '@tauri-apps/plugin-fs';
+import { join } from '@tauri-apps/api/path';
+import { showToast } from '../utils/toast';
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)));
const seen = new Set();
@@ -17,6 +27,11 @@ async function fetchByGenres(genres: string[]): Promise {
export default function NewReleases() {
const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
+ const auth = useAuthStore();
+ const serverId = useAuthStore(s => s.activeServerId ?? '');
+ const { downloadAlbum } = useOfflineStore();
+ const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
+
const [albums, setAlbums] = useState([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
@@ -25,6 +40,53 @@ export default function NewReleases() {
const observerTarget = useRef(null);
const filtered = selectedGenres.length > 0;
+ 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;
+ let done = 0;
+ for (const album of selectedAlbums) {
+ showToast(t('albums.downloadingZip', { current: done + 1, total: selectedAlbums.length, name: album.name }), 8000, 'info');
+ try {
+ const blob = await fetch(buildDownloadUrl(album.id)).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); });
+ const path = await join(folder, `${sanitizeFilename(album.name)}.zip`);
+ await writeFile(path, new Uint8Array(await blob.arrayBuffer()));
+ done++;
+ } catch (e) {
+ console.error('ZIP download failed for', album.name, e);
+ showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
+ }
+ }
+ showToast(t('albums.downloadZipDone', { count: done }), 4000, 'info');
+ clearSelection();
+ };
+
+ 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) => {
setLoading(true);
try {
@@ -71,8 +133,37 @@ export default function NewReleases() {
return (
-
{t('sidebar.newReleases')}
-
+
+ {selectionMode && selectedIds.size > 0
+ ? t('albums.selectionCount', { count: selectedIds.size })
+ : t('sidebar.newReleases')}
+
+
+ {selectionMode && selectedIds.size > 0 ? (
+ <>
+
+
+ {t('albums.addOffline')}
+
+
+
+ {t('albums.downloadZips')}
+
+ >
+ ) : (
+
+ )}
+
+
+ {selectionMode ? t('albums.cancelSelect') : t('albums.select')}
+
+
{loading && albums.length === 0 ? (
@@ -82,7 +173,15 @@ export default function NewReleases() {
) : (
<>
- {albums.map(a =>
)}
+ {albums.map(a => (
+
+ ))}
{!filtered && (
diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx
index bb939c9f..9404c444 100644
--- a/src/pages/RandomAlbums.tsx
+++ b/src/pages/RandomAlbums.tsx
@@ -1,18 +1,26 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
-import { RefreshCw } from 'lucide-react';
-import { getAlbumList, getAlbumsByGenre, SubsonicAlbum } from '../api/subsonic';
+import { RefreshCw, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
+import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
+import { useOfflineStore } from '../store/offlineStore';
+import { useDownloadModalStore } from '../store/downloadModalStore';
+import { writeFile } from '@tauri-apps/plugin-fs';
+import { join } from '@tauri-apps/api/path';
+import { showToast } from '../utils/toast';
const ALBUM_COUNT = 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)));
const seen = new Set();
const union = results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; });
- // Fisher-Yates shuffle
for (let i = union.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[union[i], union[j]] = [union[j], union[i]];
@@ -23,20 +31,70 @@ async function fetchByGenres(genres: string[]): Promise {
export default function RandomAlbums() {
const { t } = useTranslation();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
+ const auth = useAuthStore();
+ const serverId = useAuthStore(s => s.activeServerId ?? '');
+ const { downloadAlbum } = useOfflineStore();
+ const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
+
const [albums, setAlbums] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedGenres, setSelectedGenres] = useState([]);
const loadingRef = useRef(false);
const filtered = selectedGenres.length > 0;
+ 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;
+ let done = 0;
+ for (const album of selectedAlbums) {
+ showToast(t('albums.downloadingZip', { current: done + 1, total: selectedAlbums.length, name: album.name }), 8000, 'info');
+ try {
+ const blob = await fetch(buildDownloadUrl(album.id)).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.blob(); });
+ const path = await join(folder, `${sanitizeFilename(album.name)}.zip`);
+ await writeFile(path, new Uint8Array(await blob.arrayBuffer()));
+ done++;
+ } catch (e) {
+ console.error('ZIP download failed for', album.name, e);
+ showToast(t('albums.downloadZipFailed', { name: album.name }), 4000, 'error');
+ }
+ }
+ showToast(t('albums.downloadZipDone', { count: done }), 4000, 'info');
+ clearSelection();
+ };
+
+ 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 (genres: string[]) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
- const data = genres.length > 0
- ? await fetchByGenres(genres)
- : await getAlbumList('random', ALBUM_COUNT);
+ const data = genres.length > 0 ? await fetchByGenres(genres) : await getAlbumList('random', ALBUM_COUNT);
setAlbums(data);
} catch (e) {
console.error(e);
@@ -51,17 +109,46 @@ export default function RandomAlbums() {
return (
-
{t('randomAlbums.title')}
+
+ {selectionMode && selectedIds.size > 0
+ ? t('albums.selectionCount', { count: selectedIds.size })
+ : t('randomAlbums.title')}
+
-
+ {selectionMode && selectedIds.size > 0 ? (
+ <>
+
+
+ {t('albums.addOffline')}
+
+
+
+ {t('albums.downloadZips')}
+
+ >
+ ) : (
+ <>
+
+ load(selectedGenres)}
+ disabled={loading}
+ data-tooltip={t('randomAlbums.refresh')}
+ >
+
+ {t('randomAlbums.refresh')}
+
+ >
+ )}
load(selectedGenres)}
- disabled={loading}
- data-tooltip={t('randomAlbums.refresh')}
+ className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
+ onClick={toggleSelectionMode}
+ data-tooltip={selectionMode ? t('albums.cancelSelect') : t('albums.startSelect')}
+ data-tooltip-pos="bottom"
+ style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
-
- {t('randomAlbums.refresh')}
+
+ {selectionMode ? t('albums.cancelSelect') : t('albums.select')}
@@ -72,7 +159,15 @@ export default function RandomAlbums() {
) : (
- {albums.map(a =>
)}
+ {albums.map(a => (
+
+ ))}
)}
diff --git a/src/styles/components.css b/src/styles/components.css
index ef435a71..bb021889 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -496,6 +496,76 @@
background: var(--accent);
}
+/* ── Album card selection ── */
+.album-card--selectable {
+ cursor: pointer;
+}
+
+.album-card--selected {
+ outline: 2px solid var(--accent);
+ outline-offset: -2px;
+}
+
+.album-card-select-check {
+ position: absolute;
+ top: 6px;
+ left: 6px;
+ width: 22px;
+ height: 22px;
+ border-radius: var(--radius-sm);
+ border: 2px solid rgba(255,255,255,0.7);
+ background: rgba(0,0,0,0.35);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 3;
+ pointer-events: none;
+}
+
+.album-card-select-check--on {
+ background: var(--accent);
+ border-color: var(--accent);
+ color: var(--ctp-crust);
+}
+
+/* ── Albums selection bar (portal) ── */
+.albums-selection-bar {
+ position: fixed;
+ bottom: calc(var(--player-height) + 12px);
+ left: 50%;
+ transform: translateX(-50%);
+ z-index: 9000;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ background: var(--bg-card);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-lg);
+ padding: 8px 14px;
+ box-shadow: var(--shadow-lg);
+ white-space: nowrap;
+}
+
+.albums-selection-count {
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--text-primary);
+ padding-right: 4px;
+ border-right: 1px solid var(--border-subtle);
+}
+
+.albums-selection-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.albums-selection-action-btn {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 12px;
+}
.album-card-info {
padding: var(--space-3) var(--space-4) var(--space-4);