Something went wrong. Try again.
a simple but full-featured web player for OpenSubsonic music servers (Navidrome, Gonic, Ampache, etc) devins.page/tinysub
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588// queue management, sorting, virtual scrolling, and track display
class QueueSnapshot { static create(queue, queueIndex) { return { queue: structuredClone(queue), queueIndex, timestamp: Date.now(), }; }
static apply(snapshot, state) { state.queue = structuredClone(snapshot.queue); state.queueIndex = snapshot.queueIndex; }}
// create snapshot of current queue statefunction snapshotQueue() { return QueueSnapshot.create(state.queue, state.queueIndex);}
// restore queue state from snapshot and update UIfunction restoreQueue(snapshot) { if (!snapshot) return; QueueSnapshot.apply(snapshot, state); updateQueue();}
// sort or shuffle queue, preserving current track position// if items are selected, only sorts selected items, otherwise sorts entire queuefunction sortQueue(field, ascending) { history.saveState(snapshotQueue()); 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 IndexedDBasync function saveQueue() { try { await queueStorage.save(state.queue, state.queueIndex); } catch (error) { console.warn("[Queue] storage error:", error.message); }}
// get callbacks for queue movement operationsfunction getQueueCallbacks() { return { onSelectionChange: (newIndices) => queueSelection?.setSelection(newIndices), onQueueChange: () => updateQueueDisplay(), };}
// move items within queuefunction moveQueueItems(queue, selectedIndices, insertPos, callbacks = {}) { history.saveState(snapshotQueue()); 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 IndexedDBasync 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 queueasync function addToQueue(fetcher, songExtractor, insertNext = false) { history.saveState(snapshotQueue()); 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 functionsconst 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 statefunction updateQueue() { updateQueueDisplay(); saveQueue();}
let virtualScroller = null;
// initialize virtual scroller for queue tablefunction 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 scrollerfunction updateQueueDisplay() { ui.queueCount.textContent = state.queue.length; if (!virtualScroller) initVirtualScroller(); virtualScroller?.updateItemCount(state.queue.length);}
// clear selected rowsfunction clearSelectedRows() { history.saveState(snapshotQueue()); 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 necessaryfunction removeFromQueue(idx) { history.saveState(snapshotQueue()); 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 rowsfunction 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 rowsfunction 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 deletedfunction handleCurrentTrackDeleted() { if (state.queue.length > 0) { const wasPlaying = !ui.player.paused; playTrack(state.queue[state.queueIndex], wasPlaying); } else { clearPlayerUI(); }}
// remove all items from queuefunction clearQueue() { history.saveState(snapshotQueue()); state.queue = []; state.queueIndex = -1; saveQueue(); clearPlayerUI(); updateQueueDisplay();}
// buttons for queue row actionsconst 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 queuefunction 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 handlersconst 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(); } },};