a simple web player for subsonic
tinysub.devins.page
subsonic
navidrome
javascript
1// queue management, sorting, virtual scrolling, and track display
2
3// sort or shuffle queue, preserving current track position
4// if items are selected, only sorts selected items, otherwise sorts entire queue
5function sortQueue(field, ascending) {
6 const currentTrack =
7 state.queueIndex >= 0 ? state.queue[state.queueIndex] : null;
8 const selectedIndices = selectionManager?.getSelected() ?? [];
9 const hasSelection = selectedIndices.length > 0;
10
11 // extract items to sort: either selected songs or entire queue
12 const extractItems = () => {
13 if (hasSelection) {
14 return selectedIndices.map((idx) => ({
15 song: state.queue[idx],
16 }));
17 }
18 return state.queue.map((song) => ({ song }));
19 };
20
21 // compare disc and track numbers (always ascending to preserve album structure)
22 const compareDiscAndTrack = (a, b) => {
23 const aDisc = a.song.discNumber || 0;
24 const bDisc = b.song.discNumber || 0;
25 const discCmp = aDisc < bDisc ? -1 : aDisc > bDisc ? 1 : 0;
26 if (discCmp !== 0) return discCmp;
27
28 const aTrack = a.song.track || 0;
29 const bTrack = b.song.track || 0;
30 return aTrack < bTrack ? -1 : aTrack > bTrack ? 1 : 0;
31 };
32
33 // update queue with sorted items
34 const updateQueueWithItems = (itemsToSort) => {
35 if (hasSelection) {
36 selectedIndices.forEach((idx, pos) => {
37 state.queue[idx] = itemsToSort[pos].song;
38 });
39 } else {
40 state.queue.splice(
41 0,
42 state.queue.length,
43 ...itemsToSort.map((x) => x.song),
44 );
45 }
46 };
47
48 if (field === "shuffle") {
49 // randomly reorder queue items
50 const itemsToShuffle = extractItems();
51
52 for (let i = itemsToShuffle.length - 1; i > 0; i--) {
53 const j = Math.floor(Math.random() * (i + 1));
54 [itemsToShuffle[i], itemsToShuffle[j]] = [
55 itemsToShuffle[j],
56 itemsToShuffle[i],
57 ];
58 }
59
60 updateQueueWithItems(itemsToShuffle);
61 } else if (field === "album") {
62 // hierarchical: album name → disc number → track number
63 const itemsToSort = extractItems();
64 itemsToSort.sort((a, b) => {
65 const albumCmp = (a.song.album || "").localeCompare(
66 b.song.album || "",
67 undefined,
68 { numeric: true },
69 );
70 if (albumCmp !== 0) return ascending ? albumCmp : -albumCmp;
71 return compareDiscAndTrack(a, b);
72 });
73 updateQueueWithItems(itemsToSort);
74 } else if (field === "artist") {
75 // hierarchical. artist name -> album name (always A-Z) -> disc number -> track number
76 const itemsToSort = extractItems();
77 itemsToSort.sort((a, b) => {
78 const artistCmp = (a.song.artist || "").localeCompare(
79 b.song.artist || "",
80 undefined,
81 { numeric: true },
82 );
83 if (artistCmp !== 0) return ascending ? artistCmp : -artistCmp;
84
85 const albumCmp = (a.song.album || "").localeCompare(
86 b.song.album || "",
87 undefined,
88 { numeric: true },
89 );
90 if (albumCmp !== 0) return albumCmp;
91
92 return compareDiscAndTrack(a, b);
93 });
94 updateQueueWithItems(itemsToSort);
95 } else {
96 // standard sort by field
97 const getValue = (song) => {
98 if (field === "favorited") return state.favorites.has(song.id) ? 1 : 0;
99 if (field === "duration") return song.duration || 0;
100 return song[field] || "";
101 };
102
103 const itemsToSort = extractItems();
104 itemsToSort.sort((a, b) => {
105 const aVal = getValue(a.song);
106 const bVal = getValue(b.song);
107
108 let cmp;
109 if (typeof aVal === "string") {
110 cmp = aVal.localeCompare(bVal, undefined, { numeric: true });
111 } else {
112 cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
113 }
114
115 return ascending ? cmp : -cmp;
116 });
117 updateQueueWithItems(itemsToSort);
118 }
119
120 // preserve current track index after operation
121 if (currentTrack) {
122 state.queueIndex = state.queue.indexOf(currentTrack);
123 }
124
125 updateQueue();
126}
127
128// persist queue and current index to IndexedDB
129async function saveQueue() {
130 try {
131 await queueStorage.save(state.queue, state.queueIndex);
132 } catch (error) {
133 console.warn("[Queue] storage error:", error.message);
134 }
135}
136
137// move items within queue and maintain proper indices
138function moveQueueItems(queue, selectedIndices, insertPos, callbacks = {}) {
139 const { onSelectionChange, onQueueChange } = callbacks;
140 const sortedIndices = Array.from(selectedIndices).sort((a, b) => a - b);
141 const movedItems = sortedIndices.map((i) => queue[i]);
142
143 // remove items in reverse order to avoid index shifting
144 for (
145 let i = sortedIndices[sortedIndices.length - 1];
146 i >= sortedIndices[0];
147 i--
148 ) {
149 if (selectedIndices.includes(i)) queue.splice(i, 1);
150 }
151
152 // normalize insert position for removed items
153 insertPos -= sortedIndices.filter((i) => i < insertPos).length;
154
155 // insert moved items at target position
156 queue.splice(insertPos, 0, ...movedItems);
157
158 // update queue index for moved/removed items
159 if (state.queueIndex >= 0) {
160 if (selectedIndices.includes(state.queueIndex)) {
161 const positionInMoved = sortedIndices.filter(
162 (i) => i < state.queueIndex,
163 ).length;
164 state.queueIndex = insertPos + positionInMoved;
165 } else {
166 const newIndex =
167 state.queueIndex -
168 sortedIndices.filter((i) => i < state.queueIndex).length;
169 state.queueIndex =
170 insertPos <= newIndex ? newIndex + movedItems.length : newIndex;
171 }
172 }
173
174 saveQueue();
175
176 // batch DOM updates together to avoid reflows
177 if (onSelectionChange)
178 onSelectionChange(sortedIndices.map((_, i) => insertPos + i));
179 if (onQueueChange) onQueueChange();
180}
181
182// restore queue from IndexedDB
183async function loadQueue() {
184 try {
185 const { songs, queueIndex } = await queueStorage.load();
186 if (!Array.isArray(songs) || songs.length === 0) return false;
187
188 state.queue = songs;
189 if (isValidQueueIndex(queueIndex, state.queue.length)) {
190 state.queueIndex = queueIndex;
191 }
192 return true;
193 } catch (error) {
194 console.warn("[Queue] Failed to load queue:", error.message);
195 return false;
196 }
197}
198
199const SONG_EXTRACTORS = {
200 artist: async (data) => {
201 const albums = toArray(data.artist?.album);
202 const songArrays = await Promise.all(
203 albums.map((album) =>
204 state.api
205 .getAlbum(album.id)
206 .then((result) => toArray(result.album?.song))
207 .catch(() => []),
208 ),
209 );
210 return songArrays.flat();
211 },
212 album: (data) => toArray(data.album?.song),
213 playlist: (data) => toArray(data.entry || data.playlist?.entry),
214 song: (s) => [s],
215};
216
217// add songs to queue
218async function addToQueue(fetcher, songExtractor, insertNext = false) {
219 const songs = await songExtractor(await fetcher());
220 if (insertNext) {
221 const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0;
222 state.queue.splice(insertPos, 0, ...songs);
223 } else {
224 state.queue.push(...songs);
225 }
226 updateQueue();
227}
228
229// factory for creating queue add functions
230const createQueueAdder = (fetcher, extractor) => (id) =>
231 addToQueue(() => fetcher(id), extractor);
232
233const addArtistToQueue = createQueueAdder(
234 (id) => state.api.getArtist(id),
235 SONG_EXTRACTORS.artist,
236);
237const addAlbumToQueue = createQueueAdder(
238 (id) => state.api.getAlbum(id),
239 SONG_EXTRACTORS.album,
240);
241const addPlaylistToQueue = createQueueAdder(
242 (id) => state.api.getPlaylist(id),
243 SONG_EXTRACTORS.playlist,
244);
245const addSongToQueue = (song) =>
246 addToQueue(() => Promise.resolve(song), SONG_EXTRACTORS.song);
247
248// update queue display and save state
249function updateQueue() {
250 updateQueueDisplay();
251 saveQueue();
252}
253
254let virtualScroller = null;
255
256// initialize virtual scroller for queue table
257function initVirtualScroller() {
258 if (virtualScroller) return;
259 const container = document.querySelector("main");
260 if (!container) return;
261
262 virtualScroller = new VirtualScroller(
263 container,
264 ui.queueList,
265 state.queue.length,
266 (idx) => createQueueRow(state.queue[idx], idx),
267 {
268 onScroll: () => {
269 selectionManager.updateUI();
270 highlightCurrentTrack();
271 },
272 },
273 );
274}
275
276// update queue display using virtual scroller
277function updateQueueDisplay() {
278 ui.queueCount.textContent = state.queue.length;
279 (
280 virtualScroller || (initVirtualScroller(), virtualScroller)
281 )?.updateItemCount(state.queue.length);
282}
283
284// clear selected rows
285function clearSelectedRows() {
286 const toRemove = selectionManager.getSelected();
287 if (toRemove.length === 0) return;
288
289 const toRemoveSet = new Set(toRemove);
290 const wasCurrentTrackDeleted = toRemoveSet.has(state.queueIndex);
291 const originalLength = state.queue.length;
292 const countDeletionsBefore = (idx) => toRemove.filter((i) => i < idx).length;
293
294 state.queue = state.queue.filter((_, idx) => !toRemoveSet.has(idx));
295
296 if (!wasCurrentTrackDeleted) {
297 state.queueIndex -= countDeletionsBefore(state.queueIndex);
298 } else {
299 let nextIndex = -1;
300 for (let i = state.queueIndex + 1; i < originalLength; i++) {
301 if (!toRemoveSet.has(i)) {
302 nextIndex = i - countDeletionsBefore(i);
303 break;
304 }
305 }
306 state.queueIndex =
307 nextIndex >= 0 ? nextIndex : Math.max(-1, state.queue.length - 1);
308 handleCurrentTrackDeleted();
309 }
310
311 // pause mutation observer to prevent interference during DOM update
312 tabOrderObserver?.pauseUpdates();
313 selectionManager.clear();
314 updateQueue();
315 highlightCurrentTrack();
316 // resume after update completes
317 tabOrderObserver?.resumeUpdates();
318}
319
320// remove a song by index, adjusting queue index if necessary
321function removeFromQueue(idx) {
322 const isCurrentTrack = idx === state.queueIndex;
323
324 state.queue.splice(idx, 1);
325
326 if (isCurrentTrack) {
327 if (state.queue.length > 0) {
328 state.queueIndex = Math.min(idx, state.queue.length - 1);
329 } else {
330 state.queueIndex = -1;
331 }
332 } else if (idx < state.queueIndex) {
333 state.queueIndex--;
334 }
335
336 saveQueue();
337 return isCurrentTrack;
338}
339
340// handle UI update when current track is deleted
341function handleCurrentTrackDeleted() {
342 if (state.queue.length > 0) {
343 const wasPlaying = !ui.player.paused;
344 playTrack(state.queue[state.queueIndex]);
345 if (!wasPlaying) ui.player.pause();
346 } else {
347 resetPlayerUI();
348 }
349}
350
351// remove all items from queue
352function clearQueue() {
353 state.queue = [];
354 state.queueIndex = -1;
355 saveQueue();
356 resetPlayerUI();
357 updateQueueDisplay();
358}
359
360// buttons for queue row actions
361const ROW_BUTTON_CONFIG = [
362 {
363 className: CLASSES.QUEUE_PLAY,
364 label: "play",
365 icon: ICONS.PLAY,
366 },
367 {
368 className: CLASSES.QUEUE_PLAY_NEXT,
369 label: "play next",
370 icon: ICONS.PLAY_NEXT,
371 },
372 {
373 className: CLASSES.QUEUE_FAVORITE,
374 label: "favorite",
375 icon: ICONS.FAVORITE,
376 },
377 {
378 className: CLASSES.QUEUE_MOVE_UP,
379 label: "move up",
380 icon: ICONS.MOVE_UP,
381 },
382 {
383 className: CLASSES.QUEUE_MOVE_DOWN,
384 label: "move down",
385 icon: ICONS.MOVE_DOWN,
386 },
387 {
388 className: CLASSES.QUEUE_CLEAR,
389 label: "clear",
390 icon: ICONS.REMOVE,
391 },
392];
393
394// create table row element for a song in queue
395function createQueueRow(song, idx) {
396 const tr = document.createElement("tr");
397 tr.draggable = true;
398 tr.setAttribute(DATA_ATTRS.INDEX, idx);
399 tr.dataset.songId = song.id;
400
401 // batch all cells in fragment for efficient DOM insertion
402 const cells = document.createDocumentFragment();
403
404 // cover art cell
405 const coverCell = document.createElement("td");
406 // Prefer album art (albumId) over individual song art (coverArt) for consistency
407 const artId = song.albumId || song.coverArt;
408 if (artId && state.settings.artSong > 0) {
409 const img = document.createElement("img");
410 img.className = CLASSES.QUEUE_COVER;
411 coverCell.appendChild(img);
412 loadCachedImage(img, artId, "artSong");
413 }
414 cells.appendChild(coverCell);
415
416 // text cells
417 const titleCell = document.createElement("td");
418 titleCell.textContent = song.title;
419 cells.appendChild(titleCell);
420
421 const artistCell = document.createElement("td");
422 artistCell.textContent = song.artist || "";
423 cells.appendChild(artistCell);
424
425 const albumCell = document.createElement("td");
426 albumCell.textContent = song.album || "";
427 cells.appendChild(albumCell);
428
429 const durationCell = document.createElement("td");
430 durationCell.textContent = formatDuration(song.duration);
431 cells.appendChild(durationCell);
432
433 // action buttons cell
434 const actionsCell = document.createElement("td");
435 const isFavorited = state.favorites.has(song.id);
436 ROW_BUTTON_CONFIG.forEach(({ className, label, icon }) => {
437 const btn = createIconButton(className, label, icon, label);
438 if (className === CLASSES.QUEUE_FAVORITE && isFavorited) {
439 btn.classList.add(CLASSES.FAVORITED);
440 }
441 actionsCell.appendChild(btn);
442 });
443 cells.appendChild(actionsCell);
444
445 tr.appendChild(cells);
446 return tr;
447}
448
449// callbacks for queue operations
450const queueCallbacks = {
451 onSelectionChange: (newIndices) => selectionManager?.setSelection(newIndices),
452 onQueueChange: () => updateQueueDisplay(),
453};
454
455// map button classes to queue action handlers
456const QUEUE_BUTTON_HANDLERS = {
457 // play the selected track
458 [CLASSES.QUEUE_PLAY]: (idx) => {
459 playQueueTrack(idx);
460 updateQueue();
461 },
462 // insert selected track after current track
463 [CLASSES.QUEUE_PLAY_NEXT]: (idx) => {
464 const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0;
465 moveQueueItems(state.queue, [idx], insertPos, queueCallbacks);
466 },
467 // move up one position
468 [CLASSES.QUEUE_MOVE_UP]: (idx) => {
469 if (idx > 0) {
470 moveQueueItems(state.queue, [idx], idx - 1, queueCallbacks);
471 }
472 },
473 // move down one position
474 [CLASSES.QUEUE_MOVE_DOWN]: (idx) => {
475 if (idx < state.queue.length - 1) {
476 moveQueueItems(state.queue, [idx], idx + 2, queueCallbacks);
477 }
478 },
479 // clear from queue
480 [CLASSES.QUEUE_CLEAR]: (idx) => {
481 const isCurrentTrack = removeFromQueue(idx);
482
483 if (isCurrentTrack) {
484 handleCurrentTrackDeleted();
485 highlightCurrentTrack();
486 }
487
488 updateQueueDisplay();
489 },
490 // toggle favorite status
491 [CLASSES.QUEUE_FAVORITE]: async (idx) => {
492 const song = state.queue[idx];
493 if (song) {
494 await setFavoriteSong(song);
495 updateQueueDisplay();
496 }
497 },
498};