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; + }); + }; +})();