lots of cleanups

This commit is contained in:
peterino2 2026-02-06 16:31:19 -08:00
parent a9f69752ed
commit 62c7fb9e19
3 changed files with 111 additions and 106 deletions

View File

@ -121,7 +121,7 @@
return { track: track || { id, title: 'Unknown track', duration: 0 }, originalIndex: i }; return { track: track || { id, title: 'Unknown track', duration: 0 }, originalIndex: i };
}); });
}, },
canRemove: isMine, isPlaylistOwner: isMine,
playlistId: selectedPlaylistId playlistId: selectedPlaylistId
}); });

View File

@ -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; } .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-separator { height: 1px; background: #333; margin: 0.2rem 0; }
.context-menu-item.has-submenu { position: relative; } .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-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 { color: #666; cursor: default; }
.context-menu-item.disabled:hover { background: transparent; } .context-menu-item.disabled:hover { background: transparent; }

View File

@ -37,9 +37,9 @@
* @param {HTMLElement} config.element - Container DOM element * @param {HTMLElement} config.element - Container DOM element
* @param {Function} config.getTracks - Returns array of tracks to render * @param {Function} config.getTracks - Returns array of tracks to render
* @param {Function} [config.getFilteredTracks] - Returns filtered tracks (for library search) * @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.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 {string} [config.playlistId] - Playlist ID (for playlist type)
* @param {Function} [config.onRender] - Callback after render * @param {Function} [config.onRender] - Callback after render
*/ */
@ -50,15 +50,15 @@
getTracks, getTracks,
getFilteredTracks, getFilteredTracks,
canReorder = false, canReorder = false,
canRemove = false, isPlaylistOwner = false,
playlistId = null, playlistId = null,
onRender onRender
} = config; } = config;
let currentTracks = []; let currentTracks = [];
// Get canEdit dynamically (permissions may change) // Get canEditQueue dynamically (permissions may change)
const getCanEdit = () => config.canEdit ?? M.canControl(); const getCanEditQueue = () => config.canEditQueue ?? M.canControl();
// Track if this container needs a render after drag ends // Track if this container needs a render after drag ends
let pendingRender = false; let pendingRender = false;
@ -70,17 +70,17 @@
return; return;
} }
pendingRender = false; pendingRender = false;
const canEdit = getCanEdit(); const canEditQueue = getCanEditQueue();
element.innerHTML = ""; element.innerHTML = "";
// Get tracks (filtered for library, direct for queue/playlist) // Get tracks (filtered for library, direct for queue/playlist)
currentTracks = getFilteredTracks ? getFilteredTracks() : getTracks(); currentTracks = getFilteredTracks ? getFilteredTracks() : getTracks();
// Always wire up container drop handlers first (even for empty containers) // Always wire up container drop handlers first (even for empty containers)
if (canEdit && type === 'queue') { if (canEditQueue && type === 'queue') {
wireQueueContainerDrop(element); wireQueueContainerDrop(element);
} }
if (canRemove && type === 'playlist' && playlistId) { if (isPlaylistOwner && type === 'playlist' && playlistId) {
wirePlaylistContainerDrop(element); wirePlaylistContainerDrop(element);
} }
@ -108,7 +108,7 @@
const isActive = type === 'queue' && index === M.currentIndex; const isActive = type === 'queue' && index === M.currentIndex;
// Library tracks are always draggable, queue tracks need edit permission // 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, { const div = M.trackComponent.render(track, {
view: type, view: type,
@ -122,7 +122,7 @@
}); });
// Wire up event handlers // Wire up event handlers
wireTrackEvents(div, track, filteredIndex, index, canEdit); wireTrackEvents(div, track, filteredIndex, index, canEditQueue);
element.appendChild(div); element.appendChild(div);
}); });
@ -160,7 +160,7 @@
const targetPos = dropTargetIndex !== null ? dropTargetIndex : currentTracks.length; const targetPos = dropTargetIndex !== null ? dropTargetIndex : currentTracks.length;
if (dragSource === 'playlist') { if (dragSource === 'playlist') {
reorderPlaylist(draggedTrackIds, targetPos); reorderPlaylist(draggedIndices, targetPos);
} else if (dragSource === 'queue' || dragSource === 'library') { } else if (dragSource === 'queue' || dragSource === 'library') {
if (currentTracks.length === 0) { if (currentTracks.length === 0) {
addTracksToPlaylist(draggedTrackIds); addTracksToPlaylist(draggedTrackIds);
@ -189,14 +189,13 @@
if (res.ok) { if (res.ok) {
M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''} to playlist`); M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''} to playlist`);
if (M.playlists) M.playlists.reloadCurrentPlaylist(); if (M.playlists) M.playlists.reloadCurrentPlaylist();
} else {
M.showToast("Failed to add to playlist", "error");
} }
} }
async function addTracksToPlaylistAt(trackIds, position) { async function addTracksToPlaylistAt(trackIds, position) {
if (!playlistId) { if (!playlistId) return;
console.error("[addTracksToPlaylistAt] No playlistId");
return;
}
// Get current tracks and insert at position // Get current tracks and insert at position
const current = currentTracks.map(t => (t.track || t).id); const current = currentTracks.map(t => (t.track || t).id);
@ -212,31 +211,37 @@
M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`); M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`);
if (M.playlists) M.playlists.reloadCurrentPlaylist(); if (M.playlists) M.playlists.reloadCurrentPlaylist();
} else { } 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; if (!playlistId) return;
// Get current track IDs // Get current track IDs
const current = currentTracks.map(t => (t.track || t).id); const current = currentTracks.map(t => (t.track || t).id);
// Remove the dragged tracks from their current positions // Sort indices descending for safe removal
const remaining = current.filter(id => !trackIds.includes(id)); 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) // Calculate insertion position (adjusted for removed items before target)
let insertAt = targetIndex; let insertAt = targetIndex;
for (const id of trackIds) { for (const idx of indices) {
const originalPos = current.indexOf(id); if (idx < targetIndex) {
if (originalPos < targetIndex) {
insertAt--; 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)); insertAt = Math.max(0, Math.min(insertAt, remaining.length));
// Insert at new position // 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`, { const res = await fetch(`/api/playlists/${playlistId}/tracks`, {
method: "PATCH", method: "PATCH",
@ -248,17 +253,19 @@
selection.playlist.clear(); selection.playlist.clear();
lastSelected.playlist = null; lastSelected.playlist = null;
if (M.playlists) M.playlists.reloadCurrentPlaylist(); 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 trackId = track.id || track.filename;
const index = type === 'queue' ? originalIndex : filteredIndex; const index = type === 'queue' ? originalIndex : filteredIndex;
// Click - toggle selection // Click - handle selection (Ctrl = toggle, Shift = range, plain = select only this)
div.onclick = (e) => { div.onclick = (e) => {
if (e.target.closest('.track-actions')) return; if (e.target.closest('.track-actions')) return;
toggleSelection(index, trackId, e.shiftKey); toggleSelection(index, trackId, e.shiftKey, e.ctrlKey || e.metaKey);
render(); render();
}; };
@ -283,81 +290,38 @@
// Context menu // Context menu
div.oncontextmenu = (e) => { div.oncontextmenu = (e) => {
e.preventDefault(); e.preventDefault();
showContextMenu(e, track, originalIndex, canEdit); showContextMenu(e, track, originalIndex, canEditQueue);
}; };
// Drag start/end handlers - library always, queue/playlist with permissions // 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) { if (canDrag) {
div.ondragstart = (e) => handleDragStart(e, track, originalIndex, div); div.ondragstart = (e) => handleDragStart(e, track, originalIndex, div);
div.ondragend = (e) => handleDragEnd(e, div); div.ondragend = (e) => handleDragEnd(e, div);
} }
// Drop handlers - only queue accepts drops (from library/playlist) // Drop handlers - queue and playlist accept drops
if (canEdit && type === 'queue') { if (canEditQueue && type === 'queue') {
div.ondragover = (e) => handleDragOver(e, div, originalIndex); div.ondragover = (e) => handleDragOver(e, div, originalIndex);
div.ondragleave = (e) => handleDragLeave(e, div); div.ondragleave = (e) => handleDragLeave(e, div);
div.ondrop = (e) => handleDrop(e, div, originalIndex); div.ondrop = (e) => handleDrop(e, div, originalIndex);
} }
// For playlist tracks, allow reordering and insertion (separate from canEdit) if (type === 'playlist' && playlistId && isPlaylistOwner) {
if (type === 'playlist' && playlistId && canRemove) { div.ondragover = (e) => handleDragOver(e, div, filteredIndex);
div.ondragover = (e) => { div.ondragleave = (e) => handleDragLeave(e, div);
e.preventDefault(); div.ondrop = (e) => handleDrop(e, div, filteredIndex);
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) { function toggleSelection(index, trackId, shiftKey, ctrlKey) {
// Queue and playlist use indices, library uses trackIds // Queue and playlist use indices, library uses trackIds
const key = type === 'library' ? trackId : index; const key = type === 'library' ? trackId : index;
const sel = selection[type]; const sel = selection[type];
const currentIdx = type === 'library' ? getFilteredIndex(trackId) : index;
if (shiftKey && lastSelected[type] !== null) { if (shiftKey && lastSelected[type] !== null) {
// Range select // Shift+click: Range select (add to existing selection)
const currentIdx = type === 'library' ? getFilteredIndex(trackId) : index;
const start = Math.min(lastSelected[type], currentIdx); const start = Math.min(lastSelected[type], currentIdx);
const end = Math.max(lastSelected[type], currentIdx); const end = Math.max(lastSelected[type], currentIdx);
@ -373,13 +337,19 @@
sel.add(i); sel.add(i);
} }
} }
} else { } else if (ctrlKey) {
// Ctrl+click: Toggle single item
if (sel.has(key)) { if (sel.has(key)) {
sel.delete(key); sel.delete(key);
} else { } else {
sel.add(key); 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) { function handleDragOver(e, div, index) {
e.preventDefault(); 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 rect = div.getBoundingClientRect();
const midY = rect.top + rect.height / 2; const midY = rect.top + rect.height / 2;
@ -447,7 +423,7 @@
}); });
// Don't show indicator on dragged items // 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"); div.classList.add(isAbove ? "drop-above" : "drop-below");
dropTargetIndex = isAbove ? index : index + 1; dropTargetIndex = isAbove ? index : index + 1;
@ -459,20 +435,32 @@
function handleDrop(e, div, index) { function handleDrop(e, div, index) {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
div.classList.remove("drop-above", "drop-below"); div.classList.remove("drop-above", "drop-below");
element.classList.remove("drop-target");
if (dropTargetIndex === null) return; if (dropTargetIndex === null) return;
if (dragSource === 'queue' && draggedIndices.length > 0) { if (type === 'queue') {
// Reorder within queue if (dragSource === 'queue' && draggedIndices.length > 0) {
const minDragged = Math.min(...draggedIndices); // Reorder within queue
const maxDragged = Math.max(...draggedIndices); const minDragged = Math.min(...draggedIndices);
if (dropTargetIndex < minDragged || dropTargetIndex > maxDragged + 1) { const maxDragged = Math.max(...draggedIndices);
reorderQueue(draggedIndices, dropTargetIndex); 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 = []; draggedIndices = [];
@ -524,6 +512,8 @@
else if (res.ok) { else if (res.ok) {
selection.queue.clear(); selection.queue.clear();
lastSelected.queue = null; lastSelected.queue = null;
} else {
M.showToast("Failed to reorder queue", "error");
} }
} }
@ -540,6 +530,8 @@
else if (res.ok) { else if (res.ok) {
M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`); M.showToast(`Added ${trackIds.length} track${trackIds.length > 1 ? 's' : ''}`);
clearSelection(); clearSelection();
} else {
M.showToast("Failed to add tracks", "error");
} }
} }
@ -588,13 +580,16 @@
M.localTimestamp = 0; M.localTimestamp = 0;
M.audio.play(); M.audio.play();
if (M.synced) { // Desync and disable auto-resync
if (M.synced || M.wantSync) {
M.synced = false; M.synced = false;
M.wantSync = false;
M.showToast("Previewing track (desynced)"); 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 trackId = track.id || track.filename;
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, ""); const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
@ -618,16 +613,16 @@
const menuItems = []; const menuItems = [];
// Play (queue only, single track) // Play (queue only, single track or single selection)
if (type === 'queue' && !hasSelection) { if (type === 'queue' && selectedCount === 1) {
menuItems.push({ menuItems.push({
label: "▶ Play", label: "▶ Play",
action: () => playTrack(track, index) action: () => playTrack(track, index)
}); });
} }
// Preview (all views, single track) // Preview (all views, single track or single selection)
if (!hasSelection) { if (selectedCount === 1) {
menuItems.push({ menuItems.push({
label: "⏵ Preview", label: "⏵ Preview",
action: () => previewTrack(track) action: () => previewTrack(track)
@ -635,7 +630,7 @@
} }
// Queue actions // Queue actions
if (type === 'queue' && canEdit) { if (type === 'queue' && canEditQueue) {
menuItems.push({ menuItems.push({
label: selectedCount > 1 ? ` Add ${selectedCount} again` : " Add again", label: selectedCount > 1 ? ` Add ${selectedCount} again` : " Add again",
action: () => addToQueue(idsForAction) action: () => addToQueue(idsForAction)
@ -651,8 +646,8 @@
}); });
} }
// Library actions // Library actions - can add to queue if user has queue edit permission
if (type === 'library' && canEdit) { if (type === 'library' && canEditQueue) {
menuItems.push({ menuItems.push({
label: selectedCount > 1 ? ` Add ${selectedCount} to queue` : " Add to queue", label: selectedCount > 1 ? ` Add ${selectedCount} to queue` : " Add to queue",
action: () => addToQueue(idsForAction) action: () => addToQueue(idsForAction)
@ -665,7 +660,8 @@
// Playlist actions // Playlist actions
if (type === 'playlist') { if (type === 'playlist') {
if (canEdit) { // Can add to queue if user has queue edit permission
if (canEditQueue) {
menuItems.push({ menuItems.push({
label: selectedCount > 1 ? ` Add ${selectedCount} to queue` : " Add to queue", label: selectedCount > 1 ? ` Add ${selectedCount} to queue` : " Add to queue",
action: () => addToQueue(idsForAction) action: () => addToQueue(idsForAction)
@ -675,7 +671,8 @@
action: () => addToQueue(idsForAction, true) action: () => addToQueue(idsForAction, true)
}); });
} }
if (canRemove && playlistId) { // Can remove if user owns the playlist
if (isPlaylistOwner && playlistId) {
menuItems.push({ menuItems.push({
label: selectedCount > 1 ? `🗑️ Remove ${selectedCount}` : "🗑️ Remove", label: selectedCount > 1 ? `🗑️ Remove ${selectedCount}` : "🗑️ Remove",
danger: true, danger: true,
@ -753,6 +750,8 @@
M.showToast(playNext ? "Playing next" : "Added to queue"); M.showToast(playNext ? "Playing next" : "Added to queue");
clearSelection(); clearSelection();
render(); render();
} else {
M.showToast("Failed to add to queue", "error");
} }
} }
@ -769,6 +768,8 @@
else if (res.ok) { else if (res.ok) {
M.showToast("Removed"); M.showToast("Removed");
clearSelection(); clearSelection();
} else {
M.showToast("Failed to remove from queue", "error");
} }
} }
@ -785,6 +786,8 @@
M.showToast("Removed from playlist"); M.showToast("Removed from playlist");
clearSelection(); clearSelection();
if (M.playlists) M.playlists.reloadCurrentPlaylist(); if (M.playlists) M.playlists.reloadCurrentPlaylist();
} else {
M.showToast("Failed to remove from playlist", "error");
} }
} }