refactored trackComponent and fixed a lot of bugs with playlist editing

This commit is contained in:
peterino2 2026-02-06 16:00:50 -08:00
parent 3da3a5e482
commit f7a743c600
6 changed files with 1146 additions and 947 deletions

View File

@ -180,6 +180,8 @@
<script src="/trackStorage.js"></script> <script src="/trackStorage.js"></script>
<script src="/core.js"></script> <script src="/core.js"></script>
<script src="/utils.js"></script> <script src="/utils.js"></script>
<script src="/trackComponent.js"></script>
<script src="/trackContainer.js"></script>
<script src="/audioCache.js"></script> <script src="/audioCache.js"></script>
<script src="/channelSync.js"></script> <script src="/channelSync.js"></script>
<script src="/ui.js"></script> <script src="/ui.js"></script>

View File

@ -91,44 +91,50 @@
} }
} }
// Playlist tracks container instance
let playlistContainer = null;
function renderPlaylistContents() { function renderPlaylistContents() {
const header = $('#selected-playlist-name'); const header = $('#selected-playlist-name');
const actions = $('#playlist-actions'); const actions = $('#playlist-actions');
const container = $('#playlist-tracks'); const containerEl = $('#playlist-tracks');
if (!selectedPlaylist) { if (!selectedPlaylist) {
header.textContent = 'Select a playlist'; header.textContent = 'Select a playlist';
actions.classList.add('hidden'); actions.classList.add('hidden');
container.innerHTML = ''; containerEl.innerHTML = '';
return; return;
} }
header.textContent = selectedPlaylist.name; header.textContent = selectedPlaylist.name;
actions.classList.remove('hidden'); actions.classList.remove('hidden');
if (selectedPlaylist.trackIds.length === 0) { const isMine = myPlaylists.some(p => p.id === selectedPlaylistId);
container.innerHTML = '<div class="empty-playlist-tracks">No tracks in this playlist</div>'; console.log("[Playlists] Creating container - isMine:", isMine, "selectedPlaylistId:", selectedPlaylistId);
return;
// Create or update container (even for empty playlists, to enable drag-drop)
playlistContainer = M.trackContainer.createContainer({
type: 'playlist',
element: containerEl,
getTracks: () => {
return selectedPlaylist.trackIds.map((id, i) => {
const track = M.library.find(t => t.id === id);
return { track: track || { id, title: 'Unknown track', duration: 0 }, originalIndex: i };
});
},
canRemove: isMine,
playlistId: selectedPlaylistId
});
console.log("[Playlists] About to call render(), playlistContainer:", playlistContainer);
playlistContainer.render();
console.log("[Playlists] After render()");
} }
// Get track info from library function reloadCurrentPlaylist() {
const tracks = selectedPlaylist.trackIds.map(id => { if (selectedPlaylistId) {
const track = M.library.find(t => t.id === id); selectPlaylist(selectedPlaylistId);
return track || { id, title: 'Unknown track', duration: 0 }; }
});
container.innerHTML = tracks.map((track, i) => `
<div class="playlist-track" data-id="${track.id}" data-index="${i}">
<span class="track-number">${i + 1}</span>
<span class="track-title">${escapeHtml(track.title || track.filename || 'Unknown')}</span>
<span class="track-duration">${formatTime(track.duration)}</span>
</div>
`).join('');
// Attach click handlers
container.querySelectorAll('.playlist-track').forEach(el => {
el.oncontextmenu = (e) => showPlaylistTrackContextMenu(e, el.dataset.id, parseInt(el.dataset.index));
});
} }
function showPlaylistContextMenu(e, playlistId, isMine) { function showPlaylistContextMenu(e, playlistId, isMine) {
@ -193,45 +199,6 @@
M.contextMenu.show(e, items); M.contextMenu.show(e, items);
} }
function showPlaylistTrackContextMenu(e, trackId, index) {
e.preventDefault();
M.contextMenu.hide();
const isMine = myPlaylists.some(p => p.id === selectedPlaylistId);
const items = [];
// Play
items.push({
label: '▶ Play',
action: () => playTrackFromPlaylist(trackId)
});
items.push({ separator: true });
// Add to queue
items.push({
label: ' Add to Queue',
action: () => addTracksToQueue([trackId])
});
items.push({
label: '⏭ Play Next',
action: () => addTracksToQueue([trackId], true)
});
if (isMine) {
items.push({ separator: true });
// Remove from playlist
items.push({
label: '🗑️ Remove from Playlist',
action: () => removeTrackFromPlaylist(index),
className: 'danger'
});
}
M.contextMenu.show(e, items);
}
async function addPlaylistToQueue(playlistId, playNext = false) { async function addPlaylistToQueue(playlistId, playNext = false) {
const playlist = [...myPlaylists, ...sharedPlaylists].find(p => p.id === playlistId); const playlist = [...myPlaylists, ...sharedPlaylists].find(p => p.id === playlistId);
if (!playlist || playlist.trackIds.length === 0) { if (!playlist || playlist.trackIds.length === 0) {
@ -565,6 +532,8 @@
init: initPlaylists, init: initPlaylists,
getMyPlaylists: () => myPlaylists, getMyPlaylists: () => myPlaylists,
showAddToPlaylistMenu, showAddToPlaylistMenu,
addTracksToPlaylist addTracksToPlaylist,
renderPlaylistContents,
reloadCurrentPlaylist
}; };
})(); })();

