From 63a3bcd0f47678e4929134cfdd48810f1ce91bc0 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Tue, 7 Apr 2026 19:00:49 +0200 Subject: [PATCH] feat(playlists): add ZIP download button to playlist detail page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/locales/de.ts | 1 + src/locales/en.ts | 1 + src/locales/fr.ts | 1 + src/locales/nb.ts | 1 + src/locales/nl.ts | 1 + src/locales/ru.ts | 1 + src/locales/zh.ts | 1 + src/pages/PlaylistDetail.tsx | 74 +++++++++++++++++++++++++++++++++++- 8 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/locales/de.ts b/src/locales/de.ts index 398f75c2..4ff70c22 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -775,6 +775,7 @@ export const deTranslation = { removeCover: 'Foto entfernen', coverUpdated: 'Cover aktualisiert', metaSaved: 'Playlist aktualisiert', + downloadZip: 'Herunterladen (ZIP)', }, radio: { title: 'Internetradio', diff --git a/src/locales/en.ts b/src/locales/en.ts index 77b1ff2a..40eb8ea6 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -776,6 +776,7 @@ export const enTranslation = { removeCover: 'Remove photo', coverUpdated: 'Cover updated', metaSaved: 'Playlist updated', + downloadZip: 'Download (ZIP)', }, radio: { title: 'Internet Radio', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 542fd916..8cf18391 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -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', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 3ce076c3..1a8deaff 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -774,6 +774,7 @@ export const nbTranslation = { removeCover: 'Fjern bilde', coverUpdated: 'Omslag oppdatert', metaSaved: 'Spillelisten er oppdatert', + downloadZip: 'Last ned (ZIP)', }, radio: { title: 'Internettradio', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index ea7be849..655d9a48 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -775,6 +775,7 @@ export const nlTranslation = { removeCover: 'Foto verwijderen', coverUpdated: 'Omslag bijgewerkt', metaSaved: 'Playlist bijgewerkt', + downloadZip: 'Downloaden (ZIP)', }, radio: { title: 'Internetradio', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 51b606c2..2625135a 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -829,6 +829,7 @@ export const ruTranslation = { removeCover: 'Убрать фото', coverUpdated: 'Обложка обновлена', metaSaved: 'Плейлист сохранён', + downloadZip: 'Скачать (ZIP)', }, radio: { title: 'Онлайн-радио', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 03ca0eca..a9b753b2 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -771,6 +771,7 @@ export const zhTranslation = { removeCover: '删除照片', coverUpdated: '封面已更新', metaSaved: '播放列表已更新', + downloadZip: '下载 (ZIP)', }, radio: { title: '网络电台', diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 38668b1b..31043159 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -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(null); const [songs, setSongs] = useState([]); @@ -101,6 +115,7 @@ export default function PlaylistDetail() { const [hoveredSuggestionId, setHoveredSuggestionId] = useState(null); const [contextMenuSongId, setContextMenuSongId] = useState(null); const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen); + const [downloadProgress, setDownloadProgress] = useState(null); // ── Bulk select ─────────────────────────────────────────────────── const [selectedIds, setSelectedIds] = useState>(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[] = []; + 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() { ); })()} + {songs.length > 0 && ( + downloadProgress !== null ? ( +
+ +
+
+
+ {downloadProgress}% +
+ ) : ( + + ) + )}