diff --git a/public/index.html b/public/index.html
index aadbfae..b75990b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -180,6 +180,8 @@
+
+
diff --git a/public/playlists.js b/public/playlists.js
index 65e1dbd..79324ad 100644
--- a/public/playlists.js
+++ b/public/playlists.js
@@ -91,44 +91,50 @@
}
}
+ // Playlist tracks container instance
+ let playlistContainer = null;
+
function renderPlaylistContents() {
const header = $('#selected-playlist-name');
const actions = $('#playlist-actions');
- const container = $('#playlist-tracks');
+ const containerEl = $('#playlist-tracks');
if (!selectedPlaylist) {
header.textContent = 'Select a playlist';
actions.classList.add('hidden');
- container.innerHTML = '';
+ containerEl.innerHTML = '';
return;
}
header.textContent = selectedPlaylist.name;
actions.classList.remove('hidden');
- if (selectedPlaylist.trackIds.length === 0) {
- container.innerHTML = '
No tracks in this playlist
';
- return;
+ const isMine = myPlaylists.some(p => p.id === selectedPlaylistId);
+ console.log("[Playlists] Creating container - isMine:", isMine, "selectedPlaylistId:", selectedPlaylistId);
+
+ // Create or update container (even for empty playlists, to enable drag-drop)
+ playlistContainer = M.trackContainer.createContainer({
+ type: 'playlist',
+ element: containerEl,
+ getTracks: () => {
+ return selectedPlaylist.trackIds.map((id, i) => {
+ const track = M.library.find(t => t.id === id);
+ return { track: track || { id, title: 'Unknown track', duration: 0 }, originalIndex: i };
+ });
+ },
+ canRemove: isMine,
+ playlistId: selectedPlaylistId
+ });
+
+ console.log("[Playlists] About to call render(), playlistContainer:", playlistContainer);
+ playlistContainer.render();
+ console.log("[Playlists] After render()");
+ }
+
+ function reloadCurrentPlaylist() {
+ if (selectedPlaylistId) {
+ selectPlaylist(selectedPlaylistId);
}
-
- // Get track info from library
- const tracks = selectedPlaylist.trackIds.map(id => {
- const track = M.library.find(t => t.id === id);
- return track || { id, title: 'Unknown track', duration: 0 };
- });
-
- container.innerHTML = tracks.map((track, i) => `
-
- ${i + 1}
- ${escapeHtml(track.title || track.filename || 'Unknown')}
- ${formatTime(track.duration)}
-
- `).join('');
-
- // Attach click handlers
- container.querySelectorAll('.playlist-track').forEach(el => {
- el.oncontextmenu = (e) => showPlaylistTrackContextMenu(e, el.dataset.id, parseInt(el.dataset.index));
- });
}
function showPlaylistContextMenu(e, playlistId, isMine) {
@@ -193,45 +199,6 @@
M.contextMenu.show(e, items);
}
- function showPlaylistTrackContextMenu(e, trackId, index) {
- e.preventDefault();
- M.contextMenu.hide();
-
- const isMine = myPlaylists.some(p => p.id === selectedPlaylistId);
- const items = [];
-
- // Play
- items.push({
- label: '▶ Play',
- action: () => playTrackFromPlaylist(trackId)
- });
-
- items.push({ separator: true });
-
- // Add to queue
- items.push({
- label: '➕ Add to Queue',
- action: () => addTracksToQueue([trackId])
- });
- items.push({
- label: '⏭ Play Next',
- action: () => addTracksToQueue([trackId], true)
- });
-
- if (isMine) {
- items.push({ separator: true });
-
- // Remove from playlist
- items.push({
- label: '🗑️ Remove from Playlist',
- action: () => removeTrackFromPlaylist(index),
- className: 'danger'
- });
- }
-
- M.contextMenu.show(e, items);
- }
-
async function addPlaylistToQueue(playlistId, playNext = false) {
const playlist = [...myPlaylists, ...sharedPlaylists].find(p => p.id === playlistId);
if (!playlist || playlist.trackIds.length === 0) {
@@ -565,6 +532,8 @@
init: initPlaylists,
getMyPlaylists: () => myPlaylists,
showAddToPlaylistMenu,
- addTracksToPlaylist
+ addTracksToPlaylist,
+ renderPlaylistContents,
+ reloadCurrentPlaylist
};
})();
diff --git a/public/queue.js b/public/queue.js
index d2724c5..1aa7745 100644
--- a/public/queue.js
+++ b/public/queue.js
@@ -1,28 +1,21 @@
// MusicRoom - Queue module
-// Queue rendering and library display
+// Queue and library display using trackContainer
(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; // Index in filtered list, not original library
-
- // Current filtered library (for shift-select to work correctly with search)
- let currentFilteredLibrary = [];
-
- // Context menu state
- let activeContextMenu = null;
-
// 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) {
@@ -84,38 +77,31 @@
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");
+ M.showToast("No exportable tracks found", "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);
+ M.showToast(`Exporting ${exportQueue.length} tracks...`);
let exported = 0;
for (const { id, filename } of exportQueue) {
- if (!isExporting) break; // Allow cancellation
+ if (!isExporting) break;
try {
const cached = await TrackStorage.get(id);
@@ -129,8 +115,6 @@
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) {
@@ -149,318 +133,51 @@
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(filteredIndex, shiftKey = false) {
- if (shiftKey && lastSelectedLibraryIndex !== null && currentFilteredLibrary.length > 0) {
- // Range select: select all between last and current in FILTERED list
- const start = Math.min(lastSelectedLibraryIndex, filteredIndex);
- const end = Math.max(lastSelectedLibraryIndex, filteredIndex);
- for (let i = start; i <= end; i++) {
- if (currentFilteredLibrary[i]) {
- M.selectedLibraryIds.add(currentFilteredLibrary[i].track.id);
- }
- }
- } else {
- const item = currentFilteredLibrary[filteredIndex];
- if (!item) return;
- const trackId = item.track.id;
- if (M.selectedLibraryIds.has(trackId)) {
- M.selectedLibraryIds.delete(trackId);
- } else {
- M.selectedLibraryIds.add(trackId);
- }
- lastSelectedLibraryIndex = filteredIndex;
- }
- 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)
+ // 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 filename-based entries");
+ console.log("[Cache] Migrating: removing", oldEntries.length, "old 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");
+ console.log("[Cache] Updated:", M.cachedTracks.size, "tracks cached");
};
-
- // Debug: log cache status for current track
+
+ // Debug functions
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
+ segments: `${trackCache.size}/${M.SEGMENTS}`,
+ inCachedTracks: M.cachedTracks.has(M.currentTrackId),
+ hasBlobUrl: M.trackBlobs.has(M.currentTrackId)
});
};
-
- // 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 ===");
+ console.log("M.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)}...`);
+ console.log(` [${i}] ${t.title?.slice(0, 30)} | cached: ${M.cachedTracks.has(id)}`);
});
};
-
- // 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();
@@ -469,344 +186,61 @@
M.bulkDownloadStarted.clear();
M.renderQueue();
M.renderLibrary();
- console.log("[Cache] All caches cleared. Refresh the page.");
+ console.log("[Cache] Cleared. Refresh the page.");
};
-
- // Render the current queue
- M.renderQueue = function() {
- const container = M.$("#queue");
- if (!container) return;
- container.innerHTML = "";
+
+ // Initialize containers
+ function initContainers() {
+ const queueEl = M.$("#queue");
+ const libraryEl = M.$("#library");
- 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 = 'Queue empty - drag tracks here
';
- 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) };
+ if (queueEl && !queueContainer) {
+ queueContainer = M.trackContainer.createContainer({
+ type: 'queue',
+ element: queueEl,
+ getTracks: () => M.queue,
+ canReorder: true,
+ onRender: () => M.updateNowPlayingBar()
});
- 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 ? `✓` : '';
- const trackNum = `${i + 1}.`;
- div.innerHTML = `${checkmark}${trackNum}${title}${M.fmt(track.duration)}`;
-
- // 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];
+ 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 }));
}
- 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
+ return M.library
+ .map((track, i) => ({ track, originalIndex: i }))
+ .filter(({ track }) => {
+ const title = track.title?.trim() || track.filename || '';
+ return title.toLowerCase().includes(query);
});
- } 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();
+ });
+ }
+ }
+
+ // Render functions
+ M.renderQueue = function() {
+ initContainers();
+ if (queueContainer) {
+ queueContainer.render();
+ }
};
-
- // Update the now-playing bar above the queue
+
+ M.renderLibrary = function() {
+ initContainers();
+ if (libraryContainer) {
+ libraryContainer.render();
+ }
+ };
+
+ // Now-playing bar
M.updateNowPlayingBar = function() {
const bar = M.$("#now-playing-bar");
if (!bar) return;
@@ -822,8 +256,7 @@
bar.title = title;
bar.classList.remove("hidden");
};
-
- // Scroll queue to current track
+
M.scrollToCurrentTrack = function() {
const container = M.$("#queue");
if (!container) return;
@@ -833,246 +266,14 @@
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 = 'No tracks discovered
';
- return;
- }
-
- const canEdit = M.canControl();
- const query = M.librarySearchQuery.toLowerCase();
-
- // Filter library by search query
- currentFilteredLibrary = 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 (currentFilteredLibrary.length === 0) {
- container.innerHTML = 'No matches
';
- return;
- }
-
- currentFilteredLibrary.forEach(({ track, i }, filteredIndex) => {
- 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 ? `✓` : '';
- div.innerHTML = `${checkmark}${title}${M.fmt(track.duration)}`;
-
- // 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(filteredIndex, 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);
- });
+
+ // Backwards compatibility
+ M.clearSelections = function() {
+ M.clearAllSelections();
+ M.renderQueue();
+ M.renderLibrary();
};
-
+
// Load library from server
M.loadLibrary = async function() {
try {
@@ -1083,9 +284,14 @@
console.warn("Failed to load library");
}
};
-
- // Setup library search
+
+ // 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) => {
diff --git a/public/styles.css b/public/styles.css
index 3b67e38..c3b9a2e 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -125,9 +125,9 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
.search-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; }
.search-input::placeholder { color: #666; }
#library, #queue { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; }
-#library .track, #queue .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; min-width: 0; }
-#library .track[title], #queue .track[title] { cursor: pointer; }
-#library .track:hover, #queue .track:hover { background: #222; }
+#library .track, #queue .track, #playlist-tracks .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; min-width: 0; }
+#library .track[title], #queue .track[title], #playlist-tracks .track[title] { cursor: pointer; }
+#library .track:hover, #queue .track:hover, #playlist-tracks .track:hover { background: #222; }
#queue .track.active { background: #2a4a3a; color: #4e8; }
.cache-indicator { width: 3px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 4px 0 0 4px; }
.track.cached .cache-indicator { background: #4e8; }
@@ -136,6 +136,10 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
.track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.track-actions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
.track-actions .duration { color: #666; font-size: 0.75rem; }
+.track-actions .track-play-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; }
+.track-actions .track-play-btn:hover { background: #48f; color: #fff; }
+.track-actions .track-preview-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; }
+.track-actions .track-preview-btn:hover { background: #4a4; color: #fff; }
.track-actions .track-add, .track-actions .track-remove { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.85rem; color: #ccc; cursor: pointer; opacity: 0; transition: opacity 0.2s; }
.track:hover .track-add, .track:hover .track-remove { opacity: 0.6; }
.track-actions .track-add:hover, .track-actions .track-remove:hover { opacity: 1; background: #444; }
@@ -146,9 +150,13 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
.track-checkmark { color: #4e8; font-weight: bold; margin-right: 0.4rem; font-size: 0.85rem; }
.track.selected { background: #2a3a4a; }
.track.dragging { opacity: 0.5; }
+/* Allow drop events to pass through to parent track element */
+.track > * { pointer-events: none; }
+.track > .track-actions { pointer-events: auto; }
+.track > .track-actions > * { pointer-events: auto; }
.track.drop-above::before { content: ""; position: absolute; top: -1px; left: 0; right: 0; height: 2px; background: #4e8; border-radius: 2px; }
.track.drop-below::after { content: ""; position: absolute; bottom: -1px; left: 0; right: 0; height: 2px; background: #4e8; border-radius: 2px; }
-#queue.drop-target, #queue .drop-zone { border: 2px dashed #4e8; border-radius: 4px; }
+#queue.drop-target, #queue .drop-zone, #playlist-tracks.drop-target { border: 2px dashed #4e8; border-radius: 4px; }
#queue .drop-zone { padding: 1.5rem; text-align: center; color: #4e8; }
/* Context menu */
@@ -292,7 +300,8 @@ button:hover { background: #333; }
.empty-playlist-tracks { color: #555; font-size: 0.8rem; padding: 1rem; text-align: center; font-style: italic; }
.context-menu-separator { height: 1px; background: #333; margin: 0.2rem 0; }
.context-menu-item.has-submenu { position: relative; }
-.context-submenu { position: absolute; display: none; top: 0; left: 100%; margin-left: 2px; }
+.context-submenu { position: absolute; display: none; top: 0; left: 100%; margin-left: 2px; background: #222; border: 1px solid #444; border-radius: 4px; min-width: 120px; z-index: 1001; }
+.context-menu-item.has-submenu:hover > .context-submenu { display: block; }
.context-menu-item.disabled { color: #666; cursor: default; }
.context-menu-item.disabled:hover { background: transparent; }
diff --git a/public/trackComponent.js b/public/trackComponent.js
new file mode 100644
index 0000000..f9db290
--- /dev/null
+++ b/public/trackComponent.js
@@ -0,0 +1,91 @@
+// MusicRoom - Track Component
+// Pure rendering for track rows - no event handlers attached
+
+(function() {
+ const M = window.MusicRoom;
+
+ /**
+ * Render a track row element (pure rendering, no handlers)
+ * @param {Object} track - Track object with id, title, filename, duration
+ * @param {Object} config - Configuration options
+ * @param {string} config.view - 'queue' | 'library' | 'playlist'
+ * @param {number} config.index - Index in the list
+ * @param {number} [config.displayIndex] - Display number (1-based)
+ * @param {boolean} config.isSelected - Whether track is selected
+ * @param {boolean} config.isCached - Whether track is cached locally
+ * @param {boolean} config.isActive - Whether this is the currently playing track
+ * @param {boolean} config.showPlayButton - Show play button (queue only)
+ * @param {boolean} config.draggable - Whether element is draggable
+ * @returns {HTMLElement}
+ */
+ function render(track, config) {
+ const {
+ view,
+ index,
+ displayIndex,
+ isSelected,
+ isCached,
+ isActive,
+ showPlayButton,
+ draggable
+ } = config;
+
+ const div = document.createElement("div");
+ const trackId = track.id || track.filename;
+
+ // Build class list
+ const classes = ["track"];
+ if (isActive) classes.push("active");
+ if (isCached) classes.push("cached");
+ else classes.push("not-cached");
+ if (isSelected) classes.push("selected");
+ div.className = classes.join(" ");
+
+ // Store data attributes
+ div.dataset.index = index;
+ div.dataset.trackId = trackId;
+ div.dataset.view = view;
+
+ // Build title
+ const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
+ div.title = title;
+
+ // Build HTML
+ const checkmark = isSelected ? '✓' : '';
+ const trackNum = displayIndex != null ? `${displayIndex}.` : '';
+ const playBtn = showPlayButton ? '' : '';
+ const previewBtn = '';
+
+ div.innerHTML = `
+ ${checkmark}
+
+ ${trackNum}
+ ${escapeHtml(title)}
+
+ ${playBtn}
+ ${previewBtn}
+ ${M.fmt(track.duration)}
+
+ `;
+
+ if (draggable) {
+ div.draggable = true;
+ }
+
+ return div;
+ }
+
+ // HTML escape helper
+ function escapeHtml(str) {
+ if (!str) return '';
+ return str.replace(/[&<>"']/g, c => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": '''
+ })[c]);
+ }
+
+ // Export
+ M.trackComponent = {
+ render,
+ escapeHtml
+ };
+})();
diff --git a/public/trackContainer.js b/public/trackContainer.js
new file mode 100644
index 0000000..8d80078
--- /dev/null
+++ b/public/trackContainer.js
@@ -0,0 +1,922 @@
+// MusicRoom - Track Container
+// Manages track lists with selection, drag-and-drop, and context menus
+
+(function() {
+ const M = window.MusicRoom;
+
+ // Global debug: see if ANY drop event fires
+ document.addEventListener('drop', (e) => {
+ console.log("[GLOBAL drop] target:", e.target.tagName, e.target.className, "id:", e.target.id);
+ }, true);
+
+ document.addEventListener('dragend', (e) => {
+ console.log("[GLOBAL dragend] target:", e.target.tagName, e.target.className);
+ }, true);
+
+ // Track if a drag is in progress (to prevent re-renders from canceling it)
+ let isDragging = false;
+
+ // Selection state per container type
+ const selection = {
+ queue: new Set(), // indices
+ library: new Set(), // track IDs
+ playlist: new Set() // indices (for duplicate support)
+ };
+
+ // Last selected for shift-select
+ const lastSelected = {
+ queue: null,
+ library: null,
+ playlist: null
+ };
+
+ // Drag state (shared across containers)
+ let dragSource = null;
+ let draggedIndices = [];
+ let draggedTrackIds = [];
+ let dropTargetIndex = null;
+
+ // Active context menu
+ let activeContextMenu = null;
+
+ /**
+ * Create a track container manager
+ * @param {Object} config
+ * @param {string} config.type - 'queue' | 'library' | 'playlist'
+ * @param {HTMLElement} config.element - Container DOM element
+ * @param {Function} config.getTracks - Returns array of tracks to render
+ * @param {Function} [config.getFilteredTracks] - Returns filtered tracks (for library search)
+ * @param {boolean} [config.canEdit] - Whether user can modify queue
+ * @param {boolean} [config.canReorder] - Whether tracks can be reordered (queue only)
+ * @param {boolean} [config.canRemove] - Whether tracks can be removed from playlist
+ * @param {string} [config.playlistId] - Playlist ID (for playlist type)
+ * @param {Function} [config.onRender] - Callback after render
+ */
+ function createContainer(config) {
+ const {
+ type,
+ element,
+ getTracks,
+ getFilteredTracks,
+ canReorder = false,
+ canRemove = false,
+ playlistId = null,
+ onRender
+ } = config;
+
+ console.log("[createContainer] type:", type, "canRemove:", canRemove, "playlistId:", playlistId);
+
+ let currentTracks = [];
+
+ // Get canEdit dynamically (permissions may change)
+ const getCanEdit = () => config.canEdit ?? M.canControl();
+
+ // Track if this container needs a render after drag ends
+ let pendingRender = false;
+
+ function render() {
+ // Defer render if a drag is in progress (would cancel the drag)
+ if (isDragging) {
+ console.log("[render] DEFERRED - drag in progress, type:", type);
+ pendingRender = true;
+ return;
+ }
+ pendingRender = false;
+ console.log("[render] type:", type, "canRemove:", canRemove, "playlistId:", playlistId);
+ const canEdit = getCanEdit();
+ element.innerHTML = "";
+
+ // Get tracks (filtered for library, direct for queue/playlist)
+ currentTracks = getFilteredTracks ? getFilteredTracks() : getTracks();
+
+ // Always wire up container drop handlers first (even for empty containers)
+ if (canEdit && type === 'queue') {
+ wireQueueContainerDrop(element);
+ }
+ if (canRemove && type === 'playlist' && playlistId) {
+ console.log("[Playlist] Wiring container drop on element:", element.id || element.className);
+ wirePlaylistContainerDrop(element);
+ }
+
+ if (currentTracks.length === 0) {
+ const emptyMsg = type === 'queue' ? 'Queue empty - drag tracks here'
+ : type === 'library' ? 'No tracks'
+ : 'No tracks - drag here to add';
+ element.innerHTML = `${emptyMsg}
`;
+ if (onRender) onRender();
+ return;
+ }
+
+ currentTracks.forEach((item, filteredIndex) => {
+ // item can be { track, originalIndex } or just track
+ const track = item.track || item;
+ const index = type === 'queue' ? filteredIndex : (item.originalIndex ?? filteredIndex);
+ const trackId = track.id || track.filename;
+
+ // Queue and playlist use indices, library uses trackIds
+ const isSelected = type === 'library'
+ ? selection.library.has(trackId)
+ : selection[type].has(index);
+
+ const isCached = M.cachedTracks.has(trackId);
+ const isActive = type === 'queue' && index === M.currentIndex;
+
+ // Library tracks are always draggable, queue tracks need edit permission
+ const isDraggable = type === 'library' || (type === 'queue' && canEdit) || (type === 'playlist' && canRemove);
+
+ const div = M.trackComponent.render(track, {
+ view: type,
+ index: filteredIndex,
+ displayIndex: type === 'queue' || type === 'playlist' ? filteredIndex + 1 : null,
+ isSelected,
+ isCached,
+ isActive,
+ showPlayButton: type === 'queue',
+ draggable: isDraggable
+ });
+
+ // Wire up event handlers
+ wireTrackEvents(div, track, filteredIndex, index, canEdit);
+
+ element.appendChild(div);
+ });
+
+ if (onRender) onRender();
+ }
+
+ function wirePlaylistContainerDrop(container) {
+ container.ondragover = (e) => {
+ if (dragSource === 'queue' || dragSource === 'library' || dragSource === 'playlist') {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy";
+ container.classList.add("drop-target");
+ }
+ };
+
+ container.ondragleave = (e) => {
+ if (!container.contains(e.relatedTarget)) {
+ container.classList.remove("drop-target");
+ }
+ };
+
+ container.ondrop = (e) => {
+ console.log("[Container ondrop] dragSource:", dragSource, "trackIds:", draggedTrackIds, "dropTargetIndex:", dropTargetIndex);
+ container.classList.remove("drop-target");
+ // Clear any drop indicators on tracks
+ container.querySelectorAll(".drop-above, .drop-below").forEach(el => {
+ el.classList.remove("drop-above", "drop-below");
+ });
+
+ if (draggedTrackIds.length > 0) {
+ e.preventDefault();
+
+ // If dropTargetIndex was set by a track, use that position
+ // Otherwise append to end
+ const targetPos = dropTargetIndex !== null ? dropTargetIndex : currentTracks.length;
+
+ if (dragSource === 'playlist') {
+ reorderPlaylist(draggedTrackIds, targetPos);
+ } else if (dragSource === 'queue' || dragSource === 'library') {
+ if (currentTracks.length === 0) {
+ addTracksToPlaylist(draggedTrackIds);
+ } else {
+ addTracksToPlaylistAt(draggedTrackIds, targetPos);
+ }
+ }
+
+ draggedTrackIds = [];
+ draggedIndices = [];
+ dragSource = null;
+ dropTargetIndex = null;
+ }
+ };
+ }
+
+ async function addTracksToPlaylist(trackIds) {
+ if (!playlistId) return;
+
+ const res = await fetch(`/api/playlists/${playlistId}/tracks`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ add: trackIds })
+ });
+
+ if (res.ok) {
+ M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''} to playlist`);
+ if (M.playlists) M.playlists.reloadCurrentPlaylist();
+ }
+ }
+
+ async function addTracksToPlaylistAt(trackIds, position) {
+ if (!playlistId) {
+ console.error("[addTracksToPlaylistAt] No playlistId");
+ return;
+ }
+
+ // Get current tracks and insert at position
+ const current = currentTracks.map(t => (t.track || t).id);
+ console.log("[addTracksToPlaylistAt] current:", current, "inserting:", trackIds, "at:", position);
+ const newList = [...current.slice(0, position), ...trackIds, ...current.slice(position)];
+ console.log("[addTracksToPlaylistAt] newList:", newList);
+
+ const res = await fetch(`/api/playlists/${playlistId}/tracks`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ set: newList })
+ });
+
+ if (res.ok) {
+ M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`);
+ if (M.playlists) M.playlists.reloadCurrentPlaylist();
+ } else {
+ console.error("[addTracksToPlaylistAt] Failed:", await res.text());
+ }
+ }
+
+ async function reorderPlaylist(trackIds, targetIndex) {
+ if (!playlistId) return;
+
+ // Get current track IDs
+ const current = currentTracks.map(t => (t.track || t).id);
+
+ // Remove the dragged tracks from their current positions
+ const remaining = current.filter(id => !trackIds.includes(id));
+
+ // Calculate insertion position (adjusted for removed items before target)
+ let insertAt = targetIndex;
+ for (const id of trackIds) {
+ const originalPos = current.indexOf(id);
+ if (originalPos < targetIndex) {
+ insertAt--;
+ }
+ }
+ insertAt = Math.max(0, Math.min(insertAt, remaining.length));
+
+ // Insert at new position
+ const newList = [...remaining.slice(0, insertAt), ...trackIds, ...remaining.slice(insertAt)];
+
+ const res = await fetch(`/api/playlists/${playlistId}/tracks`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ set: newList })
+ });
+
+ if (res.ok) {
+ selection.playlist.clear();
+ lastSelected.playlist = null;
+ if (M.playlists) M.playlists.reloadCurrentPlaylist();
+ }
+ }
+
+ function wireTrackEvents(div, track, filteredIndex, originalIndex, canEdit) {
+ const trackId = track.id || track.filename;
+ const index = type === 'queue' ? originalIndex : filteredIndex;
+
+ // Click - toggle selection
+ div.onclick = (e) => {
+ if (e.target.closest('.track-actions')) return;
+ toggleSelection(index, trackId, e.shiftKey);
+ render();
+ };
+
+ // Play button (queue only)
+ const playBtn = div.querySelector('.track-play-btn');
+ if (playBtn) {
+ playBtn.onclick = (e) => {
+ e.stopPropagation();
+ playTrack(track, originalIndex);
+ };
+ }
+
+ // Preview button
+ const previewBtn = div.querySelector('.track-preview-btn');
+ if (previewBtn) {
+ previewBtn.onclick = (e) => {
+ e.stopPropagation();
+ previewTrack(track);
+ };
+ }
+
+ // Context menu
+ div.oncontextmenu = (e) => {
+ e.preventDefault();
+ showContextMenu(e, track, originalIndex, canEdit);
+ };
+
+ // Drag start/end handlers - library always, queue/playlist with permissions
+ const canDrag = type === 'library' || (type === 'queue' && canEdit) || (type === 'playlist' && canRemove);
+ if (canDrag) {
+ div.ondragstart = (e) => handleDragStart(e, track, originalIndex, div);
+ div.ondragend = (e) => handleDragEnd(e, div);
+ }
+
+ // Drop handlers - only queue accepts drops (from library/playlist)
+ if (canEdit && type === 'queue') {
+ div.ondragover = (e) => handleDragOver(e, div, originalIndex);
+ div.ondragleave = (e) => handleDragLeave(e, div);
+ div.ondrop = (e) => handleDrop(e, div, originalIndex);
+ }
+
+ // Debug: log playlist track wiring conditions
+ if (type === 'playlist') {
+ console.log("[Playlist wireTrackEvents] type:", type, "playlistId:", playlistId, "canRemove:", canRemove);
+ }
+
+ // For playlist tracks, allow reordering and insertion (separate from canEdit)
+ if (type === 'playlist' && playlistId && canRemove) {
+ console.log("[Playlist] Wiring drag handlers for track:", trackId);
+
+ div.ondragover = (e) => {
+ console.log("[Playlist track ondragover] dragSource:", dragSource);
+ e.preventDefault();
+ e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy";
+
+ const rect = div.getBoundingClientRect();
+ const midY = rect.top + rect.height / 2;
+ const isAbove = e.clientY < midY;
+
+ // Clear other indicators
+ element.querySelectorAll(".drop-above, .drop-below").forEach(el => {
+ el.classList.remove("drop-above", "drop-below");
+ });
+
+ // Don't show indicator on dragged items (for reorder)
+ if (dragSource === 'playlist' && draggedTrackIds.includes(trackId)) return;
+
+ div.classList.add(isAbove ? "drop-above" : "drop-below");
+ dropTargetIndex = isAbove ? filteredIndex : filteredIndex + 1;
+ };
+
+ div.ondragleave = () => {
+ div.classList.remove("drop-above", "drop-below");
+ };
+
+ div.ondrop = (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ div.classList.remove("drop-above", "drop-below");
+ element.classList.remove("drop-target");
+
+ console.log("[Track ondrop] dragSource:", dragSource, "trackIds:", draggedTrackIds, "dropTargetIndex:", dropTargetIndex);
+
+ if (draggedTrackIds.length > 0 && dropTargetIndex !== null) {
+ if (dragSource === 'playlist') {
+ // Reorder within playlist
+ reorderPlaylist(draggedTrackIds, dropTargetIndex);
+ } else if (dragSource === 'queue' || dragSource === 'library') {
+ // Insert at position
+ addTracksToPlaylistAt(draggedTrackIds, dropTargetIndex);
+ }
+ }
+
+ draggedTrackIds = [];
+ draggedIndices = [];
+ dragSource = null;
+ dropTargetIndex = null;
+ };
+ }
+ }
+
+ function toggleSelection(index, trackId, shiftKey) {
+ // Queue and playlist use indices, library uses trackIds
+ const key = type === 'library' ? trackId : index;
+ const sel = selection[type];
+
+ if (shiftKey && lastSelected[type] !== null) {
+ // Range select
+ const currentIdx = type === 'library' ? getFilteredIndex(trackId) : index;
+ const start = Math.min(lastSelected[type], currentIdx);
+ const end = Math.max(lastSelected[type], currentIdx);
+
+ for (let i = start; i <= end; i++) {
+ if (type === 'library') {
+ const t = currentTracks[i];
+ if (t) {
+ const id = (t.track || t).id;
+ if (id) sel.add(id);
+ }
+ } else {
+ // Queue and playlist use indices
+ sel.add(i);
+ }
+ }
+ } else {
+ if (sel.has(key)) {
+ sel.delete(key);
+ } else {
+ sel.add(key);
+ }
+ lastSelected[type] = type === 'library' ? getFilteredIndex(trackId) : index;
+ }
+ }
+
+ function getFilteredIndex(trackId) {
+ return currentTracks.findIndex(t => ((t.track || t).id) === trackId);
+ }
+
+ function handleDragStart(e, track, index, div) {
+ isDragging = true;
+ const trackId = track.id || track.filename;
+ dragSource = type;
+
+ if (type === 'queue') {
+ draggedIndices = selection.queue.has(index) ? [...selection.queue] : [index];
+ draggedTrackIds = draggedIndices.map(i => M.queue[i]?.id).filter(Boolean);
+ } else {
+ draggedTrackIds = selection[type].has(trackId) ? [...selection[type]] : [trackId];
+ draggedIndices = [];
+ }
+
+ div.classList.add("dragging");
+ // Use "copyMove" to allow both copy and move operations
+ e.dataTransfer.effectAllowed = "copyMove";
+ e.dataTransfer.setData("text/plain", `${type}:${draggedTrackIds.join(",")}`);
+ }
+
+ function handleDragEnd(e, div) {
+ isDragging = false;
+ div.classList.remove("dragging");
+ draggedIndices = [];
+ draggedTrackIds = [];
+ dragSource = null;
+ dropTargetIndex = null;
+
+ // Clear all drop indicators
+ element.querySelectorAll(".drop-above, .drop-below").forEach(el => {
+ el.classList.remove("drop-above", "drop-below");
+ });
+
+ // Execute deferred render if any
+ if (pendingRender) {
+ console.log("[handleDragEnd] Executing deferred render for:", type);
+ setTimeout(() => render(), 50);
+ }
+ }
+
+ function handleDragOver(e, div, index) {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = dragSource === 'queue' ? "move" : "copy";
+
+ const rect = div.getBoundingClientRect();
+ const midY = rect.top + rect.height / 2;
+ const isAbove = e.clientY < midY;
+
+ // Clear other indicators
+ element.querySelectorAll(".drop-above, .drop-below").forEach(el => {
+ el.classList.remove("drop-above", "drop-below");
+ });
+
+ // Don't show indicator on dragged items
+ if (dragSource === 'queue' && draggedIndices.includes(index)) return;
+
+ div.classList.add(isAbove ? "drop-above" : "drop-below");
+ dropTargetIndex = isAbove ? index : index + 1;
+ }
+
+ function handleDragLeave(e, div) {
+ div.classList.remove("drop-above", "drop-below");
+ }
+
+ function handleDrop(e, div, index) {
+ console.log("[handleDrop] type:", type, "dragSource:", dragSource, "dropTargetIndex:", dropTargetIndex, "draggedIndices:", draggedIndices);
+ e.preventDefault();
+ div.classList.remove("drop-above", "drop-below");
+
+ if (dropTargetIndex === null) return;
+
+ if (dragSource === 'queue' && draggedIndices.length > 0) {
+ // Reorder within queue
+ const minDragged = Math.min(...draggedIndices);
+ const maxDragged = Math.max(...draggedIndices);
+ if (dropTargetIndex < minDragged || dropTargetIndex > maxDragged + 1) {
+ reorderQueue(draggedIndices, dropTargetIndex);
+ }
+ } else if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) {
+ // Insert tracks from library or playlist
+ insertTracksAtPosition(draggedTrackIds, dropTargetIndex);
+ }
+
+ draggedIndices = [];
+ draggedTrackIds = [];
+ dragSource = null;
+ dropTargetIndex = null;
+ }
+
+ function wireQueueContainerDrop(container) {
+ container.ondragover = (e) => {
+ if (dragSource === 'library' || dragSource === 'playlist') {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "copy";
+ if (M.queue.length === 0) {
+ container.classList.add("drop-target");
+ }
+ }
+ };
+
+ container.ondragleave = (e) => {
+ if (!container.contains(e.relatedTarget)) {
+ container.classList.remove("drop-target");
+ }
+ };
+
+ container.ondrop = (e) => {
+ container.classList.remove("drop-target");
+ if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) {
+ e.preventDefault();
+ const targetIndex = dropTargetIndex !== null ? dropTargetIndex : M.queue.length;
+ insertTracksAtPosition(draggedTrackIds, targetIndex);
+ draggedTrackIds = [];
+ dragSource = null;
+ dropTargetIndex = null;
+ }
+ };
+ }
+
+ async function reorderQueue(indices, targetIndex) {
+ if (!M.currentChannelId) return;
+
+ const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ move: indices, to: targetIndex })
+ });
+
+ if (res.status === 403) M.flashPermissionDenied();
+ else if (res.ok) {
+ selection.queue.clear();
+ lastSelected.queue = null;
+ }
+ }
+
+ async function insertTracksAtPosition(trackIds, position) {
+ if (!M.currentChannelId) return;
+
+ const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ add: trackIds, insertAt: position })
+ });
+
+ if (res.status === 403) M.flashPermissionDenied();
+ else if (res.ok) {
+ M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`);
+ clearSelection();
+ }
+ }
+
+ async function playTrack(track, index) {
+ const trackId = track.id || track.filename;
+ const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
+
+ if (type === 'queue') {
+ // Jump to track in queue
+ 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 })
+ });
+ if (res.status === 403) M.flashPermissionDenied();
+ } else {
+ // Local playback
+ M.currentIndex = index;
+ 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();
+ render();
+ }
+ }
+ }
+
+ async function previewTrack(track) {
+ const trackId = track.id || track.filename;
+ const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
+
+ 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();
+
+ if (M.synced) {
+ M.synced = false;
+ M.showToast("Previewing track (desynced)");
+ }
+ }
+
+ function showContextMenu(e, track, index, canEdit) {
+ console.log("[showContextMenu] type:", type, "canEdit:", canEdit, "canRemove:", canRemove, "playlistId:", playlistId);
+ const trackId = track.id || track.filename;
+ const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
+
+ const sel = selection[type];
+ const hasSelection = sel.size > 0;
+ const selectedCount = hasSelection ? sel.size : 1;
+
+ // Get IDs/indices for bulk operations
+ let idsForAction, indicesToRemove;
+ if (type === 'queue') {
+ indicesToRemove = hasSelection ? [...sel] : [index];
+ idsForAction = indicesToRemove.map(i => M.queue[i]?.id).filter(Boolean);
+ } else if (type === 'playlist') {
+ // Playlist uses indices for selection/removal (supports duplicates)
+ indicesToRemove = hasSelection ? [...sel] : [index];
+ idsForAction = indicesToRemove.map(i => currentTracks[i]?.track?.id || currentTracks[i]?.id).filter(Boolean);
+ } else {
+ // Library uses trackIds
+ idsForAction = hasSelection ? [...sel] : [trackId];
+ }
+
+ const menuItems = [];
+
+ // Play (queue only, single track)
+ if (type === 'queue' && !hasSelection) {
+ menuItems.push({
+ label: "▶ Play",
+ action: () => playTrack(track, index)
+ });
+ }
+
+ // Preview (all views, single track)
+ if (!hasSelection) {
+ menuItems.push({
+ label: "⏵ Preview",
+ action: () => previewTrack(track)
+ });
+ }
+
+ // Queue actions
+ if (type === 'queue' && canEdit) {
+ menuItems.push({
+ label: selectedCount > 1 ? `+ Add ${selectedCount} again` : "+ Add again",
+ action: () => addToQueue(idsForAction)
+ });
+ menuItems.push({
+ label: selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next",
+ action: () => addToQueue(idsForAction, true)
+ });
+ menuItems.push({
+ label: selectedCount > 1 ? `✕ Remove ${selectedCount}` : "✕ Remove",
+ danger: true,
+ action: () => removeFromQueue(indicesToRemove)
+ });
+ }
+
+ // Library actions
+ if (type === 'library' && canEdit) {
+ menuItems.push({
+ label: selectedCount > 1 ? `+ Add ${selectedCount} to queue` : "+ Add to queue",
+ action: () => addToQueue(idsForAction)
+ });
+ menuItems.push({
+ label: selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next",
+ action: () => addToQueue(idsForAction, true)
+ });
+ }
+
+ // Playlist actions
+ if (type === 'playlist') {
+ if (canEdit) {
+ menuItems.push({
+ label: selectedCount > 1 ? `+ Add ${selectedCount} to queue` : "+ Add to queue",
+ action: () => addToQueue(idsForAction)
+ });
+ menuItems.push({
+ label: selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next",
+ action: () => addToQueue(idsForAction, true)
+ });
+ }
+ if (canRemove && playlistId) {
+ menuItems.push({
+ label: selectedCount > 1 ? `🗑️ Remove ${selectedCount}` : "🗑️ Remove",
+ danger: true,
+ action: () => removeFromPlaylist(indicesToRemove)
+ });
+ }
+ }
+
+ // Preload (library/queue, non-stream mode)
+ if ((type === 'library' || type === 'queue') && !M.streamOnly) {
+ menuItems.push({
+ label: selectedCount > 1 ? `Preload ${selectedCount}` : "Preload",
+ action: () => {
+ const uncached = idsForAction.filter(id => !M.cachedTracks.has(id));
+ if (uncached.length === 0) {
+ M.showToast("Already cached");
+ return;
+ }
+ M.showToast(`Preloading ${uncached.length}...`);
+ uncached.forEach(id => M.downloadAndCacheTrack(id));
+ }
+ });
+ }
+
+ // Add to Playlist
+ if (M.playlists && !M.currentUser?.is_guest) {
+ const submenu = M.playlists.showAddToPlaylistMenu(idsForAction);
+ if (submenu && submenu.length > 0) {
+ menuItems.push({
+ label: idsForAction.length > 1 ? `📁 Add ${idsForAction.length} to Playlist...` : "📁 Add to Playlist...",
+ submenu
+ });
+ }
+ }
+
+ // Copy link (single)
+ if (!hasSelection) {
+ menuItems.push({
+ label: "🔗 Copy link",
+ action: () => {
+ navigator.clipboard.writeText(`${location.origin}/listen/${encodeURIComponent(trackId)}`);
+ M.showToast("Link copied");
+ }
+ });
+ }
+
+ // Clear selection
+ if (hasSelection) {
+ menuItems.push({
+ label: "Clear selection",
+ action: () => { clearSelection(); render(); }
+ });
+ }
+
+ M.contextMenu.show(e, menuItems);
+ }
+
+ async function addToQueue(trackIds, playNext = false) {
+ if (!M.currentChannelId) {
+ M.showToast("No channel selected");
+ return;
+ }
+ const body = playNext
+ ? { add: trackIds, insertAt: (M.currentIndex ?? 0) + 1 }
+ : { add: trackIds };
+
+ const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body)
+ });
+
+ if (res.status === 403) M.flashPermissionDenied();
+ else if (res.ok) {
+ M.showToast(playNext ? "Playing next" : "Added to queue");
+ clearSelection();
+ render();
+ }
+ }
+
+ async function removeFromQueue(indices) {
+ console.log("[removeFromQueue] indices:", indices, "channelId:", M.currentChannelId);
+ if (!M.currentChannelId) return;
+
+ const res = await fetch("/api/channels/" + M.currentChannelId + "/queue", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ remove: indices })
+ });
+
+ if (res.status === 403) M.flashPermissionDenied();
+ else if (res.ok) {
+ M.showToast("Removed");
+ clearSelection();
+ }
+ }
+
+ async function removeFromPlaylist(indices) {
+ console.log("[removeFromPlaylist] indices:", indices, "playlistId:", playlistId);
+ if (!playlistId) return;
+
+ const res = await fetch(`/api/playlists/${playlistId}/tracks`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ remove: indices })
+ });
+
+ if (res.ok) {
+ M.showToast("Removed from playlist");
+ clearSelection();
+ if (M.playlists) M.playlists.reloadCurrentPlaylist();
+ }
+ }
+
+ function clearSelection() {
+ selection[type].clear();
+ lastSelected[type] = null;
+ }
+
+ function getSelection() {
+ return [...selection[type]];
+ }
+
+ return {
+ render,
+ clearSelection,
+ getSelection,
+ get currentTracks() { return currentTracks; }
+ };
+ }
+
+ // Context menu rendering (shared)
+ function showContextMenuUI(e, items) {
+ e.preventDefault();
+ hideContextMenu();
+
+ 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) {
+ el.classList.add("has-submenu");
+ el.innerHTML += ' ▸';
+
+ const sub = document.createElement("div");
+ sub.className = "context-submenu";
+ item.submenu.forEach(subItem => {
+ const subEl = document.createElement("div");
+ subEl.className = "context-menu-item";
+ subEl.textContent = subItem.label;
+ subEl.onclick = (e) => {
+ e.stopPropagation();
+ hideContextMenu();
+ subItem.action();
+ };
+ sub.appendChild(subEl);
+ });
+ el.appendChild(sub);
+ } else if (!item.disabled) {
+ el.onclick = () => {
+ hideContextMenu();
+ item.action();
+ };
+ }
+
+ menu.appendChild(el);
+ });
+
+ menu.style.left = e.clientX + "px";
+ menu.style.top = e.clientY + "px";
+ document.body.appendChild(menu);
+
+ // Adjust if off-screen
+ const rect = menu.getBoundingClientRect();
+ if (rect.right > window.innerWidth) {
+ menu.style.left = (window.innerWidth - rect.width - 5) + "px";
+ }
+ if (rect.bottom > window.innerHeight) {
+ menu.style.top = (window.innerHeight - rect.height - 5) + "px";
+ }
+
+ activeContextMenu = menu;
+
+ // Close on click outside
+ setTimeout(() => {
+ document.addEventListener("click", hideContextMenu, { once: true });
+ }, 0);
+ }
+
+ function hideContextMenu() {
+ if (activeContextMenu) {
+ activeContextMenu.remove();
+ activeContextMenu = null;
+ }
+ }
+
+ // Export
+ M.trackContainer = { createContainer };
+ M.contextMenu = {
+ show: showContextMenuUI,
+ hide: hideContextMenu
+ };
+
+ // Clear all selections helper
+ M.clearAllSelections = function() {
+ Object.keys(selection).forEach(k => {
+ selection[k].clear();
+ lastSelected[k] = null;
+ });
+ };
+})();