This commit is contained in:
peterino2 2026-02-03 01:44:15 -08:00
parent 0a67dc864e
commit beef395456
4 changed files with 179 additions and 20 deletions

1
.gitignore vendored
View File

@ -35,5 +35,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
tmp/
library_cache.db
musicroom.db

Binary file not shown.

View File

@ -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,20 +613,30 @@
});
}
// 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";
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)
});
}
// Clear selection option (if items selected)
@ -678,17 +820,35 @@
});
}
// 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";
// 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()
});
}
// Clear selection option (if items selected)

View File

@ -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 });