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: sort by

this was much more complex than i thought it would be

also moved shuffle into here as a sorting option

(if you have a queue and you update, the track/disc sorting wont work until you make a new queue since those werent saved to localstorage previously.)

(in the future i may change it so that the queue only stores song ids, and fetches all data from server on startup, instead of most track metadata to prevent hitting localstorage limits. and also get proper updates if songs change. its not like you can really use this offline anyway since we dont have track caching and i dont plan on implementing that.)

intergrav eb94d756 347057e5

+164 -43
+5 -9
src/index.html
··· 17 17 id="icon-play-next" 18 18 src="static/famfamfam-silk/control_fastforward_blue.png" 19 19 /> 20 - <img id="icon-loop" src="static/famfamfam-silk/control_repeat.png" /> 21 20 <img id="icon-add" src="static/famfamfam-silk/add.png" /> 22 21 <img id="icon-favorite" src="static/famfamfam-silk/heart.png" /> 23 22 <img id="icon-move-up" src="static/famfamfam-silk/arrow_up.png" /> ··· 195 194 <img src="static/famfamfam-silk/control_repeat.png" alt="loop" /> 196 195 <span>loop</span> 197 196 </button> 198 - <button id="shuffle-btn" aria-label="shuffle"> 199 - <img 200 - src="static/famfamfam-silk/arrow_refresh_small.png" 201 - alt="shuffle" 202 - /> 203 - <span>shuffle all</span> 204 - </button> 205 197 <button id="clear-btn" aria-label="clear"> 206 198 <img src="static/famfamfam-silk/cross.png" alt="clear" /> 207 - <span>clear all</span> 199 + <span>clear</span> 200 + </button> 201 + <button id="sort-btn" aria-label="sort"> 202 + <img src="static/famfamfam-silk/arrow_switch.png" alt="sort" /> 203 + <span>sort</span> 208 204 </button> 209 205 <button id="settings-btn" aria-label="settings"> 210 206 <img src="static/famfamfam-silk/cog.png" alt="settings" />
+1 -1
src/js/constants.js
··· 51 51 NEXT_BTN: "next-btn", 52 52 PROGRESS: "progress", 53 53 TIME_DISPLAY: "time-display", 54 - SHUFFLE_BTN: "shuffle-btn", 55 54 LOOP_BTN: "loop-btn", 55 + SORT_BTN: "sort-btn", 56 56 CLEAR_BTN: "clear-btn", 57 57 SECTION_TOGGLE: "section-toggle", 58 58 };
+26 -15
src/js/events.js
··· 235 235 ui.progress.addEventListener("input", seekHandler); 236 236 ui.progress.addEventListener("change", seekHandler); 237 237 238 - // shuffle queue while preserving current track position 239 - ui.shuffleBtn.addEventListener("click", () => { 240 - const currentTrack = hasValidTrack() ? state.queue[state.queueIndex] : null; 241 - shuffleQueue(); 242 - if (currentTrack) { 243 - state.queueIndex = state.queue.indexOf(currentTrack); 244 - updateQueue(); 245 - } 246 - }); 247 - 238 + // loop toggle 248 239 ui.loopBtn.addEventListener("click", () => { 249 240 state.loop = !state.loop; 250 241 ui.loopBtn.classList.toggle("active", state.loop); 251 242 }); 252 243 244 + // sort queue 245 + ui.sortBtn.addEventListener("click", (e) => { 246 + const rect = e.target.getBoundingClientRect(); 247 + showContextMenu(rect.left, rect.bottom, { 248 + Shuffle: () => sortQueue("shuffle"), 249 + "Song (A-Z)": () => sortQueue("title", true), 250 + "Song (Z-A)": () => sortQueue("title", false), 251 + "Artist (A-Z)": () => sortQueue("artist", true), 252 + "Artist (Z-A)": () => sortQueue("artist", false), 253 + "Album (A-Z)": () => sortQueue("album", true), 254 + "Album (Z-A)": () => sortQueue("album", false), 255 + "Duration (short to long)": () => sortQueue("duration", true), 256 + "Duration (long to short)": () => sortQueue("duration", false), 257 + "Favorited First": () => sortQueue("favorited", false), 258 + "Favorited Last": () => sortQueue("favorited", true), 259 + }); 260 + }); 261 + 262 + // clear queue 253 263 ui.clearBtn.addEventListener("click", () => { 254 264 clearQueue(); 255 265 resetPlayerUI(); ··· 287 297 } 288 298 }); 289 299 290 - // deselect when clicking outside queue table 300 + // deselect when clicking outside queue table or buttons 291 301 document.addEventListener("click", (e) => { 292 - if ( 293 - !e.target.closest(`#${DOM_IDS.QUEUE_TABLE}`) && 294 - !e.target.closest(`#${DOM_IDS.CONTEXT_MENU}`) 295 - ) { 302 + const isInQueueTable = e.target.closest(`#${DOM_IDS.QUEUE_TABLE}`); 303 + const isInContextMenu = e.target.closest(`#${DOM_IDS.CONTEXT_MENU}`); 304 + const isButton = e.target.closest("button"); 305 + 306 + if (!isInQueueTable && !isInContextMenu && !isButton) { 296 307 clearSelection(); 297 308 } 298 309 });
+131 -17
src/js/queue.js
··· 1 + // sort or shuffle queue, preserving current track position 2 + // if items are selected, only sorts selected items, otherwise sorts entire queue 3 + function sortQueue(field, ascending) { 4 + const currentTrack = 5 + state.queueIndex >= 0 ? state.queue[state.queueIndex] : null; 6 + const selectedIndices = 7 + typeof selectionManager !== "undefined" 8 + ? selectionManager.getSelected() 9 + : []; 10 + const hasSelection = selectedIndices.length > 0; 11 + 12 + // extract items to sort: either selected songs or entire queue 13 + const extractItems = () => { 14 + if (hasSelection) { 15 + return selectedIndices.map((idx) => ({ 16 + song: state.queue[idx], 17 + })); 18 + } 19 + return state.queue.map((song) => ({ song })); 20 + }; 21 + 22 + // compare disc and track numbers (always ascending to preserve album structure) 23 + const compareDiscAndTrack = (a, b) => { 24 + const aDisc = a.song.discNumber || 0; 25 + const bDisc = b.song.discNumber || 0; 26 + const discCmp = aDisc < bDisc ? -1 : aDisc > bDisc ? 1 : 0; 27 + if (discCmp !== 0) return discCmp; 28 + 29 + const aTrack = a.song.track || 0; 30 + const bTrack = b.song.track || 0; 31 + return aTrack < bTrack ? -1 : aTrack > bTrack ? 1 : 0; 32 + }; 33 + 34 + // update queue with sorted items 35 + const updateQueueWithItems = (itemsToSort) => { 36 + if (hasSelection) { 37 + selectedIndices.forEach((idx, pos) => { 38 + state.queue[idx] = itemsToSort[pos].song; 39 + }); 40 + } else { 41 + state.queue.splice( 42 + 0, 43 + state.queue.length, 44 + ...itemsToSort.map((x) => x.song), 45 + ); 46 + } 47 + }; 48 + 49 + if (field === "shuffle") { 50 + // randomly reorder queue items 51 + const itemsToShuffle = extractItems(); 52 + 53 + for (let i = itemsToShuffle.length - 1; i > 0; i--) { 54 + const j = Math.floor(Math.random() * (i + 1)); 55 + [itemsToShuffle[i], itemsToShuffle[j]] = [ 56 + itemsToShuffle[j], 57 + itemsToShuffle[i], 58 + ]; 59 + } 60 + 61 + updateQueueWithItems(itemsToShuffle); 62 + } else if (field === "album") { 63 + // hierarchical: album name → disc number → track number 64 + const itemsToSort = extractItems(); 65 + itemsToSort.sort((a, b) => { 66 + const albumCmp = (a.song.album || "").localeCompare( 67 + b.song.album || "", 68 + undefined, 69 + { numeric: true }, 70 + ); 71 + if (albumCmp !== 0) return ascending ? albumCmp : -albumCmp; 72 + return compareDiscAndTrack(a, b); 73 + }); 74 + updateQueueWithItems(itemsToSort); 75 + } else if (field === "artist") { 76 + // hierarchical. artist name -> album name (always A-Z) -> disc number -> track number 77 + const itemsToSort = extractItems(); 78 + itemsToSort.sort((a, b) => { 79 + const artistCmp = (a.song.artist || "").localeCompare( 80 + b.song.artist || "", 81 + undefined, 82 + { numeric: true }, 83 + ); 84 + if (artistCmp !== 0) return ascending ? artistCmp : -artistCmp; 85 + 86 + const albumCmp = (a.song.album || "").localeCompare( 87 + b.song.album || "", 88 + undefined, 89 + { numeric: true }, 90 + ); 91 + if (albumCmp !== 0) return albumCmp; 92 + 93 + return compareDiscAndTrack(a, b); 94 + }); 95 + updateQueueWithItems(itemsToSort); 96 + } else { 97 + // standard sort by field 98 + const getValue = (song) => { 99 + if (field === "favorited") return state.favorites.has(song.id) ? 1 : 0; 100 + if (field === "duration") return song.duration || 0; 101 + return song[field] || ""; 102 + }; 103 + 104 + const itemsToSort = extractItems(); 105 + itemsToSort.sort((a, b) => { 106 + const aVal = getValue(a.song); 107 + const bVal = getValue(b.song); 108 + 109 + let cmp; 110 + if (typeof aVal === "string") { 111 + cmp = aVal.localeCompare(bVal, undefined, { numeric: true }); 112 + } else { 113 + cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; 114 + } 115 + 116 + return ascending ? cmp : -cmp; 117 + }); 118 + updateQueueWithItems(itemsToSort); 119 + } 120 + 121 + // preserve current track index after operation 122 + if (currentTrack) { 123 + state.queueIndex = state.queue.indexOf(currentTrack); 124 + } 125 + 126 + saveQueue(); 127 + updateQueue(); 128 + } 129 + 1 130 // persist queue and current index to localStorage 2 131 function saveQueue() { 3 132 try { ··· 8 137 album: song.album, 9 138 coverArt: song.coverArt, 10 139 duration: song.duration, 140 + track: song.track, 141 + discNumber: song.discNumber, 11 142 })); 12 143 localStorage.setItem("tinysub_queue", JSON.stringify(serialized)); 13 144 localStorage.setItem( ··· 149 280 clearSelection(); 150 281 updateQueue(); 151 282 highlightCurrentTrack(); 152 - } 153 - 154 - // randomly reorder queue items while preserving current track position 155 - function shuffleQueue() { 156 - const currentTrack = 157 - state.queueIndex >= 0 ? state.queue[state.queueIndex] : null; 158 - 159 - for (let i = state.queue.length - 1; i > 0; i--) { 160 - const j = Math.floor(Math.random() * (i + 1)); 161 - [state.queue[i], state.queue[j]] = [state.queue[j], state.queue[i]]; 162 - } 163 - 164 - if (currentTrack) { 165 - state.queueIndex = state.queue.indexOf(currentTrack); 166 - } 167 - 168 - updateQueue(); 169 283 } 170 284 171 285 // remove a song by index, adjusting queue index if necessary
+1 -1
src/js/state.js
··· 47 47 timeDisplay: document.getElementById(DOM_IDS.TIME_DISPLAY), 48 48 settingsBtn: document.getElementById("settings-btn"), 49 49 sectionToggles: document.querySelectorAll(`.${DOM_IDS.SECTION_TOGGLE}`), 50 - shuffleBtn: document.getElementById(DOM_IDS.SHUFFLE_BTN), 51 50 loopBtn: document.getElementById(DOM_IDS.LOOP_BTN), 51 + sortBtn: document.getElementById(DOM_IDS.SORT_BTN), 52 52 clearBtn: document.getElementById(DOM_IDS.CLEAR_BTN), 53 53 };