dev/playlists #13

Merged
peterino merged 19 commits from dev/playlists into integration 2026-02-09 18:58:41 +00:00
3 changed files with 111 additions and 106 deletions
Showing only changes of commit 62c7fb9e19 - Show all commits

View File

@ -121,7 +121,7 @@
return { track: track || { id, title: 'Unknown track', duration: 0 }, originalIndex: i };
});
},
canRemove: isMine,
isPlaylistOwner: isMine,
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; }
.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; }

View File

@ -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");
}
}