feat(playlists): add ZIP download button to playlist detail page

Mirrors the album download UX: Download (ZIP) button in the playlist
hero header, progress bar while downloading, folder picker via
downloadModalStore (remembers last used folder). Uses the same
Subsonic /rest/download.view endpoint as album downloads — it accepts
playlist IDs just as well as album IDs.

Closes #127

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-07 19:00:49 +02:00
parent a4c400cc69
commit 63a3bcd0f4
8 changed files with 79 additions and 2 deletions
+1
View File
@@ -775,6 +775,7 @@ export const deTranslation = {
removeCover: 'Foto entfernen',
coverUpdated: 'Cover aktualisiert',
metaSaved: 'Playlist aktualisiert',
downloadZip: 'Herunterladen (ZIP)',
},
radio: {
title: 'Internetradio',
+1
View File
@@ -776,6 +776,7 @@ export const enTranslation = {
removeCover: 'Remove photo',
coverUpdated: 'Cover updated',
metaSaved: 'Playlist updated',
downloadZip: 'Download (ZIP)',
},
radio: {
title: 'Internet Radio',
+1
View File
@@ -775,6 +775,7 @@ export const frTranslation = {
removeCover: 'Supprimer la photo',
coverUpdated: 'Pochette mise à jour',
metaSaved: 'Playlist mise à jour',
downloadZip: 'Télécharger (ZIP)',
},
radio: {
title: 'Radio Internet',
+1
View File
@@ -774,6 +774,7 @@ export const nbTranslation = {
removeCover: 'Fjern bilde',
coverUpdated: 'Omslag oppdatert',
metaSaved: 'Spillelisten er oppdatert',
downloadZip: 'Last ned (ZIP)',
},
radio: {
title: 'Internettradio',
+1
View File
@@ -775,6 +775,7 @@ export const nlTranslation = {
removeCover: 'Foto verwijderen',
coverUpdated: 'Omslag bijgewerkt',
metaSaved: 'Playlist bijgewerkt',
downloadZip: 'Downloaden (ZIP)',
},
radio: {
title: 'Internetradio',
+1
View File
@@ -829,6 +829,7 @@ export const ruTranslation = {
removeCover: 'Убрать фото',
coverUpdated: 'Обложка обновлена',
metaSaved: 'Плейлист сохранён',
downloadZip: 'Скачать (ZIP)',
},
radio: {
title: 'Онлайн-радио',
+1
View File
@@ -771,6 +771,7 @@ export const zhTranslation = {
removeCover: '删除照片',
coverUpdated: '封面已更新',
metaSaved: '播放列表已更新',
downloadZip: '下载 (ZIP)',
},
radio: {
title: '网络电台',
+72 -2
View File
@@ -1,24 +1,35 @@
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera } from 'lucide-react';
import { ChevronDown, ChevronLeft, Play, ListPlus, Trash2, Search, X, Loader2, Plus, GripVertical, Star, RefreshCw, Shuffle, Heart, HardDriveDownload, Check, Pencil, Globe, Lock, Camera, Download } from 'lucide-react';
import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
import {
getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt,
search, setRating, star, unstar,
getRandomSongs, SubsonicPlaylist, SubsonicSong,
getRandomSongs, buildDownloadUrl, SubsonicPlaylist, SubsonicSong,
} from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { usePlaylistStore } from '../store/playlistStore';
import { useOfflineStore } from '../store/offlineStore';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
import { useDragDrop } from '../contexts/DragDropContext';
import CachedImage, { useCachedUrl } from '../components/CachedImage';
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { showToast } from '../utils/toast';
function sanitizeFilename(name: string): string {
return name
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\.{2,}/g, '.')
.replace(/^[\s.]+|[\s.]+$/g, '')
.substring(0, 200) || 'download';
}
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
@@ -89,6 +100,9 @@ export default function PlaylistDetail() {
const { startDrag, isDragging } = useDragDrop();
const { downloadPlaylist, isAlbumDownloading, isAlbumDownloaded, getAlbumProgress } = useOfflineStore();
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
const downloadFolder = useAuthStore(s => s.downloadFolder);
const setDownloadFolder = useAuthStore(s => s.setDownloadFolder);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null);
const [songs, setSongs] = useState<SubsonicSong[]>([]);
@@ -101,6 +115,7 @@ export default function PlaylistDetail() {
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
// ── Bulk select ───────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -294,6 +309,46 @@ export default function PlaylistDetail() {
setEditingMeta(false);
};
// ── ZIP Download ──────────────────────────────────────────────
const handleDownload = async () => {
if (!playlist || !id) return;
const folder = downloadFolder || await requestDownloadFolder();
if (!folder) return;
setDownloadProgress(0);
try {
const url = buildDownloadUrl(id);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const contentLength = response.headers.get('Content-Length');
const total = contentLength ? parseInt(contentLength, 10) : 0;
const chunks: Uint8Array<ArrayBuffer>[] = [];
if (total && response.body) {
const reader = response.body.getReader();
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
setDownloadProgress(Math.round((received / total) * 100));
}
} else {
const buffer = await response.arrayBuffer() as ArrayBuffer;
chunks.push(new Uint8Array(buffer));
setDownloadProgress(100);
}
const blob = new Blob(chunks);
const buffer = await blob.arrayBuffer();
const path = await join(folder, `${sanitizeFilename(playlist.name)}.zip`);
await writeFile(path, new Uint8Array(buffer));
} catch (e) {
console.error('Download failed:', e);
setDownloadProgress(null);
} finally {
setTimeout(() => setDownloadProgress(null), 60000);
}
};
// ── Remove ────────────────────────────────────────────────────
const removeSong = (idx: number) => {
const prevCount = songs.length;
@@ -565,6 +620,21 @@ export default function PlaylistDetail() {
</button>
);
})()}
{songs.length > 0 && (
downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
</div>
) : (
<button className="btn btn-ghost" onClick={handleDownload} data-tooltip={t('playlists.downloadZip')}>
<Download size={16} /> {t('playlists.downloadZip')}
</button>
)
)}
</div>
</div>
</div>