a simple web player for subsonic
tinysub.devins.page
subsonic
navidrome
javascript
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");