a simple web player for subsonic
at main 554 lines 15 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 = 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};