a simple web player for subsonic tinysub.devins.page
subsonic navidrome javascript
at main 498 lines 14 kB view raw
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};