// MusicRoom - Track Container
// Manages track lists with selection, drag-and-drop, and context menus
(function() {
const M = window.MusicRoom;
// Global debug: see if ANY drop event fires
document.addEventListener('drop', (e) => {
console.log("[GLOBAL drop] target:", e.target.tagName, e.target.className, "id:", e.target.id);
}, true);
document.addEventListener('dragend', (e) => {
console.log("[GLOBAL dragend] target:", e.target.tagName, e.target.className);
}, true);
// 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.canEdit] - 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 {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,
canRemove = false,
playlistId = null,
onRender
} = config;
console.log("[createContainer] type:", type, "canRemove:", canRemove, "playlistId:", playlistId);
let currentTracks = [];
// Get canEdit dynamically (permissions may change)
const getCanEdit = () => config.canEdit ?? 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) {
console.log("[render] DEFERRED - drag in progress, type:", type);
pendingRender = true;
return;
}
pendingRender = false;
console.log("[render] type:", type, "canRemove:", canRemove, "playlistId:", playlistId);
const canEdit = getCanEdit();
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') {
wireQueueContainerDrop(element);
}
if (canRemove && type === 'playlist' && playlistId) {
console.log("[Playlist] Wiring container drop on element:", element.id || element.className);
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 = `
${emptyMsg}
`;
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' && canEdit) || (type === 'playlist' && canRemove);
const div = M.trackComponent.render(track, {
view: type,
index: filteredIndex,
displayIndex: type === 'queue' || type === 'playlist' ? filteredIndex + 1 : null,
isSelected,
isCached,
isActive,
showPlayButton: type === 'queue',
draggable: isDraggable
});
// Wire up event handlers
wireTrackEvents(div, track, filteredIndex, index, canEdit);
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) => {
console.log("[Container ondrop] dragSource:", dragSource, "trackIds:", draggedTrackIds, "dropTargetIndex:", dropTargetIndex);
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(draggedTrackIds, 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();
}
}
async function addTracksToPlaylistAt(trackIds, position) {
if (!playlistId) {
console.error("[addTracksToPlaylistAt] No playlistId");
return;
}
// Get current tracks and insert at position
const current = currentTracks.map(t => (t.track || t).id);
console.log("[addTracksToPlaylistAt] current:", current, "inserting:", trackIds, "at:", position);
const newList = [...current.slice(0, position), ...trackIds, ...current.slice(position)];
console.log("[addTracksToPlaylistAt] newList:", newList);
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 {
console.error("[addTracksToPlaylistAt] Failed:", await res.text());
}
}
async function reorderPlaylist(trackIds, 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));
// 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) {
insertAt--;
}
}
insertAt = Math.max(0, Math.min(insertAt, remaining.length));
// Insert at new position
const newList = [...remaining.slice(0, insertAt), ...trackIds, ...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();
}
}
function wireTrackEvents(div, track, filteredIndex, originalIndex, canEdit) {
const trackId = track.id || track.filename;
const index = type === 'queue' ? originalIndex : filteredIndex;
// Click - toggle selection
div.onclick = (e) => {
if (e.target.closest('.track-actions')) return;
toggleSelection(index, trackId, e.shiftKey);
render();
};
// Play button (queue only)
const playBtn = div.querySelector('.track-play-btn');
if (playBtn) {
playBtn.onclick = (e) => {
e.stopPropagation();
playTrack(track, originalIndex);
};
}
// Preview button
const previewBtn = div.querySelector('.track-preview-btn');
if (previewBtn) {
previewBtn.onclick = (e) => {
e.stopPropagation();
previewTrack(track);
};
}
// Context menu
div.oncontextmenu = (e) => {
e.preventDefault();
showContextMenu(e, track, originalIndex, canEdit);
};
// Drag start/end handlers - library always, queue/playlist with permissions
const canDrag = type === 'library' || (type === 'queue' && canEdit) || (type === 'playlist' && canRemove);
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') {
div.ondragover = (e) => handleDragOver(e, div, originalIndex);
div.ondragleave = (e) => handleDragLeave(e, div);
div.ondrop = (e) => handleDrop(e, div, originalIndex);
}
// Debug: log playlist track wiring conditions
if (type === 'playlist') {
console.log("[Playlist wireTrackEvents] type:", type, "playlistId:", playlistId, "canRemove:", canRemove);
}
// For playlist tracks, allow reordering and insertion (separate from canEdit)
if (type === 'playlist' && playlistId && canRemove) {
console.log("[Playlist] Wiring drag handlers for track:", trackId);
div.ondragover = (e) => {
console.log("[Playlist track ondragover] dragSource:", dragSource);
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");
console.log("[Track ondrop] dragSource:", dragSource, "trackIds:", draggedTrackIds, "dropTargetIndex:", dropTargetIndex);
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) {
// Queue and playlist use indices, library uses trackIds
const key = type === 'library' ? trackId : index;
const sel = selection[type];
if (shiftKey && lastSelected[type] !== null) {
// Range select
const currentIdx = type === 'library' ? getFilteredIndex(trackId) : index;
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 (sel.has(key)) {
sel.delete(key);
} else {
sel.add(key);
}
lastSelected[type] = type === 'library' ? getFilteredIndex(trackId) : index;
}
}
function getFilteredIndex(trackId) {
return currentTracks.findIndex(t => ((t.track || t).id) === trackId);
}
function handleDragStart(e, track, index, div) {
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 {
draggedTrackIds = selection[type].has(trackId) ? [...selection[type]] : [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) {
console.log("[handleDragEnd] Executing deferred render for:", type);
setTimeout(() => render(), 50);
}
}
function handleDragOver(e, div, index) {
e.preventDefault();
e.dataTransfer.dropEffect = dragSource === 'queue' ? "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 === 'queue' && 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("[handleDrop] type:", type, "dragSource:", dragSource, "dropTargetIndex:", dropTargetIndex, "draggedIndices:", draggedIndices);
e.preventDefault();
div.classList.remove("drop-above", "drop-below");
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);
}
} else if ((dragSource === 'library' || dragSource === 'playlist') && draggedTrackIds.length > 0) {
// Insert tracks from library or playlist
insertTracksAtPosition(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;
}
}
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();
}
}
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();
if (M.synced) {
M.synced = false;
M.showToast("Previewing track (desynced)");
}
}
function showContextMenu(e, track, index, canEdit) {
console.log("[showContextMenu] type:", type, "canEdit:", canEdit, "canRemove:", canRemove, "playlistId:", playlistId);
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)
if (type === 'queue' && !hasSelection) {
menuItems.push({
label: "▶ Play",
action: () => playTrack(track, index)
});
}
// Preview (all views, single track)
if (!hasSelection) {
menuItems.push({
label: "⏵ Preview",
action: () => previewTrack(track)
});
}
// Queue actions
if (type === 'queue' && canEdit) {
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
if (type === 'library' && canEdit) {
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') {
if (canEdit) {
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)
});
}
if (canRemove && 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();
}
}
async function removeFromQueue(indices) {
console.log("[removeFromQueue] indices:", indices, "channelId:", M.currentChannelId);
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();
}
}
async function removeFromPlaylist(indices) {
console.log("[removeFromPlaylist] indices:", indices, "playlistId:", playlistId);
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();
}
}
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";
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();
};
sub.appendChild(subEl);
});
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;
});
};
})();