diff --git a/.gitignore b/.gitignore index 92ad8c0..321f6e2 100644 --- a/.gitignore +++ b/.gitignore @@ -35,5 +35,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json tmp/ library_cache.db - musicroom.db diff --git a/musicroom.db b/musicroom.db deleted file mode 100644 index 2397c17..0000000 Binary files a/musicroom.db and /dev/null differ diff --git a/public/queue.js b/public/queue.js index 1d7b647..91287d0 100644 --- a/public/queue.js +++ b/public/queue.js @@ -15,6 +15,138 @@ // Context menu state let activeContextMenu = null; + // Download state - only one at a time + let isDownloading = false; + let exportQueue = []; + let isExporting = false; + + // Download a track to user's device (uses cache if available) + async function downloadTrack(trackId, filename) { + if (isDownloading) { + M.showToast("Download already in progress", "warning"); + return; + } + + isDownloading = true; + M.showToast(`Downloading: ${filename}`); + + try { + let blob = null; + + // Try to get from cache first + if (M.cachedTracks.has(trackId)) { + try { + const cached = await TrackStorage.get(trackId); + if (cached && cached.blob) { + blob = cached.blob; + } + } catch (e) { + console.log("Cache miss, fetching from server"); + } + } + + // Fall back to fetching from server + if (!blob) { + const res = await fetch(`/api/tracks/${encodeURIComponent(trackId)}`); + if (!res.ok) throw new Error(`Server returned ${res.status}`); + blob = await res.blob(); + } + + if (!blob || blob.size === 0) { + throw new Error("Empty blob"); + } + + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + M.showToast(`Downloaded: ${filename}`); + } catch (e) { + console.error("Download error:", e); + M.showToast(`Download failed: ${e.message}`, "error"); + } finally { + isDownloading = false; + } + } + + // Export all cached tracks + M.exportAllCached = async function() { + if (isExporting) { + M.showToast("Export already in progress", "warning"); + return; + } + + // Build list of cached tracks with filenames + const cachedIds = [...M.cachedTracks]; + if (cachedIds.length === 0) { + M.showToast("No cached tracks to export", "warning"); + return; + } + + // Find filenames from library or queue + const trackMap = new Map(); + M.library.forEach(t => { if (t.filename) trackMap.set(t.id, t.filename); }); + M.queue.forEach(t => { if (t.filename && !trackMap.has(t.id)) trackMap.set(t.id, t.filename); }); + + // Only export tracks with known filenames + exportQueue = cachedIds + .filter(id => trackMap.has(id)) + .map(id => ({ id, filename: trackMap.get(id) })); + + const skipped = cachedIds.length - exportQueue.length; + if (exportQueue.length === 0) { + M.showToast("No exportable tracks (filenames unknown)", "warning"); + return; + } + + isExporting = true; + const msg = skipped > 0 + ? `Exporting ${exportQueue.length} tracks (${skipped} skipped - not in library)` + : `Exporting ${exportQueue.length} cached tracks...`; + M.showToast(msg); + + let exported = 0; + for (const { id, filename } of exportQueue) { + if (!isExporting) break; // Allow cancellation + + try { + const cached = await TrackStorage.get(id); + if (cached && cached.blob) { + const url = URL.createObjectURL(cached.blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + exported++; + + // Small delay between downloads to not overwhelm browser + await new Promise(r => setTimeout(r, 500)); + } + } catch (e) { + console.error(`Export error for ${filename}:`, e); + } + } + + isExporting = false; + exportQueue = []; + M.showToast(`Exported ${exported} tracks`); + }; + + M.cancelExport = function() { + if (isExporting) { + isExporting = false; + M.showToast("Export cancelled"); + } + }; + // Close context menu when clicking elsewhere document.addEventListener("click", () => { if (activeContextMenu) { @@ -481,19 +613,29 @@ }); } - // Preload track(s) option + // Preload track(s) option - always show const idsToPreload = hasSelection ? [...M.selectedQueueIndices].map(idx => M.queue[idx]?.id).filter(Boolean) : [trackId]; - const uncachedIds = idsToPreload.filter(id => !M.cachedTracks.has(id)); - if (uncachedIds.length > 0) { - const preloadLabel = uncachedIds.length > 1 ? `⬇ Preload ${uncachedIds.length} tracks` : "⬇ Preload track"; - menuItems.push({ - label: preloadLabel, - action: () => { - M.showToast(`Preloading ${uncachedIds.length} track${uncachedIds.length > 1 ? 's' : ''}...`); - uncachedIds.forEach(id => M.downloadAndCacheTrack(id)); + const preloadLabel = hasSelection && idsToPreload.length > 1 ? `Preload ${idsToPreload.length} tracks` : "Preload track"; + menuItems.push({ + label: preloadLabel, + action: () => { + const uncachedIds = idsToPreload.filter(id => !M.cachedTracks.has(id)); + if (uncachedIds.length === 0) { + M.showToast("All tracks already cached"); + return; } + M.showToast(`Preloading ${uncachedIds.length} track${uncachedIds.length > 1 ? 's' : ''}...`); + uncachedIds.forEach(id => M.downloadAndCacheTrack(id)); + } + }); + + // Download track option (single track only) + if (!hasSelection) { + menuItems.push({ + label: "Download", + action: () => downloadTrack(trackId, track.filename) }); } @@ -678,16 +820,34 @@ }); } - // Preload track(s) option - const uncachedIds = idsToAdd.filter(id => !M.cachedTracks.has(id)); - if (uncachedIds.length > 0) { - const preloadLabel = uncachedIds.length > 1 ? `⬇ Preload ${uncachedIds.length} tracks` : "⬇ Preload track"; - menuItems.push({ - label: preloadLabel, - action: () => { - M.showToast(`Preloading ${uncachedIds.length} track${uncachedIds.length > 1 ? 's' : ''}...`); - uncachedIds.forEach(id => M.downloadAndCacheTrack(id)); + // Preload track(s) option - always show, will skip already cached + const preloadLabel = selectedCount > 1 ? `Preload ${selectedCount} tracks` : "Preload track"; + menuItems.push({ + label: preloadLabel, + action: () => { + const uncachedIds = idsToAdd.filter(id => !M.cachedTracks.has(id)); + if (uncachedIds.length === 0) { + M.showToast("All tracks already cached"); + return; } + M.showToast(`Preloading ${uncachedIds.length} track${uncachedIds.length > 1 ? 's' : ''}...`); + uncachedIds.forEach(id => M.downloadAndCacheTrack(id)); + } + }); + + // Download track option (single track only) + if (!hasSelection) { + menuItems.push({ + label: "Download", + action: () => downloadTrack(track.id, track.filename) + }); + } + + // Export all cached option (if there are cached tracks) + if (M.cachedTracks.size > 0) { + menuItems.push({ + label: `Preload and export ${M.cachedTracks.size} cached`, + action: () => M.exportAllCached() }); } diff --git a/server.ts b/server.ts index 910b7ac..e1f6fdc 100644 --- a/server.ts +++ b/server.ts @@ -819,7 +819,7 @@ serve({ if (!channel) return new Response("Not found", { status: 404 }); try { const body = await req.json(); - const validModes = ["repeat-all", "repeat-one", "shuffle"]; + const validModes = ["once", "repeat-all", "repeat-one", "shuffle"]; if (typeof body.mode === "string" && validModes.includes(body.mode)) { channel.setPlaybackMode(body.mode); return Response.json({ success: true, playbackMode: channel.playbackMode });