// library tree rendering and navigation // global map to store library items by ID for keyboard access const libraryItemsById = new Map(); // add library item to queue (end or next after current track) function addLibraryItem(addNext = false) { const data = librarySelection.getCurrentItemData(); if (!data || !data.itemId) return; const link = librarySelection.currentFocusedItem; const li = link?.closest("li"); if (!li) return; // determine item type based on nesting level in tree let level = 0; let parent = li.parentElement; const container = data.section === "artists" ? ui.artistsTree : ui.playlistsTree; while (parent && parent !== container) { if ( parent.classList.contains(CLASSES.NESTED) || parent.classList.contains(CLASSES.NESTED_SONGS) ) { level++; } parent = parent.parentElement; } let type; if (data.section === "playlists") { type = level >= 1 ? "song" : "playlist"; } else if (level === 1) { type = "album"; } else if (level >= 2) { type = "song"; } else { type = "artist"; } // for songs, look up the full object from library cache or create minimal one let itemData = data.itemId; if (type === "song") { const song = libraryItemsById.get(data.itemId); if (song) { itemData = song; } else { itemData = { id: data.itemId }; } } const handler = addNext ? addNextByType[type] : addByType[type]; if (handler) { handler(itemData); } } const addByType = { artist: addArtistToQueue, album: addAlbumToQueue, playlist: addPlaylistToQueue, song: addSongToQueue, }; const addNextByType = { artist: (id) => addToQueue(() => state.api.getArtist(id), SONG_EXTRACTORS.artist, true), album: (id) => addToQueue(() => state.api.getAlbum(id), SONG_EXTRACTORS.album, true), playlist: (id) => addToQueue(() => state.api.getPlaylist(id), SONG_EXTRACTORS.playlist, true), song: (song) => addToQueue(() => Promise.resolve(song), SONG_EXTRACTORS.song, true), }; // create tree item with cover art and action buttons function createTreeItem( name, coverArtId, onToggle, onAdd, onAddNext, artType, itemId, ) { const li = createElement("li"); const div = createElement("div", { className: CLASSES.TREE_ITEM }); if (itemId) li.dataset.itemId = itemId; const linkChildren = []; if (coverArtId) { const imgEl = createElement("img"); // HACK: upscale artist images 2x, since non-1:1 aspect ratios seems to make images look blurry, which is very common on artist profiles const size = state.settings[artType] * (artType === "artArtist" ? 2 : 1); const url1x = state.api.getCoverArtUrl(coverArtId, size); const url2x = state.api.getCoverArtUrl(coverArtId, size * 2); const url3x = state.api.getCoverArtUrl(coverArtId, size * 3); imgEl.src = url1x; imgEl.srcset = `${url1x}, ${url2x} 2x, ${url3x} 3x`; imgEl.loading = "lazy"; linkChildren.push(imgEl); } linkChildren.push(createElement("span", { textContent: name })); const linkEl = createElement("a", { className: onToggle ? CLASSES.TREE_TOGGLE : CLASSES.TREE_NAME, listeners: { click: (e) => { e.preventDefault(); onToggle?.(li); }, }, children: linkChildren, }); div.appendChild(linkEl); if (onAdd) div.appendChild(createIconButton("tree-action", "add", ICONS.ADD, onAdd)); if (onAddNext) div.appendChild( createIconButton( "tree-action", "add next", ICONS.CONTROL_FASTFORWARD, onAddNext, ), ); li.appendChild(div); return li; } // toggle library section expand/collapse function handleSectionToggle(e) { e.preventDefault(); const section = e.target.dataset.section; state.expanded[section] = !state.expanded[section]; if ( state.expanded[section] && typeof state.expanded.items[section] === "undefined" ) { state.expanded.items[section] = {}; } if (section === "artists") { renderLibraryTree(); } else { renderPlaylistsTree(); } } async function loadData(config) { const { fetcher, transformer, stateKey, renderFn } = config; const data = await fetcher(); state[stateKey] = transformer(data); renderFn(); } async function loadLibrary() { return loadData({ fetcher: () => state.api.getArtists(), transformer: (data) => data.artists?.index?.flatMap((idx) => toArray(idx.artist)) || [], stateKey: "library", renderFn: renderLibraryTree, }); } async function loadPlaylists() { return loadData({ fetcher: () => state.api.getPlaylists(), transformer: (data) => toArray(data.playlist || data.playlists?.playlist), stateKey: "playlists", renderFn: renderPlaylistsTree, }); } async function loadFavorites() { const data = await state.api.getStarred2(); const songs = toArray(data.starred2?.song || data.song); state.favorites.clear(); songs.forEach((song) => state.favorites.add(song.id)); updateQueueDisplay(); } function clearNestedList(parentLi, className) { const existingUl = parentLi.querySelector(`ul.${className}`); if (existingUl) existingUl.remove(); } function buildTreeItem(item, mapped, onToggle, onAction, onAddNext, artType) { libraryItemsById.set(item.id, item); return createTreeItem( mapped.label, mapped.cover || null, onToggle ? (li) => onToggle(item, li) : null, () => onAction(item), onAddNext ? () => onAddNext(item) : null, artType, item.id, ); } async function loadAndRenderItems(config) { const { fetcher, itemExtractor, parentLi, ulClassName, itemMapper, onToggle, onAction, onAddNext, onExpanded, artType, } = config; try { const data = await fetcher(); const items = itemExtractor(data); if (!items.length) return; clearNestedList(parentLi, ulClassName); const ul = createElement("ul", { className: ulClassName }); items.forEach((item) => { const mapped = itemMapper(item); const li = buildTreeItem( item, mapped, onToggle, onAction, onAddNext, artType, ); ul.appendChild(li); if (onExpanded && mapped.isExpanded) onExpanded(item, li); }); parentLi.appendChild(ul); } catch (error) { console.error("[Library] Failed to load items:", error); } } async function loadAndRenderAlbums(artistId, parentLi) { return loadAndRenderItems({ fetcher: () => state.api.getArtist(artistId), itemExtractor: (data) => toArray(data.artist?.album), parentLi, ulClassName: CLASSES.NESTED, artType: "artAlbum", itemMapper: (album) => ({ label: album.name, cover: state.settings.artAlbum > 0 ? album.coverArt : null, isExpanded: state.expanded.items.albums[album.id], }), onToggle: (album, li) => { state.expanded.items.albums[album.id] = !state.expanded.items.albums[album.id]; // clear any existing songs before toggling li.querySelector(`ul.${CLASSES.NESTED_SONGS}`)?.remove(); if (state.expanded.items.albums[album.id]) { loadAndRenderSongs(album.id, li); } }, onExpanded: (album, li) => loadAndRenderSongs(album.id, li), onAction: (album) => addAlbumToQueue(album.id), onAddNext: (album) => addNextByType.album(album.id), }); } async function loadAndRenderSongs(albumId, parentLi) { return loadAndRenderItems({ fetcher: () => state.api.getAlbum(albumId), itemExtractor: (data) => toArray(data.album?.song), parentLi, ulClassName: CLASSES.NESTED_SONGS, artType: "artSong", itemMapper: (song) => ({ label: song.title, }), onAction: (song) => addSongToQueue(song), onAddNext: (song) => addNextByType.song(song), }); } // tree configuration for sidebar sections const TREE_CONFIGS = { artists: { container: () => ui.artistsTree, data: () => state.library, expandedKey: "artists", expandedMap: () => state.expanded.items.artists, message: STRINGS.NO_ARTISTS, artType: "artArtist", itemMapper: (artist) => ({ label: artist.title || artist.name, cover: state.settings.artArtist > 0 ? artist.coverArt : null, }), onToggle: (artist, li) => { const map = state.expanded.items.artists; map[artist.id] = !map[artist.id]; li.querySelector(`ul.${CLASSES.NESTED}`)?.remove(); if (map[artist.id]) loadAndRenderAlbums(artist.id, li); }, onAction: (artist) => addArtistToQueue(artist.id), onAddNext: (artist) => addNextByType.artist(artist.id), onRestore: (item) => loadAndRenderAlbums(item.id), }, playlists: { container: () => ui.playlistsTree, data: () => state.playlists, expandedKey: "playlists", expandedMap: () => state.expanded.items.playlists, message: STRINGS.NO_PLAYLISTS, artType: "artArtist", itemMapper: (playlist) => ({ label: playlist.name, cover: null, }), onToggle: null, onAction: (playlist) => addPlaylistToQueue(playlist.id), onAddNext: (playlist) => addNextByType.playlist(playlist.id), }, }; // render library or playlists tree const renderLibraryTree = () => renderSidebarTree("artists"); const renderPlaylistsTree = () => renderSidebarTree("playlists"); // render a sidebar tree section using config function renderSidebarTree(sectionKey) { const config = TREE_CONFIGS[sectionKey]; const container = config.container(); const items = config.data(); container.innerHTML = ""; if (!state.expanded[config.expandedKey]) return; if (!items.length) { container.innerHTML = `