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