692 lines
24 KiB
JavaScript
692 lines
24 KiB
JavaScript
// 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;
|
||
|
||
// 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>';
|
||
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(/\.[^.]+$/, "");
|
||
|
||
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
|
||
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));
|
||
}
|
||
});
|
||
}
|
||
|
||
// Clear selection option (if items selected)
|
||
if (hasSelection) {
|
||
menuItems.push({
|
||
label: "Clear selection",
|
||
action: () => M.clearSelections()
|
||
});
|
||
}
|
||
|
||
showContextMenu(e, menuItems);
|
||
};
|
||
|
||
container.appendChild(div);
|
||
});
|
||
};
|
||
|
||
// 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(/\.[^.]+$/, "");
|
||
|
||
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
|
||
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));
|
||
}
|
||
});
|
||
}
|
||
|
||
// 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();
|
||
});
|
||
}
|
||
});
|
||
})();
|