323 lines
9.0 KiB
JavaScript
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();
|
|
});
|
|
}
|
|
});
|
|
})();
|