blastoise/public/queue.js

1091 lines
38 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 => {
if (item.separator) {
const sep = document.createElement("div");
sep.className = "context-menu-separator";
menu.appendChild(sep);
return;
}
const el = document.createElement("div");
el.className = "context-menu-item" + (item.danger ? " danger" : "") + (item.disabled ? " disabled" : "");
el.textContent = item.label;
if (item.submenu) {
// Has submenu - show on hover
el.classList.add("has-submenu");
el.innerHTML += ' ▸';
const submenu = document.createElement("div");
submenu.className = "context-menu context-submenu";
item.submenu.forEach(sub => {
const subEl = document.createElement("div");
subEl.className = "context-menu-item";
subEl.textContent = sub.label;
subEl.onclick = (ev) => {
ev.stopPropagation();
menu.remove();
activeContextMenu = null;
sub.action();
};
submenu.appendChild(subEl);
});
el.appendChild(submenu);
// Position submenu on hover
el.onmouseenter = () => {
const rect = el.getBoundingClientRect();
submenu.style.display = "block";
submenu.style.left = rect.width + "px";
submenu.style.top = "0px";
// Check if submenu goes off screen
const subRect = submenu.getBoundingClientRect();
if (subRect.right > window.innerWidth) {
submenu.style.left = (-subRect.width) + "px";
}
if (subRect.bottom > window.innerHeight) {
submenu.style.top = (window.innerHeight - subRect.bottom - 5) + "px";
}
};
el.onmouseleave = () => {
submenu.style.display = "none";
};
} else {
el.onclick = (ev) => {
ev.stopPropagation();
if (item.disabled) return;
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;
}
// Expose context menu API for other modules (playlists.js)
M.contextMenu = {
show: showContextMenu,
hide: () => {
if (activeContextMenu) {
activeContextMenu.remove();
activeContextMenu = null;
}
}
};
// 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) {
// Get track IDs for the selected indices
const idsToAdd = hasSelection
? [...M.selectedQueueIndices].map(idx => M.queue[idx]?.id).filter(Boolean)
: [trackId];
// Add again option - duplicate tracks at end of queue
const addAgainLabel = selectedCount > 1 ? ` Add ${selectedCount} again` : " Add again";
menuItems.push({
label: addAgainLabel,
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({ add: idsToAdd })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast(selectedCount > 1 ? `Added ${selectedCount} tracks again` : "Track added again");
M.clearSelections();
}
}
});
// Play next option - insert after current track
const playNextLabel = selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next";
menuItems.push({
label: playNextLabel,
action: async () => {
if (!M.currentChannelId) return;
const insertAt = (M.currentIndex ?? 0) + 1;
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ add: idsToAdd, insertAt })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast(selectedCount > 1 ? `${selectedCount} tracks playing next` : "Track playing next");
M.clearSelections();
}
}
});
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)
});
// Copy link option
menuItems.push({
label: "🔗 Generate listening link",
action: () => {
const url = `${location.origin}/listen/${encodeURIComponent(trackId)}`;
navigator.clipboard.writeText(url).then(() => {
M.showToast("Link copied to clipboard");
}).catch(() => {
M.showToast("Failed to copy link", "error");
});
}
});
}
// Add to Playlist option
if (M.playlists && !M.currentUser?.is_guest) {
const trackIds = hasSelection
? [...M.selectedQueueIndices].map(idx => M.queue[idx]?.id).filter(Boolean)
: [trackId];
const submenu = M.playlists.showAddToPlaylistMenu(trackIds);
if (submenu && submenu.length > 0) {
menuItems.push({
label: hasSelection && trackIds.length > 1 ? `📁 Add ${trackIds.length} to Playlist...` : "📁 Add to Playlist...",
submenu: submenu
});
} else if (M.playlists.getMyPlaylists().length === 0) {
menuItems.push({
label: "📁 Add to Playlist...",
disabled: true,
action: () => M.showToast("Create a playlist first in the Playlists tab", "info")
});
}
}
// 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();
}
}
});
// Play next option - insert after current track
const playNextLabel = selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next";
menuItems.push({
label: playNextLabel,
action: async () => {
if (!M.currentChannelId) {
M.showToast("No channel selected");
return;
}
const insertAt = (M.currentIndex ?? 0) + 1;
const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ add: idsToAdd, insertAt })
});
if (res.status === 403) M.flashPermissionDenied();
else if (res.ok) {
M.showToast(selectedCount > 1 ? `${selectedCount} tracks playing next` : "Track playing next");
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)
});
// Copy link option
menuItems.push({
label: "🔗 Generate listening link",
action: () => {
const url = `${location.origin}/listen/${encodeURIComponent(track.id)}`;
navigator.clipboard.writeText(url).then(() => {
M.showToast("Link copied to clipboard");
}).catch(() => {
M.showToast("Failed to copy link", "error");
});
}
});
}
// Add to Playlist option
if (M.playlists && !M.currentUser?.is_guest) {
const submenu = M.playlists.showAddToPlaylistMenu(idsToAdd);
if (submenu && submenu.length > 0) {
menuItems.push({
label: hasSelection && idsToAdd.length > 1 ? `📁 Add ${idsToAdd.length} to Playlist...` : "📁 Add to Playlist...",
submenu: submenu
});
} else if (M.playlists.getMyPlaylists().length === 0) {
menuItems.push({
label: "📁 Add to Playlist...",
disabled: true,
action: () => M.showToast("Create a playlist first in the Playlists tab", "info")
});
}
}
// 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();
});
}
});
})();