From 62c7fb9e197b89f68dd9c5889f5f77035db4e922 Mon Sep 17 00:00:00 2001 From: peterino2 Date: Fri, 6 Feb 2026 16:31:19 -0800 Subject: [PATCH] lots of cleanups --- public/playlists.js | 2 +- public/styles.css | 4 +- public/trackContainer.js | 211 ++++++++++++++++++++------------------- 3 files changed, 111 insertions(+), 106 deletions(-) diff --git a/public/playlists.js b/public/playlists.js index 8112e36..6b947c8 100644 --- a/public/playlists.js +++ b/public/playlists.js @@ -121,7 +121,7 @@ return { track: track || { id, title: 'Unknown track', duration: 0 }, originalIndex: i }; }); }, - canRemove: isMine, + isPlaylistOwner: isMine, playlistId: selectedPlaylistId }); diff --git a/public/styles.css b/public/styles.css index c3b9a2e..489c1a3 100644 --- a/public/styles.css +++ b/public/styles.css @@ -300,8 +300,10 @@ 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; background: #222; border: 1px solid #444; border-radius: 4px; min-width: 120px; z-index: 1001; } +.context-submenu { position: absolute; display: none; top: -4px; left: calc(100% - 8px); padding-left: 8px; background: transparent; min-width: 120px; z-index: 1001; } +.context-submenu-inner { background: #222; border: 1px solid #444; border-radius: 4px; padding: 0.2rem 0; } .context-menu-item.has-submenu:hover > .context-submenu { display: block; } +.context-submenu .context-menu-item { padding: 0.4rem 0.75rem; } .context-menu-item.disabled { color: #666; cursor: default; } .context-menu-item.disabled:hover { background: transparent; } diff --git a/public/trackContainer.js b/public/trackContainer.js index 5d143f5..7f5cbc8 100644 --- a/public/trackContainer.js +++ b/public/trackContainer.js @@ -37,9 +37,9 @@ * @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.canEditQueue] - 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 {boolean} [config.isPlaylistOwner] - Whether user owns the playlist (can remove/reorder) * @param {string} [config.playlistId] - Playlist ID (for playlist type) * @param {Function} [config.onRender] - Callback after render */ @@ -50,15 +50,15 @@ getTracks, getFilteredTracks, canReorder = false, - canRemove = false, + isPlaylistOwner = false, playlistId = null, onRender } = config; let currentTracks = []; - // Get canEdit dynamically (permissions may change) - const getCanEdit = () => config.canEdit ?? M.canControl(); + // Get canEditQueue dynamically (permissions may change) + const getCanEditQueue = () => config.canEditQueue ?? M.canControl(); // Track if this container needs a render after drag ends let pendingRender = false; @@ -70,17 +70,17 @@ return; } pendingRender = false; - const canEdit = getCanEdit(); + const canEditQueue = getCanEditQueue(); 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') { + if (canEditQueue && type === 'queue') { wireQueueContainerDrop(element); } - if (canRemove && type === 'playlist' && playlistId) { + if (isPlaylistOwner && type === 'playlist' && playlistId) { wirePlaylistContainerDrop(element); } @@ -108,7 +108,7 @@ 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 isDraggable = type === 'library' || (type === 'queue' && canEditQueue) || (type === 'playlist' && isPlaylistOwner); const div = M.trackComponent.render(track, { view: type, @@ -122,7 +122,7 @@ }); // Wire up event handlers - wireTrackEvents(div, track, filteredIndex, index, canEdit); + wireTrackEvents(div, track, filteredIndex, index, canEditQueue); element.appendChild(div); }); @@ -160,7 +160,7 @@ const targetPos = dropTargetIndex !== null ? dropTargetIndex : currentTracks.length; if (dragSource === 'playlist') { - reorderPlaylist(draggedTrackIds, targetPos); + reorderPlaylist(draggedIndices, targetPos); } else if (dragSource === 'queue' || dragSource === 'library') { if (currentTracks.length === 0) { addTracksToPlaylist(draggedTrackIds); @@ -189,14 +189,13 @@ if (res.ok) { M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''} to playlist`); if (M.playlists) M.playlists.reloadCurrentPlaylist(); + } else { + M.showToast("Failed to add to playlist", "error"); } } async function addTracksToPlaylistAt(trackIds, position) { - if (!playlistId) { - console.error("[addTracksToPlaylistAt] No playlistId"); - return; - } + if (!playlistId) return; // Get current tracks and insert at position const current = currentTracks.map(t => (t.track || t).id); @@ -212,31 +211,37 @@ M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`); if (M.playlists) M.playlists.reloadCurrentPlaylist(); } else { - console.error("[addTracksToPlaylistAt] Failed:", await res.text()); + M.showToast("Failed to add to playlist", "error"); } } - async function reorderPlaylist(trackIds, targetIndex) { + async function reorderPlaylist(indices, 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)); + // Sort indices descending for safe removal + const sortedIndices = [...indices].sort((a, b) => b - a); + + // Get track IDs being moved + const movedTrackIds = indices.map(i => current[i]); // 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) { + for (const idx of indices) { + if (idx < targetIndex) { insertAt--; } } + + // Remove tracks at indices (from end to preserve indices) + const remaining = current.filter((_, i) => !indices.includes(i)); + insertAt = Math.max(0, Math.min(insertAt, remaining.length)); // Insert at new position - const newList = [...remaining.slice(0, insertAt), ...trackIds, ...remaining.slice(insertAt)]; + const newList = [...remaining.slice(0, insertAt), ...movedTrackIds, ...remaining.slice(insertAt)]; const res = await fetch(`/api/playlists/${playlistId}/tracks`, { method: "PATCH", @@ -248,17 +253,19 @@ selection.playlist.clear(); lastSelected.playlist = null; if (M.playlists) M.playlists.reloadCurrentPlaylist(); + } else { + M.showToast("Failed to reorder playlist", "error"); } } - function wireTrackEvents(div, track, filteredIndex, originalIndex, canEdit) { + function wireTrackEvents(div, track, filteredIndex, originalIndex, canEditQueue) { const trackId = track.id || track.filename; const index = type === 'queue' ? originalIndex : filteredIndex; - // Click - toggle selection + // Click - handle selection (Ctrl = toggle, Shift = range, plain = select only this) div.onclick = (e) => { if (e.target.closest('.track-actions')) return; - toggleSelection(index, trackId, e.shiftKey); + toggleSelection(index, trackId, e.shiftKey, e.ctrlKey || e.metaKey); render(); }; @@ -283,81 +290,38 @@ // Context menu div.oncontextmenu = (e) => { e.preventDefault(); - showContextMenu(e, track, originalIndex, canEdit); + showContextMenu(e, track, originalIndex, canEditQueue); }; // Drag start/end handlers - library always, queue/playlist with permissions - const canDrag = type === 'library' || (type === 'queue' && canEdit) || (type === 'playlist' && canRemove); + const canDrag = type === 'library' || (type === 'queue' && canEditQueue) || (type === 'playlist' && isPlaylistOwner); 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') { + // Drop handlers - queue and playlist accept drops + if (canEditQueue && 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; - }; + if (type === 'playlist' && playlistId && isPlaylistOwner) { + div.ondragover = (e) => handleDragOver(e, div, filteredIndex); + div.ondragleave = (e) => handleDragLeave(e, div); + div.ondrop = (e) => handleDrop(e, div, filteredIndex); } } - function toggleSelection(index, trackId, shiftKey) { + function toggleSelection(index, trackId, shiftKey, ctrlKey) { // Queue and playlist use indices, library uses trackIds const key = type === 'library' ? trackId : index; const sel = selection[type]; + const currentIdx = type === 'library' ? getFilteredIndex(trackId) : index; if (shiftKey && lastSelected[type] !== null) { - // Range select - const currentIdx = type === 'library' ? getFilteredIndex(trackId) : index; + // Shift+click: Range select (add to existing selection) const start = Math.min(lastSelected[type], currentIdx); const end = Math.max(lastSelected[type], currentIdx); @@ -373,13 +337,19 @@ sel.add(i); } } - } else { + } else if (ctrlKey) { + // Ctrl+click: Toggle single item if (sel.has(key)) { sel.delete(key); } else { sel.add(key); } - lastSelected[type] = type === 'library' ? getFilteredIndex(trackId) : index; + lastSelected[type] = currentIdx; + } else { + // Plain click: Select only this item (clear others) + sel.clear(); + sel.add(key); + lastSelected[type] = currentIdx; } } @@ -435,7 +405,13 @@ function handleDragOver(e, div, index) { e.preventDefault(); - e.dataTransfer.dropEffect = dragSource === 'queue' ? "move" : "copy"; + + // Set drop effect based on source + if (type === 'queue') { + e.dataTransfer.dropEffect = dragSource === 'queue' ? "move" : "copy"; + } else if (type === 'playlist') { + e.dataTransfer.dropEffect = dragSource === 'playlist' ? "move" : "copy"; + } const rect = div.getBoundingClientRect(); const midY = rect.top + rect.height / 2; @@ -447,7 +423,7 @@ }); // Don't show indicator on dragged items - if (dragSource === 'queue' && draggedIndices.includes(index)) return; + if (dragSource === type && draggedIndices.includes(index)) return; div.classList.add(isAbove ? "drop-above" : "drop-below"); dropTargetIndex = isAbove ? index : index + 1; @@ -459,20 +435,32 @@ function handleDrop(e, div, index) { e.preventDefault(); + e.stopPropagation(); div.classList.remove("drop-above", "drop-below"); + element.classList.remove("drop-target"); 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); + if (type === 'queue') { + 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); + } + } else if (type === 'playlist') { + if (dragSource === 'playlist' && draggedIndices.length > 0) { + // Reorder within playlist + reorderPlaylist(draggedIndices, dropTargetIndex); + } else if ((dragSource === 'queue' || dragSource === 'library') && draggedTrackIds.length > 0) { + // Insert at position + addTracksToPlaylistAt(draggedTrackIds, dropTargetIndex); } - } else if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) { - // Insert tracks from library or playlist - insertTracksAtPosition(draggedTrackIds, dropTargetIndex); } draggedIndices = []; @@ -524,6 +512,8 @@ else if (res.ok) { selection.queue.clear(); lastSelected.queue = null; + } else { + M.showToast("Failed to reorder queue", "error"); } } @@ -540,6 +530,8 @@ else if (res.ok) { M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`); clearSelection(); + } else { + M.showToast("Failed to add tracks", "error"); } } @@ -588,13 +580,16 @@ M.localTimestamp = 0; M.audio.play(); - if (M.synced) { + // Desync and disable auto-resync + if (M.synced || M.wantSync) { M.synced = false; + M.wantSync = false; M.showToast("Previewing track (desynced)"); + M.updateUI(); } } - function showContextMenu(e, track, index, canEdit) { + function showContextMenu(e, track, index, canEditQueue) { const trackId = track.id || track.filename; const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, ""); @@ -618,16 +613,16 @@ const menuItems = []; - // Play (queue only, single track) - if (type === 'queue' && !hasSelection) { + // Play (queue only, single track or single selection) + if (type === 'queue' && selectedCount === 1) { menuItems.push({ label: "▶ Play", action: () => playTrack(track, index) }); } - // Preview (all views, single track) - if (!hasSelection) { + // Preview (all views, single track or single selection) + if (selectedCount === 1) { menuItems.push({ label: "⏵ Preview", action: () => previewTrack(track) @@ -635,7 +630,7 @@ } // Queue actions - if (type === 'queue' && canEdit) { + if (type === 'queue' && canEditQueue) { menuItems.push({ label: selectedCount > 1 ? `+ Add ${selectedCount} again` : "+ Add again", action: () => addToQueue(idsForAction) @@ -651,8 +646,8 @@ }); } - // Library actions - if (type === 'library' && canEdit) { + // Library actions - can add to queue if user has queue edit permission + if (type === 'library' && canEditQueue) { menuItems.push({ label: selectedCount > 1 ? `+ Add ${selectedCount} to queue` : "+ Add to queue", action: () => addToQueue(idsForAction) @@ -665,7 +660,8 @@ // Playlist actions if (type === 'playlist') { - if (canEdit) { + // Can add to queue if user has queue edit permission + if (canEditQueue) { menuItems.push({ label: selectedCount > 1 ? `+ Add ${selectedCount} to queue` : "+ Add to queue", action: () => addToQueue(idsForAction) @@ -675,7 +671,8 @@ action: () => addToQueue(idsForAction, true) }); } - if (canRemove && playlistId) { + // Can remove if user owns the playlist + if (isPlaylistOwner && playlistId) { menuItems.push({ label: selectedCount > 1 ? `🗑️ Remove ${selectedCount}` : "🗑️ Remove", danger: true, @@ -753,6 +750,8 @@ M.showToast(playNext ? "Playing next" : "Added to queue"); clearSelection(); render(); + } else { + M.showToast("Failed to add to queue", "error"); } } @@ -769,6 +768,8 @@ else if (res.ok) { M.showToast("Removed"); clearSelection(); + } else { + M.showToast("Failed to remove from queue", "error"); } } @@ -785,6 +786,8 @@ M.showToast("Removed from playlist"); clearSelection(); if (M.playlists) M.playlists.reloadCurrentPlaylist(); + } else { + M.showToast("Failed to remove from playlist", "error"); } }