// MusicRoom - Track Container // Manages track lists with selection, drag-and-drop, and context menus (function() { const M = window.MusicRoom; // 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; 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) { pendingRender = true; return; } pendingRender = false; 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) { 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) => { 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); const newList = [...current.slice(0, position), ...trackIds, ...current.slice(position)]; 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); } // For playlist tracks, allow reordering and insertion (separate from canEdit) if (type === 'playlist' && playlistId && canRemove) { div.ondragover = (e) => { 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"); 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 if (type === 'playlist') { // Playlist uses indices for selection (supports duplicates) draggedIndices = selection.playlist.has(index) ? [...selection.playlist] : [index]; draggedTrackIds = draggedIndices.map(i => { const t = currentTracks[i]; return t ? (t.track || t).id : null; }).filter(Boolean); } else { // Library uses trackIds draggedTrackIds = selection.library.has(trackId) ? [...selection.library] : [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) { 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) { 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) { 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) { 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) { 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; }); }; })();