blastoise/public/queue.js

897 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// MusicRoom - Queue module
// Queue rendering and library display
(function() {
const M = window.MusicRoom;
// Selection state for bulk operations
M.selectedQueueIndices = new Set();
M.selectedLibraryIds = new Set();
// Last selected index for shift-select range
let lastSelectedQueueIndex = null;
let lastSelectedLibraryIndex = null;
// 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) {
activeContextMenu.remove();
activeContextMenu = null;
}
});
// Show context menu
function showContextMenu(e, items) {
e.preventDefault();
if (activeContextMenu) activeContextMenu.remove();
const menu = document.createElement("div");
menu.className = "context-menu";
items.forEach(item => {
const el = document.createElement("div");
el.className = "context-menu-item" + (item.danger ? " danger" : "");
el.textContent = item.label;
el.onclick = (ev) => {
ev.stopPropagation();
menu.remove();
activeContextMenu = null;
item.action();
};
menu.appendChild(el);
});
document.body.appendChild(menu);
// Position menu, keep within viewport
let x = e.clientX;
let y = e.clientY;
const rect = menu.getBoundingClientRect();
if (x + rect.width > window.innerWidth) x = window.innerWidth - rect.width - 5;
if (y + rect.height > window.innerHeight) y = window.innerHeight - rect.height - 5;
menu.style.left = x + "px";
menu.style.top = y + "px";
activeContextMenu = menu;
}
// Drag state for queue reordering
let draggedIndices = [];
let draggedLibraryIds = [];
let dropTargetIndex = null;
let dragSource = null; // 'queue' or 'library'
// Insert library tracks into queue at position
async function insertTracksAtPosition(trackIds, position) {
if (!M.currentChannelId || trackIds.length === 0) return;
// Build new queue with tracks inserted at position
const newQueue = [...M.queue];
const newTrackIds = [...newQueue.map(t => t.id)];
newTrackIds.splice(position, 0, ...trackIds);
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ set: newTrackIds })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`);
M.clearSelections();
}
}
// Reorder queue on server
async function reorderQueue(fromIndices, toIndex) {
if (!M.currentChannelId || fromIndices.length === 0) return;
// Build new queue order
const newQueue = [...M.queue];
// Sort indices descending to remove from end first
const sortedIndices = [...fromIndices].sort((a, b) => b - a);
const movedTracks = [];
// Remove items (in reverse order to preserve indices)
for (const idx of sortedIndices) {
movedTracks.unshift(newQueue.splice(idx, 1)[0]);
}
// Adjust target index for removed items before it
let adjustedTarget = toIndex;
for (const idx of fromIndices) {
if (idx < toIndex) adjustedTarget--;
}
// Insert at new position
newQueue.splice(adjustedTarget, 0, ...movedTracks);
// Send to server
const trackIds = newQueue.map(t => t.id);
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ set: trackIds })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.clearSelections();
}
}
// Toggle selection mode (with optional shift for range select)
M.toggleQueueSelection = function(index, shiftKey = false) {
if (shiftKey && lastSelectedQueueIndex !== null) {
// Range select: select all between last and current
const start = Math.min(lastSelectedQueueIndex, index);
const end = Math.max(lastSelectedQueueIndex, index);
for (let i = start; i <= end; i++) {
M.selectedQueueIndices.add(i);
}
} else {
if (M.selectedQueueIndices.has(index)) {
M.selectedQueueIndices.delete(index);
} else {
M.selectedQueueIndices.add(index);
}
lastSelectedQueueIndex = index;
}
M.renderQueue();
};
M.toggleLibrarySelection = function(index, shiftKey = false) {
if (shiftKey && lastSelectedLibraryIndex !== null) {
// Range select: select all between last and current
const start = Math.min(lastSelectedLibraryIndex, index);
const end = Math.max(lastSelectedLibraryIndex, index);
for (let i = start; i <= end; i++) {
M.selectedLibraryIds.add(M.library[i].id);
}
} else {
const trackId = M.library[index].id;
if (M.selectedLibraryIds.has(trackId)) {
M.selectedLibraryIds.delete(trackId);
} else {
M.selectedLibraryIds.add(trackId);
}
lastSelectedLibraryIndex = index;
}
M.renderLibrary();
};
M.clearSelections = function() {
M.selectedQueueIndices.clear();
M.selectedLibraryIds.clear();
lastSelectedQueueIndex = null;
lastSelectedLibraryIndex = null;
M.renderQueue();
M.renderLibrary();
};
// Update cache status for all tracks
M.updateCacheStatus = async function() {
const cached = await TrackStorage.list();
// Migration: remove old filename-based cache entries (keep only sha256: prefixed)
const oldEntries = cached.filter(id => !id.startsWith("sha256:"));
if (oldEntries.length > 0) {
console.log("[Cache] Migrating: removing", oldEntries.length, "old filename-based entries");
for (const oldId of oldEntries) {
await TrackStorage.remove(oldId);
}
// Re-fetch after cleanup
const updated = await TrackStorage.list();
M.cachedTracks = new Set(updated);
} else {
M.cachedTracks = new Set(cached);
}
console.log("[Cache] Updated cache status:", M.cachedTracks.size, "tracks cached");
};
// Debug: log cache status for current track
M.debugCacheStatus = function() {
if (!M.currentTrackId) {
console.log("[Cache Debug] No current track");
return;
}
const trackCache = M.getTrackCache(M.currentTrackId);
const segmentsPct = Math.round((trackCache.size / M.SEGMENTS) * 100);
const inCachedTracks = M.cachedTracks.has(M.currentTrackId);
const hasBlobUrl = M.trackBlobs.has(M.currentTrackId);
const bulkStarted = M.bulkDownloadStarted.get(M.currentTrackId);
console.log("[Cache Debug]", {
trackId: M.currentTrackId.slice(0, 16) + "...",
segments: `${trackCache.size}/${M.SEGMENTS} (${segmentsPct}%)`,
inCachedTracks,
hasBlobUrl,
bulkStarted,
loadingSegments: [...M.loadingSegments],
cachedTracksSize: M.cachedTracks.size
});
};
// Debug: compare queue track IDs with cached track IDs
M.debugCacheMismatch = function() {
console.log("[Cache Mismatch Debug]");
console.log("=== Raw State ===");
console.log("M.cachedTracks:", M.cachedTracks);
console.log("M.trackCaches:", M.trackCaches);
console.log("M.trackBlobs keys:", [...M.trackBlobs.keys()]);
console.log("M.bulkDownloadStarted:", M.bulkDownloadStarted);
console.log("=== Queue Tracks ===");
M.queue.forEach((t, i) => {
const id = t.id || t.filename;
console.log(` [${i}] ${t.title?.slice(0, 30)} | id: ${id?.slice(0, 12)}... | cached: ${M.cachedTracks.has(id)}`);
});
console.log("=== Cached Track IDs ===");
[...M.cachedTracks].forEach(id => {
console.log(` ${id.slice(0, 20)}...`);
});
};
// Debug: detailed info for a specific track
M.debugTrack = function(index) {
const track = M.queue[index];
if (!track) {
console.log("[Debug] No track at index", index);
return;
}
const id = track.id || track.filename;
console.log("[Debug Track]", {
index,
title: track.title,
id,
idPrefix: id?.slice(0, 16),
inCachedTracks: M.cachedTracks.has(id),
inTrackCaches: M.trackCaches.has(id),
segmentCount: M.trackCaches.get(id)?.size || 0,
inTrackBlobs: M.trackBlobs.has(id),
bulkStarted: M.bulkDownloadStarted.get(id)
});
};
// Clear all caches (for debugging)
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] All caches cleared. Refresh the page.");
};
// Render the current queue
M.renderQueue = function() {
const container = M.$("#queue");
if (!container) return;
container.innerHTML = "";
const canEdit = M.canControl();
// Setup container-level drag handlers for dropping from library
if (canEdit) {
container.ondragover = (e) => {
if (dragSource === 'library') {
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
// If no tracks or hovering at bottom, show we can drop
if (M.queue.length === 0) {
container.classList.add("drop-target");
}
}
};
container.ondragleave = (e) => {
// Only remove if leaving the container entirely
if (!container.contains(e.relatedTarget)) {
container.classList.remove("drop-target");
}
};
container.ondrop = (e) => {
container.classList.remove("drop-target");
// Handle drop on empty queue or at the end
if (dragSource === 'library' && draggedLibraryIds.length > 0) {
e.preventDefault();
const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length;
insertTracksAtPosition(draggedLibraryIds, targetIndex);
draggedLibraryIds = [];
dragSource = null;
dropTargetIndex = null;
}
};
}
if (M.queue.length === 0) {
container.innerHTML = '<div class="empty drop-zone">Queue empty - drag tracks here</div>';
M.updateNowPlayingBar();
return;
}
// Debug: log first few track cache statuses
if (M.queue.length > 0 && M.cachedTracks.size > 0) {
const sample = M.queue.slice(0, 3).map(t => {
const id = t.id || t.filename;
return { title: t.title?.slice(0, 20), id: id?.slice(0, 12), cached: M.cachedTracks.has(id) };
});
console.log("[Queue Render] Sample tracks:", sample, "| cachedTracks sample:", [...M.cachedTracks].slice(0, 3).map(s => s.slice(0, 12)));
}
M.queue.forEach((track, i) => {
const div = document.createElement("div");
const trackId = track.id || track.filename;
const isCached = M.cachedTracks.has(trackId);
const isSelected = M.selectedQueueIndices.has(i);
div.className = "track" + (i === M.currentIndex ? " active" : "") + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : "");
div.dataset.index = i;
const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, "");
div.title = title; // Tooltip for full name
const checkmark = isSelected ? `<span class="track-checkmark">✓</span>` : '';
const trackNum = `<span class="track-number">${i + 1}.</span>`;
div.innerHTML = `${checkmark}<span class="cache-indicator"></span>${trackNum}<span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`;
// Drag and drop for reordering (if user can edit)
if (canEdit) {
div.draggable = true;
div.ondragstart = (e) => {
dragSource = 'queue';
draggedLibraryIds = [];
// If dragging a selected item, drag all selected; otherwise just this one
if (M.selectedQueueIndices.has(i)) {
draggedIndices = [...M.selectedQueueIndices];
} else {
draggedIndices = [i];
}
div.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", "queue:" + draggedIndices.join(","));
};
div.ondragend = () => {
div.classList.remove("dragging");
draggedIndices = [];
draggedLibraryIds = [];
dragSource = null;
// Clear all drop indicators
container.querySelectorAll(".drop-above, .drop-below").forEach(el => {
el.classList.remove("drop-above", "drop-below");
});
};
div.ondragover = (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
// Determine if dropping above or below
const rect = div.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const isAbove = e.clientY < midY;
// Clear other indicators
container.querySelectorAll(".drop-above, .drop-below").forEach(el => {
el.classList.remove("drop-above", "drop-below");
});
// Don't show indicator on dragged queue items (for reorder)
if (dragSource === 'queue' && draggedIndices.includes(i)) return;
div.classList.add(isAbove ? "drop-above" : "drop-below");
dropTargetIndex = isAbove ? i : i + 1;
};
div.ondragleave = () => {
div.classList.remove("drop-above", "drop-below");
};
div.ondrop = (e) => {
e.preventDefault();
div.classList.remove("drop-above", "drop-below");
if (dragSource === 'library' && draggedLibraryIds.length > 0 && dropTargetIndex !== null) {
// Insert library tracks at drop position
insertTracksAtPosition(draggedLibraryIds, dropTargetIndex);
} else if (dragSource === 'queue' && draggedIndices.length > 0 && dropTargetIndex !== null) {
// Reorder queue
const minDragged = Math.min(...draggedIndices);
const maxDragged = Math.max(...draggedIndices);
if (dropTargetIndex < minDragged || dropTargetIndex > maxDragged + 1) {
reorderQueue(draggedIndices, dropTargetIndex);
}
}
draggedIndices = [];
draggedLibraryIds = [];
dragSource = null;
dropTargetIndex = null;
};
}
// Click toggles selection
div.onclick = (e) => {
if (e.target.closest('.track-actions')) return;
M.toggleQueueSelection(i, e.shiftKey);
};
// Right-click context menu
div.oncontextmenu = (e) => {
const menuItems = [];
const hasSelection = M.selectedQueueIndices.size > 0;
const selectedCount = hasSelection ? M.selectedQueueIndices.size : 1;
const indicesToRemove = hasSelection ? [...M.selectedQueueIndices] : [i];
// Play track option (only for single track, not bulk)
if (!hasSelection) {
menuItems.push({
label: "▶ Play track",
action: async () => {
if (M.synced && M.currentChannelId) {
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ index: i })
});
if (res.status === 403) M.flashPermissionDenied();
} else {
M.currentIndex = i;
M.currentTrackId = trackId;
M.serverTrackDuration = track.duration;
M.setTrackTitle(title);
M.loadingSegments.clear();
const cachedUrl = await M.loadTrackBlob(trackId);
M.audio.src = cachedUrl || M.getTrackUrl(trackId);
M.audio.currentTime = 0;
M.localTimestamp = 0;
M.audio.play();
M.renderQueue();
}
}
});
}
// Remove track(s) option (if user can edit)
if (canEdit) {
const label = selectedCount > 1 ? `✕ Remove ${selectedCount} tracks` : "✕ Remove track";
menuItems.push({
label,
danger: true,
action: async () => {
if (!M.currentChannelId) return;
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ remove: indicesToRemove })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast(selectedCount > 1 ? `Removed ${selectedCount} tracks` : "Track removed");
M.clearSelections();
}
}
});
}
// Preload track(s) option - only show if not in stream-only mode
if (!M.streamOnly) {
const idsToPreload = hasSelection
? [...M.selectedQueueIndices].map(idx => M.queue[idx]?.id).filter(Boolean)
: [trackId];
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)
if (hasSelection) {
menuItems.push({
label: "Clear selection",
action: () => M.clearSelections()
});
}
showContextMenu(e, menuItems);
};
container.appendChild(div);
});
M.updateNowPlayingBar();
};
// Update the now-playing bar above the queue
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");
};
// Scroll queue to current track
M.scrollToCurrentTrack = function() {
const container = M.$("#queue");
if (!container) return;
const activeTrack = container.querySelector(".track.active");
if (activeTrack) {
activeTrack.scrollIntoView({ behavior: "smooth", block: "center" });
}
};
// Setup now-playing bar click handler
document.addEventListener("DOMContentLoaded", () => {
const bar = M.$("#now-playing-bar");
if (bar) {
bar.onclick = () => M.scrollToCurrentTrack();
}
});
// Library search state
M.librarySearchQuery = "";
// Render the library
M.renderLibrary = function() {
const container = M.$("#library");
if (!container) return;
container.innerHTML = "";
if (M.library.length === 0) {
container.innerHTML = '<div class="empty">No tracks discovered</div>';
return;
}
const canEdit = M.canControl();
const query = M.librarySearchQuery.toLowerCase();
// Filter library by search query
const filteredLibrary = query
? M.library.map((track, i) => ({ track, i })).filter(({ track }) => {
const title = track.title?.trim() || track.filename;
return title.toLowerCase().includes(query);
})
: M.library.map((track, i) => ({ track, i }));
if (filteredLibrary.length === 0) {
container.innerHTML = '<div class="empty">No matches</div>';
return;
}
filteredLibrary.forEach(({ track, i }) => {
const div = document.createElement("div");
const isCached = M.cachedTracks.has(track.id);
const isSelected = M.selectedLibraryIds.has(track.id);
div.className = "track" + (isCached ? " cached" : " not-cached") + (isSelected ? " selected" : "");
const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, "");
div.title = title; // Tooltip for full name
const checkmark = isSelected ? `<span class="track-checkmark">✓</span>` : '';
div.innerHTML = `${checkmark}<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions"><span class="duration">${M.fmt(track.duration)}</span></span>`;
// Drag from library to queue (if user can edit)
if (canEdit) {
div.draggable = true;
div.ondragstart = (e) => {
dragSource = 'library';
draggedIndices = [];
// If dragging a selected item, drag all selected; otherwise just this one
if (M.selectedLibraryIds.has(track.id)) {
draggedLibraryIds = [...M.selectedLibraryIds];
} else {
draggedLibraryIds = [track.id];
}
div.classList.add("dragging");
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("text/plain", "library:" + draggedLibraryIds.join(","));
};
div.ondragend = () => {
div.classList.remove("dragging");
draggedIndices = [];
draggedLibraryIds = [];
dragSource = null;
// Clear drop indicators in queue
const queueContainer = M.$("#queue");
if (queueContainer) {
queueContainer.querySelectorAll(".drop-above, .drop-below").forEach(el => {
el.classList.remove("drop-above", "drop-below");
});
}
};
}
// Click toggles selection
div.onclick = (e) => {
if (e.target.closest('.track-actions')) return;
M.toggleLibrarySelection(i, e.shiftKey);
};
// Right-click context menu
div.oncontextmenu = (e) => {
const menuItems = [];
const hasSelection = M.selectedLibraryIds.size > 0;
const selectedCount = hasSelection ? M.selectedLibraryIds.size : 1;
const idsToAdd = hasSelection ? [...M.selectedLibraryIds] : [track.id];
// Play track option (local mode only, single track)
if (!M.synced && !hasSelection) {
menuItems.push({
label: "▶ Play track",
action: async () => {
M.currentTrackId = track.id;
M.serverTrackDuration = track.duration;
M.setTrackTitle(title);
M.loadingSegments.clear();
const cachedUrl = await M.loadTrackBlob(track.id);
M.audio.src = cachedUrl || M.getTrackUrl(track.id);
M.audio.currentTime = 0;
M.localTimestamp = 0;
M.audio.play();
}
});
}
// Add to queue option (if user can edit)
if (canEdit) {
const label = selectedCount > 1 ? ` Add ${selectedCount} to queue` : " Add to queue";
menuItems.push({
label,
action: async () => {
if (!M.currentChannelId) {
M.showToast("No channel selected");
return;
}
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ add: idsToAdd })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast(selectedCount > 1 ? `Added ${selectedCount} tracks` : "Track added to queue");
M.clearSelections();
}
}
});
}
// Preload track(s) option - only show if not in stream-only mode
if (!M.streamOnly) {
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)
if (hasSelection) {
menuItems.push({
label: "Clear selection",
action: () => M.clearSelections()
});
}
if (menuItems.length > 0) {
showContextMenu(e, menuItems);
}
};
container.appendChild(div);
});
};
// 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 library search
document.addEventListener("DOMContentLoaded", () => {
const searchInput = M.$("#library-search");
if (searchInput) {
searchInput.addEventListener("input", (e) => {
M.librarySearchQuery = e.target.value;
M.renderLibrary();
});
}
});
})();