mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
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:
@@ -775,6 +775,7 @@ export const deTranslation = {
|
|||||||
removeCover: 'Foto entfernen',
|
removeCover: 'Foto entfernen',
|
||||||
coverUpdated: 'Cover aktualisiert',
|
coverUpdated: 'Cover aktualisiert',
|
||||||
metaSaved: 'Playlist aktualisiert',
|
metaSaved: 'Playlist aktualisiert',
|
||||||
|
downloadZip: 'Herunterladen (ZIP)',
|
||||||
},
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Internetradio',
|
title: 'Internetradio',
|
||||||
|
|||||||
@@ -776,6 +776,7 @@ export const enTranslation = {
|
|||||||
removeCover: 'Remove photo',
|
removeCover: 'Remove photo',
|
||||||
coverUpdated: 'Cover updated',
|
coverUpdated: 'Cover updated',
|
||||||
metaSaved: 'Playlist updated',
|
metaSaved: 'Playlist updated',
|
||||||
|
downloadZip: 'Download (ZIP)',
|
||||||
},
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Internet Radio',
|
title: 'Internet Radio',
|
||||||
|
|||||||
@@ -775,6 +775,7 @@ export const frTranslation = {
|
|||||||
removeCover: 'Supprimer la photo',
|
removeCover: 'Supprimer la photo',
|
||||||
coverUpdated: 'Pochette mise à jour',
|
coverUpdated: 'Pochette mise à jour',
|
||||||
metaSaved: 'Playlist mise à jour',
|
metaSaved: 'Playlist mise à jour',
|
||||||
|
downloadZip: 'Télécharger (ZIP)',
|
||||||
},
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Radio Internet',
|
title: 'Radio Internet',
|
||||||
|
|||||||
@@ -774,6 +774,7 @@ export const nbTranslation = {
|
|||||||
removeCover: 'Fjern bilde',
|
removeCover: 'Fjern bilde',
|
||||||
coverUpdated: 'Omslag oppdatert',
|
coverUpdated: 'Omslag oppdatert',
|
||||||
metaSaved: 'Spillelisten er oppdatert',
|
metaSaved: 'Spillelisten er oppdatert',
|
||||||
|
downloadZip: 'Last ned (ZIP)',
|
||||||
},
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Internettradio',
|
title: 'Internettradio',
|
||||||
|
|||||||
@@ -775,6 +775,7 @@ export const nlTranslation = {
|
|||||||
removeCover: 'Foto verwijderen',
|
removeCover: 'Foto verwijderen',
|
||||||
coverUpdated: 'Omslag bijgewerkt',
|
coverUpdated: 'Omslag bijgewerkt',
|
||||||
metaSaved: 'Playlist bijgewerkt',
|
metaSaved: 'Playlist bijgewerkt',
|
||||||
|
downloadZip: 'Downloaden (ZIP)',
|
||||||
},
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Internetradio',
|
title: 'Internetradio',
|
||||||
|
|||||||
@@ -829,6 +829,7 @@ export const ruTranslation = {
|
|||||||
removeCover: 'Убрать фото',
|
removeCover: 'Убрать фото',
|
||||||
coverUpdated: 'Обложка обновлена',
|
coverUpdated: 'Обложка обновлена',
|
||||||
metaSaved: 'Плейлист сохранён',
|
metaSaved: 'Плейлист сохранён',
|
||||||
|
downloadZip: 'Скачать (ZIP)',
|
||||||
},
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: 'Онлайн-радио',
|
title: 'Онлайн-радио',
|
||||||
|
|||||||
@@ -771,6 +771,7 @@ export const zhTranslation = {
|
|||||||
removeCover: '删除照片',
|
removeCover: '删除照片',
|
||||||
coverUpdated: '封面已更新',
|
coverUpdated: '封面已更新',
|
||||||
metaSaved: '播放列表已更新',
|
metaSaved: '播放列表已更新',
|
||||||
|
downloadZip: '下载 (ZIP)',
|
||||||
},
|
},
|
||||||
radio: {
|
radio: {
|
||||||
title: '网络电台',
|
title: '网络电台',
|
||||||
|
|||||||
@@ -1,24 +1,35 @@
|
|||||||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
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 { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns';
|
||||||
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
import { AddToPlaylistSubmenu } from '../components/ContextMenu';
|
||||||
import {
|
import {
|
||||||
getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt,
|
getPlaylist, updatePlaylist, updatePlaylistMeta, uploadPlaylistCoverArt,
|
||||||
search, setRating, star, unstar,
|
search, setRating, star, unstar,
|
||||||
getRandomSongs, SubsonicPlaylist, SubsonicSong,
|
getRandomSongs, buildDownloadUrl, SubsonicPlaylist, SubsonicSong,
|
||||||
} from '../api/subsonic';
|
} from '../api/subsonic';
|
||||||
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
import { usePlayerStore, songToTrack } from '../store/playerStore';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { usePlaylistStore } from '../store/playlistStore';
|
import { usePlaylistStore } from '../store/playlistStore';
|
||||||
import { useOfflineStore } from '../store/offlineStore';
|
import { useOfflineStore } from '../store/offlineStore';
|
||||||
import { useAuthStore } from '../store/authStore';
|
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 { useDragDrop } from '../contexts/DragDropContext';
|
||||||
import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
import CachedImage, { useCachedUrl } from '../components/CachedImage';
|
||||||
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
|
import { coverArtCacheKey, buildCoverArtUrl } from '../api/subsonic';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { showToast } from '../utils/toast';
|
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 {
|
function formatDuration(seconds: number): string {
|
||||||
const m = Math.floor(seconds / 60);
|
const m = Math.floor(seconds / 60);
|
||||||
const s = seconds % 60;
|
const s = seconds % 60;
|
||||||
@@ -89,6 +100,9 @@ export default function PlaylistDetail() {
|
|||||||
const { startDrag, isDragging } = useDragDrop();
|
const { startDrag, isDragging } = useDragDrop();
|
||||||
const { downloadPlaylist, isAlbumDownloading, isAlbumDownloaded, getAlbumProgress } = useOfflineStore();
|
const { downloadPlaylist, isAlbumDownloading, isAlbumDownloaded, getAlbumProgress } = useOfflineStore();
|
||||||
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
|
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 [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null);
|
||||||
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
const [songs, setSongs] = useState<SubsonicSong[]>([]);
|
||||||
@@ -101,6 +115,7 @@ export default function PlaylistDetail() {
|
|||||||
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
|
const [hoveredSuggestionId, setHoveredSuggestionId] = useState<string | null>(null);
|
||||||
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
|
||||||
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
|
||||||
|
const [downloadProgress, setDownloadProgress] = useState<number | null>(null);
|
||||||
|
|
||||||
// ── Bulk select ───────────────────────────────────────────────────
|
// ── Bulk select ───────────────────────────────────────────────────
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
@@ -294,6 +309,46 @@ export default function PlaylistDetail() {
|
|||||||
setEditingMeta(false);
|
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 ────────────────────────────────────────────────────
|
// ── Remove ────────────────────────────────────────────────────
|
||||||
const removeSong = (idx: number) => {
|
const removeSong = (idx: number) => {
|
||||||
const prevCount = songs.length;
|
const prevCount = songs.length;
|
||||||
@@ -565,6 +620,21 @@ export default function PlaylistDetail() {
|
|||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user