a simple web player for subsonic tinysub.devins.page
subsonic navidrome javascript
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: dynamic favicon toggle

and general cleanup

intergrav ac20d08b 50f02ac9

+166 -171
+1 -1
README.md
··· 1 1 # tinysub 2 2 3 - _tinysub_ is a simple web player for _Subsonic_. it's currently tested on a Navidrome server, [and may not work elsewhere yet](https://tangled.org/devins.page/tinysub/issues/17). i don't really like Navidrome's web client, and i wanted something akin to [Strawberry Music Player](https://www.strawberrymusicplayer.org), but i could run anywhere. 3 + _tinysub_ is a simple web player for _Open Subsonic compatible music servers_, such as [Navidrome](https://github.com/navidrome/navidrome) and [Gonic](https://github.com/sentriz/gonic). i don't really like Navidrome's web client for various reasons, and i wanted something akin to [Strawberry Music Player](https://www.strawberrymusicplayer.org), but i could run anywhere. 4 4 5 5 to use, visit [https://tinysub.devins.page](https://tinysub.devins.page), or clone to your own machine and open `/src/index.html` in a browser. it's just plain JavaScript, HTML, and CSS, nothing fancy.. you can also build to a single index.html with [monolith](https://github.com/Y2Z/monolith) by running `nix build` (manifest for PWA doesn't seem to work with monolith though) 6 6
+7 -1
src/index.html
··· 70 70 <div class="form-group"> 71 71 <label> 72 72 <input type="checkbox" id="scrobbling-toggle" /> 73 - enable scrobbling 73 + scrobbling 74 + </label> 75 + </div> 76 + <div class="form-group"> 77 + <label> 78 + <input type="checkbox" id="dynamic-favicon-toggle" /> 79 + dynamic favicon 74 80 </label> 75 81 </div> 76 82 <div class="form-group">
+4 -4
src/js/events.js
··· 9 9 Object.entries({ 10 10 play: () => ui.player.play(), 11 11 pause: () => ui.player.pause(), 12 - previoustrack: previousTrack, 13 - nexttrack: nextTrack, 12 + previoustrack: () => navigateTrack(-1), 13 + nexttrack: () => navigateTrack(1), 14 14 seekto: (e) => (ui.player.currentTime = e.seekTime), 15 15 }).forEach(([action, handler]) => 16 16 navigator.mediaSession.setActionHandler(action, handler), ··· 78 78 79 79 // playback controls 80 80 ui.playBtn.addEventListener("click", togglePlayback); 81 - ui.prevBtn.addEventListener("click", previousTrack); 82 - ui.nextBtn.addEventListener("click", nextTrack); 81 + ui.prevBtn.addEventListener("click", () => navigateTrack(-1)); 82 + ui.nextBtn.addEventListener("click", () => navigateTrack(1)); 83 83 84 84 // progress slider 85 85 const seekHandler = (e) => {
+5 -21
src/js/input.js
··· 115 115 updateQueue(); 116 116 refocusContext(true, false); 117 117 }, 118 - favorite: async (selectedIndices) => { 119 - await Promise.all( 120 - selectedIndices.map((i) => setFavoriteSong(state.queue[i], true)), 121 - ); 122 - updateQueueDisplay(); 123 - refocusContext(true, false); 124 - }, 125 - playNext: (selectedIndices) => { 126 - const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0; 127 - moveQueueItems(state.queue, selectedIndices, insertPos, queueCallbacks); 128 - refocusContext(true, false); 129 - }, 130 118 moveUp: (selectedIndices) => { 131 119 const firstIdx = Math.min(...selectedIndices); 132 120 if (firstIdx > 0) { ··· 144 132 if (lastIdx < state.queue.length - 1) { 145 133 moveQueueItems(state.queue, selectedIndices, lastIdx + 2, queueCallbacks); 146 134 } 147 - refocusContext(true, false); 148 - }, 149 - delete: (selectedIndices) => { 150 - clearSelectedRows(); 151 135 refocusContext(true, false); 152 136 }, 153 137 showContextMenu: (selectedIndices) => { ··· 358 342 case "KeyP": { 359 343 if (!isInSidebar) return; // library only 360 344 if (e.altKey) { 361 - // Alt+P for play next in library 345 + // Alt+P for add next in library 362 346 e.preventDefault(); 363 - playLibraryItem(true); 347 + addLibraryItem(true); 364 348 updateQueue(); 365 349 } else { 366 350 // P for add to queue 367 351 e.preventDefault(); 368 - playLibraryItem(false); 352 + addLibraryItem(false); 369 353 updateQueue(); 370 354 } 371 355 refocusContext(false, true); ··· 425 409 e.preventDefault(); 426 410 if (e.altKey) { 427 411 // Alt+J for previous track 428 - previousTrack(); 412 + navigateTrack(-1); 429 413 } else { 430 414 // J for seek -10s 431 415 ui.player.currentTime = Math.max(0, ui.player.currentTime - 10); ··· 438 422 e.preventDefault(); 439 423 if (e.altKey) { 440 424 // Alt+L for next track 441 - nextTrack(); 425 + navigateTrack(1); 442 426 } else { 443 427 // L for seek +10s 444 428 ui.player.currentTime = Math.min(
+55 -74
src/js/library.js
··· 11 11 // get all focusable items in library (organized by section for natural navigation) 12 12 getFocusableItems() { 13 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 + ]; 14 24 15 - // organize by section: artists header + items, then playlists header + items 16 - const artistsToggle = document.querySelector(`[data-section="artists"]`); 17 - if (artistsToggle) { 18 - items.push(artistsToggle); 19 - const artistItems = Array.from( 20 - document.querySelectorAll( 21 - `#${DOM_IDS.LIBRARY_TREE} .${CLASSES.TREE_TOGGLE}, #${DOM_IDS.LIBRARY_TREE} .${CLASSES.TREE_NAME}`, 22 - ), 23 - ); 24 - items.push(...artistItems); 25 - } 26 - 27 - const playlistsToggle = document.querySelector( 28 - `[data-section="playlists"]`, 29 - ); 30 - if (playlistsToggle) { 31 - items.push(playlistsToggle); 32 - const playlistItems = Array.from( 33 - document.querySelectorAll( 34 - `#${DOM_IDS.PLAYLISTS_TREE} .${CLASSES.TREE_TOGGLE}, #${DOM_IDS.PLAYLISTS_TREE} .${CLASSES.TREE_NAME}`, 35 - ), 36 - ); 37 - items.push(...playlistItems); 38 - } 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 + }); 39 37 40 38 return items; 41 39 }, ··· 63 61 if (items.length > 0) this.focusItem(items[items.length - 1]); 64 62 }, 65 63 66 - // navigate up/down through items 67 - navigate(direction) { 64 + // navigate with direction and optional offset (for page navigation) 65 + _navigateTo(calcNewIndex) { 68 66 const items = this.getFocusableItems(); 69 67 if (items.length === 0) return; 70 68 ··· 74 72 } 75 73 76 74 const currentIndex = items.indexOf(this.currentFocusedItem); 77 - const newIndex = currentIndex + direction; 75 + const newIndex = calcNewIndex(currentIndex, items.length); 78 76 if (newIndex >= 0 && newIndex < items.length) { 79 77 this.focusItem(items[newIndex]); 80 78 } 79 + }, 80 + 81 + // navigate up/down through items 82 + navigate(direction) { 83 + this._navigateTo((idx) => idx + direction); 81 84 }, 82 85 83 86 // navigate by page (jump multiple items) 84 87 navigatePageUp(pageSize = 10) { 85 - const items = this.getFocusableItems(); 86 - if (items.length === 0) return; 87 - 88 - if (!this.currentFocusedItem) { 89 - this.focusItem(items[0]); 90 - return; 91 - } 92 - 93 - const currentIndex = items.indexOf(this.currentFocusedItem); 94 - const newIndex = Math.max(0, currentIndex - pageSize); 95 - this.focusItem(items[newIndex]); 88 + this._navigateTo((idx) => Math.max(0, idx - pageSize)); 96 89 }, 97 90 98 91 navigatePageDown(pageSize = 10) { 99 - const items = this.getFocusableItems(); 100 - if (items.length === 0) return; 101 - 102 - if (!this.currentFocusedItem) { 103 - this.focusItem(items[0]); 104 - return; 105 - } 106 - 107 - const currentIndex = items.indexOf(this.currentFocusedItem); 108 - const newIndex = Math.min(items.length - 1, currentIndex + pageSize); 109 - this.focusItem(items[newIndex]); 92 + this._navigateTo((idx, len) => Math.min(len - 1, idx + pageSize)); 110 93 }, 111 94 112 95 // toggle expand/collapse on current item (if it's a tree toggle) ··· 174 157 return libraryItemsById.get(songId); 175 158 } 176 159 177 - // trigger play or play next for library item 178 - function playLibraryItem(playNext = false) { 160 + // add library item to queue (end or next after current track) 161 + function addLibraryItem(addNext = false) { 179 162 const data = LibraryNavigator.getCurrentItemData(); 180 163 if (!data || !data.itemId) return; 181 164 ··· 192 175 if (song) { 193 176 itemData = song; 194 177 } else { 195 - // if not in cache, create a minimal song object with just ID 196 - // the server will have more info, or queue will show it with limited data 197 178 itemData = { id: data.itemId }; 198 179 } 199 180 } 200 181 201 182 // get handler and execute 202 - const handler = playNext ? playNextByType[type] : playByType[type]; 183 + const handler = addNext ? addNextByType[type] : addByType[type]; 203 184 204 185 if (handler) { 205 186 handler(itemData); 206 187 } 207 188 } 208 189 209 - const playByType = { 190 + const addByType = { 210 191 artist: (id) => addArtistToQueue(id), 211 192 album: (id) => addAlbumToQueue(id), 212 193 playlist: (id) => addPlaylistToQueue(id), 213 194 song: (song) => addSongToQueue(song), 214 195 }; 215 196 216 - const playNextByType = { 197 + const addNextByType = { 217 198 artist: (id) => 218 199 addToQueue(() => state.api.getArtist(id), SONG_EXTRACTORS.artist, true), 219 200 album: (id) => ··· 230 211 coverArtId, 231 212 onToggle, 232 213 onAdd, 233 - onPlayNext, 214 + onAddNext, 234 215 artType, 235 216 itemId, 236 217 ) { ··· 268 249 }); 269 250 270 251 div.appendChild(linkEl); 271 - if (onPlayNext) 252 + if (onAdd) 253 + div.appendChild( 254 + createIconButton("tree-action", "add", ICONS.ADD, "add", onAdd), 255 + ); 256 + if (onAddNext) 272 257 div.appendChild( 273 258 createIconButton( 274 259 "tree-action", 275 - "play next", 260 + "add next", 276 261 ICONS.PLAY_NEXT, 277 - "play next", 278 - onPlayNext, 262 + "add next", 263 + onAddNext, 279 264 ), 280 - ); 281 - if (onAdd) 282 - div.appendChild( 283 - createIconButton("tree-action", "add", ICONS.ADD, "add", onAdd), 284 265 ); 285 266 286 267 li.appendChild(div); ··· 339 320 updateQueueDisplay(); 340 321 } 341 322 342 - function buildTreeItem(item, mapped, onToggle, onAction, onPlayNext, artType) { 323 + function buildTreeItem(item, mapped, onToggle, onAction, onAddNext, artType) { 343 324 libraryItemsById.set(item.id, item); 344 325 345 326 return createTreeItem( ··· 347 328 mapped.cover || null, 348 329 onToggle ? (li) => onToggle(item, li) : null, 349 330 () => onAction(item), 350 - onPlayNext ? () => onPlayNext(item) : null, 331 + onAddNext ? () => onAddNext(item) : null, 351 332 artType, 352 333 item.id, 353 334 ); ··· 362 343 itemMapper, 363 344 onToggle, 364 345 onAction, 365 - onPlayNext, 346 + onAddNext, 366 347 onExpanded, 367 348 artType, 368 349 } = config; ··· 379 360 mapped, 380 361 onToggle, 381 362 onAction, 382 - onPlayNext, 363 + onAddNext, 383 364 artType, 384 365 ); 385 366 ul.appendChild(li); ··· 414 395 }, 415 396 onExpanded: (album, li) => loadAndRenderSongs(album.id, li), 416 397 onAction: (album) => addAlbumToQueue(album.id), 417 - onPlayNext: (album) => playNextByType.album(album.id), 398 + onAddNext: (album) => addNextByType.album(album.id), 418 399 }); 419 400 } 420 401 ··· 429 410 label: song.title, 430 411 }), 431 412 onAction: (song) => addSongToQueue(song), 432 - onPlayNext: (song) => playNextByType.song(song), 413 + onAddNext: (song) => addNextByType.song(song), 433 414 }); 434 415 } 435 416 ··· 453 434 if (map[artist.id]) loadAndRenderAlbums(artist.id, li); 454 435 }, 455 436 onAction: (artist) => addArtistToQueue(artist.id), 456 - onPlayNext: (artist) => playNextByType.artist(artist.id), 437 + onAddNext: (artist) => addNextByType.artist(artist.id), 457 438 onRestore: (item) => loadAndRenderAlbums(item.id), 458 439 }, 459 440 playlists: { ··· 469 450 }), 470 451 onToggle: null, 471 452 onAction: (playlist) => addPlaylistToQueue(playlist.id), 472 - onPlayNext: (playlist) => playNextByType.playlist(playlist.id), 453 + onAddNext: (playlist) => addNextByType.playlist(playlist.id), 473 454 }, 474 455 }; 475 456 ··· 494 475 mapped, 495 476 config.onToggle, 496 477 config.onAction, 497 - config.onPlayNext, 478 + config.onAddNext, 498 479 config.artType, 499 480 ); 500 481 ul.appendChild(li);
+56 -41
src/js/player.js
··· 1 1 // audio playback control and player state management 2 2 3 + // fixed resolution for cover art display 4 + // why? the media session cover art has to be a pretty decent resolution to look good on phones (and hidpi), and if there were a bunch of separate sizes then we are just downloading more images for no good reason 5 + const FIXED_ART_SIZE = 512; 6 + 7 + // update favicon with song art or default icon 8 + function updateFavicon(artId) { 9 + const link = document.querySelector('link[rel="icon"]'); 10 + if (!link) return; 11 + if (state.settings.dynamicFavicon && artId) { 12 + const url = state.api.getCoverArtUrl(artId, FIXED_ART_SIZE); 13 + imageCache.get(url).then((src) => { 14 + link.href = src; 15 + link.type = "image/jpeg"; 16 + }); 17 + } else { 18 + link.href = ICONS.TINYSUB; 19 + link.type = "image/svg+xml"; 20 + } 21 + } 22 + 3 23 // load and play a song 4 24 function playTrack(song) { 5 25 if (!song) { ··· 10 30 ui.trackTitle.textContent = song.title || STRINGS.UNKNOWN_TRACK; 11 31 ui.trackArtist.textContent = song.artist || STRINGS.UNKNOWN_ARTIST; 12 32 document.title = `${song.artist || STRINGS.UNKNOWN_ARTIST} - ${song.title || STRINGS.UNKNOWN_TRACK}`; 13 - { 14 - const faviconLink = document.querySelector('link[rel="icon"]'); 15 - // Prefer album art (albumId) over individual song art (coverArt) 16 - const artId = song?.albumId || song?.coverArt; 17 - if (artId && faviconLink) { 18 - faviconLink.href = state.api.getCoverArtUrl( 19 - artId, 20 - state.settings.artNowPlaying, 21 - ); 22 - faviconLink.type = "image/jpeg"; 23 - } else if (faviconLink) { 24 - faviconLink.href = ICONS.TINYSUB; 25 - faviconLink.type = "image/svg+xml"; 26 - } 27 - } 28 33 // Prefer album art (albumId) over individual song art (coverArt) 29 34 const artId = song.albumId || song.coverArt; 35 + updateFavicon(artId); 30 36 if (artId) { 31 - loadCachedImage(ui.coverArt, artId, "artNowPlaying"); 37 + const url = state.api.getCoverArtUrl(artId, FIXED_ART_SIZE); 38 + imageCache.get(url).then((src) => { 39 + ui.coverArt.src = src; 40 + }); 32 41 } else { 33 42 ui.coverArt.src = ""; 34 43 ui.coverArt.srcset = ""; ··· 36 45 ui.player.src = state.api.getStreamUrl(song.id); 37 46 ui.player.play(); 38 47 if (state.settings.scrobbling) { 39 - state.api.nowPlaying(song.id).catch(() => {}); // notify server track is now playing 48 + state.api.nowPlaying(song.id).catch(() => {}); 40 49 } 41 50 loadLyricsForSong(song); 42 51 highlightCurrentTrack(); ··· 74 83 } 75 84 } 76 85 77 - const previousTrack = () => navigateTrack(-1); 78 - const nextTrack = () => navigateTrack(1); 79 - 80 86 // toggle playback or start queue if nothing playing 81 87 const togglePlayback = () => { 82 88 if (hasValidTrack()) { ··· 88 94 } else if (state.queue.length > 0) { 89 95 state.queueIndex = 0; 90 96 playTrack(state.queue[0]); 91 - updateQueue(); 97 + updateQueueDisplay(); 98 + saveQueue(); 92 99 } 93 100 }; 94 101 ··· 142 149 ui.trackTitle.textContent = STRINGS.NO_TRACK_PLAYING; 143 150 ui.trackArtist.textContent = ""; 144 151 document.title = "tinysub"; 145 - { 146 - const faviconLink = document.querySelector('link[rel="icon"]'); 147 - if (faviconLink) { 148 - faviconLink.href = ICONS.TINYSUB; 149 - faviconLink.type = "image/svg+xml"; 150 - } 151 - } 152 + updateFavicon(); 152 153 clearLyrics(); 153 154 ui.coverArt.src = ICONS.TINYSUB; 154 155 ui.coverArt.srcset = ""; ··· 196 197 // update media session metadata with current song 197 198 const updateMediaSession = (song) => { 198 199 if (!song) return; 199 - withMediaSession((ms) => { 200 - // Prefer album art (albumId) over individual song art (coverArt) 201 - const artId = song.albumId || song.coverArt; 202 - ms.metadata = new MediaMetadata({ 203 - title: song.title || STRINGS.UNKNOWN_TRACK, 204 - artist: song.artist || STRINGS.UNKNOWN_ARTIST, 205 - album: song.album || "", 206 - artwork: artId 207 - ? [ 200 + // Prefer album art (albumId) over individual song art (coverArt) 201 + const artId = song.albumId || song.coverArt; 202 + const artwork = []; 203 + if (artId) { 204 + const url = state.api.getCoverArtUrl(artId, FIXED_ART_SIZE); 205 + // pre-cache the image 206 + imageCache.get(url).then((cachedUrl) => { 207 + withMediaSession((ms) => { 208 + ms.metadata = new MediaMetadata({ 209 + title: song.title || STRINGS.UNKNOWN_TRACK, 210 + artist: song.artist || STRINGS.UNKNOWN_ARTIST, 211 + album: song.album || "", 212 + artwork: [ 208 213 { 209 - src: state.api.getCoverArtUrl(artId, 256), 214 + src: cachedUrl, 210 215 sizes: "512x512", 211 216 type: "image/jpeg", 212 217 }, 213 - ] 214 - : [], 218 + ], 219 + }); 220 + }); 215 221 }); 216 - }); 222 + } else { 223 + withMediaSession((ms) => { 224 + ms.metadata = new MediaMetadata({ 225 + title: song.title || STRINGS.UNKNOWN_TRACK, 226 + artist: song.artist || STRINGS.UNKNOWN_ARTIST, 227 + album: song.album || "", 228 + artwork: [], 229 + }); 230 + }); 231 + } 217 232 };
-23
src/js/queue-storage.js
··· 87 87 } 88 88 }); 89 89 } 90 - 91 - async clear() { 92 - const db = await this.ensureDB(); 93 - if (!db) return; 94 - 95 - return new Promise((resolve) => { 96 - try { 97 - const tx = db.transaction(["queueStorage"], "readwrite"); 98 - const store = tx.objectStore("queueStorage"); 99 - 100 - store.clear(); 101 - 102 - tx.onerror = () => { 103 - console.error("[QueueStorage] Failed to clear queue"); 104 - resolve(); 105 - }; 106 - tx.oncomplete = () => resolve(); 107 - } catch (error) { 108 - console.error("[QueueStorage] Clear failed:", error); 109 - resolve(); 110 - } 111 - }); 112 - } 113 90 } 114 91 115 92 const queueStorage = new QueueStorage();
+16 -6
src/js/queue.js
··· 226 226 updateQueue(); 227 227 } 228 228 229 - const addArtistToQueue = (id) => 230 - addToQueue(() => state.api.getArtist(id), SONG_EXTRACTORS.artist); 231 - const addAlbumToQueue = (id) => 232 - addToQueue(() => state.api.getAlbum(id), SONG_EXTRACTORS.album); 233 - const addPlaylistToQueue = (id) => 234 - addToQueue(() => state.api.getPlaylist(id), SONG_EXTRACTORS.playlist); 229 + // factory for creating queue add functions 230 + const createQueueAdder = (fetcher, extractor) => (id) => 231 + addToQueue(() => fetcher(id), extractor); 232 + 233 + const addArtistToQueue = createQueueAdder( 234 + (id) => state.api.getArtist(id), 235 + SONG_EXTRACTORS.artist, 236 + ); 237 + const addAlbumToQueue = createQueueAdder( 238 + (id) => state.api.getAlbum(id), 239 + SONG_EXTRACTORS.album, 240 + ); 241 + const addPlaylistToQueue = createQueueAdder( 242 + (id) => state.api.getPlaylist(id), 243 + SONG_EXTRACTORS.playlist, 244 + ); 235 245 const addSongToQueue = (song) => 236 246 addToQueue(() => Promise.resolve(song), SONG_EXTRACTORS.song); 237 247
+21
src/js/settings.js
··· 27 27 const settingsBtn = document.getElementById("settings-btn"); 28 28 const closeBtn = document.getElementById("close-settings-btn"); 29 29 const scrobbleToggle = document.getElementById("scrobbling-toggle"); 30 + const dynamicFaviconToggle = document.getElementById( 31 + "dynamic-favicon-toggle", 32 + ); 30 33 31 34 // load and apply settings from localStorage 32 35 const saved = localStorage.getItem("tinysub_settings"); ··· 41 44 applySettings(); 42 45 43 46 scrobbleToggle.checked = state.settings.scrobbling; 47 + dynamicFaviconToggle.checked = state.settings.dynamicFavicon; 44 48 45 49 const sliderConfig = ART_TYPES.map(({ name, key }) => ({ 46 50 input: document.getElementById(`${name}-size`), ··· 79 83 scrobbleToggle.addEventListener("change", () => { 80 84 state.settings.scrobbling = scrobbleToggle.checked; 81 85 localStorage.setItem("tinysub_settings", JSON.stringify(state.settings)); 86 + }); 87 + 88 + dynamicFaviconToggle.addEventListener("change", () => { 89 + state.settings.dynamicFavicon = dynamicFaviconToggle.checked; 90 + localStorage.setItem("tinysub_settings", JSON.stringify(state.settings)); 91 + // update favicon when toggling setting 92 + if (dynamicFaviconToggle.checked) { 93 + // turned ON - update to current track if playing 94 + if (state.queueIndex >= 0 && state.queue[state.queueIndex]) { 95 + const track = state.queue[state.queueIndex]; 96 + const artId = track.albumId || track.coverArt; 97 + updateFavicon(artId); 98 + } 99 + } else { 100 + // turned OFF - reset to default favicon 101 + updateFavicon(); 102 + } 82 103 }); 83 104 84 105 // clear dbs button
+1
src/js/state.js
··· 17 17 }, 18 18 settings: { 19 19 scrobbling: true, 20 + dynamicFavicon: true, 20 21 artArtist: 0, 21 22 artAlbum: 32, 22 23 artSong: 0,