a simple web player for subsonic tinysub.devins.page
subsonic navidrome javascript
at main 501 lines 13 kB view raw
1// library tree rendering and navigation 2 3// global map to store library items by ID for keyboard access 4const libraryItemsById = new Map(); 5 6// library keyboard navigation manager 7const LibraryNavigator = { 8 currentFocusedItem: null, 9 currentSection: "artists", // "artists" or "playlists" 10 11 // get all focusable items in library (organized by section for natural navigation) 12 getFocusableItems() { 13 const items = []; 14 const sections = [ 15 { 16 selector: '[data-section="artists"]', 17 containerId: DOM_IDS.LIBRARY_TREE, 18 }, 19 { 20 selector: '[data-section="playlists"]', 21 containerId: DOM_IDS.PLAYLISTS_TREE, 22 }, 23 ]; 24 25 sections.forEach(({ selector, containerId }) => { 26 const toggle = document.querySelector(selector); 27 if (toggle) { 28 items.push(toggle); 29 const sectionItems = Array.from( 30 document.querySelectorAll( 31 `#${containerId} .${CLASSES.TREE_TOGGLE}, #${containerId} .${CLASSES.TREE_NAME}`, 32 ), 33 ); 34 items.push(...sectionItems); 35 } 36 }); 37 38 return items; 39 }, 40 41 // set focus to specific item 42 focusItem(element) { 43 if (this.currentFocusedItem) { 44 this.currentFocusedItem.classList.remove("library-focused"); 45 } 46 if (element) { 47 element.classList.add("library-focused"); 48 element.scrollIntoView({ block: "nearest" }); 49 this.currentFocusedItem = element; 50 } 51 }, 52 53 // focus first item (first section header) 54 navigateFirst() { 55 const items = this.getFocusableItems(); 56 if (items.length > 0) this.focusItem(items[0]); 57 }, 58 59 navigateLast() { 60 const items = this.getFocusableItems(); 61 if (items.length > 0) this.focusItem(items[items.length - 1]); 62 }, 63 64 // navigate with direction and optional offset (for page navigation) 65 _navigateTo(calcNewIndex) { 66 const items = this.getFocusableItems(); 67 if (items.length === 0) return; 68 69 if (!this.currentFocusedItem) { 70 this.focusItem(items[0]); 71 return; 72 } 73 74 const currentIndex = items.indexOf(this.currentFocusedItem); 75 const newIndex = calcNewIndex(currentIndex, items.length); 76 if (newIndex >= 0 && newIndex < items.length) { 77 this.focusItem(items[newIndex]); 78 } 79 }, 80 81 // navigate up/down through items 82 navigate(direction) { 83 this._navigateTo((idx) => idx + direction); 84 }, 85 86 // navigate by page (jump multiple items) 87 navigatePageUp(pageSize = 10) { 88 this._navigateTo((idx) => Math.max(0, idx - pageSize)); 89 }, 90 91 navigatePageDown(pageSize = 10) { 92 this._navigateTo((idx, len) => Math.min(len - 1, idx + pageSize)); 93 }, 94 95 // toggle expand/collapse on current item (if it's a tree toggle) 96 toggleCurrent() { 97 if (!this.currentFocusedItem) return; 98 const isToggle = this.currentFocusedItem.classList.contains( 99 CLASSES.TREE_TOGGLE, 100 ); 101 if (isToggle) { 102 this.currentFocusedItem.click(); 103 } else if (this.currentFocusedItem.classList.contains("section-toggle")) { 104 // also allow toggling section headers 105 this.currentFocusedItem.click(); 106 } 107 }, 108 109 // get data (item id and type) from currently focused item 110 getCurrentItemData() { 111 if (!this.currentFocusedItem) return null; 112 const li = this.currentFocusedItem.closest("li"); 113 if (!li) return null; 114 return { 115 itemId: li.dataset.itemId, 116 section: this.determineSection(), 117 }; 118 }, 119 120 // determine which section (artists or playlists) the current item is in 121 determineSection() { 122 if (!this.currentFocusedItem) return "artists"; 123 const inArtists = 124 this.currentFocusedItem.closest(`#${DOM_IDS.LIBRARY_TREE}`) !== null; 125 return inArtists ? "artists" : "playlists"; 126 }, 127}; 128 129// determine item type based on nesting level in tree 130function getLibraryItemType(li, section) { 131 let level = 0; 132 let parent = li.parentElement; 133 const container = section === "artists" ? ui.artistsTree : ui.playlistsTree; 134 while (parent && parent !== container) { 135 if ( 136 parent.classList.contains(CLASSES.NESTED) || 137 parent.classList.contains(CLASSES.NESTED_SONGS) 138 ) { 139 level++; 140 } 141 parent = parent.parentElement; 142 } 143 144 // map level to type: for artists tree use level, for playlists distinguish between playlist container and songs 145 if (section === "playlists") { 146 return level >= 1 ? "song" : "playlist"; 147 } else if (level === 1) { 148 return "album"; 149 } else if (level >= 2) { 150 return "song"; 151 } 152 return "artist"; 153} 154 155// look up a song in the library cache by ID 156function lookupSongInLibrary(songId) { 157 return libraryItemsById.get(songId); 158} 159 160// add library item to queue (end or next after current track) 161function addLibraryItem(addNext = false) { 162 const data = LibraryNavigator.getCurrentItemData(); 163 if (!data || !data.itemId) return; 164 165 const link = LibraryNavigator.currentFocusedItem; 166 const li = link?.closest("li"); 167 if (!li) return; 168 169 const type = getLibraryItemType(li, data.section); 170 171 // for songs, look up the full object from library cache or create minimal one 172 let itemData = data.itemId; 173 if (type === "song") { 174 const song = lookupSongInLibrary(data.itemId); 175 if (song) { 176 itemData = song; 177 } else { 178 itemData = { id: data.itemId }; 179 } 180 } 181 182 // get handler and execute 183 const handler = addNext ? addNextByType[type] : addByType[type]; 184 185 if (handler) { 186 handler(itemData); 187 } 188} 189 190const addByType = { 191 artist: (id) => addArtistToQueue(id), 192 album: (id) => addAlbumToQueue(id), 193 playlist: (id) => addPlaylistToQueue(id), 194 song: (song) => addSongToQueue(song), 195}; 196 197const addNextByType = { 198 artist: (id) => 199 addToQueue(() => state.api.getArtist(id), SONG_EXTRACTORS.artist, true), 200 album: (id) => 201 addToQueue(() => state.api.getAlbum(id), SONG_EXTRACTORS.album, true), 202 playlist: (id) => 203 addToQueue(() => state.api.getPlaylist(id), SONG_EXTRACTORS.playlist, true), 204 song: (song) => 205 addToQueue(() => Promise.resolve(song), SONG_EXTRACTORS.song, true), 206}; 207 208// create tree item with cover art and action buttons 209function createTreeItem( 210 name, 211 coverArtId, 212 onToggle, 213 onAdd, 214 onAddNext, 215 artType, 216 itemId, 217) { 218 const li = createElement("li"); 219 const div = createElement("div", { className: CLASSES.TREE_ITEM }); 220 221 if (itemId) li.dataset.itemId = itemId; 222 223 const linkChildren = []; 224 if (coverArtId) { 225 const imgEl = createElement("img", { 226 attributes: { 227 alt: "cover", 228 loading: "lazy", 229 }, 230 }); 231 loadCachedImage(imgEl, coverArtId, artType); 232 const wrapper = createElement("div", { 233 className: "tree-cover", 234 children: [imgEl], 235 }); 236 linkChildren.push(wrapper); 237 } 238 linkChildren.push(createElement("span", { textContent: name })); 239 240 const linkEl = createElement("a", { 241 className: onToggle ? CLASSES.TREE_TOGGLE : CLASSES.TREE_NAME, 242 listeners: { 243 click: (e) => { 244 e.preventDefault(); 245 onToggle?.(li); 246 }, 247 }, 248 children: linkChildren, 249 }); 250 251 div.appendChild(linkEl); 252 if (onAdd) 253 div.appendChild( 254 createIconButton("tree-action", "add", ICONS.ADD, "add", onAdd), 255 ); 256 if (onAddNext) 257 div.appendChild( 258 createIconButton( 259 "tree-action", 260 "add next", 261 ICONS.PLAY_NEXT, 262 "add next", 263 onAddNext, 264 ), 265 ); 266 267 li.appendChild(div); 268 return li; 269} 270 271// toggle library section expand/collapse 272function handleSectionToggle(e) { 273 e.preventDefault(); 274 const section = e.target.dataset.section; 275 state.expanded[section] = !state.expanded[section]; 276 if ( 277 state.expanded[section] && 278 typeof state.expanded.items[section] === "undefined" 279 ) { 280 state.expanded.items[section] = {}; 281 } 282 const renderers = { 283 artists: renderLibraryTree, 284 playlists: renderPlaylistsTree, 285 }; 286 renderers[section]?.(); 287} 288 289async function loadData(config) { 290 const { fetcher, transformer, stateKey, renderFn } = config; 291 const data = await fetcher(); 292 state[stateKey] = transformer(data); 293 renderFn(); 294} 295 296async function loadLibrary() { 297 return loadData({ 298 fetcher: () => state.api.getArtists(), 299 transformer: (data) => 300 data.artists?.index?.flatMap((idx) => toArray(idx.artist)) || [], 301 stateKey: "library", 302 renderFn: renderLibraryTree, 303 }); 304} 305 306async function loadPlaylists() { 307 return loadData({ 308 fetcher: () => state.api.getPlaylists(), 309 transformer: (data) => toArray(data.playlist || data.playlists?.playlist), 310 stateKey: "playlists", 311 renderFn: renderPlaylistsTree, 312 }); 313} 314 315async function loadFavorites() { 316 const data = await state.api.getStarred2(); 317 const songs = toArray(data.starred2?.song || data.song); 318 state.favorites.clear(); 319 songs.forEach((song) => state.favorites.add(song.id)); 320 updateQueueDisplay(); 321} 322 323function buildTreeItem(item, mapped, onToggle, onAction, onAddNext, artType) { 324 libraryItemsById.set(item.id, item); 325 326 return createTreeItem( 327 mapped.label, 328 mapped.cover || null, 329 onToggle ? (li) => onToggle(item, li) : null, 330 () => onAction(item), 331 onAddNext ? () => onAddNext(item) : null, 332 artType, 333 item.id, 334 ); 335} 336 337async function loadAndRenderItems(config) { 338 const { 339 fetcher, 340 itemExtractor, 341 parentLi, 342 ulClassName, 343 itemMapper, 344 onToggle, 345 onAction, 346 onAddNext, 347 onExpanded, 348 artType, 349 } = config; 350 try { 351 const data = await fetcher(); 352 const items = itemExtractor(data); 353 if (!items.length) return; 354 355 const ul = createElement("ul", { className: ulClassName }); 356 items.forEach((item) => { 357 const mapped = itemMapper(item); 358 const li = buildTreeItem( 359 item, 360 mapped, 361 onToggle, 362 onAction, 363 onAddNext, 364 artType, 365 ); 366 ul.appendChild(li); 367 if (onExpanded && mapped.isExpanded) onExpanded(item, li); 368 }); 369 parentLi.appendChild(ul); 370 } catch (error) { 371 console.error("[Library] Failed to load items:", error); 372 } 373} 374 375async function loadAndRenderAlbums(artistId, parentLi) { 376 return loadAndRenderItems({ 377 fetcher: () => state.api.getArtist(artistId), 378 itemExtractor: (data) => toArray(data.artist?.album), 379 parentLi, 380 ulClassName: CLASSES.NESTED, 381 artType: "artAlbum", 382 itemMapper: (album) => ({ 383 label: album.name, 384 cover: shouldShowArt("artAlbum", album.coverArt), 385 isExpanded: state.expanded.items.albums[album.id], 386 }), 387 onToggle: (album, li) => { 388 state.expanded.items.albums[album.id] = 389 !state.expanded.items.albums[album.id]; 390 // clear any existing songs before toggling 391 li.querySelector(`ul.${CLASSES.NESTED_SONGS}`)?.remove(); 392 if (state.expanded.items.albums[album.id]) { 393 loadAndRenderSongs(album.id, li); 394 } 395 }, 396 onExpanded: (album, li) => loadAndRenderSongs(album.id, li), 397 onAction: (album) => addAlbumToQueue(album.id), 398 onAddNext: (album) => addNextByType.album(album.id), 399 }); 400} 401 402async function loadAndRenderSongs(albumId, parentLi) { 403 return loadAndRenderItems({ 404 fetcher: () => state.api.getAlbum(albumId), 405 itemExtractor: (data) => toArray(data.album?.song), 406 parentLi, 407 ulClassName: CLASSES.NESTED_SONGS, 408 artType: "artSong", 409 itemMapper: (song) => ({ 410 label: song.title, 411 }), 412 onAction: (song) => addSongToQueue(song), 413 onAddNext: (song) => addNextByType.song(song), 414 }); 415} 416 417// tree configuration for sidebar sections 418const TREE_CONFIGS = { 419 artists: { 420 container: () => ui.artistsTree, 421 data: () => state.library, 422 expandedKey: "artists", 423 expandedMap: () => state.expanded.items.artists, 424 message: STRINGS.NO_ARTISTS, 425 artType: "artArtist", 426 itemMapper: (artist) => ({ 427 label: artist.title || artist.name, 428 cover: shouldShowArt("artArtist", artist.coverArt), 429 }), 430 onToggle: (artist, li) => { 431 const map = state.expanded.items.artists; 432 map[artist.id] = !map[artist.id]; 433 li.querySelector(`ul.${CLASSES.NESTED}`)?.remove(); 434 if (map[artist.id]) loadAndRenderAlbums(artist.id, li); 435 }, 436 onAction: (artist) => addArtistToQueue(artist.id), 437 onAddNext: (artist) => addNextByType.artist(artist.id), 438 onRestore: (item) => loadAndRenderAlbums(item.id), 439 }, 440 playlists: { 441 container: () => ui.playlistsTree, 442 data: () => state.playlists, 443 expandedKey: "playlists", 444 expandedMap: () => state.expanded.items.playlists, 445 message: STRINGS.NO_PLAYLISTS, 446 artType: "artArtist", 447 itemMapper: (playlist) => ({ 448 label: playlist.name, 449 cover: null, 450 }), 451 onToggle: null, 452 onAction: (playlist) => addPlaylistToQueue(playlist.id), 453 onAddNext: (playlist) => addNextByType.playlist(playlist.id), 454 }, 455}; 456 457// render a sidebar tree section using config 458function renderSidebarTree(sectionKey) { 459 const config = TREE_CONFIGS[sectionKey]; 460 const container = config.container(); 461 const items = config.data(); 462 463 container.innerHTML = ""; 464 if (!state.expanded[config.expandedKey]) return; 465 if (!items.length) { 466 container.innerHTML = `<div class="item">${config.message}</div>`; 467 return; 468 } 469 470 const ul = createElement("ul"); 471 items.forEach((item) => { 472 const mapped = config.itemMapper(item); 473 const li = buildTreeItem( 474 item, 475 mapped, 476 config.onToggle, 477 config.onAction, 478 config.onAddNext, 479 config.artType, 480 ); 481 ul.appendChild(li); 482 }); 483 container.appendChild(ul); 484 485 // restore expanded items for artists only 486 if (config.onRestore && items.length) { 487 const expandMap = config.expandedMap(); 488 items.forEach((item) => { 489 if (expandMap[item.id]) { 490 const li = container.querySelector(`li[data-item-id="${item.id}"]`); 491 if (li && !li.querySelector(`ul.${CLASSES.NESTED}`)) { 492 loadAndRenderAlbums(item.id, li); 493 } 494 } 495 }); 496 } 497} 498 499// export for SECTION_RENDERERS 500const renderLibraryTree = () => renderSidebarTree("artists"); 501const renderPlaylistsTree = () => renderSidebarTree("playlists");