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

feat: star ratings

off by default

for consistency, also adds "show favorites" setting

+146 -9
+20 -6
src/css/components.css
··· 230 230 /* queue - cover art column */ 231 231 #queue #queue-table th:nth-child(1), 232 232 #queue #queue-table td:nth-child(1) { 233 - width: calc(var(--art-song) * 2); 233 + width: calc(var(--art-song) * 1.5); 234 234 } 235 235 236 236 /* queue - duration column */ ··· 248 248 } 249 249 250 250 #queue #queue-table td:nth-child(6) button { 251 - margin-inline-end: 0.5rem; 252 - } 253 - 254 - #queue #queue-table td:nth-child(6) button:last-child { 255 - margin-inline-end: 0; 251 + margin-inline-start: 0.5rem; 256 252 } 257 253 258 254 /* queue - rows */ ··· 304 300 } 305 301 306 302 #queue #queue-table .queue-favorite.favorited { 303 + opacity: 1; 304 + } 305 + 306 + #queue #queue-table .queue-rating-star { 307 + opacity: 0.25; 308 + } 309 + 310 + #queue #queue-table .queue-rating-star:hover { 311 + opacity: 0.5; 312 + } 313 + 314 + #queue #queue-table .queue-rating-star.rated { 307 315 opacity: 1; 308 316 } 309 317 ··· 500 508 #queue #queue-table .queue-move-up, 501 509 #queue #queue-table .queue-move-down { 502 510 display: inline-block; 511 + } 512 + 513 + /* queue - hide artist column */ 514 + #queue-table th:nth-child(3), 515 + #queue-table td:nth-child(3) { 516 + display: none; 503 517 } 504 518 }
+14 -2
src/index.html
··· 92 92 <div class="form-group"> 93 93 <label> 94 94 <input type="checkbox" id="scrobbling-toggle" /> 95 - scrobbling 95 + submit scrobbles 96 96 </label> 97 97 </div> 98 98 <div class="form-group"> 99 99 <label> 100 100 <input type="checkbox" id="dynamic-favicon-toggle" /> 101 - dynamic favicon 101 + show dynamic favicon 102 + </label> 103 + </div> 104 + <div class="form-group"> 105 + <label> 106 + <input type="checkbox" id="favorites-toggle" /> 107 + show favorites 108 + </label> 109 + </div> 110 + <div class="form-group"> 111 + <label> 112 + <input type="checkbox" id="ratings-toggle" /> 113 + show star ratings 102 114 </label> 103 115 </div> 104 116 <div class="form-group">
+12
src/js/api.js
··· 158 158 return this._validateAndRequest(id, "unstar.view", {}, "Song ID"); 159 159 } 160 160 161 + setRating(id, rating) { 162 + if (rating < 0 || rating > 5 || !Number.isInteger(rating)) { 163 + throw new Error("Rating must be 0-5"); 164 + } 165 + return this._validateAndRequest( 166 + id, 167 + "setRating.view", 168 + { rating }, 169 + "Song ID", 170 + ); 171 + } 172 + 161 173 getLyricsBySongId(id) { 162 174 return this._validateAndRequest( 163 175 id,
+1
src/js/auth.js
··· 6 6 await loadQueue().catch(() => {}); 7 7 updateQueueDisplay(); 8 8 await loadLibrary(); 9 + loadRatings(); 9 10 await loadPlaylists(); 10 11 await loadFavorites(); 11 12 // restore song display on startup
+2
src/js/constants.js
··· 21 21 QUEUE_MOVE_UP: "queue-move-up", 22 22 QUEUE_PLAY_NEXT: "queue-play-next", 23 23 QUEUE_PLAY: "queue-play", 24 + QUEUE_RATING_STAR: "queue-rating-star", 25 + RATED: "rated", 24 26 SELECTED: "selected", 25 27 STRIPE: "stripe", 26 28 TREE_ITEM: "tree-item",
+8
src/js/contextmenu.js
··· 201 201 sortQueue("favorited", true); 202 202 updateQueue(); 203 203 }, 204 + [STRINGS.CONTEXT_SORT_RATING_LOW_HIGH]: () => { 205 + sortQueue("rating", true); 206 + updateQueue(); 207 + }, 208 + [STRINGS.CONTEXT_SORT_RATING_HIGH_LOW]: () => { 209 + sortQueue("rating", false); 210 + updateQueue(); 211 + }, 204 212 }; 205 213 } 206 214
+20
src/js/library.js
··· 184 184 updateQueueDisplay(); 185 185 } 186 186 187 + function loadRatings() { 188 + state.ratings.clear(); 189 + const recordIfRated = (item) => { 190 + if (item.userRating && item.userRating > 0) { 191 + state.ratings.set(item.id, item.userRating); 192 + } 193 + }; 194 + const walk = (items) => { 195 + if (!Array.isArray(items)) return; 196 + items.forEach((item) => { 197 + recordIfRated(item); 198 + if (item.artist) walk(item.artist); 199 + if (item.album) walk(item.album); 200 + if (item.song) walk(item.song); 201 + }); 202 + }; 203 + walk(state.library); 204 + state.queue.forEach(recordIfRated); 205 + } 206 + 187 207 function clearNestedList(parentLi, className) { 188 208 const existingUl = parentLi.querySelector(`ul.${className}`); 189 209 if (existingUl) existingUl.remove();
+12
src/js/player.js
··· 109 109 } 110 110 }; 111 111 112 + // set song rating (1-5) 113 + const setSongRating = async (song, rating) => { 114 + if (!song || !state.api || !state.settings.enableRatings) return; 115 + if (rating < 0 || rating > 5) return; 116 + if (rating === 0) { 117 + state.ratings.delete(song.id); 118 + } else { 119 + await state.api.setRating(song.id, rating); 120 + state.ratings.set(song.id, rating); 121 + } 122 + }; 123 + 112 124 // highlight currently playing song in queue 113 125 function highlightCurrentTrack() { 114 126 clearRowClasses(ui.queueList, ["currently-playing"]);
+36 -1
src/js/queue.js
··· 98 98 itemsToSort.sort((a, b) => { 99 99 const getValue = (song) => { 100 100 if (field === "favorited") return state.favorites.has(song.id) ? 1 : 0; 101 + if (field === "rating") return state.ratings.get(song.id) || 0; 101 102 if (field === "duration") return song.duration || 0; 102 103 return song[field] || ""; 103 104 }; ··· 221 222 // add songs to queue 222 223 async function addToQueue(fetcher, songExtractor, insertNext = false) { 223 224 const songs = await songExtractor(await fetcher()); 225 + songs.forEach((song) => { 226 + if (song.userRating && song.userRating > 0) { 227 + state.ratings.set(song.id, song.userRating); 228 + } 229 + }); 224 230 if (insertNext) { 225 231 const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0; 226 232 state.queue.splice(insertPos, 0, ...songs); ··· 455 461 durationCell.textContent = formatDuration(song.duration); 456 462 cells.appendChild(durationCell); 457 463 458 - // action buttons cell 464 + // action buttons cell (includes ratings if enabled) 459 465 const actionsCell = document.createElement("td"); 460 466 const isFavorited = state.favorites.has(song.id); 467 + 468 + if (state.settings.enableRatings) { 469 + const currentRating = state.ratings.get(song.id) || 0; 470 + for (let i = 1; i <= 5; i++) { 471 + const star = createIconButton( 472 + CLASSES.QUEUE_RATING_STAR, 473 + `Rate ${i}`, 474 + ICONS.STAR, 475 + ); 476 + star.dataset.rating = i; 477 + if (i <= currentRating) { 478 + star.classList.add(CLASSES.RATED); 479 + } 480 + star.addEventListener("click", async (e) => { 481 + e.stopPropagation(); 482 + const newRating = i === currentRating ? 0 : i; 483 + await setSongRating(song, newRating); 484 + updateQueueDisplay(); 485 + }); 486 + actionsCell.appendChild(star); 487 + } 488 + } 489 + 461 490 ROW_BUTTON_CONFIG.forEach(({ className, label, icon }) => { 491 + if ( 492 + className === CLASSES.QUEUE_FAVORITE && 493 + !state.settings.enableFavorites 494 + ) { 495 + return; 496 + } 462 497 const btn = createIconButton(className, label, icon); 463 498 if (className === CLASSES.QUEUE_FAVORITE && isFavorited) { 464 499 btn.classList.add(CLASSES.FAVORITED);
+16
src/js/settings.js
··· 29 29 const dynamicFaviconToggle = document.getElementById( 30 30 "dynamic-favicon-toggle", 31 31 ); 32 + const favoritesToggle = document.getElementById("favorites-toggle"); 33 + const ratingsToggle = document.getElementById("ratings-toggle"); 32 34 33 35 // load and apply settings from localStorage 34 36 const saved = localStorage.getItem("tinysub_settings"); ··· 42 44 43 45 scrobbleToggle.checked = state.settings.scrobbling; 44 46 dynamicFaviconToggle.checked = state.settings.dynamicFavicon; 47 + favoritesToggle.checked = state.settings.enableFavorites; 48 + ratingsToggle.checked = state.settings.enableRatings; 45 49 46 50 // initialize sliders and setup listeners 47 51 Object.entries(ART_SETTINGS).forEach(([name, key]) => { ··· 93 97 // turned off resets to default favicon 94 98 updateFavicon(); 95 99 } 100 + }); 101 + 102 + ratingsToggle.addEventListener("change", () => { 103 + state.settings.enableRatings = ratingsToggle.checked; 104 + localStorage.setItem("tinysub_settings", JSON.stringify(state.settings)); 105 + updateQueueDisplay(); 106 + }); 107 + 108 + favoritesToggle.addEventListener("change", () => { 109 + state.settings.enableFavorites = favoritesToggle.checked; 110 + localStorage.setItem("tinysub_settings", JSON.stringify(state.settings)); 111 + updateQueueDisplay(); 96 112 }); 97 113 98 114 // logout button
+3
src/js/state.js
··· 6 6 queue: [], 7 7 queueIndex: -1, 8 8 favorites: new Set(), 9 + ratings: new Map(), 9 10 loop: false, 10 11 expanded: { 11 12 artists: false, ··· 18 19 settings: { 19 20 scrobbling: true, 20 21 dynamicFavicon: true, 22 + enableFavorites: true, 23 + enableRatings: false, 21 24 artArtist: 24, 22 25 artAlbum: 32, 23 26 artSong: 16,
+2
src/js/strings/en.js
··· 26 26 CONTEXT_SORT_DURATION_SHORT_LONG: "duration (short to long)", 27 27 CONTEXT_SORT_FAVORITED_FIRST: "favorited first", 28 28 CONTEXT_SORT_FAVORITED_LAST: "favorited last", 29 + CONTEXT_SORT_RATING_LOW_HIGH: "rating (low to high)", 30 + CONTEXT_SORT_RATING_HIGH_LOW: "rating (high to low)", 29 31 CONTEXT_SORT_SHUFFLE: "shuffle", 30 32 CONTEXT_SORT_SONG_AZ: "song (a-z)", 31 33 CONTEXT_SORT_SONG_ZA: "song (z-a)",