blastoise/public/queue.js

323 lines
9.0 KiB
JavaScript

// MusicRoom - Queue module
// Queue and library display using trackContainer
(function() {
const M = window.MusicRoom;
// Download state - only one at a time
let isDownloading = false;
let exportQueue = [];
let isExporting = false;
// Container instances
let queueContainer = null;
let libraryContainer = null;
// Library search state
M.librarySearchQuery = "";
// 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;
}
const cachedIds = [...M.cachedTracks];
if (cachedIds.length === 0) {
M.showToast("No cached tracks to export", "warning");
return;
}
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); });
exportQueue = cachedIds
.filter(id => trackMap.has(id))
.map(id => ({ id, filename: trackMap.get(id) }));
if (exportQueue.length === 0) {
M.showToast("No exportable tracks found", "warning");
return;
}
isExporting = true;
M.showToast(`Exporting ${exportQueue.length} tracks...`);
let exported = 0;
for (const { id, filename } of exportQueue) {
if (!isExporting) break;
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++;
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");
}
};
// Update cache status for all tracks
M.updateCacheStatus = async function() {
const cached = await TrackStorage.list();
// Migration: remove old filename-based cache entries
const oldEntries = cached.filter(id => !id.startsWith("sha256:"));
if (oldEntries.length > 0) {
console.log("[Cache] Migrating: removing", oldEntries.length, "old entries");
for (const oldId of oldEntries) {
await TrackStorage.remove(oldId);
}
const updated = await TrackStorage.list();
M.cachedTracks = new Set(updated);
} else {
M.cachedTracks = new Set(cached);
}
console.log("[Cache] Updated:", M.cachedTracks.size, "tracks cached");
};
// Debug functions
M.debugCacheStatus = function() {
if (!M.currentTrackId) {
console.log("[Cache Debug] No current track");
return;
}
const trackCache = M.getTrackCache(M.currentTrackId);
console.log("[Cache Debug]", {
trackId: M.currentTrackId.slice(0, 16) + "...",
segments: `${trackCache.size}/${M.SEGMENTS}`,
inCachedTracks: M.cachedTracks.has(M.currentTrackId),
hasBlobUrl: M.trackBlobs.has(M.currentTrackId)
});
};
M.debugCacheMismatch = function() {
console.log("[Cache Mismatch Debug]");
console.log("M.cachedTracks:", M.cachedTracks);
console.log("M.queue tracks:");
M.queue.forEach((t, i) => {
const id = t.id || t.filename;
console.log(` [${i}] ${t.title?.slice(0, 30)} | cached: ${M.cachedTracks.has(id)}`);
});
};
M.clearAllCaches = async function() {
await TrackStorage.clear();
M.cachedTracks.clear();
M.trackCaches.clear();
M.trackBlobs.clear();
M.bulkDownloadStarted.clear();
M.renderQueue();
M.renderLibrary();
console.log("[Cache] Cleared. Refresh the page.");
};
// Initialize containers
function initContainers() {
const queueEl = M.$("#queue");
const libraryEl = M.$("#library");
if (queueEl && !queueContainer) {
queueContainer = M.trackContainer.createContainer({
type: 'queue',
element: queueEl,
getTracks: () => M.queue,
canReorder: true,
onRender: () => M.updateNowPlayingBar()
});
}
if (libraryEl && !libraryContainer) {
libraryContainer = M.trackContainer.createContainer({
type: 'library',
element: libraryEl,
getTracks: () => M.library,
getFilteredTracks: () => {
const query = M.librarySearchQuery.toLowerCase();
if (!query) {
return M.library.map((track, i) => ({ track, originalIndex: i }));
}
return M.library
.map((track, i) => ({ track, originalIndex: i }))
.filter(({ track }) => {
const title = track.title?.trim() || track.filename || '';
return title.toLowerCase().includes(query);
});
}
});
}
}
// Render functions
M.renderQueue = function() {
initContainers();
if (queueContainer) {
queueContainer.render();
}
updateQueueDuration();
};
function updateQueueDuration() {
const el = M.$("#queue-duration");
if (!el) return;
const totalSecs = M.queue.reduce((sum, t) => sum + (t.duration || 0), 0);
if (totalSecs === 0) {
el.textContent = "";
return;
}
const hours = Math.floor(totalSecs / 3600);
const mins = Math.floor((totalSecs % 3600) / 60);
const secs = Math.floor(totalSecs % 60);
let text = "";
if (hours > 0) text = `${hours}h ${mins}m`;
else if (mins > 0) text = `${mins}m ${secs}s`;
else text = `${secs}s`;
el.textContent = `(${M.queue.length} tracks · ${text})`;
}
M.renderLibrary = function() {
initContainers();
if (libraryContainer) {
libraryContainer.render();
}
};
// Now-playing bar
M.updateNowPlayingBar = function() {
const bar = M.$("#now-playing-bar");
if (!bar) return;
const track = M.queue[M.currentIndex];
if (!track) {
bar.classList.add("hidden");
return;
}
const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, "");
bar.innerHTML = `<span class="label">Now playing:</span> ${title}`;
bar.title = title;
bar.classList.remove("hidden");
};
M.scrollToCurrentTrack = function() {
const container = M.$("#queue");
if (!container) return;
const activeTrack = container.querySelector(".track.active");
if (activeTrack) {
activeTrack.scrollIntoView({ behavior: "smooth", block: "center" });
}
};
// Backwards compatibility
M.clearSelections = function() {
M.clearAllSelections();
M.renderQueue();
M.renderLibrary();
};
// Load library from server
M.loadLibrary = async function() {
try {
const res = await fetch("/api/library");
M.library = await res.json();
M.renderLibrary();
} catch (e) {
console.warn("Failed to load library");
}
};
// Setup event listeners
document.addEventListener("DOMContentLoaded", () => {
const bar = M.$("#now-playing-bar");
if (bar) {
bar.onclick = () => M.scrollToCurrentTrack();
}
const searchInput = M.$("#library-search");
if (searchInput) {
searchInput.addEventListener("input", (e) => {
M.librarySearchQuery = e.target.value;
M.renderLibrary();
});
}
});
})();