forked from
devins.page/tinysub
a simple web player for subsonic
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 = queueSelection?.getSelected() ?? [];
9 const hasSelection = selectedIndices.length > 0;
10
11 // extract items to sort
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 itemsToSort = extractItems();
98 itemsToSort.sort((a, b) => {
99 const getValue = (song) => {
100 if (field === "favorited") return state.favorites.has(song.id) ? 1 : 0;
101 if (field === "rating") return state.ratings.get(song.id) || 0;
102 if (field === "duration") return song.duration || 0;
103 return song[field] || "";
104 };
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// save current 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// get callbacks for queue movement operations
138function getQueueCallbacks() {
139 return {
140 onSelectionChange: (newIndices) => queueSelection?.setSelection(newIndices),
141 onQueueChange: () => updateQueueDisplay(),
142 };
143}
144
145// move items within queue
146function moveQueueItems(queue, selectedIndices, insertPos, callbacks = {}) {
147 const { onSelectionChange, onQueueChange } = callbacks;
148 const sortedIndices = Array.from(selectedIndices).sort((a, b) => a - b);
149 const movedItems = sortedIndices.map((i) => queue[i]);
150
151 // remove items in reverse order to avoid index shifting
152 for (
153 let i = sortedIndices[sortedIndices.length - 1];
154 i >= sortedIndices[0];
155 i--
156 ) {
157 if (selectedIndices.includes(i)) queue.splice(i, 1);
158 }
159
160 // normalize insert position for removed items
161 insertPos -= sortedIndices.filter((i) => i < insertPos).length;
162
163 // insert moved items at target position
164 queue.splice(insertPos, 0, ...movedItems);
165
166 // update queue index for moved/removed items
167 if (state.queueIndex >= 0) {
168 if (selectedIndices.includes(state.queueIndex)) {
169 const positionInMoved = sortedIndices.filter(
170 (i) => i < state.queueIndex,
171 ).length;
172 state.queueIndex = insertPos + positionInMoved;
173 } else {
174 const newIndex =
175 state.queueIndex -
176 sortedIndices.filter((i) => i < state.queueIndex).length;
177 state.queueIndex =
178 insertPos <= newIndex ? newIndex + movedItems.length : newIndex;
179 }
180 }
181
182 saveQueue();
183
184 if (onSelectionChange)
185 onSelectionChange(sortedIndices.map((_, i) => insertPos + i));
186 if (onQueueChange) onQueueChange();
187}
188
189// restore queue from IndexedDB
190async function loadQueue() {
191 try {
192 const { songs, queueIndex } = await queueStorage.load();
193 if (!Array.isArray(songs) || songs.length === 0) return false;
194
195 state.queue = songs;
196 state.queueIndex = queueIndex;
197 return true;
198 } catch (error) {
199 console.warn("[Queue] Failed to load queue:", error.message);
200 return false;
201 }
202}
203
204const SONG_EXTRACTORS = {
205 artist: async (data) => {
206 const albums = toArray(data.artist?.album);
207 const songArrays = await Promise.all(
208 albums.map((album) =>
209 state.api
210 .getAlbum(album.id)
211 .then((result) => toArray(result.album?.song))
212 .catch(() => []),
213 ),
214 );
215 return songArrays.flat();
216 },
217 album: (data) => toArray(data.album?.song),
218 playlist: (data) => toArray(data.entry || data.playlist?.entry),
219 song: (s) => [s],
220};
221
222// add songs to queue
223async function addToQueue(fetcher, songExtractor, insertNext = false) {
224 const songs = await songExtractor(await fetcher());
225 songs.forEach((song) => {
226 if (song.userRating && song.userRating > 0) {
227 state.ratings.set(song.id, song.userRating);
228 }
229 });
230 if (insertNext) {
231 const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0;
232 state.queue.splice(insertPos, 0, ...songs);
233 } else {
234 state.queue.push(...songs);
235 }
236 updateQueue();
237}
238
239// factory for creating queue add functions
240const createQueueAdder = (fetcher, extractor) => (id) =>
241 addToQueue(() => fetcher(id), extractor);
242
243const addArtistToQueue = createQueueAdder(
244 (id) => state.api.getArtist(id),
245 SONG_EXTRACTORS.artist,
246);
247const addAlbumToQueue = createQueueAdder(
248 (id) => state.api.getAlbum(id),
249 SONG_EXTRACTORS.album,
250);
251const addPlaylistToQueue = createQueueAdder(
252 (id) => state.api.getPlaylist(id),
253 SONG_EXTRACTORS.playlist,
254);
255const addSongToQueue = (song) =>
256 addToQueue(() => Promise.resolve(song), SONG_EXTRACTORS.song);
257
258// update queue display and save state
259function updateQueue() {
260 updateQueueDisplay();
261 saveQueue();
262}
263
264let virtualScroller = null;
265
266// initialize virtual scroller for queue table
267function initVirtualScroller() {
268 if (virtualScroller) return;
269 const container = document.querySelector("main");
270 if (!container) return;
271
272 virtualScroller = new QueueVirtualScroller(
273 container,
274 ui.queueList,
275 state.queue.length,
276 (idx) => createQueueRow(state.queue[idx], idx),
277 {
278 onScroll: () => {
279 queueSelection.updateUI();
280 highlightCurrentTrack();
281 },
282 },
283 );
284 queueSelection.virtualScroller = virtualScroller;
285}
286
287// update queue display using virtual scroller
288function updateQueueDisplay() {
289 ui.queueCount.textContent = state.queue.length;
290 if (!virtualScroller) initVirtualScroller();
291 virtualScroller?.updateItemCount(state.queue.length);
292}
293
294// clear selected rows
295function clearSelectedRows() {
296 const toRemove = queueSelection.getSelected();
297 if (toRemove.length === 0) return;
298
299 const toRemoveSet = new Set(toRemove);
300 const wasCurrentTrackDeleted = toRemoveSet.has(state.queueIndex);
301 const originalLength = state.queue.length;
302
303 state.queue = state.queue.filter((_, idx) => !toRemoveSet.has(idx));
304
305 if (!wasCurrentTrackDeleted) {
306 state.queueIndex -= toRemove.filter((i) => i < state.queueIndex).length;
307 } else {
308 let nextIndex = -1;
309 for (let i = state.queueIndex + 1; i < originalLength; i++) {
310 if (!toRemoveSet.has(i)) {
311 nextIndex = i - toRemove.filter((j) => j < i).length;
312 break;
313 }
314 }
315 state.queueIndex =
316 nextIndex >= 0 ? nextIndex : Math.max(-1, state.queue.length - 1);
317 handleCurrentTrackDeleted();
318 }
319
320 // pause mutation observer to prevent interference during DOM update
321 tabOrderObserver?.pauseUpdates();
322 queueSelection.clear();
323 updateQueue();
324 highlightCurrentTrack();
325 // resume after update completes
326 tabOrderObserver?.resumeUpdates();
327}
328
329// remove a song by index, adjusting queue index if necessary
330function removeFromQueue(idx) {
331 const isCurrentTrack = idx === state.queueIndex;
332
333 state.queue.splice(idx, 1);
334
335 if (isCurrentTrack) {
336 if (state.queue.length > 0) {
337 state.queueIndex = Math.min(idx, state.queue.length - 1);
338 } else {
339 state.queueIndex = -1;
340 }
341 } else if (idx < state.queueIndex) {
342 state.queueIndex--;
343 }
344
345 saveQueue();
346 return isCurrentTrack;
347}
348
349// remove classes from queue table rows
350function clearRowClasses(container, classNames) {
351 const classes = Array.isArray(classNames) ? classNames : [classNames];
352 container.querySelectorAll("tr").forEach((row) => {
353 classes.forEach((cls) => row.classList.remove(cls));
354 });
355}
356
357// add or remove class from specific rows
358function updateRowClass(container, indices, className, add = true) {
359 const indexSet = new Set(indices);
360 container.querySelectorAll("tr").forEach((row) => {
361 const idx = parseInt(row.getAttribute("data-index"));
362 row.classList.toggle(className, indexSet.has(idx) === add);
363 });
364}
365
366// handle current track is deleted
367function handleCurrentTrackDeleted() {
368 if (state.queue.length > 0) {
369 const wasPlaying = !ui.player.paused;
370 playTrack(state.queue[state.queueIndex], wasPlaying);
371 } else {
372 clearPlayerUI();
373 }
374}
375
376// remove all items from queue
377function clearQueue() {
378 state.queue = [];
379 state.queueIndex = -1;
380 saveQueue();
381 clearPlayerUI();
382 updateQueueDisplay();
383}
384
385// buttons for queue row actions
386const ROW_BUTTON_CONFIG = [
387 {
388 className: CLASSES.QUEUE_PLAY,
389 label: "play",
390 icon: ICONS.CONTROL_PLAY,
391 },
392 {
393 className: CLASSES.QUEUE_PLAY_NEXT,
394 label: "play next",
395 icon: ICONS.CONTROL_FASTFORWARD,
396 },
397 {
398 className: CLASSES.QUEUE_FAVORITE,
399 label: "favorite",
400 icon: ICONS.HEART,
401 },
402 {
403 className: CLASSES.QUEUE_MOVE_UP,
404 label: "move up",
405 icon: ICONS.ARROW_UP,
406 },
407 {
408 className: CLASSES.QUEUE_MOVE_DOWN,
409 label: "move down",
410 icon: ICONS.ARROW_DOWN,
411 },
412 {
413 className: CLASSES.QUEUE_CLEAR,
414 label: "clear",
415 icon: ICONS.CROSS,
416 },
417];
418
419// create table row element for a song in queue
420function createQueueRow(song, idx) {
421 const tr = document.createElement("tr");
422 tr.draggable = true;
423 tr.setAttribute("data-index", idx);
424 tr.dataset.songId = song.id;
425
426 // batch all cells in fragment for efficient DOM insertion
427 const cells = document.createDocumentFragment();
428
429 // cover art cell
430 const coverCell = document.createElement("td");
431 // Prefer album art (albumId) over individual song art (coverArt) for consistency
432 const artId = song.albumId || song.coverArt;
433 if (artId && state.settings.artSong > 0) {
434 const img = document.createElement("img");
435 img.className = CLASSES.QUEUE_COVER;
436 const size = state.settings.artSong;
437 const url1x = state.api.getCoverArtUrl(artId, size);
438 const url2x = state.api.getCoverArtUrl(artId, size * 2);
439 const url3x = state.api.getCoverArtUrl(artId, size * 3);
440 img.src = url1x;
441 img.srcset = `${url1x}, ${url2x} 2x, ${url3x} 3x`;
442 img.loading = "lazy";
443 coverCell.appendChild(img);
444 }
445 cells.appendChild(coverCell);
446
447 // text cells
448 const titleCell = document.createElement("td");
449 titleCell.textContent = song.title;
450 cells.appendChild(titleCell);
451
452 const artistCell = document.createElement("td");
453 artistCell.textContent = song.artist || "";
454 cells.appendChild(artistCell);
455
456 const albumCell = document.createElement("td");
457 albumCell.textContent = song.album || "";
458 cells.appendChild(albumCell);
459
460 const durationCell = document.createElement("td");
461 durationCell.textContent = formatDuration(song.duration);
462 cells.appendChild(durationCell);
463
464 // action buttons cell (includes ratings if enabled)
465 const actionsCell = document.createElement("td");
466 const isFavorited = state.favorites.has(song.id);
467
468 if (state.settings.enableRatings) {
469 const currentRating = state.ratings.get(song.id) || 0;
470 for (let i = 1; i <= 5; i++) {
471 const star = createIconButton(
472 CLASSES.QUEUE_RATING_STAR,
473 `Rate ${i}`,
474 ICONS.STAR,
475 );
476 star.dataset.rating = i;
477 if (i <= currentRating) {
478 star.classList.add(CLASSES.RATED);
479 }
480 star.addEventListener("click", async (e) => {
481 e.stopPropagation();
482 const newRating = i === currentRating ? 0 : i;
483 await setSongRating(song, newRating);
484 updateQueueDisplay();
485 });
486 actionsCell.appendChild(star);
487 }
488 }
489
490 ROW_BUTTON_CONFIG.forEach(({ className, label, icon }) => {
491 if (
492 className === CLASSES.QUEUE_FAVORITE &&
493 !state.settings.enableFavorites
494 ) {
495 return;
496 }
497 const btn = createIconButton(className, label, icon);
498 if (className === CLASSES.QUEUE_FAVORITE && isFavorited) {
499 btn.classList.add(CLASSES.FAVORITED);
500 }
501 actionsCell.appendChild(btn);
502 });
503 cells.appendChild(actionsCell);
504
505 tr.appendChild(cells);
506 return tr;
507}
508
509// map button classes to queue action handlers
510const QUEUE_BUTTON_HANDLERS = {
511 // play the selected track
512 [CLASSES.QUEUE_PLAY]: (idx) => {
513 state.queueIndex = idx;
514 saveQueue();
515 playTrack(state.queue[idx]);
516 updateQueue();
517 },
518 // insert selected track after current track
519 [CLASSES.QUEUE_PLAY_NEXT]: (idx) => {
520 const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0;
521 moveQueueItems(state.queue, [idx], insertPos, getQueueCallbacks());
522 },
523 // move up one position
524 [CLASSES.QUEUE_MOVE_UP]: (idx) => {
525 if (idx > 0) {
526 moveQueueItems(state.queue, [idx], idx - 1, getQueueCallbacks());
527 }
528 },
529 // move down one position
530 [CLASSES.QUEUE_MOVE_DOWN]: (idx) => {
531 if (idx < state.queue.length - 1) {
532 moveQueueItems(state.queue, [idx], idx + 2, getQueueCallbacks());
533 }
534 },
535 // clear from queue
536 [CLASSES.QUEUE_CLEAR]: (idx) => {
537 const isCurrentTrack = removeFromQueue(idx);
538
539 if (isCurrentTrack) {
540 handleCurrentTrackDeleted();
541 highlightCurrentTrack();
542 }
543
544 updateQueueDisplay();
545 },
546 // toggle favorite status
547 [CLASSES.QUEUE_FAVORITE]: async (idx) => {
548 const song = state.queue[idx];
549 if (song) {
550 await setFavoriteSong(song);
551 updateQueueDisplay();
552 }
553 },
554};