898 lines
30 KiB
JavaScript
898 lines
30 KiB
JavaScript
// 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.canEditQueue] - Whether user can modify queue
|
||
* @param {boolean} [config.canReorder] - Whether tracks can be reordered (queue only)
|
||
* @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
|
||
*/
|
||
function createContainer(config) {
|
||
const {
|
||
type,
|
||
element,
|
||
getTracks,
|
||
getFilteredTracks,
|
||
canReorder = false,
|
||
isPlaylistOwner = false,
|
||
playlistId = null,
|
||
onRender
|
||
} = config;
|
||
|
||
let currentTracks = [];
|
||
|
||
// 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;
|
||
|
||
function render() {
|
||
// Defer render if a drag is in progress (would cancel the drag)
|
||
if (isDragging) {
|
||
pendingRender = true;
|
||
return;
|
||
}
|
||
pendingRender = false;
|
||
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 (canEditQueue && type === 'queue') {
|
||
wireQueueContainerDrop(element);
|
||
}
|
||
if (isPlaylistOwner && 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 = `<div class="empty">${emptyMsg}</div>`;
|
||
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' && canEditQueue) || (type === 'playlist' && isPlaylistOwner);
|
||
|
||
const div = M.trackComponent.render(track, {
|
||
view: type,
|
||
index: filteredIndex,
|
||
displayIndex: type === 'queue' || type === 'playlist' ? filteredIndex + 1 : null,
|
||
isSelected,
|
||
isCached,
|
||
isActive,
|
||
draggable: isDraggable
|
||
});
|
||
|
||
// Wire up event handlers
|
||
wireTrackEvents(div, track, filteredIndex, index, canEditQueue);
|
||
|
||
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(draggedIndices, 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();
|
||
} else {
|
||
M.showToast("Failed to add to playlist", "error");
|
||
}
|
||
}
|
||
|
||
async function addTracksToPlaylistAt(trackIds, position) {
|
||
if (!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 {
|
||
M.showToast("Failed to add to playlist", "error");
|
||
}
|
||
}
|
||
|
||
async function reorderPlaylist(indices, targetIndex) {
|
||
if (!playlistId) return;
|
||
|
||
// Get current track IDs
|
||
const current = currentTracks.map(t => (t.track || t).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 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), ...movedTrackIds, ...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();
|
||
} else {
|
||
M.showToast("Failed to reorder playlist", "error");
|
||
}
|
||
}
|
||
|
||
function wireTrackEvents(div, track, filteredIndex, originalIndex, canEditQueue) {
|
||
const trackId = track.id || track.filename;
|
||
const index = type === 'queue' ? originalIndex : filteredIndex;
|
||
|
||
// 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, e.ctrlKey || e.metaKey);
|
||
render();
|
||
};
|
||
|
||
// Context menu
|
||
div.oncontextmenu = (e) => {
|
||
e.preventDefault();
|
||
showContextMenu(e, track, originalIndex, canEditQueue);
|
||
};
|
||
|
||
// Drag start/end handlers - library/playlist always (read access), queue needs edit permission
|
||
const canDrag = type === 'library' || type === 'playlist' || (type === 'queue' && canEditQueue);
|
||
console.log(`[Drag] wireTrackEvents: type=${type} canDrag=${canDrag} canEditQueue=${canEditQueue}`);
|
||
if (canDrag) {
|
||
div.ondragstart = (e) => handleDragStart(e, track, originalIndex, div);
|
||
div.ondragend = (e) => handleDragEnd(e, div);
|
||
}
|
||
|
||
// Drop handlers - queue and playlist accept drops
|
||
if (canEditQueue && type === 'queue') {
|
||
console.log(`[Drag] Wiring drop handlers for queue track ${originalIndex}`);
|
||
div.ondragover = (e) => handleDragOver(e, div, originalIndex);
|
||
div.ondragleave = (e) => handleDragLeave(e, div);
|
||
div.ondrop = (e) => handleDrop(e, div, originalIndex);
|
||
}
|
||
|
||
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, 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) {
|
||
// Shift+click: Range select (add to existing selection)
|
||
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 (ctrlKey) {
|
||
// Ctrl+click: Toggle single item
|
||
if (sel.has(key)) {
|
||
sel.delete(key);
|
||
} else {
|
||
sel.add(key);
|
||
}
|
||
lastSelected[type] = currentIdx;
|
||
} else {
|
||
// Plain click: Select only this item (clear others)
|
||
sel.clear();
|
||
sel.add(key);
|
||
lastSelected[type] = currentIdx;
|
||
}
|
||
}
|
||
|
||
function getFilteredIndex(trackId) {
|
||
return currentTracks.findIndex(t => ((t.track || t).id) === trackId);
|
||
}
|
||
|
||
function handleDragStart(e, track, index, div) {
|
||
console.log(`[Drag] handleDragStart: type=${type} index=${index} track=${track.title || track.filename}`);
|
||
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();
|
||
|
||
// 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;
|
||
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 === type && 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) {
|
||
console.log(`[Drag] handleDrop: type=${type} index=${index} dropTargetIndex=${dropTargetIndex} dragSource=${dragSource} draggedIndices=${draggedIndices}`);
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
div.classList.remove("drop-above", "drop-below");
|
||
element.classList.remove("drop-target");
|
||
|
||
if (dropTargetIndex === null) {
|
||
console.log(`[Drag] handleDrop: dropTargetIndex is null, aborting`);
|
||
return;
|
||
}
|
||
|
||
if (type === 'queue') {
|
||
if (dragSource === 'queue' && draggedIndices.length > 0) {
|
||
// Reorder within queue
|
||
const minDragged = Math.min(...draggedIndices);
|
||
const maxDragged = Math.max(...draggedIndices);
|
||
console.log(`[Drag] Reorder check: dropTargetIndex=${dropTargetIndex} minDragged=${minDragged} maxDragged=${maxDragged}`);
|
||
if (dropTargetIndex < minDragged || dropTargetIndex > maxDragged + 1) {
|
||
console.log(`[Drag] Calling reorderQueue(${draggedIndices}, ${dropTargetIndex})`);
|
||
reorderQueue(draggedIndices, dropTargetIndex);
|
||
} else {
|
||
console.log(`[Drag] Skipping reorder - dropping on self`);
|
||
}
|
||
} 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);
|
||
}
|
||
}
|
||
|
||
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;
|
||
} else {
|
||
M.showToast("Failed to reorder queue", "error");
|
||
}
|
||
}
|
||
|
||
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();
|
||
} else {
|
||
M.showToast("Failed to add tracks", "error");
|
||
}
|
||
}
|
||
|
||
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();
|
||
|
||
// 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, canEditQueue) {
|
||
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 or single selection)
|
||
if (type === 'queue' && selectedCount === 1) {
|
||
menuItems.push({
|
||
label: "▶ Play",
|
||
action: () => playTrack(track, index)
|
||
});
|
||
}
|
||
|
||
// Preview (all views, single track or single selection)
|
||
if (selectedCount === 1) {
|
||
menuItems.push({
|
||
label: "⏵ Preview",
|
||
action: () => previewTrack(track)
|
||
});
|
||
}
|
||
|
||
// Queue actions
|
||
if (type === 'queue' && canEditQueue) {
|
||
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 - 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)
|
||
});
|
||
menuItems.push({
|
||
label: selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next",
|
||
action: () => addToQueue(idsForAction, true)
|
||
});
|
||
}
|
||
|
||
// Playlist actions
|
||
if (type === 'playlist') {
|
||
// 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)
|
||
});
|
||
menuItems.push({
|
||
label: selectedCount > 1 ? `⏭ Play ${selectedCount} next` : "⏭ Play next",
|
||
action: () => addToQueue(idsForAction, true)
|
||
});
|
||
}
|
||
// Can remove if user owns the playlist
|
||
if (isPlaylistOwner && 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();
|
||
} else {
|
||
M.showToast("Failed to add to queue", "error");
|
||
}
|
||
}
|
||
|
||
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();
|
||
render();
|
||
} else {
|
||
M.showToast("Failed to remove from queue", "error");
|
||
}
|
||
}
|
||
|
||
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();
|
||
} else {
|
||
M.showToast("Failed to remove from playlist", "error");
|
||
}
|
||
}
|
||
|
||
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";
|
||
const subInner = document.createElement("div");
|
||
subInner.className = "context-submenu-inner";
|
||
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();
|
||
};
|
||
subInner.appendChild(subEl);
|
||
});
|
||
sub.appendChild(subInner);
|
||
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;
|
||
});
|
||
};
|
||
})();
|