// queue management, sorting, virtual scrolling, and track display // sort or shuffle queue, preserving current track position // if items are selected, only sorts selected items, otherwise sorts entire queue function sortQueue(field, ascending) { const currentTrack = state.queueIndex >= 0 ? state.queue[state.queueIndex] : null; const selectedIndices = queueSelection?.getSelected() ?? []; const hasSelection = selectedIndices.length > 0; // extract items to sort const extractItems = () => { if (hasSelection) { return selectedIndices.map((idx) => ({ song: state.queue[idx], })); } return state.queue.map((song) => ({ song })); }; // compare disc and track numbers (always ascending to preserve album structure) const compareDiscAndTrack = (a, b) => { const aDisc = a.song.discNumber || 0; const bDisc = b.song.discNumber || 0; const discCmp = aDisc < bDisc ? -1 : aDisc > bDisc ? 1 : 0; if (discCmp !== 0) return discCmp; const aTrack = a.song.track || 0; const bTrack = b.song.track || 0; return aTrack < bTrack ? -1 : aTrack > bTrack ? 1 : 0; }; // update queue with sorted items const updateQueueWithItems = (itemsToSort) => { if (hasSelection) { selectedIndices.forEach((idx, pos) => { state.queue[idx] = itemsToSort[pos].song; }); } else { state.queue.splice( 0, state.queue.length, ...itemsToSort.map((x) => x.song), ); } }; if (field === "shuffle") { // randomly reorder queue items const itemsToShuffle = extractItems(); for (let i = itemsToShuffle.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [itemsToShuffle[i], itemsToShuffle[j]] = [ itemsToShuffle[j], itemsToShuffle[i], ]; } updateQueueWithItems(itemsToShuffle); } else if (field === "album") { // hierarchical: album name → disc number → track number const itemsToSort = extractItems(); itemsToSort.sort((a, b) => { const albumCmp = (a.song.album || "").localeCompare( b.song.album || "", undefined, { numeric: true }, ); if (albumCmp !== 0) return ascending ? albumCmp : -albumCmp; return compareDiscAndTrack(a, b); }); updateQueueWithItems(itemsToSort); } else if (field === "artist") { // hierarchical. artist name -> album name (always A-Z) -> disc number -> track number const itemsToSort = extractItems(); itemsToSort.sort((a, b) => { const artistCmp = (a.song.artist || "").localeCompare( b.song.artist || "", undefined, { numeric: true }, ); if (artistCmp !== 0) return ascending ? artistCmp : -artistCmp; const albumCmp = (a.song.album || "").localeCompare( b.song.album || "", undefined, { numeric: true }, ); if (albumCmp !== 0) return albumCmp; return compareDiscAndTrack(a, b); }); updateQueueWithItems(itemsToSort); } else { // standard sort by field const itemsToSort = extractItems(); itemsToSort.sort((a, b) => { const getValue = (song) => { if (field === "favorited") return state.favorites.has(song.id) ? 1 : 0; if (field === "rating") return state.ratings.get(song.id) || 0; if (field === "duration") return song.duration || 0; return song[field] || ""; }; const aVal = getValue(a.song); const bVal = getValue(b.song); let cmp; if (typeof aVal === "string") { cmp = aVal.localeCompare(bVal, undefined, { numeric: true }); } else { cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; } return ascending ? cmp : -cmp; }); updateQueueWithItems(itemsToSort); } // preserve current track index after operation if (currentTrack) { state.queueIndex = state.queue.indexOf(currentTrack); } updateQueue(); } // save current queue and current index to IndexedDB async function saveQueue() { try { await queueStorage.save(state.queue, state.queueIndex); } catch (error) { console.warn("[Queue] storage error:", error.message); } } // get callbacks for queue movement operations function getQueueCallbacks() { return { onSelectionChange: (newIndices) => queueSelection?.setSelection(newIndices), onQueueChange: () => updateQueueDisplay(), }; } // move items within queue function moveQueueItems(queue, selectedIndices, insertPos, callbacks = {}) { const { onSelectionChange, onQueueChange } = callbacks; const sortedIndices = Array.from(selectedIndices).sort((a, b) => a - b); const movedItems = sortedIndices.map((i) => queue[i]); // remove items in reverse order to avoid index shifting for ( let i = sortedIndices[sortedIndices.length - 1]; i >= sortedIndices[0]; i-- ) { if (selectedIndices.includes(i)) queue.splice(i, 1); } // normalize insert position for removed items insertPos -= sortedIndices.filter((i) => i < insertPos).length; // insert moved items at target position queue.splice(insertPos, 0, ...movedItems); // update queue index for moved/removed items if (state.queueIndex >= 0) { if (selectedIndices.includes(state.queueIndex)) { const positionInMoved = sortedIndices.filter( (i) => i < state.queueIndex, ).length; state.queueIndex = insertPos + positionInMoved; } else { const newIndex = state.queueIndex - sortedIndices.filter((i) => i < state.queueIndex).length; state.queueIndex = insertPos <= newIndex ? newIndex + movedItems.length : newIndex; } } saveQueue(); if (onSelectionChange) onSelectionChange(sortedIndices.map((_, i) => insertPos + i)); if (onQueueChange) onQueueChange(); } // restore queue from IndexedDB async function loadQueue() { try { const { songs, queueIndex } = await queueStorage.load(); if (!Array.isArray(songs) || songs.length === 0) return false; state.queue = songs; state.queueIndex = queueIndex; return true; } catch (error) { console.warn("[Queue] Failed to load queue:", error.message); return false; } } const SONG_EXTRACTORS = { artist: async (data) => { const albums = toArray(data.artist?.album); const songArrays = await Promise.all( albums.map((album) => state.api .getAlbum(album.id) .then((result) => toArray(result.album?.song)) .catch(() => []), ), ); return songArrays.flat(); }, album: (data) => toArray(data.album?.song), playlist: (data) => toArray(data.entry || data.playlist?.entry), song: (s) => [s], }; // add songs to queue async function addToQueue(fetcher, songExtractor, insertNext = false) { const songs = await songExtractor(await fetcher()); songs.forEach((song) => { if (song.userRating && song.userRating > 0) { state.ratings.set(song.id, song.userRating); } }); if (insertNext) { const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0; state.queue.splice(insertPos, 0, ...songs); } else { state.queue.push(...songs); } updateQueue(); } // factory for creating queue add functions const createQueueAdder = (fetcher, extractor) => (id) => addToQueue(() => fetcher(id), extractor); const addArtistToQueue = createQueueAdder( (id) => state.api.getArtist(id), SONG_EXTRACTORS.artist, ); const addAlbumToQueue = createQueueAdder( (id) => state.api.getAlbum(id), SONG_EXTRACTORS.album, ); const addPlaylistToQueue = createQueueAdder( (id) => state.api.getPlaylist(id), SONG_EXTRACTORS.playlist, ); const addSongToQueue = (song) => addToQueue(() => Promise.resolve(song), SONG_EXTRACTORS.song); // update queue display and save state function updateQueue() { updateQueueDisplay(); saveQueue(); } let virtualScroller = null; // initialize virtual scroller for queue table function initVirtualScroller() { if (virtualScroller) return; const container = document.querySelector("main"); if (!container) return; virtualScroller = new QueueVirtualScroller( container, ui.queueList, state.queue.length, (idx) => createQueueRow(state.queue[idx], idx), { onScroll: () => { queueSelection.updateUI(); highlightCurrentTrack(); }, }, ); queueSelection.virtualScroller = virtualScroller; } // update queue display using virtual scroller function updateQueueDisplay() { ui.queueCount.textContent = state.queue.length; if (!virtualScroller) initVirtualScroller(); virtualScroller?.updateItemCount(state.queue.length); } // clear selected rows function clearSelectedRows() { const toRemove = queueSelection.getSelected(); if (toRemove.length === 0) return; const toRemoveSet = new Set(toRemove); const wasCurrentTrackDeleted = toRemoveSet.has(state.queueIndex); const originalLength = state.queue.length; state.queue = state.queue.filter((_, idx) => !toRemoveSet.has(idx)); if (!wasCurrentTrackDeleted) { state.queueIndex -= toRemove.filter((i) => i < state.queueIndex).length; } else { let nextIndex = -1; for (let i = state.queueIndex + 1; i < originalLength; i++) { if (!toRemoveSet.has(i)) { nextIndex = i - toRemove.filter((j) => j < i).length; break; } } state.queueIndex = nextIndex >= 0 ? nextIndex : Math.max(-1, state.queue.length - 1); handleCurrentTrackDeleted(); } // pause mutation observer to prevent interference during DOM update tabOrderObserver?.pauseUpdates(); queueSelection.clear(); updateQueue(); highlightCurrentTrack(); // resume after update completes tabOrderObserver?.resumeUpdates(); } // remove a song by index, adjusting queue index if necessary function removeFromQueue(idx) { const isCurrentTrack = idx === state.queueIndex; state.queue.splice(idx, 1); if (isCurrentTrack) { if (state.queue.length > 0) { state.queueIndex = Math.min(idx, state.queue.length - 1); } else { state.queueIndex = -1; } } else if (idx < state.queueIndex) { state.queueIndex--; } saveQueue(); return isCurrentTrack; } // remove classes from queue table rows function clearRowClasses(container, classNames) { const classes = Array.isArray(classNames) ? classNames : [classNames]; container.querySelectorAll("tr").forEach((row) => { classes.forEach((cls) => row.classList.remove(cls)); }); } // add or remove class from specific rows function updateRowClass(container, indices, className, add = true) { const indexSet = new Set(indices); container.querySelectorAll("tr").forEach((row) => { const idx = parseInt(row.getAttribute("data-index")); row.classList.toggle(className, indexSet.has(idx) === add); }); } // handle current track is deleted function handleCurrentTrackDeleted() { if (state.queue.length > 0) { const wasPlaying = !ui.player.paused; playTrack(state.queue[state.queueIndex], wasPlaying); } else { clearPlayerUI(); } } // remove all items from queue function clearQueue() { state.queue = []; state.queueIndex = -1; saveQueue(); clearPlayerUI(); updateQueueDisplay(); } // buttons for queue row actions const ROW_BUTTON_CONFIG = [ { className: CLASSES.QUEUE_PLAY, label: "play", icon: ICONS.CONTROL_PLAY, }, { className: CLASSES.QUEUE_PLAY_NEXT, label: "play next", icon: ICONS.CONTROL_FASTFORWARD, }, { className: CLASSES.QUEUE_FAVORITE, label: "favorite", icon: ICONS.HEART, }, { className: CLASSES.QUEUE_MOVE_UP, label: "move up", icon: ICONS.ARROW_UP, }, { className: CLASSES.QUEUE_MOVE_DOWN, label: "move down", icon: ICONS.ARROW_DOWN, }, { className: CLASSES.QUEUE_CLEAR, label: "clear", icon: ICONS.CROSS, }, ]; // create table row element for a song in queue function createQueueRow(song, idx) { const tr = document.createElement("tr"); tr.draggable = true; tr.setAttribute("data-index", idx); tr.dataset.songId = song.id; // batch all cells in fragment for efficient DOM insertion const cells = document.createDocumentFragment(); // cover art cell const coverCell = document.createElement("td"); // Prefer album art (albumId) over individual song art (coverArt) for consistency const artId = song.albumId || song.coverArt; if (artId && state.settings.artSong > 0) { const img = document.createElement("img"); img.className = CLASSES.QUEUE_COVER; const size = state.settings.artSong; const url1x = state.api.getCoverArtUrl(artId, size); const url2x = state.api.getCoverArtUrl(artId, size * 2); const url3x = state.api.getCoverArtUrl(artId, size * 3); img.src = url1x; img.srcset = `${url1x}, ${url2x} 2x, ${url3x} 3x`; img.loading = "lazy"; coverCell.appendChild(img); } cells.appendChild(coverCell); // text cells const titleCell = document.createElement("td"); titleCell.textContent = song.title; cells.appendChild(titleCell); const artistCell = document.createElement("td"); artistCell.textContent = song.artist || ""; cells.appendChild(artistCell); const albumCell = document.createElement("td"); albumCell.textContent = song.album || ""; cells.appendChild(albumCell); const durationCell = document.createElement("td"); durationCell.textContent = formatDuration(song.duration); cells.appendChild(durationCell); // action buttons cell (includes ratings if enabled) const actionsCell = document.createElement("td"); const isFavorited = state.favorites.has(song.id); if (state.settings.enableRatings) { const currentRating = state.ratings.get(song.id) || 0; for (let i = 1; i <= 5; i++) { const star = createIconButton( CLASSES.QUEUE_RATING_STAR, `Rate ${i}`, ICONS.STAR, ); star.dataset.rating = i; if (i <= currentRating) { star.classList.add(CLASSES.RATED); } star.addEventListener("click", async (e) => { e.stopPropagation(); const newRating = i === currentRating ? 0 : i; await setSongRating(song, newRating); updateQueueDisplay(); }); actionsCell.appendChild(star); } } ROW_BUTTON_CONFIG.forEach(({ className, label, icon }) => { if ( className === CLASSES.QUEUE_FAVORITE && !state.settings.enableFavorites ) { return; } const btn = createIconButton(className, label, icon); if (className === CLASSES.QUEUE_FAVORITE && isFavorited) { btn.classList.add(CLASSES.FAVORITED); } actionsCell.appendChild(btn); }); cells.appendChild(actionsCell); tr.appendChild(cells); return tr; } // map button classes to queue action handlers const QUEUE_BUTTON_HANDLERS = { // play the selected track [CLASSES.QUEUE_PLAY]: (idx) => { state.queueIndex = idx; saveQueue(); playTrack(state.queue[idx]); updateQueue(); }, // insert selected track after current track [CLASSES.QUEUE_PLAY_NEXT]: (idx) => { const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0; moveQueueItems(state.queue, [idx], insertPos, getQueueCallbacks()); }, // move up one position [CLASSES.QUEUE_MOVE_UP]: (idx) => { if (idx > 0) { moveQueueItems(state.queue, [idx], idx - 1, getQueueCallbacks()); } }, // move down one position [CLASSES.QUEUE_MOVE_DOWN]: (idx) => { if (idx < state.queue.length - 1) { moveQueueItems(state.queue, [idx], idx + 2, getQueueCallbacks()); } }, // clear from queue [CLASSES.QUEUE_CLEAR]: (idx) => { const isCurrentTrack = removeFromQueue(idx); if (isCurrentTrack) { handleCurrentTrackDeleted(); highlightCurrentTrack(); } updateQueueDisplay(); }, // toggle favorite status [CLASSES.QUEUE_FAVORITE]: async (idx) => { const song = state.queue[idx]; if (song) { await setFavoriteSong(song); updateQueueDisplay(); } }, };