refactored trackComponent and fixed a lot of bugs with playlist editing
This commit is contained in:
parent
3da3a5e482
commit
f7a743c600
|
|
@ -180,6 +180,8 @@
|
|||
<script src="/trackStorage.js"></script>
|
||||
<script src="/core.js"></script>
|
||||
<script src="/utils.js"></script>
|
||||
<script src="/trackComponent.js"></script>
|
||||
<script src="/trackContainer.js"></script>
|
||||
<script src="/audioCache.js"></script>
|
||||
<script src="/channelSync.js"></script>
|
||||
<script src="/ui.js"></script>
|
||||
|
|
|
|||
|
|
@ -91,44 +91,50 @@
|
|||
}
|
||||
}
|
||||
|
||||
// Playlist tracks container instance
|
||||
let playlistContainer = null;
|
||||
|
||||
function renderPlaylistContents() {
|
||||
const header = $('#selected-playlist-name');
|
||||
const actions = $('#playlist-actions');
|
||||
const container = $('#playlist-tracks');
|
||||
const containerEl = $('#playlist-tracks');
|
||||
|
||||
if (!selectedPlaylist) {
|
||||
header.textContent = 'Select a playlist';
|
||||
actions.classList.add('hidden');
|
||||
container.innerHTML = '';
|
||||
containerEl.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
header.textContent = selectedPlaylist.name;
|
||||
actions.classList.remove('hidden');
|
||||
|
||||
if (selectedPlaylist.trackIds.length === 0) {
|
||||
container.innerHTML = '<div class="empty-playlist-tracks">No tracks in this playlist</div>';
|
||||
return;
|
||||
const isMine = myPlaylists.some(p => p.id === selectedPlaylistId);
|
||||
console.log("[Playlists] Creating container - isMine:", isMine, "selectedPlaylistId:", selectedPlaylistId);
|
||||
|
||||
// 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
|
||||
const tracks = selectedPlaylist.trackIds.map(id => {
|
||||
const track = M.library.find(t => t.id === id);
|
||||
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 reloadCurrentPlaylist() {
|
||||
if (selectedPlaylistId) {
|
||||
selectPlaylist(selectedPlaylistId);
|
||||
}
|
||||
}
|
||||
|
||||
function showPlaylistContextMenu(e, playlistId, isMine) {
|
||||
|
|
@ -193,45 +199,6 @@
|
|||
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) {
|
||||
const playlist = [...myPlaylists, ...sharedPlaylists].find(p => p.id === playlistId);
|
||||
if (!playlist || playlist.trackIds.length === 0) {
|
||||
|
|
@ -565,6 +532,8 @@
|
|||
init: initPlaylists,
|
||||
getMyPlaylists: () => myPlaylists,
|
||||
showAddToPlaylistMenu,
|
||||
addTracksToPlaylist
|
||||
addTracksToPlaylist,
|
||||
renderPlaylistContents,
|
||||
reloadCurrentPlaylist
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
948
public/queue.js
948
public/queue.js
File diff suppressed because it is too large
Load Diff
|
|
@ -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::placeholder { color: #666; }
|
||||
#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[title], #queue .track[title] { cursor: pointer; }
|
||||
#library .track:hover, #queue .track:hover { background: #222; }
|
||||
#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], #playlist-tracks .track[title] { cursor: pointer; }
|
||||
#library .track:hover, #queue .track:hover, #playlist-tracks .track:hover { background: #222; }
|
||||
#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; }
|
||||
.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-actions { display: flex; align-items: center; gap: 0.4rem; flex-shrink: 0; }
|
||||
.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:hover .track-add, .track:hover .track-remove { opacity: 0.6; }
|
||||
.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.selected { background: #2a3a4a; }
|
||||
.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-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; }
|
||||
|
||||
/* 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; }
|
||||
.context-menu-separator { height: 1px; background: #333; margin: 0.2rem 0; }
|
||||
.context-menu-item.has-submenu { position: relative; }
|
||||
.context-submenu { position: absolute; display: none; top: 0; left: 100%; margin-left: 2px; }
|
||||
.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:hover { background: transparent; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
})[c]);
|
||||
}
|
||||
|
||||
// Export
|
||||
M.trackComponent = {
|
||||
render,
|
||||
escapeHtml
|
||||
};
|
||||
})();
|
||||
|
|
@ -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;
|
||||
});
|
||||
};
|
||||
})();
|
||||
Loading…
Reference in New Issue