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

Configure Feed

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

refactor: general cleanup

i removed a lot of wrappers that weren't necessary anymore

also added all icons to cache, and fixed an issue with index saving when skipping last track in queue

also separated the library and queue selection managers into their own modules just to make it a little easier to maintain :p along with changing name of drag module to be more fitting since it's made for queue

also removed a lot of comments that were probably not necessary

also fixed script reordering

also many other things

goodnight zzzz....🐈

+474 -565
-1
src/css/base.css
··· 14 14 /* state colors */ 15 15 --playing: hsl(200 90% 50% / 0.25); 16 16 --playing-pulse: hsl(200 90% 50% / 0.5); 17 - --selection-color: hsl(220 80% 45%); 18 17 --error-color: hsl(0 80% 50%); 19 18 20 19 /* cover art sizing (customize via settings) */
+22 -8
src/index.html
··· 15 15 <div id="icon-cache" style="display: none"> 16 16 <img id="icon-add" src="static/famfamfam-silk/add.png" /> 17 17 <img id="icon-arrow_down" src="static/famfamfam-silk/arrow_down.png" /> 18 + <img 19 + id="icon-arrow_switch" 20 + src="static/famfamfam-silk/arrow_switch.png" 21 + /> 18 22 <img id="icon-arrow_up" src="static/famfamfam-silk/arrow_up.png" /> 19 23 <img id="icon-cd" src="static/famfamfam-silk/cd.png" /> 24 + <img id="icon-cog" src="static/famfamfam-silk/cog.png" /> 20 25 <img 21 26 id="icon-control_fastforward" 22 27 src="static/famfamfam-silk/control_fastforward.png" ··· 29 34 id="icon-control_play" 30 35 src="static/famfamfam-silk/control_play.png" 31 36 /> 37 + <img 38 + id="icon-control_repeat" 39 + src="static/famfamfam-silk/control_repeat.png" 40 + /> 41 + <img 42 + id="icon-control_rewind" 43 + src="static/famfamfam-silk/control_rewind.png" 44 + /> 32 45 <img id="icon-cross" src="static/famfamfam-silk/cross.png" /> 33 46 <img id="icon-heart" src="static/famfamfam-silk/heart.png" /> 34 47 <img id="icon-star" src="static/famfamfam-silk/star.png" /> 35 - <img id="icon-tinysub" src="static/tinysub.svg" /> 36 48 <img id="icon-user" src="static/famfamfam-silk/user.png" /> 49 + <img id="icon-tinysub" src="static/tinysub.svg" /> 37 50 </div> 38 51 <!-- auth modal --> 39 52 <div id="auth-modal" class="modal hidden"> ··· 282 295 <script src="js/strings/en.js"></script> 283 296 <script src="js/constants.js"></script> 284 297 <script src="js/validation.js"></script> 285 - <script src="js/selection.js"></script> 286 - <script src="js/api.js"></script> 298 + <script src="js/ui.js"></script> 287 299 <script src="js/state.js"></script> 300 + <script src="js/api.js"></script> 288 301 <script src="js/queue-storage.js"></script> 289 302 <script src="js/virtual-scroll.js"></script> 290 303 <script src="js/queue.js"></script> 304 + <script src="js/library-selection.js"></script> 291 305 <script src="js/library.js"></script> 292 306 <script src="js/modal.js"></script> 307 + <script src="js/settings.js"></script> 308 + <script src="js/player.js"></script> 309 + <script src="js/queue-selection.js"></script> 310 + <script src="js/queue-drag.js"></script> 293 311 <script src="js/input.js"></script> 294 312 <script src="js/contextmenu.js"></script> 295 - <script src="js/ui.js"></script> 296 - <script src="js/settings.js"></script> 313 + <script src="js/spark-md5.js"></script> 297 314 <script src="js/auth.js"></script> 298 - <script src="js/spark-md5.js"></script> 299 - <script src="js/player.js"></script> 300 - <script src="js/draggable.js"></script> 301 315 <script src="js/lyrics.js"></script> 302 316 <script src="js/events.js"></script> 303 317 </body>
+22 -23
src/js/auth.js
··· 2 2 3 3 // load library, playlists, and favorites after successful login 4 4 async function initializeApp() { 5 - const authModal = document.getElementById(DOM_IDS.AUTH_MODAL); 6 - if (authModal) hideModal(DOM_IDS.AUTH_MODAL); 5 + hideModal(DOM_IDS.AUTH_MODAL); 7 6 await loadQueue().catch(() => {}); 8 7 updateQueueDisplay(); 9 8 await loadLibrary(); 10 9 await loadPlaylists(); 11 10 await loadFavorites(); 12 11 // restore song display on startup 13 - if (hasValidTrack()) { 12 + if (state.queueIndex >= 0 && state.queueIndex < state.queue.length) { 14 13 playTrack(state.queue[state.queueIndex], false); 15 14 } 16 15 } ··· 43 42 const validServerUrl = urlValidation.value; 44 43 45 44 try { 46 - // Generate salt+token from password 45 + // generate salt+token 47 46 const salt = Math.random().toString(36).substring(2, 8); 48 47 const token = SparkMD5.hash(validPassword + salt); 49 48 50 - // Test with token mode API 49 + // test 51 50 state.api = new SubsonicAPI(validServerUrl, validUsername, token, salt); 52 51 await state.api.ping(); 53 52 54 - // Save credentials for auto-login 53 + // save credentials for auto-login 55 54 localStorage.setItem( 56 55 "tinysub_credentials", 57 56 JSON.stringify({ ··· 89 88 location.reload(); 90 89 } 91 90 92 - // attempt auto-login on page load 91 + // attempt auto-login 93 92 async function attemptAutoLogin() { 94 93 const saved = localStorage.getItem("tinysub_credentials"); 95 - const creds = saved 96 - ? JSON.parse(saved) 97 - : { server: "", username: "", token: "", salt: "" }; 94 + let creds = { server: "", username: "", token: "", salt: "" }; 98 95 99 - // migration from 1.7.x 100 96 if (saved) { 101 97 try { 102 - const parsed = JSON.parse(saved); 103 - if (parsed.password) { 98 + creds = JSON.parse(saved); 99 + // migration from 1.7.x 100 + if (creds.password) { 104 101 console.warn("[Auth] Old password format detected, logging out"); 105 102 alert( 106 103 "tinysub 1.8+ now uses token-based auth and also has other incompatible changes from <1.8, logging out to clear old credentials", ··· 108 105 await handleLogout(); 109 106 return; 110 107 } 111 - } catch {} 108 + } catch (error) { 109 + creds = { server: "", username: "", token: "", salt: "" }; 110 + } 112 111 } 113 112 114 113 // no stored credentials 115 - const hasCredentials = 116 - creds.server && creds.username && creds.token && creds.salt; 117 - if (!hasCredentials) { 118 - const authModal = document.getElementById(DOM_IDS.AUTH_MODAL); 119 - if (authModal) showModal(authModal, { focusSelector: "input" }); 114 + if (!creds.server || !creds.username || !creds.token || !creds.salt) { 115 + showModal(document.getElementById(DOM_IDS.AUTH_MODAL), { 116 + focusSelector: "input", 117 + }); 120 118 return; 121 119 } 122 120 ··· 128 126 creds.token, 129 127 creds.salt, 130 128 ); 131 - // validate credentials are still valid by calling ping() 129 + // test credentials are still valid 132 130 await state.api.ping(); 133 131 await initializeApp(); 134 132 } catch (error) { ··· 140 138 // pre-populate form with stored server and username 141 139 ui.serverInput.value = creds.server; 142 140 ui.usernameInput.value = creds.username; 143 - const authModal = document.getElementById(DOM_IDS.AUTH_MODAL); 144 - if (authModal) showModal(authModal, { focusSelector: "input" }); 141 + showModal(document.getElementById(DOM_IDS.AUTH_MODAL), { 142 + focusSelector: "input", 143 + }); 145 144 } 146 145 } 147 146 148 - // run auto-login when scripts are ready 147 + // run auto-login when everything is ready 149 148 if (document.readyState === "loading") { 150 149 document.addEventListener("DOMContentLoaded", attemptAutoLogin); 151 150 } else {
+53 -67
src/js/constants.js
··· 1 1 // css classes and ui constants 2 2 3 3 const CLASSES = { 4 - SELECTED: "selected", 5 - DRAGGING: "dragging", 4 + CONTEXT_MENU_ITEM: "context-menu-item", 5 + CURRENTLY_PLAYING: "currently-playing", 6 + DISABLED: "disabled", 6 7 DRAG_OVER_ABOVE: "drag-over-above", 7 8 DRAG_OVER_BELOW: "drag-over-below", 8 - CURRENTLY_PLAYING: "currently-playing", 9 - CONTEXT_MENU_ITEM: "context-menu-item", 10 - TREE_TOGGLE: "tree-toggle", 11 - TREE_NAME: "tree-name", 12 - TREE_ITEM: "tree-item", 13 - NESTED: "nested", 9 + DRAGGING: "dragging", 10 + ERROR: "error", 11 + FAVORITED: "favorited", 14 12 NESTED_SONGS: "nested-songs", 15 - QUEUE_PLAY: "queue-play", 13 + NESTED: "nested", 14 + QUEUE_CLEAR: "queue-clear", 15 + QUEUE_COVER: "queue-cover", 16 16 QUEUE_FAVORITE: "queue-favorite", 17 + QUEUE_MOVE_DOWN: "queue-move-down", 18 + QUEUE_MOVE_UP: "queue-move-up", 17 19 QUEUE_PLAY_NEXT: "queue-play-next", 18 - QUEUE_MOVE_UP: "queue-move-up", 19 - QUEUE_MOVE_DOWN: "queue-move-down", 20 - QUEUE_CLEAR: "queue-clear", 21 - QUEUE_COVER: "queue-cover", 22 - FAVORITED: "favorited", 23 - ERROR: "error", 24 - DISABLED: "disabled", 20 + QUEUE_PLAY: "queue-play", 21 + SELECTED: "selected", 22 + TREE_ITEM: "tree-item", 23 + TREE_NAME: "tree-name", 24 + TREE_TOGGLE: "tree-toggle", 25 25 }; 26 26 27 27 const DATA_ATTRS = { ··· 29 29 }; 30 30 31 31 const DOM_IDS = { 32 + AUTH_ERROR: "auth-error", 32 33 AUTH_MODAL: "auth-modal", 33 - SETTINGS_MODAL: "settings-modal", 34 - LOGIN_FORM: "login-form", 34 + CLEAR_BTN: "clear-btn", 35 35 CONTEXT_MENU: "context-menu", 36 - QUEUE_TABLE: "queue-table", 37 - AUTH_ERROR: "auth-error", 38 - SERVER_INPUT: "server", 39 - USERNAME_INPUT: "username", 40 - PASSWORD_INPUT: "password", 41 - LIBRARY_TREE: "artists-tree", 42 - PLAYLISTS_TREE: "playlists-tree", 43 - QUEUE_LIST: "queue-list", 44 - QUEUE_COUNT: "queue-count", 45 36 COVER_ART: "cover-art", 46 - TRACK_TITLE: "track-title", 47 - TRACK_ARTIST: "track-artist", 48 - TRACK_LYRIC: "track-lyric", 37 + LIBRARY_TREE: "artists-tree", 38 + LOGIN_FORM: "login-form", 39 + LOOP_BTN: "loop-btn", 40 + NEXT_BTN: "next-btn", 41 + PASSWORD_INPUT: "password", 42 + PLAY_BTN: "play-btn", 49 43 PLAYER: "player", 50 - PLAY_BTN: "play-btn", 44 + PLAYLISTS_TREE: "playlists-tree", 51 45 PREV_BTN: "prev-btn", 52 - NEXT_BTN: "next-btn", 53 46 PROGRESS: "progress", 54 - TIME_DISPLAY: "time-display", 55 - LOOP_BTN: "loop-btn", 56 - SORT_BTN: "sort-btn", 57 - CLEAR_BTN: "clear-btn", 47 + QUEUE_COUNT: "queue-count", 48 + QUEUE_LIST: "queue-list", 49 + QUEUE_TABLE: "queue-table", 58 50 SECTION_TOGGLE: "section-toggle", 51 + SERVER_INPUT: "server", 52 + SETTINGS_BTN: "settings-btn", 53 + SETTINGS_MODAL: "settings-modal", 54 + SORT_BTN: "sort-btn", 55 + TIME_DISPLAY: "time-display", 56 + TRACK_ARTIST: "track-artist", 57 + TRACK_LYRIC: "track-lyric", 58 + TRACK_TITLE: "track-title", 59 + USERNAME_INPUT: "username", 59 60 }; 60 61 61 62 const ICONS = { 62 - ADD: 63 - document.getElementById("icon-add")?.src || "static/famfamfam-silk/add.png", 64 - ARROW_DOWN: 65 - document.getElementById("icon-arrow_down")?.src || 66 - "static/famfamfam-silk/arrow_down.png", 67 - ARROW_UP: 68 - document.getElementById("icon-arrow_up")?.src || 69 - "static/famfamfam-silk/arrow_up.png", 70 - CD: document.getElementById("icon-cd")?.src || "static/famfamfam-silk/cd.png", 71 - CONTROL_FASTFORWARD: 72 - document.getElementById("icon-control_fastforward")?.src || 73 - "static/famfamfam-silk/control_fastforward.png", 74 - CONTROL_PAUSE: 75 - document.getElementById("icon-control_pause")?.src || 76 - "static/famfamfam-silk/control_pause.png", 77 - CONTROL_PLAY: 78 - document.getElementById("icon-control_play")?.src || 79 - "static/famfamfam-silk/control_play.png", 80 - CROSS: 81 - document.getElementById("icon-cross")?.src || 82 - "static/famfamfam-silk/cross.png", 83 - HEART: 84 - document.getElementById("icon-heart")?.src || 85 - "static/famfamfam-silk/heart.png", 86 - STAR: 87 - document.getElementById("icon-star")?.src || 88 - "static/famfamfam-silk/star.png", 89 - TINYSUB: document.getElementById("icon-tinysub")?.src || "static/tinysub.svg", 90 - USER: 91 - document.getElementById("icon-user")?.src || 92 - "static/famfamfam-silk/user.png", 63 + ADD: document.getElementById("icon-add").src, 64 + ARROW_DOWN: document.getElementById("icon-arrow_down").src, 65 + ARROW_SWITCH: document.getElementById("icon-arrow_switch").src, 66 + ARROW_UP: document.getElementById("icon-arrow_up").src, 67 + CD: document.getElementById("icon-cd").src, 68 + COG: document.getElementById("icon-cog").src, 69 + CONTROL_FASTFORWARD: document.getElementById("icon-control_fastforward").src, 70 + CONTROL_PAUSE: document.getElementById("icon-control_pause").src, 71 + CONTROL_PLAY: document.getElementById("icon-control_play").src, 72 + CONTROL_REPEAT: document.getElementById("icon-control_repeat").src, 73 + CONTROL_REWIND: document.getElementById("icon-control_rewind").src, 74 + CROSS: document.getElementById("icon-cross").src, 75 + HEART: document.getElementById("icon-heart").src, 76 + STAR: document.getElementById("icon-star").src, 77 + USER: document.getElementById("icon-user").src, 78 + TINYSUB: document.getElementById("icon-tinysub").src, 93 79 };
+62 -65
src/js/contextmenu.js
··· 48 48 49 49 // display context menu with given items at position 50 50 function showContextMenu(x, y, items) { 51 - // close any existing menu 51 + // close existing menu 52 52 removeContextMenuDisplay(); 53 53 54 54 contextMenuEl = createElement("div", { ··· 63 63 attributes: { role: "menuitem", tabindex: "-1" }, 64 64 listeners: { 65 65 mousedown: (e) => e.preventDefault(), // prevent focus on mousedown 66 - click: (e) => { 66 + click: async (e) => { 67 67 e.stopPropagation(); 68 - handler(); 68 + try { 69 + await handler(); 70 + } finally { 71 + cleanupContextMenu(); 72 + } 69 73 }, 70 74 }, 71 75 }); ··· 77 81 // NOTE: if we ever add context menus to library items, we'll want to make this dynamic but i think this is ok for now 78 82 getMainEl().appendChild(contextMenuEl); 79 83 80 - // position menu with bounds checking to keep within viewport 84 + // position menu 81 85 const rect = contextMenuEl.getBoundingClientRect(); 82 - const clampPosition = (pos, size, limit) => 83 - Math.max(0, Math.min(pos, limit - size)); 84 - contextMenuEl.style.left = `${clampPosition(x, rect.width, window.innerWidth)}px`; 85 - contextMenuEl.style.top = `${clampPosition(y, rect.height, window.innerHeight)}px`; 86 + contextMenuEl.style.left = `${Math.max(0, Math.min(x, window.innerWidth - rect.width))}px`; 87 + contextMenuEl.style.top = `${Math.max(0, Math.min(y, window.innerHeight - rect.height))}px`; 86 88 87 89 // keyboard navigation for context menu 88 90 let focusedIndex = 0; ··· 133 135 }; 134 136 document.addEventListener("click", currentClickHandler, { capture: true }); 135 137 136 - // prevent default browser context menu from appearing 138 + // prevent default browser context menu 137 139 currentSystemContextmenuEventHandler = (e) => { 138 140 if ( 139 141 !contextMenuEl?.contains(e.target) && ··· 152 154 ); 153 155 } 154 156 155 - // wrap handler to auto-cleanup 156 - const withContextMenuCleanup = 157 - (handler) => 158 - async (...args) => { 159 - try { 160 - await handler(...args); 161 - } finally { 162 - cleanupContextMenu(); 163 - } 164 - }; 165 - 166 157 // get sort menu items 167 158 function getSortMenuItems() { 168 159 return { 169 - [STRINGS.CONTEXT_SORT_SHUFFLE]: withContextMenuCleanup(() => { 160 + [STRINGS.CONTEXT_SORT_SHUFFLE]: () => { 170 161 sortQueue("shuffle"); 171 162 updateQueue(); 172 - }), 173 - [STRINGS.CONTEXT_SORT_SONG_AZ]: withContextMenuCleanup(() => { 163 + }, 164 + [STRINGS.CONTEXT_SORT_SONG_AZ]: () => { 174 165 sortQueue("title", true); 175 166 updateQueue(); 176 - }), 177 - [STRINGS.CONTEXT_SORT_SONG_ZA]: withContextMenuCleanup(() => { 167 + }, 168 + [STRINGS.CONTEXT_SORT_SONG_ZA]: () => { 178 169 sortQueue("title", false); 179 170 updateQueue(); 180 - }), 181 - [STRINGS.CONTEXT_SORT_ARTIST_AZ]: withContextMenuCleanup(() => { 171 + }, 172 + [STRINGS.CONTEXT_SORT_ARTIST_AZ]: () => { 182 173 sortQueue("artist", true); 183 174 updateQueue(); 184 - }), 185 - [STRINGS.CONTEXT_SORT_ARTIST_ZA]: withContextMenuCleanup(() => { 175 + }, 176 + [STRINGS.CONTEXT_SORT_ARTIST_ZA]: () => { 186 177 sortQueue("artist", false); 187 178 updateQueue(); 188 - }), 189 - [STRINGS.CONTEXT_SORT_ALBUM_AZ]: withContextMenuCleanup(() => { 179 + }, 180 + [STRINGS.CONTEXT_SORT_ALBUM_AZ]: () => { 190 181 sortQueue("album", true); 191 182 updateQueue(); 192 - }), 193 - [STRINGS.CONTEXT_SORT_ALBUM_ZA]: withContextMenuCleanup(() => { 183 + }, 184 + [STRINGS.CONTEXT_SORT_ALBUM_ZA]: () => { 194 185 sortQueue("album", false); 195 186 updateQueue(); 196 - }), 197 - [STRINGS.CONTEXT_SORT_DURATION_SHORT_LONG]: withContextMenuCleanup(() => { 187 + }, 188 + [STRINGS.CONTEXT_SORT_DURATION_SHORT_LONG]: () => { 198 189 sortQueue("duration", true); 199 190 updateQueue(); 200 - }), 201 - [STRINGS.CONTEXT_SORT_DURATION_LONG_SHORT]: withContextMenuCleanup(() => { 191 + }, 192 + [STRINGS.CONTEXT_SORT_DURATION_LONG_SHORT]: () => { 202 193 sortQueue("duration", false); 203 194 updateQueue(); 204 - }), 205 - [STRINGS.CONTEXT_SORT_FAVORITED_FIRST]: withContextMenuCleanup(() => { 195 + }, 196 + [STRINGS.CONTEXT_SORT_FAVORITED_FIRST]: () => { 206 197 sortQueue("favorited", false); 207 198 updateQueue(); 208 - }), 209 - [STRINGS.CONTEXT_SORT_FAVORITED_LAST]: withContextMenuCleanup(() => { 199 + }, 200 + [STRINGS.CONTEXT_SORT_FAVORITED_LAST]: () => { 210 201 sortQueue("favorited", true); 211 202 updateQueue(); 212 - }), 203 + }, 213 204 }; 214 205 } 215 206 216 - // show context menu 217 - function showQueueContextMenuAtSelection(x, y, selectedIndices) { 207 + // show queue context menu 208 + function showQueueContextMenu(x, y, selectedIndices) { 218 209 showContextMenu(x, y, { 219 - [STRINGS.CONTEXT_PLAY]: withContextMenuCleanup(() => { 220 - playQueueTrack(selectedIndices[0]); 210 + [STRINGS.CONTEXT_PLAY]: () => { 211 + state.queueIndex = selectedIndices[0]; 212 + saveQueue(); 213 + playTrack(state.queue[selectedIndices[0]]); 221 214 updateQueue(); 222 - }), 223 - [STRINGS.CONTEXT_PLAY_NEXT]: withContextMenuCleanup(() => { 224 - const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0; 225 - moveQueueItems(state.queue, selectedIndices, insertPos, queueCallbacks); 226 - }), 215 + }, 216 + [STRINGS.CONTEXT_PLAY_NEXT]: () => { 217 + moveQueueItems( 218 + state.queue, 219 + selectedIndices, 220 + state.queueIndex >= 0 ? state.queueIndex + 1 : 0, 221 + queueCallbacks, 222 + ); 223 + }, 227 224 [STRINGS.CONTEXT_SORT]: () => { 228 225 showContextMenu(x, y, getSortMenuItems()); 229 226 }, 230 - [STRINGS.CONTEXT_FAVORITE]: withContextMenuCleanup(async () => { 227 + [STRINGS.CONTEXT_FAVORITE]: async () => { 231 228 await Promise.all( 232 229 selectedIndices.map((i) => setFavoriteSong(state.queue[i], true)), 233 230 ); 234 231 updateQueueDisplay(); 235 - }), 236 - [STRINGS.CONTEXT_UNFAVORITE]: withContextMenuCleanup(async () => { 232 + }, 233 + [STRINGS.CONTEXT_UNFAVORITE]: async () => { 237 234 await Promise.all( 238 235 selectedIndices.map((i) => setFavoriteSong(state.queue[i], false)), 239 236 ); 240 237 updateQueueDisplay(); 241 - }), 242 - [STRINGS.CONTEXT_MOVE_UP]: withContextMenuCleanup(() => { 238 + }, 239 + [STRINGS.CONTEXT_MOVE_UP]: () => { 243 240 const firstIdx = Math.min(...selectedIndices); 244 241 if (firstIdx > 0) { 245 242 moveQueueItems( ··· 249 246 queueCallbacks, 250 247 ); 251 248 } 252 - }), 253 - [STRINGS.CONTEXT_MOVE_DOWN]: withContextMenuCleanup(() => { 249 + }, 250 + [STRINGS.CONTEXT_MOVE_DOWN]: () => { 254 251 const lastIdx = Math.max(...selectedIndices); 255 252 if (lastIdx < state.queue.length - 1) { 256 253 moveQueueItems( ··· 260 257 queueCallbacks, 261 258 ); 262 259 } 263 - }), 264 - [STRINGS.CONTEXT_CLEAR]: withContextMenuCleanup(() => { 260 + }, 261 + [STRINGS.CONTEXT_CLEAR]: () => { 265 262 clearSelectedRows(); 266 - }), 263 + }, 267 264 }); 268 265 } 269 266 ··· 278 275 e.stopPropagation(); 279 276 280 277 const idx = parseInt(row.getAttribute(DATA_ATTRS.INDEX)); 281 - if (!selectionManager.isSelected(idx)) { 282 - selectionManager.select(idx); 278 + if (!queueSelection.isSelected(idx)) { 279 + queueSelection.select(idx); 283 280 } 284 281 285 - const selectedIndices = Array.from(selectionManager.getSelected()); 286 - showQueueContextMenuAtSelection(e.clientX, e.clientY, selectedIndices); 282 + const selectedIndices = Array.from(queueSelection.getSelected()); 283 + showQueueContextMenu(e.clientX, e.clientY, selectedIndices); 287 284 }, 288 285 true, 289 286 );
+10 -11
src/js/draggable.js src/js/queue-drag.js
··· 1 1 // drag and drop support for reordering queue items 2 2 3 3 // setup drag and drop for queue reordering 4 - function setupDragAndDrop() { 4 + function setupQueueDragAndDrop() { 5 5 const isDropBelowCenter = (e, row) => 6 6 e.clientY - row.getBoundingClientRect().top > row.offsetHeight / 2; 7 7 ··· 12 12 if ( 13 13 !row || 14 14 row.classList.contains(CLASSES.DRAGGING) || 15 - selectionManager.isSelected(parseInt(row.getAttribute(DATA_ATTRS.INDEX))) 15 + queueSelection.isSelected(parseInt(row.getAttribute(DATA_ATTRS.INDEX))) 16 16 ) 17 17 return; 18 18 19 - selectionManager.clear(); 20 - selectionManager.select(parseInt(row.getAttribute(DATA_ATTRS.INDEX))); 19 + queueSelection.clear(); 20 + queueSelection.select(parseInt(row.getAttribute(DATA_ATTRS.INDEX))); 21 21 updateRowClass( 22 22 ui.queueList, 23 - selectionManager.getSelected(), 23 + queueSelection.getSelected(), 24 24 CLASSES.DRAGGING, 25 25 true, 26 26 ); ··· 28 28 e.dataTransfer.setDragImage(row, 0, 0); 29 29 }); 30 30 31 - // handle drag over - show drop indicator 31 + // handle dragover and show indicator 32 32 ui.queueList.addEventListener("dragover", (e) => { 33 33 e.preventDefault(); 34 34 e.dataTransfer.dropEffect = "move"; ··· 37 37 38 38 const isBelow = isDropBelowCenter(e, row); 39 39 40 - // only update if row changed or position within row changed 41 40 if (lastDragOverRow !== row) { 42 41 if (lastDragOverRow) { 43 42 lastDragOverRow.classList.remove( ··· 62 61 } 63 62 }); 64 63 65 - // handle drop and complete the move 64 + // handle drop and complete move 66 65 ui.queueList.addEventListener("drop", (e) => { 67 66 e.preventDefault(); 68 67 const row = e.target.closest("tr"); ··· 76 75 ); 77 76 lastDragOverRow = null; 78 77 } 79 - clearRowClasses(ui.queueList, "tr", CLASSES.DRAGGING); 78 + clearRowClasses(ui.queueList, CLASSES.DRAGGING); 80 79 81 80 const draggedIdx = parseInt(row.getAttribute(DATA_ATTRS.INDEX)); 82 81 moveQueueItems( 83 82 state.queue, 84 - selectionManager.getSelected(), 83 + queueSelection.getSelected(), 85 84 isDropBelowCenter(e, row) ? draggedIdx + 1 : draggedIdx, 86 85 queueCallbacks, 87 86 ); ··· 103 102 ); 104 103 lastDragOverRow = null; 105 104 } 106 - clearRowClasses(ui.queueList, "tr", CLASSES.DRAGGING); 105 + clearRowClasses(ui.queueList, CLASSES.DRAGGING); 107 106 }); 108 107 }
+21 -18
src/js/events.js
··· 1 1 // event listener setup and DOM event binding 2 2 3 - let selectionManager; 3 + let queueSelection; 4 4 5 5 // setup media control handlers for hardware buttons and lock screen 6 6 function setupMediaSessionHandlers() { ··· 9 9 Object.entries({ 10 10 play: () => ui.player.play(), 11 11 pause: () => ui.player.pause(), 12 - previoustrack: () => navigateTrack(-1), 13 - nexttrack: () => navigateTrack(1), 12 + previoustrack: () => skipTrack(-1), 13 + nexttrack: () => skipTrack(1), 14 14 seekto: (e) => (ui.player.currentTime = e.seekTime), 15 15 }).forEach(([action, handler]) => 16 16 navigator.mediaSession.setActionHandler(action, handler), ··· 22 22 lockTabOrder(); 23 23 tabOrderObserver = setupTabOrderObserver(); 24 24 25 - // initialize selection manager for queue table 26 - selectionManager = new SelectionManager(ui.queueList, { 25 + // initialize queueselection 26 + queueSelection = new QueueSelection(ui.queueList, { 27 27 rowSelector: "tr", 28 28 indexAttribute: DATA_ATTRS.INDEX, 29 29 selectedClass: CLASSES.SELECTED, ··· 43 43 libraryEl.addEventListener("click", () => libraryEl.focus()); 44 44 // restore library focus styling when library regains focus 45 45 libraryEl.addEventListener("focus", () => { 46 - if (LibraryNavigator.currentFocusedItem) { 47 - LibraryNavigator.currentFocusedItem.classList.add("library-focused"); 46 + if (librarySelection.currentFocusedItem) { 47 + librarySelection.currentFocusedItem.classList.add("library-focused"); 48 48 } 49 49 }); 50 50 } ··· 72 72 const current = formatDuration(ui.player.currentTime); 73 73 const total = formatDuration(ui.player.duration || 0); 74 74 ui.timeDisplay.textContent = `${current} / ${total}`; 75 - updateLyricDisplay(ui.player.currentTime); 75 + const lyric = getCurrentLyric(ui.player.currentTime); 76 + ui.trackLyric.textContent = lyric ? `♪ ${lyric.text}` : ""; 76 77 } 77 78 }); 78 79 let lastScrobbledSongId = null; ··· 92 93 93 94 // playback controls 94 95 ui.playBtn.addEventListener("click", togglePlayback); 95 - ui.prevBtn.addEventListener("click", () => navigateTrack(-1)); 96 - ui.nextBtn.addEventListener("click", () => navigateTrack(1)); 96 + ui.prevBtn.addEventListener("click", () => skipTrack(-1)); 97 + ui.nextBtn.addEventListener("click", () => skipTrack(1)); 97 98 98 99 // progress slider 99 100 const seekHandler = (e) => { ··· 116 117 117 118 // clear queue 118 119 ui.clearBtn.addEventListener("click", () => { 119 - (selectionManager?.getSelected()?.length > 0 120 + (queueSelection?.getSelected()?.length > 0 120 121 ? clearSelectedRows 121 122 : clearQueue)(); 122 123 }); ··· 129 130 const row = e.target.closest("tr"); 130 131 if (row) { 131 132 const idx = parseInt(row.getAttribute(DATA_ATTRS.INDEX)); 132 - selectionManager.select(idx, { 133 + queueSelection.select(idx, { 133 134 multi: e.ctrlKey || e.metaKey, 134 135 shift: e.shiftKey, 135 136 }); ··· 151 152 const row = e.target.closest("tr"); 152 153 if (row) { 153 154 const idx = parseInt(row.getAttribute(DATA_ATTRS.INDEX)); 154 - playQueueTrack(idx); 155 - selectionManager.clear(); 155 + state.queueIndex = idx; 156 + saveQueue(); 157 + playTrack(state.queue[idx]); 158 + queueSelection.clear(); 156 159 updateQueue(); 157 160 } 158 161 }); ··· 164 167 const isButton = e.target.closest("button"); 165 168 const isLink = e.target.closest("a"); 166 169 167 - // clear queue selection when clicking empty space in queue (not on row/button/link) 170 + // clear queue selection when clicking empty space in queue 168 171 if ( 169 172 mainEl?.contains(e.target) && 170 173 !isInQueueTable && ··· 172 175 !isLink && 173 176 !isInContextMenu 174 177 ) { 175 - selectionManager.clear(); 178 + queueSelection.clear(); 176 179 } 177 180 178 181 // clear library selection when clicking empty space in library (not on item/button/link) ··· 182 185 !isButton && 183 186 !isInContextMenu 184 187 ) { 185 - LibraryNavigator.focusItem(null); 188 + librarySelection.focusItem(null); 186 189 } 187 190 }); 188 191 ··· 192 195 ); 193 196 194 197 // setup event handlers 195 - setupDragAndDrop(); 198 + setupQueueDragAndDrop(); 196 199 setupQueueContextMenu(); 197 200 initVirtualScroller(); 198 201 setupKeyboardShortcuts();
+61 -42
src/js/input.js
··· 55 55 56 56 // navigate queue selection with arrow keys 57 57 const navigateSelection = (offset, extend = false) => { 58 - if (!selectionManager) return; 59 - const currentIdx = selectionManager.lastSelected ?? 0; 58 + if (!queueSelection) return; 59 + const currentIdx = queueSelection.lastSelected ?? 0; 60 60 const nextIdx = 61 61 offset < 0 62 62 ? Math.max(0, currentIdx - 1) 63 63 : Math.min(state.queue.length - 1, currentIdx + 1); 64 64 65 65 if (extend) { 66 - selectionManager.select(nextIdx, { shift: true }); 66 + queueSelection.select(nextIdx, { shift: true }); 67 67 } else { 68 - selectionManager.select(nextIdx); 68 + queueSelection.select(nextIdx); 69 69 } 70 70 71 71 // ensure queue container has focus for keyboard navigation ··· 111 111 const keyboardActionHandlers = { 112 112 play: (selectedIndices) => { 113 113 if (!selectedIndices || selectedIndices.length === 0) return; 114 - playQueueTrack(selectedIndices[0]); 114 + state.queueIndex = selectedIndices[0]; 115 + saveQueue(); 116 + playTrack(state.queue[selectedIndices[0]]); 115 117 updateQueue(); 116 118 refocusContext(true, false); 117 119 }, ··· 142 144 ); 143 145 if (row) { 144 146 const rect = row.getBoundingClientRect(); 145 - showQueueContextMenuAtSelection( 146 - rect.left, 147 - rect.top + rect.height, 148 - selectedIndices, 149 - ); 147 + showQueueContextMenu(rect.left, rect.top + rect.height, selectedIndices); 150 148 } 151 149 refocusContext(true, false); 152 150 }, ··· 169 167 if (document.activeElement.matches("input, textarea")) return; 170 168 171 169 // skip keyboard shortcuts if a modal is open (let modal handle it) 172 - if (isModalOpen()) return; 170 + if ( 171 + modalRegistry.size > 0 || 172 + (document.getElementById("context-menu") && 173 + document.body.contains(document.getElementById("context-menu"))) 174 + ) 175 + return; 173 176 174 177 // cache element lookups for this event to avoid repeated DOM queries 175 178 const mainEl = getMainEl(); ··· 190 193 case "Backspace": { 191 194 if (!isInMain) return; // queue only 192 195 e.preventDefault(); 193 - if (selectionManager?.count() > 0) { 194 - const selected = selectionManager.getSelected(); 196 + if (queueSelection?.selected.size > 0) { 197 + const selected = queueSelection.getSelected(); 195 198 const nextIdx = Math.min( 196 199 selected[0], 197 200 state.queue.length - selected.length - 1, 198 201 ); 199 202 clearSelectedRows(); 200 203 if (nextIdx >= 0 && state.queue.length > 0) { 201 - selectionManager.select(nextIdx); 204 + queueSelection.select(nextIdx); 202 205 } 203 206 } 204 207 refocusContext(true, false); ··· 208 211 case "ArrowUp": { 209 212 e.preventDefault(); 210 213 if (isInSidebar) { 211 - LibraryNavigator.navigate(-1); 214 + librarySelection.navigate(-1); 212 215 } else if (e.altKey) { 213 - selectionManager?.executeAction("moveUp", keyboardActionHandlers); 216 + queueSelection?.executeAction("moveUp", keyboardActionHandlers); 214 217 } else { 215 218 navigateSelection(-1, e.shiftKey); 216 219 } ··· 221 224 case "ArrowDown": { 222 225 e.preventDefault(); 223 226 if (isInSidebar) { 224 - LibraryNavigator.navigate(1); 227 + librarySelection.navigate(1); 225 228 } else if (e.altKey) { 226 - selectionManager?.executeAction("moveDown", keyboardActionHandlers); 229 + queueSelection?.executeAction("moveDown", keyboardActionHandlers); 227 230 } else { 228 231 navigateSelection(1, e.shiftKey); 229 232 } ··· 234 237 case "Home": { 235 238 e.preventDefault(); 236 239 if (isInSidebar) { 237 - LibraryNavigator.navigateFirst(); 240 + const items = librarySelection.getFocusableItems(); 241 + if (items.length > 0) librarySelection.focusItem(items[0]); 238 242 } else { 239 243 if (e.shiftKey) { 240 - selectionManager?.select(0, { shift: true }); 244 + queueSelection?.select(0, { shift: true }); 241 245 } else { 242 - selectionManager?.navigateFirst(); 246 + queueSelection?.navigateTo(0); 243 247 } 244 248 } 245 249 refocusContext(isInMain, isInSidebar); ··· 249 253 case "End": { 250 254 e.preventDefault(); 251 255 if (isInSidebar) { 252 - LibraryNavigator.navigateLast(); 256 + const items = librarySelection.getFocusableItems(); 257 + if (items.length > 0) 258 + librarySelection.focusItem(items[items.length - 1]); 253 259 } else { 254 260 const lastIdx = (state?.queue?.length || 0) - 1; 255 261 if (e.shiftKey) { 256 - selectionManager?.select(lastIdx, { shift: true }); 262 + queueSelection?.select(lastIdx, { shift: true }); 257 263 } else { 258 - selectionManager?.navigateLast(); 264 + queueSelection?.navigateTo(lastIdx); 259 265 } 260 266 } 261 267 refocusContext(isInMain, isInSidebar); ··· 265 271 case "PageUp": { 266 272 e.preventDefault(); 267 273 if (isInMain) { 268 - const currentIdx = selectionManager.lastSelected ?? 0; 274 + const currentIdx = queueSelection.lastSelected ?? 0; 269 275 const nextIdx = Math.max(0, currentIdx - 10); 270 276 if (e.shiftKey) { 271 - selectionManager?.select(nextIdx, { shift: true }); 277 + queueSelection?.select(nextIdx, { shift: true }); 272 278 } else { 273 - selectionManager?.navigatePageUp(10); 279 + queueSelection?.navigatePageUp(10); 274 280 } 275 281 } else if (isInSidebar) { 276 - LibraryNavigator.navigatePageUp(10); 282 + librarySelection.navigatePageUp(10); 277 283 } 278 284 refocusContext(isInMain, isInSidebar); 279 285 break; ··· 282 288 case "PageDown": { 283 289 e.preventDefault(); 284 290 if (isInMain) { 285 - const currentIdx = selectionManager.lastSelected ?? 0; 291 + const currentIdx = queueSelection.lastSelected ?? 0; 286 292 const lastIdx = (state?.queue?.length || 0) - 1; 287 293 const nextIdx = Math.min(lastIdx, currentIdx + 10); 288 294 if (e.shiftKey) { 289 - selectionManager?.select(nextIdx, { shift: true }); 295 + queueSelection?.select(nextIdx, { shift: true }); 290 296 } else { 291 - selectionManager?.navigatePageDown(10); 297 + queueSelection?.navigatePageDown(10); 292 298 } 293 299 } else if (isInSidebar) { 294 - LibraryNavigator.navigatePageDown(10); 300 + librarySelection.navigatePageDown(10); 295 301 } 296 302 refocusContext(isInMain, isInSidebar); 297 303 break; ··· 302 308 if (!e.ctrlKey && !e.metaKey) return; // Ctrl+A (or Cmd+A on Mac) 303 309 e.preventDefault(); 304 310 if (state.queue.length > 0) { 305 - selectionManager?.setSelection( 311 + queueSelection?.setSelection( 306 312 Array.from({ length: state.queue.length }, (_, i) => i), 307 313 ); 308 314 } ··· 313 319 case "Enter": { 314 320 e.preventDefault(); 315 321 if (isInSidebar) { 316 - LibraryNavigator.toggleCurrent(); 322 + if (librarySelection.currentFocusedItem) { 323 + const isToggle = 324 + librarySelection.currentFocusedItem.classList.contains( 325 + CLASSES.TREE_TOGGLE, 326 + ); 327 + if ( 328 + isToggle || 329 + librarySelection.currentFocusedItem.classList.contains( 330 + "section-toggle", 331 + ) 332 + ) { 333 + librarySelection.currentFocusedItem.click(); 334 + } 335 + } 317 336 refocusContext(false, true); 318 337 } else { 319 - selectionManager?.executeAction("play", keyboardActionHandlers); 338 + queueSelection?.executeAction("play", keyboardActionHandlers); 320 339 } 321 340 break; 322 341 } ··· 326 345 cleanupContextMenu(); 327 346 // clear selection only in focused container 328 347 if (isInMain) { 329 - selectionManager.clear(); 348 + queueSelection.clear(); 330 349 } else if (isInSidebar) { 331 - if (LibraryNavigator.currentFocusedItem) { 332 - LibraryNavigator.currentFocusedItem.classList.remove( 350 + if (librarySelection.currentFocusedItem) { 351 + librarySelection.currentFocusedItem.classList.remove( 333 352 "library-focused", 334 353 ); 335 - LibraryNavigator.currentFocusedItem = null; 354 + librarySelection.currentFocusedItem = null; 336 355 } 337 356 } 338 357 refocusContext(isInMain, isInSidebar); ··· 372 391 case "ContextMenu": { 373 392 if (!isInMain) return; // queue only 374 393 e.preventDefault(); 375 - selectionManager?.executeAction( 394 + queueSelection?.executeAction( 376 395 "showContextMenu", 377 396 keyboardActionHandlers, 378 397 ); ··· 404 423 e.preventDefault(); 405 424 if (e.altKey) { 406 425 // Alt+J for previous track 407 - navigateTrack(-1); 426 + skipTrack(-1); 408 427 } else { 409 428 // J for seek -10s 410 429 ui.player.currentTime = Math.max(0, ui.player.currentTime - 10); ··· 416 435 e.preventDefault(); 417 436 if (e.altKey) { 418 437 // Alt+L for next track 419 - navigateTrack(1); 438 + skipTrack(1); 420 439 } else { 421 440 // L for seek +10s 422 441 ui.player.currentTime = Math.min(
+97
src/js/library-selection.js
··· 1 + // library tree keyboard navigation manager 2 + 3 + class LibrarySelection { 4 + constructor(options = {}) { 5 + this.currentFocusedItem = null; 6 + this.currentSection = "artists"; // "artists" or "playlists" 7 + } 8 + 9 + getFocusableItems() { 10 + // get all focusable items in library (organized by section for natural navigation) 11 + const items = []; 12 + const sections = [ 13 + { 14 + selector: '[data-section="artists"]', 15 + containerId: DOM_IDS.LIBRARY_TREE, 16 + }, 17 + { 18 + selector: '[data-section="playlists"]', 19 + containerId: DOM_IDS.PLAYLISTS_TREE, 20 + }, 21 + ]; 22 + 23 + sections.forEach(({ selector, containerId }) => { 24 + const toggle = document.querySelector(selector); 25 + if (toggle) { 26 + items.push(toggle); 27 + const sectionItems = Array.from( 28 + document.querySelectorAll( 29 + `#${containerId} .${CLASSES.TREE_TOGGLE}, #${containerId} .${CLASSES.TREE_NAME}`, 30 + ), 31 + ); 32 + items.push(...sectionItems); 33 + } 34 + }); 35 + 36 + return items; 37 + } 38 + 39 + focusItem(element) { 40 + // set focus to specific item 41 + if (this.currentFocusedItem) { 42 + this.currentFocusedItem.classList.remove("library-focused"); 43 + } 44 + if (element) { 45 + element.classList.add("library-focused"); 46 + element.scrollIntoView({ block: "nearest" }); 47 + this.currentFocusedItem = element; 48 + } 49 + } 50 + 51 + _navigateTo(calcNewIndex) { 52 + // navigate with direction and optional offset (for page navigation) 53 + const items = this.getFocusableItems(); 54 + if (items.length === 0) return; 55 + 56 + if (!this.currentFocusedItem) { 57 + this.focusItem(items[0]); 58 + return; 59 + } 60 + 61 + const currentIndex = items.indexOf(this.currentFocusedItem); 62 + const newIndex = calcNewIndex(currentIndex, items.length); 63 + if (newIndex >= 0 && newIndex < items.length) { 64 + this.focusItem(items[newIndex]); 65 + } 66 + } 67 + 68 + navigate(direction) { 69 + // navigate up/down through items 70 + this._navigateTo((idx) => idx + direction); 71 + } 72 + 73 + navigatePageUp(pageSize = 10) { 74 + // navigate by page (jump multiple items) 75 + this._navigateTo((idx) => Math.max(0, idx - pageSize)); 76 + } 77 + 78 + navigatePageDown(pageSize = 10) { 79 + // navigate by page down (jump multiple items) 80 + this._navigateTo((idx, len) => Math.min(len - 1, idx + pageSize)); 81 + } 82 + 83 + getCurrentItemData() { 84 + // get data (item id and type) from currently focused item 85 + if (!this.currentFocusedItem) return null; 86 + const li = this.currentFocusedItem.closest("li"); 87 + if (!li) return null; 88 + const inArtists = 89 + this.currentFocusedItem.closest(`#${DOM_IDS.LIBRARY_TREE}`) !== null; 90 + return { 91 + itemId: li.dataset.itemId, 92 + section: inArtists ? "artists" : "playlists", 93 + }; 94 + } 95 + } 96 + 97 + const librarySelection = new LibrarySelection();
+27 -157
src/js/library.js
··· 3 3 // global map to store library items by ID for keyboard access 4 4 const libraryItemsById = new Map(); 5 5 6 - // library keyboard navigation manager 7 - const 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 - }, 6 + // add library item to queue (end or next after current track) 7 + function addLibraryItem(addNext = false) { 8 + const data = librarySelection.getCurrentItemData(); 9 + if (!data || !data.itemId) return; 119 10 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 - }; 11 + const link = librarySelection.currentFocusedItem; 12 + const li = link?.closest("li"); 13 + if (!li) return; 128 14 129 - // determine item type based on nesting level in tree 130 - function getLibraryItemType(li, section) { 15 + // determine item type based on nesting level in tree 131 16 let level = 0; 132 17 let parent = li.parentElement; 133 - const container = section === "artists" ? ui.artistsTree : ui.playlistsTree; 18 + const container = 19 + data.section === "artists" ? ui.artistsTree : ui.playlistsTree; 134 20 while (parent && parent !== container) { 135 21 if ( 136 22 parent.classList.contains(CLASSES.NESTED) || ··· 141 27 parent = parent.parentElement; 142 28 } 143 29 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"; 30 + let type; 31 + if (data.section === "playlists") { 32 + type = level >= 1 ? "song" : "playlist"; 147 33 } else if (level === 1) { 148 - return "album"; 34 + type = "album"; 149 35 } else if (level >= 2) { 150 - return "song"; 36 + type = "song"; 37 + } else { 38 + type = "artist"; 151 39 } 152 - return "artist"; 153 - } 154 - 155 - // look up a song in the library cache by ID 156 - function lookupSongInLibrary(songId) { 157 - return libraryItemsById.get(songId); 158 - } 159 - 160 - // add library item to queue (end or next after current track) 161 - function 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 40 171 41 // for songs, look up the full object from library cache or create minimal one 172 42 let itemData = data.itemId; 173 43 if (type === "song") { 174 - const song = lookupSongInLibrary(data.itemId); 44 + const song = libraryItemsById.get(data.itemId); 175 45 if (song) { 176 46 itemData = song; 177 47 } else { ··· 273 143 ) { 274 144 state.expanded.items[section] = {}; 275 145 } 276 - const renderers = { 277 - artists: renderLibraryTree, 278 - playlists: renderPlaylistsTree, 279 - }; 280 - renderers[section]?.(); 146 + if (section === "artists") { 147 + renderLibraryTree(); 148 + } else { 149 + renderPlaylistsTree(); 150 + } 281 151 } 282 152 283 153 async function loadData(config) { ··· 448 318 }, 449 319 }; 450 320 321 + // render library or playlists tree 322 + const renderLibraryTree = () => renderSidebarTree("artists"); 323 + const renderPlaylistsTree = () => renderSidebarTree("playlists"); 324 + 451 325 // render a sidebar tree section using config 452 326 function renderSidebarTree(sectionKey) { 453 327 const config = TREE_CONFIGS[sectionKey]; ··· 489 363 }); 490 364 } 491 365 } 492 - 493 - // export for SECTION_RENDERERS 494 - const renderLibraryTree = () => renderSidebarTree("artists"); 495 - const renderPlaylistsTree = () => renderSidebarTree("playlists");
+4 -2
src/js/lyrics.js
··· 80 80 if (structuredArray) { 81 81 currentLyrics = parseStructured(structuredArray); 82 82 lyricIndex = 0; 83 - updateLyricDisplay(ui.player.currentTime); 83 + const lyric = getCurrentLyric(ui.player.currentTime); 84 + ui.trackLyric.textContent = lyric ? `♪ ${lyric.text}` : ""; 84 85 return; 85 86 } 86 87 } ··· 95 96 currentLyrics = []; 96 97 lyricIndex = 0; 97 98 } 98 - updateLyricDisplay(ui.player.currentTime); 99 + const lyric = getCurrentLyric(ui.player.currentTime); 100 + ui.trackLyric.textContent = lyric ? `♪ ${lyric.text}` : ""; 99 101 } 100 102 101 103 // display lyric for current playback time
-23
src/js/modal.js
··· 24 24 clickHandler: null, 25 25 }; 26 26 27 - // show modal 28 27 modalEl.classList.remove("hidden"); 29 - 30 - // setup focus trap 31 28 cleanup.focusTrap = trapModalFocus(modalEl); 32 29 33 - // handle Escape key to close 34 30 cleanup.escapeHandler = (e) => { 35 31 if (e.code === "Escape") { 36 32 e.preventDefault(); ··· 39 35 }; 40 36 document.addEventListener("keydown", cleanup.escapeHandler); 41 37 42 - // handle clicks outside modal to close (optional - not all modals do this) 43 38 if (options.closeOnClickOutside) { 44 39 cleanup.clickHandler = (e) => { 45 40 if (e.target === modalEl) { ··· 49 44 document.addEventListener("click", cleanup.clickHandler); 50 45 } 51 46 52 - // store cleanup functions 53 47 modalRegistry.set(modalEl.id, cleanup); 54 48 55 - // call user callback 56 49 onShow?.(); 57 50 58 - // focus first focusable element 59 51 const focusable = modalEl.querySelector(focusSelector); 60 52 if (focusable) focusable.focus(); 61 53 } ··· 71 63 return; 72 64 } 73 65 74 - // call cleanup functions 75 66 cleanup.focusTrap?.(); 76 67 if (cleanup.escapeHandler) { 77 68 document.removeEventListener("keydown", cleanup.escapeHandler); ··· 80 71 document.removeEventListener("click", cleanup.clickHandler); 81 72 } 82 73 83 - // hide modal 84 74 modalEl.classList.add("hidden"); 85 75 86 - // restore focus to what was focused before modal opened 87 76 if (focusedBeforeModal && document.body.contains(focusedBeforeModal)) { 88 77 focusedBeforeModal.focus(); 89 78 } 90 79 focusedBeforeModal = null; 91 - 92 - // remove from registry 93 80 modalRegistry.delete(modalId); 94 81 } 95 82 96 - // check if any modal is open (includes context menu which isn't in registry) 97 - function isModalOpen() { 98 - if (modalRegistry.size > 0) return true; 99 - const contextMenu = document.getElementById("context-menu"); 100 - return contextMenu && document.body.contains(contextMenu); 101 - } 102 - 103 83 // trap keyboard focus within a modal 104 84 function trapModalFocus(modalEl) { 105 - // get all focusable elements 106 85 const focusableElements = Array.from( 107 86 modalEl.querySelectorAll( 108 87 "button, [href], input, select, textarea, [role='button'], [role='menuitem']", ··· 143 122 144 123 document.addEventListener("keydown", handleKeydown); 145 124 146 - // return cleanup function 147 125 return () => { 148 126 document.removeEventListener("keydown", handleKeydown); 149 - // restore original tabindex values 150 127 focusableElements.forEach((el) => { 151 128 el.tabIndex = storedTabIndices.get(el); 152 129 });
+37 -60
src/js/player.js
··· 1 - // audio playback control and player state management 1 + // player state management 2 2 3 3 // fixed resolution for cover art, favicon, and media session 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 4 + // why? the media session cover art should be a pretty decent resolution, and if there were a bunch of separate sizes then we are just downloading more images for no good reason 5 5 const FIXED_ART_SIZE = 512; 6 6 7 7 // update favicon ··· 21 21 // load and play a song 22 22 function playTrack(song, autoplay = true) { 23 23 if (!song) { 24 - resetPlayerUI(); 24 + clearPlayerUI(); 25 25 return; 26 26 } 27 27 ··· 44 44 loadLyricsForSong(song); 45 45 highlightCurrentTrack(); 46 46 updateMediaSession(song); 47 - withMediaSession( 48 - (ms) => (ms.playbackState = autoplay ? "playing" : "paused"), 49 - ); 47 + if (navigator.mediaSession) { 48 + navigator.mediaSession.playbackState = autoplay ? "playing" : "paused"; 49 + } 50 50 } 51 51 52 52 // update play button icon based on playback state 53 53 const updatePlayIcon = () => { 54 54 const isPaused = ui.player.paused; 55 - const iconPath = isPaused ? ICONS.CONTROL_PLAY : ICONS.CONTROL_PAUSE; 56 - const label = isPaused ? "play" : "pause"; 57 - ui.playBtn.innerHTML = `<img src="${iconPath}" alt="${label}" />`; 55 + ui.playBtn.innerHTML = `<img src="${isPaused ? ICONS.CONTROL_PLAY : ICONS.CONTROL_PAUSE}" alt="${isPaused ? "play" : "pause"}" />`; 58 56 if (ui.player.src) { 59 - withMediaSession( 60 - (ms) => (ms.playbackState = isPaused ? "paused" : "playing"), 61 - ); 57 + if (navigator.mediaSession) { 58 + navigator.mediaSession.playbackState = isPaused ? "paused" : "playing"; 59 + } 62 60 } 63 61 }; 64 62 65 - // move to next or previous track in queue 66 - function navigateTrack(offset) { 63 + // skip tracks by offset (can use negative too) 64 + function skipTrack(offset) { 67 65 let newIndex = state.queueIndex + offset; 68 - // loop back to start if at end and moving forward with loop enabled 66 + // loop back to start, if at end, and moving forward with loop enabled 69 67 if (offset > 0 && newIndex >= state.queue.length && state.loop) { 70 68 newIndex = 0; 71 69 } ··· 74 72 saveQueue(); 75 73 playTrack(state.queue[state.queueIndex]); 76 74 } else if (offset > 0) { 77 - // skipping past end with loop off ends track 78 - endQueue(); 75 + state.queueIndex = -1; 76 + saveQueue(); 77 + clearPlayerUI(); 79 78 } 80 79 } 81 80 82 81 // toggle playback or start queue if nothing playing 83 82 const togglePlayback = () => { 84 - if (hasValidTrack()) { 83 + if (state.queueIndex >= 0 && state.queueIndex < state.queue.length) { 85 84 if (ui.player.src) { 86 85 ui.player.paused ? ui.player.play() : ui.player.pause(); 87 86 } else { ··· 95 94 } 96 95 }; 97 96 98 - // check if queue has a valid current track 99 - const hasValidTrack = () => 100 - state.queueIndex >= 0 && state.queueIndex < state.queue.length; 101 - 102 - // helper to play a track at given queue index 103 - const playQueueTrack = (idx) => { 104 - state.queueIndex = idx; 105 - saveQueue(); 106 - playTrack(state.queue[idx]); 107 - }; 108 - 109 97 // toggle favorite status of a song 110 98 const setFavoriteSong = async (song, force) => { 111 99 if (!song || !state.api) return; ··· 123 111 124 112 // highlight currently playing song in queue 125 113 function highlightCurrentTrack() { 126 - clearRowClasses(ui.queueList, "tr", ["currently-playing"]); 114 + clearRowClasses(ui.queueList, ["currently-playing"]); 127 115 if (state.queueIndex >= 0) { 128 116 const row = ui.queueList.querySelector( 129 117 `tr[data-index="${state.queueIndex}"]`, ··· 132 120 } 133 121 } 134 122 135 - // end queue playback and update UI 136 - function endQueue() { 137 - state.queueIndex = -1; 138 - clearPlayerUI(); 139 - highlightCurrentTrack(); 140 - } 141 - 142 123 // clear player UI and reset to default state 143 124 function clearPlayerUI() { 125 + ui.player.pause(); 144 126 ui.player.src = ""; 145 127 ui.trackTitle.textContent = STRINGS.NO_TRACK_PLAYING; 146 128 ui.trackArtist.textContent = ""; 147 129 document.title = "tinysub"; 148 130 updateFavicon(); 149 - clearLyrics(); 131 + currentLyrics = []; 132 + lyricIndex = 0; 133 + ui.trackLyric.textContent = ""; 150 134 ui.coverArt.src = ICONS.TINYSUB; 151 135 ui.coverArt.srcset = ""; 152 136 ui.progress.value = 0; 153 137 ui.timeDisplay.textContent = "0:00 / 0:00"; 154 138 updatePlayIcon(); 155 - withMediaSession((ms) => { 156 - ms.metadata = null; 157 - ms.playbackState = "none"; 158 - }); 139 + highlightCurrentTrack(); 140 + if (navigator.mediaSession) { 141 + navigator.mediaSession.metadata = null; 142 + navigator.mediaSession.playbackState = "none"; 143 + } 159 144 } 160 145 161 - // move to next track when current song ends 146 + // handler for when song ends 162 147 function handleTrackEnd() { 163 148 if ( 164 149 state.queueIndex >= 0 && ··· 176 161 saveQueue(); 177 162 playTrack(state.queue[0]); 178 163 } else { 179 - endQueue(); 164 + state.queueIndex = -1; 165 + saveQueue(); 166 + clearPlayerUI(); 180 167 } 181 168 } 182 169 183 - // reset player when stopping or clearing queue 184 - function resetPlayerUI() { 185 - ui.player.pause(); 186 - clearPlayerUI(); 187 - } 188 - 189 - // safely call mediaSession methods if available 190 - const withMediaSession = (callback) => { 191 - if (navigator.mediaSession) callback(navigator.mediaSession); 192 - }; 193 - 194 - // update media session metadata with current song 170 + // update media session metadata with song 195 171 const updateMediaSession = (song) => { 196 172 if (!song) return; 197 - // Prefer album art (albumId) over individual song art (coverArt) 173 + 174 + // prefer album art to save bandwidth 198 175 const artId = song.albumId || song.coverArt; 199 176 200 - withMediaSession((ms) => { 201 - ms.metadata = new MediaMetadata({ 177 + if (navigator.mediaSession) { 178 + navigator.mediaSession.metadata = new MediaMetadata({ 202 179 title: song.title || STRINGS.UNKNOWN_TRACK, 203 180 artist: song.artist || STRINGS.UNKNOWN_ARTIST, 204 181 album: song.album || "", ··· 212 189 ] 213 190 : [], 214 191 }); 215 - }); 192 + } 216 193 };
+39 -25
src/js/queue.js
··· 5 5 function sortQueue(field, ascending) { 6 6 const currentTrack = 7 7 state.queueIndex >= 0 ? state.queue[state.queueIndex] : null; 8 - const selectedIndices = selectionManager?.getSelected() ?? []; 8 + const selectedIndices = queueSelection?.getSelected() ?? []; 9 9 const hasSelection = selectedIndices.length > 0; 10 10 11 - // extract items to sort: either selected songs or entire queue 11 + // extract items to sort 12 12 const extractItems = () => { 13 13 if (hasSelection) { 14 14 return selectedIndices.map((idx) => ({ ··· 94 94 updateQueueWithItems(itemsToSort); 95 95 } else { 96 96 // standard sort by field 97 - const getValue = (song) => { 98 - if (field === "favorited") return state.favorites.has(song.id) ? 1 : 0; 99 - if (field === "duration") return song.duration || 0; 100 - return song[field] || ""; 101 - }; 102 - 103 97 const itemsToSort = extractItems(); 104 98 itemsToSort.sort((a, b) => { 99 + const getValue = (song) => { 100 + if (field === "favorited") return state.favorites.has(song.id) ? 1 : 0; 101 + if (field === "duration") return song.duration || 0; 102 + return song[field] || ""; 103 + }; 105 104 const aVal = getValue(a.song); 106 105 const bVal = getValue(b.song); 107 106 ··· 125 124 updateQueue(); 126 125 } 127 126 128 - // persist queue and current index to IndexedDB 127 + // save current queue and current index to IndexedDB 129 128 async function saveQueue() { 130 129 try { 131 130 await queueStorage.save(state.queue, state.queueIndex); ··· 134 133 } 135 134 } 136 135 137 - // move items within queue and maintain proper indices 136 + // move items within queue 138 137 function moveQueueItems(queue, selectedIndices, insertPos, callbacks = {}) { 139 138 const { onSelectionChange, onQueueChange } = callbacks; 140 139 const sortedIndices = Array.from(selectedIndices).sort((a, b) => a - b); ··· 173 172 174 173 saveQueue(); 175 174 176 - // batch DOM updates together to avoid reflows 177 175 if (onSelectionChange) 178 176 onSelectionChange(sortedIndices.map((_, i) => insertPos + i)); 179 177 if (onQueueChange) onQueueChange(); ··· 186 184 if (!Array.isArray(songs) || songs.length === 0) return false; 187 185 188 186 state.queue = songs; 189 - if (queueIndex >= 0 && queueIndex < state.queue.length) { 190 - state.queueIndex = queueIndex; 191 - } 187 + state.queueIndex = queueIndex; 192 188 return true; 193 189 } catch (error) { 194 190 console.warn("[Queue] Failed to load queue:", error.message); ··· 266 262 (idx) => createQueueRow(state.queue[idx], idx), 267 263 { 268 264 onScroll: () => { 269 - selectionManager.updateUI(); 265 + queueSelection.updateUI(); 270 266 highlightCurrentTrack(); 271 267 }, 272 268 }, 273 269 ); 274 - selectionManager.virtualScroller = virtualScroller; 270 + queueSelection.virtualScroller = virtualScroller; 275 271 } 276 272 277 273 // update queue display using virtual scroller ··· 284 280 285 281 // clear selected rows 286 282 function clearSelectedRows() { 287 - const toRemove = selectionManager.getSelected(); 283 + const toRemove = queueSelection.getSelected(); 288 284 if (toRemove.length === 0) return; 289 285 290 286 const toRemoveSet = new Set(toRemove); 291 287 const wasCurrentTrackDeleted = toRemoveSet.has(state.queueIndex); 292 288 const originalLength = state.queue.length; 293 - const countDeletionsBefore = (idx) => toRemove.filter((i) => i < idx).length; 294 289 295 290 state.queue = state.queue.filter((_, idx) => !toRemoveSet.has(idx)); 296 291 297 292 if (!wasCurrentTrackDeleted) { 298 - state.queueIndex -= countDeletionsBefore(state.queueIndex); 293 + state.queueIndex -= toRemove.filter((i) => i < state.queueIndex).length; 299 294 } else { 300 295 let nextIndex = -1; 301 296 for (let i = state.queueIndex + 1; i < originalLength; i++) { 302 297 if (!toRemoveSet.has(i)) { 303 - nextIndex = i - countDeletionsBefore(i); 298 + nextIndex = i - toRemove.filter((j) => j < i).length; 304 299 break; 305 300 } 306 301 } ··· 311 306 312 307 // pause mutation observer to prevent interference during DOM update 313 308 tabOrderObserver?.pauseUpdates(); 314 - selectionManager.clear(); 309 + queueSelection.clear(); 315 310 updateQueue(); 316 311 highlightCurrentTrack(); 317 312 // resume after update completes ··· 338 333 return isCurrentTrack; 339 334 } 340 335 336 + // remove classes from queue table rows 337 + function clearRowClasses(container, classNames) { 338 + const classes = Array.isArray(classNames) ? classNames : [classNames]; 339 + container.querySelectorAll("tr").forEach((row) => { 340 + classes.forEach((cls) => row.classList.remove(cls)); 341 + }); 342 + } 343 + 344 + // add or remove class from specific rows 345 + function updateRowClass(container, indices, className, add = true) { 346 + const indexSet = new Set(indices); 347 + container.querySelectorAll("tr").forEach((row) => { 348 + const idx = parseInt(row.getAttribute("data-index")); 349 + row.classList.toggle(className, indexSet.has(idx) === add); 350 + }); 351 + } 352 + 341 353 // handle current track is deleted 342 354 function handleCurrentTrackDeleted() { 343 355 if (state.queue.length > 0) { 344 356 const wasPlaying = !ui.player.paused; 345 357 playTrack(state.queue[state.queueIndex], wasPlaying); 346 358 } else { 347 - resetPlayerUI(); 359 + clearPlayerUI(); 348 360 } 349 361 } 350 362 ··· 353 365 state.queue = []; 354 366 state.queueIndex = -1; 355 367 saveQueue(); 356 - resetPlayerUI(); 368 + clearPlayerUI(); 357 369 updateQueueDisplay(); 358 370 } 359 371 ··· 453 465 454 466 // callbacks for queue operations 455 467 const queueCallbacks = { 456 - onSelectionChange: (newIndices) => selectionManager?.setSelection(newIndices), 468 + onSelectionChange: (newIndices) => queueSelection?.setSelection(newIndices), 457 469 onQueueChange: () => updateQueueDisplay(), 458 470 }; 459 471 ··· 461 473 const QUEUE_BUTTON_HANDLERS = { 462 474 // play the selected track 463 475 [CLASSES.QUEUE_PLAY]: (idx) => { 464 - playQueueTrack(idx); 476 + state.queueIndex = idx; 477 + saveQueue(); 478 + playTrack(state.queue[idx]); 465 479 updateQueue(); 466 480 }, 467 481 // insert selected track after current track
+1 -21
src/js/selection.js src/js/queue-selection.js
··· 1 1 // manages row selection state for queue table (single, multi, range) 2 2 3 - class SelectionManager { 3 + class QueueSelection { 4 4 constructor(container, options = {}) { 5 5 this.container = container; 6 6 this.selected = new Set(); ··· 70 70 return Array.from(this.selected).sort((a, b) => a - b); 71 71 } 72 72 73 - count() { 74 - // get number of selected items 75 - return this.selected.size; 76 - } 77 - 78 73 updateUI() { 79 74 // update dom to match selection state, only change what's different 80 75 const rows = this.container.querySelectorAll(this.rowSelector); ··· 92 87 }); 93 88 } 94 89 95 - onChange(callback) { 96 - // register listener for selection changes 97 - this.listeners.push(callback); 98 - } 99 - 100 90 notifyListeners() { 101 91 // notify all listeners of selection changes 102 92 this.listeners.forEach((callback) => { ··· 141 131 const maxIdx = Math.max(0, (state?.queue?.length || 0) - 1); 142 132 const boundedIdx = Math.min(Math.max(0, index), maxIdx); 143 133 this.select(boundedIdx); 144 - } 145 - 146 - navigateFirst() { 147 - // navigate to first item 148 - this.navigateTo(0); 149 - } 150 - 151 - navigateLast() { 152 - // navigate to last item 153 - this.navigateTo((state?.queue?.length || 0) - 1); 154 134 } 155 135 156 136 navigatePageUp(pageSize = 10) {
+15 -22
src/js/settings.js
··· 1 1 // settings 2 2 3 - // art type configuration with CSS and HTML naming mappings 4 - const ART_TYPES = [ 5 - { name: "artist", key: "artArtist" }, 6 - { name: "album", key: "artAlbum" }, 7 - { name: "song", key: "artSong" }, 8 - { name: "now-playing", key: "artNowPlaying" }, 9 - ]; 3 + // art settings 4 + const ART_SETTINGS = { 5 + artist: "artArtist", 6 + album: "artAlbum", 7 + song: "artSong", 8 + "now-playing": "artNowPlaying", 9 + }; 10 10 11 11 // apply settings to CSS custom properties 12 12 function applySettings() { 13 - ART_TYPES.forEach(({ name, key }) => { 13 + Object.entries(ART_SETTINGS).forEach(([name, key]) => { 14 14 const size = state.settings[key]; 15 15 document.documentElement.style.setProperty( 16 16 `--art-${name}`, 17 17 size === 0 ? "0px" : `${size}px`, 18 18 ); 19 19 }); 20 - // hide cover art if size is 0 21 20 ui.coverArt.style.display = state.settings.artNowPlaying === 0 ? "none" : ""; 22 21 } 23 22 ··· 37 36 try { 38 37 const loaded = JSON.parse(saved); 39 38 state.settings = { ...state.settings, ...loaded }; 40 - } catch { 41 - // ignore parse errors, use defaults 42 - } 39 + } catch {} 43 40 } 44 41 applySettings(); 45 42 46 43 scrobbleToggle.checked = state.settings.scrobbling; 47 44 dynamicFaviconToggle.checked = state.settings.dynamicFavicon; 48 45 49 - const sliderConfig = ART_TYPES.map(({ name, key }) => ({ 50 - input: document.getElementById(`${name}-size`), 51 - display: document.getElementById(`${name}-size-display`), 52 - key, 53 - })); 46 + // initialize sliders and setup listeners 47 + Object.entries(ART_SETTINGS).forEach(([name, key]) => { 48 + const input = document.getElementById(`${name}-size`); 49 + const display = document.getElementById(`${name}-size-display`); 54 50 55 - // initialize sliders and setup listeners 56 - sliderConfig.forEach(({ input, display, key }) => { 57 51 input.value = state.settings[key]; 58 52 display.textContent = input.value; 59 53 ··· 88 82 dynamicFaviconToggle.addEventListener("change", () => { 89 83 state.settings.dynamicFavicon = dynamicFaviconToggle.checked; 90 84 localStorage.setItem("tinysub_settings", JSON.stringify(state.settings)); 91 - // update favicon when toggling setting 85 + // turned on update favicon to album art 92 86 if (dynamicFaviconToggle.checked) { 93 - // turned ON - update to current track if playing 94 87 if (state.queueIndex >= 0 && state.queue[state.queueIndex]) { 95 88 const track = state.queue[state.queueIndex]; 96 89 const artId = track.albumId || track.coverArt; 97 90 updateFavicon(artId); 98 91 } 99 92 } else { 100 - // turned OFF - reset to default favicon 93 + // turned off resets to default favicon 101 94 updateFavicon(); 102 95 } 103 96 });
+1 -1
src/js/spark-md5.js
··· 1 - /* https://www.npmjs.com/package/spark-md5 */ 1 + // https://www.npmjs.com/package/spark-md5 2 2 3 3 (function (factory) { 4 4 if (typeof exports === "object") {
+2 -2
src/js/state.js
··· 1 - // app state with api, library, queue, and user preferences 1 + // app state 2 2 const state = { 3 3 api: null, 4 4 library: [], ··· 46 46 nextBtn: document.getElementById(DOM_IDS.NEXT_BTN), 47 47 progress: document.getElementById(DOM_IDS.PROGRESS), 48 48 timeDisplay: document.getElementById(DOM_IDS.TIME_DISPLAY), 49 - settingsBtn: document.getElementById("settings-btn"), 49 + settingsBtn: document.getElementById(DOM_IDS.SETTINGS_BTN), 50 50 sectionToggles: document.querySelectorAll(`.${DOM_IDS.SECTION_TOGGLE}`), 51 51 loopBtn: document.getElementById(DOM_IDS.LOOP_BTN), 52 52 sortBtn: document.getElementById(DOM_IDS.SORT_BTN),
-17
src/js/ui.js
··· 54 54 .toString() 55 55 .padStart(2, "0")}`; 56 56 } 57 - 58 - // remove classes from only elements that have them 59 - function clearRowClasses(container, rowSelector, classNames) { 60 - const classes = Array.isArray(classNames) ? classNames : [classNames]; 61 - container.querySelectorAll(rowSelector).forEach((row) => { 62 - classes.forEach((cls) => row.classList.remove(cls)); 63 - }); 64 - } 65 - 66 - // add or remove class from specific rows 67 - function updateRowClass(container, indices, className, add = true) { 68 - const indexSet = new Set(indices); 69 - container.querySelectorAll("tr").forEach((row) => { 70 - const idx = parseInt(row.getAttribute("data-index")); 71 - row.classList.toggle(className, indexSet.has(idx) === add); 72 - }); 73 - }