File diff suppressed because it is too large Load Diff

View File

@ -125,9 +125,9 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
.search-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; } .search-input { flex: 1; background: #222; color: #eee; border: 1px solid #333; padding: 0.2rem 0.4rem; border-radius: 4px; font-size: 0.75rem; }
.search-input::placeholder { color: #666; } .search-input::placeholder { color: #666; }
#library, #queue { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; } #library, #queue { flex: 1; overflow-y: auto; overflow-x: hidden; min-width: 0; }
#library .track, #queue .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; min-width: 0; } #library .track, #queue .track, #playlist-tracks .track { padding: 0.3rem 0.5rem; border-radius: 4px; cursor: pointer; font-size: 0.85rem; display: flex; justify-content: space-between; align-items: center; position: relative; user-select: none; min-width: 0; }
#library .track[title], #queue .track[title] { cursor: pointer; } #library .track[title], #queue .track[title], #playlist-tracks .track[title] { cursor: pointer; }
#library .track:hover, #queue .track:hover { background: #222; } #library .track:hover, #queue .track:hover, #playlist-tracks .track:hover { background: #222; }
#queue .track.active { background: #2a4a3a; color: #4e8; } #queue .track.active { background: #2a4a3a; color: #4e8; }
.cache-indicator { width: 3px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 4px 0 0 4px; } .cache-indicator { width: 3px; height: 100%; position: absolute; left: 0; top: 0; border-radius: 4px 0 0 4px; }
.track.cached .cache-indicator { background: #4e8; } .track.cached .cache-indicator { background: #4e8; }
@ -136,6 +136,10 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
.track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; } .track-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.track-actions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; } .track-actions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
.track-actions .duration { color: #666; font-size: 0.75rem; } .track-actions .duration { color: #666; font-size: 0.75rem; }
.track-actions .track-play-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; }
.track-actions .track-play-btn:hover { background: #48f; color: #fff; }
.track-actions .track-preview-btn { width: 22px; height: 22px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.7rem; color: #aaa; cursor: pointer; transition: background 0.2s, color 0.2s; }
.track-actions .track-preview-btn:hover { background: #4a4; color: #fff; }
.track-actions .track-add, .track-actions .track-remove { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.85rem; color: #ccc; cursor: pointer; opacity: 0; transition: opacity 0.2s; } .track-actions .track-add, .track-actions .track-remove { width: 18px; height: 18px; display: flex; align-items: center; justify-content: center; background: #333; border: none; border-radius: 3px; font-size: 0.85rem; color: #ccc; cursor: pointer; opacity: 0; transition: opacity 0.2s; }
.track:hover .track-add, .track:hover .track-remove { opacity: 0.6; } .track:hover .track-add, .track:hover .track-remove { opacity: 0.6; }
.track-actions .track-add:hover, .track-actions .track-remove:hover { opacity: 1; background: #444; } .track-actions .track-add:hover, .track-actions .track-remove:hover { opacity: 1; background: #444; }
@ -146,9 +150,13 @@ h3 { font-size: 0.8rem; color: #666; margin-bottom: 0.3rem; text-transform: uppe
.track-checkmark { color: #4e8; font-weight: bold; margin-right: 0.4rem; font-size: 0.85rem; } .track-checkmark { color: #4e8; font-weight: bold; margin-right: 0.4rem; font-size: 0.85rem; }
.track.selected { background: #2a3a4a; } .track.selected { background: #2a3a4a; }
.track.dragging { opacity: 0.5; } .track.dragging { opacity: 0.5; }
/* Allow drop events to pass through to parent track element */
.track > * { pointer-events: none; }
.track > .track-actions { pointer-events: auto; }
.track > .track-actions > * { pointer-events: auto; }
.track.drop-above::before { content: ""; position: absolute; top: -1px; left: 0; right: 0; height: 2px; background: #4e8; border-radius: 2px; } .track.drop-above::before { content: ""; position: absolute; top: -1px; left: 0; right: 0; height: 2px; background: #4e8; border-radius: 2px; }
.track.drop-below::after { content: ""; position: absolute; bottom: -1px; left: 0; right: 0; height: 2px; background: #4e8; border-radius: 2px; } .track.drop-below::after { content: ""; position: absolute; bottom: -1px; left: 0; right: 0; height: 2px; background: #4e8; border-radius: 2px; }
#queue.drop-target, #queue .drop-zone { border: 2px dashed #4e8; border-radius: 4px; } #queue.drop-target, #queue .drop-zone, #playlist-tracks.drop-target { border: 2px dashed #4e8; border-radius: 4px; }
#queue .drop-zone { padding: 1.5rem; text-align: center; color: #4e8; } #queue .drop-zone { padding: 1.5rem; text-align: center; color: #4e8; }
/* Context menu */ /* Context menu */
@ -292,7 +300,8 @@ button:hover { background: #333; }
.empty-playlist-tracks { color: #555; font-size: 0.8rem; padding: 1rem; text-align: center; font-style: italic; } .empty-playlist-tracks { color: #555; font-size: 0.8rem; padding: 1rem; text-align: center; font-style: italic; }
.context-menu-separator { height: 1px; background: #333; margin: 0.2rem 0; } .context-menu-separator { height: 1px; background: #333; margin: 0.2rem 0; }
.context-menu-item.has-submenu { position: relative; } .context-menu-item.has-submenu { position: relative; }
.context-submenu { position: absolute; display: none; top: 0; left: 100%; margin-left: 2px; } .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-menu-item.has-submenu:hover > .context-submenu { display: block; }
.context-menu-item.disabled { color: #666; cursor: default; } .context-menu-item.disabled { color: #666; cursor: default; }
.context-menu-item.disabled:hover { background: transparent; } .context-menu-item.disabled:hover { background: transparent; }

91
public/trackComponent.js Normal file
View File

@ -0,0 +1,91 @@
// MusicRoom - Track Component
// Pure rendering for track rows - no event handlers attached
(function() {
const M = window.MusicRoom;
/**
* Render a track row element (pure rendering, no handlers)
* @param {Object} track - Track object with id, title, filename, duration
* @param {Object} config - Configuration options
* @param {string} config.view - 'queue' | 'library' | 'playlist'
* @param {number} config.index - Index in the list
* @param {number} [config.displayIndex] - Display number (1-based)
* @param {boolean} config.isSelected - Whether track is selected
* @param {boolean} config.isCached - Whether track is cached locally
* @param {boolean} config.isActive - Whether this is the currently playing track
* @param {boolean} config.showPlayButton - Show play button (queue only)
* @param {boolean} config.draggable - Whether element is draggable
* @returns {HTMLElement}
*/
function render(track, config) {
const {
view,
index,
displayIndex,
isSelected,
isCached,
isActive,
showPlayButton,
draggable
} = config;
const div = document.createElement("div");
const trackId = track.id || track.filename;
// Build class list
const classes = ["track"];
if (isActive) classes.push("active");
if (isCached) classes.push("cached");
else classes.push("not-cached");
if (isSelected) classes.push("selected");
div.className = classes.join(" ");
// Store data attributes
div.dataset.index = index;
div.dataset.trackId = trackId;
div.dataset.view = view;
// Build title
const title = track.title?.trim() || (track.filename || trackId || "Unknown").replace(/\.[^.]+$/, "");
div.title = title;
// Build HTML
const checkmark = isSelected ? '<span class="track-checkmark">✓</span>' : '';
const trackNum = displayIndex != null ? `<span class="track-number">${displayIndex}.</span>` : '';
const playBtn = showPlayButton ? '<button class="track-play-btn" title="Play">▶</button>' : '';
const previewBtn = '<button class="track-preview-btn" title="Preview">⏵</button>';
div.innerHTML = `
${checkmark}
<span class="cache-indicator"></span>
${trackNum}
<span class="track-title">${escapeHtml(title)}</span>
<span class="track-actions">
${playBtn}
${previewBtn}
<span class="duration">${M.fmt(track.duration)}</span>
</span>
`;
if (draggable) {
div.draggable = true;
}
return div;
}
// HTML escape helper
function escapeHtml(str) {
if (!str) return '';
return str.replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
})[c]);
}
// Export
M.trackComponent = {
render,
escapeHtml
};
})();

922
public/trackContainer.js Normal file
View File

@ -0,0 +1,922 @@
// 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 = `<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' && 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;
});
};
})();