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.

refactor: reorganize utilities

mostly getting stuff out of utils and moving things to their proper modules

also added a short comment for each file

intergrav 307ca1c4 ec42b76d

+414 -392
+1 -1
src/index.html
··· 210 210 211 211 <script src="js/constants.js"></script> 212 212 <script src="js/strings/en.js"></script> 213 - <script src="js/utils.js"></script> 214 213 <script src="js/validation.js"></script> 215 214 <script src="js/selection-manager.js"></script> 216 215 <script src="js/api.js"></script> ··· 227 226 <script src="js/draggable.js"></script> 228 227 <script src="js/contextmenu.js"></script> 229 228 <script src="js/lyrics.js"></script> 229 + <script src="js/input.js"></script> 230 230 <script src="js/events.js"></script> 231 231 </body> 232 232 </html>
+2 -1
src/js/api.js
··· 1 - // subsonic api client for server communication 1 + // subsonic api client 2 + 2 3 class SubsonicAPI { 3 4 constructor(serverUrl, username, password) { 4 5 this.serverUrl = serverUrl.replace(/\/$/, "");
+11 -14
src/js/auth.js
··· 1 - // credential manager with fallback handling 1 + // authorization 2 + 2 3 const CredentialManager = { 3 4 save: (server, username, password) => { 4 5 localStorage.setItem( ··· 7 8 ); 8 9 }, 9 10 load: () => { 10 - try { 11 - const saved = localStorage.getItem("tinysub_credentials"); 12 - return saved 13 - ? JSON.parse(saved) 14 - : { server: "", username: "", password: "" }; 15 - } catch { 16 - // fallback to old format if atomic json fails 17 - return { 18 - server: localStorage.getItem("tinysub_server") || "", 19 - username: localStorage.getItem("tinysub_username") || "", 20 - password: localStorage.getItem("tinysub_password") || "", 21 - }; 22 - } 11 + const saved = localStorage.getItem("tinysub_credentials"); 12 + return saved 13 + ? JSON.parse(saved) 14 + : { server: "", username: "", password: "" }; 23 15 }, 16 + }; 17 + 18 + // toggle auth modal visibility 19 + const toggleAuthModal = (show) => { 20 + document.getElementById(DOM_IDS.AUTH_MODAL).classList.toggle("hidden", !show); 24 21 }; 25 22 26 23 // load library, playlists, and favorites after successful login
+1 -1
src/js/constants.js
··· 1 - // constants 1 + // css classes and ui constants 2 2 3 3 const CLASSES = { 4 4 SELECTED: "selected",
+2
src/js/contextmenu.js
··· 1 + // context menu 2 + 1 3 let contextMenuEl = null; 2 4 let currentHideMenuHandler = null; 3 5
+2
src/js/draggable.js
··· 1 + // drag and drop support for reordering queue items 2 + 1 3 // setup drag and drop for queue reordering 2 4 function setupDragAndDrop() { 3 5 const clearDragOver = () =>
+2 -166
src/js/events.js
··· 1 - let selectionManager; 2 - 3 - // callbacks for queue operations 4 - const queueCallbacks = { 5 - onSelectionChange: (newIndices) => selectionManager.setSelection(newIndices), 6 - onQueueChange: () => updateQueueDisplay(), 7 - }; 8 - 9 - // toggle playback or start queue if nothing playing 10 - const togglePlayback = () => { 11 - if (hasValidTrack()) { 12 - if (ui.player.src) { 13 - ui.player.paused ? ui.player.play() : ui.player.pause(); 14 - } else { 15 - playTrack(state.queue[state.queueIndex]); 16 - } 17 - } else if (state.queue.length > 0) { 18 - state.queueIndex = 0; 19 - playTrack(state.queue[0]); 20 - updateQueue(); 21 - } 22 - }; 23 - 24 - // check if queue has a valid current track 25 - const hasValidTrack = () => 26 - isValidQueueIndex(state.queueIndex, state.queue.length); 27 - 28 - // helper to play a track at given queue index 29 - const playQueueTrack = (idx) => { 30 - state.queueIndex = idx; 31 - saveQueue(); 32 - playTrack(state.queue[idx]); 33 - }; 34 - 35 - // map button classes to handler functions 36 - const QUEUE_BUTTON_HANDLERS = { 37 - // play the selected track 38 - [CLASSES.QUEUE_PLAY]: (idx) => { 39 - playQueueTrack(idx); 40 - updateQueue(); 41 - }, 42 - // insert selected track after current track 43 - [CLASSES.QUEUE_PLAY_NEXT]: (idx) => { 44 - const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0; 45 - moveQueueItems(state.queue, [idx], insertPos, queueCallbacks); 46 - }, 47 - // move up one position 48 - [CLASSES.QUEUE_MOVE_UP]: (idx) => { 49 - if (idx > 0) { 50 - moveQueueItems(state.queue, [idx], idx - 1, queueCallbacks); 51 - } 52 - }, 53 - // move down one position 54 - [CLASSES.QUEUE_MOVE_DOWN]: (idx) => { 55 - if (idx < state.queue.length - 1) { 56 - moveQueueItems(state.queue, [idx], idx + 2, queueCallbacks); 57 - } 58 - }, 59 - // clear from queue 60 - [CLASSES.QUEUE_CLEAR]: (idx) => { 61 - const isCurrentTrack = removeFromQueue(idx); 62 - 63 - if (isCurrentTrack) { 64 - handleCurrentTrackDeleted(); 65 - highlightCurrentTrack(); 66 - } 1 + // event listener setup and DOM event binding 67 2 68 - updateQueue(); 69 - }, 70 - // toggle favorite status 71 - [CLASSES.QUEUE_FAVORITE]: async (idx) => { 72 - const song = state.queue[idx]; 73 - if (song) { 74 - await setFavoriteSong(song); 75 - updateQueueDisplay(); 76 - } 77 - }, 78 - }; 3 + let selectionManager; 79 4 80 5 // setup media control handlers for hardware buttons and lock screen 81 6 function setupMediaSessionHandlers() { ··· 90 15 }).forEach(([action, handler]) => 91 16 navigator.mediaSession.setActionHandler(action, handler), 92 17 ); 93 - } 94 - 95 - // navigate queue selection 96 - const navigateSelection = (offset, extend = false) => { 97 - const currentIdx = selectionManager.lastSelected ?? 0; 98 - const nextIdx = 99 - offset < 0 100 - ? Math.max(0, currentIdx - 1) 101 - : Math.min(state.queue.length - 1, currentIdx + 1); 102 - 103 - if (extend) { 104 - selectionManager.select(nextIdx, { shift: true }); 105 - } else { 106 - selectionManager.select(nextIdx); 107 - } 108 - 109 - ui.queueList 110 - .querySelector(`tr[${DATA_ATTRS.INDEX}="${nextIdx}"]`) 111 - ?.scrollIntoView({ block: "nearest" }); 112 - }; 113 - 114 - // keyboard shortcuts 115 - function setupKeyboardShortcuts() { 116 - document.addEventListener("keydown", (e) => { 117 - // skip if user is typing in an input field 118 - if (document.activeElement.matches("input, textarea")) return; 119 - 120 - switch (e.code) { 121 - case "Space": { 122 - e.preventDefault(); 123 - togglePlayback(); 124 - break; 125 - } 126 - 127 - case "Delete": 128 - case "Backspace": { 129 - e.preventDefault(); 130 - if (selectionManager.count() > 0) { 131 - const selected = selectionManager.getSelected(); 132 - const nextIdx = Math.min( 133 - selected[0], 134 - state.queue.length - selected.length - 1, 135 - ); 136 - clearSelectedRows(); 137 - if (nextIdx >= 0 && state.queue.length > 0) { 138 - selectionManager.select(nextIdx); 139 - } 140 - } 141 - break; 142 - } 143 - 144 - case "ArrowUp": { 145 - e.preventDefault(); 146 - navigateSelection(-1, e.shiftKey); 147 - break; 148 - } 149 - 150 - case "ArrowDown": { 151 - e.preventDefault(); 152 - navigateSelection(1, e.shiftKey); 153 - break; 154 - } 155 - 156 - case "KeyA": { 157 - if (!(e.ctrlKey || e.metaKey)) return; 158 - e.preventDefault(); 159 - if (state.queue.length > 0) { 160 - selectionManager.setSelection( 161 - Array.from({ length: state.queue.length }, (_, i) => i), 162 - ); 163 - } 164 - break; 165 - } 166 - 167 - case "Enter": { 168 - e.preventDefault(); 169 - const selectedIndices = Array.from(selectionManager.getSelected()); 170 - if (selectedIndices.length > 0) playQueueTrack(selectedIndices[0]); 171 - break; 172 - } 173 - 174 - case "Escape": { 175 - e.preventDefault(); 176 - cleanupContextMenu(); 177 - clearSelection(); 178 - break; 179 - } 180 - } 181 - }); 182 18 } 183 19 184 20 document.addEventListener("DOMContentLoaded", async () => {
+93
src/js/input.js
··· 1 + // input handling 2 + 3 + // navigate queue selection with arrow keys 4 + const navigateSelection = (offset, extend = false) => { 5 + if (!selectionManager) return; 6 + const currentIdx = selectionManager.lastSelected ?? 0; 7 + const nextIdx = 8 + offset < 0 9 + ? Math.max(0, currentIdx - 1) 10 + : Math.min(state.queue.length - 1, currentIdx + 1); 11 + 12 + if (extend) { 13 + selectionManager.select(nextIdx, { shift: true }); 14 + } else { 15 + selectionManager.select(nextIdx); 16 + } 17 + 18 + ui.queueList 19 + .querySelector(`tr[${DATA_ATTRS.INDEX}="${nextIdx}"]`) 20 + ?.scrollIntoView({ block: "nearest" }); 21 + }; 22 + 23 + // setup keyboard shortcuts 24 + function setupKeyboardShortcuts() { 25 + document.addEventListener("keydown", (e) => { 26 + // skip if user is typing in an input field 27 + if (document.activeElement.matches("input, textarea")) return; 28 + 29 + switch (e.code) { 30 + case "Space": { 31 + e.preventDefault(); 32 + togglePlayback(); 33 + break; 34 + } 35 + 36 + case "Delete": 37 + case "Backspace": { 38 + e.preventDefault(); 39 + if (selectionManager?.count() > 0) { 40 + const selected = selectionManager.getSelected(); 41 + const nextIdx = Math.min( 42 + selected[0], 43 + state.queue.length - selected.length - 1, 44 + ); 45 + clearSelectedRows(); 46 + if (nextIdx >= 0 && state.queue.length > 0) { 47 + selectionManager.select(nextIdx); 48 + } 49 + } 50 + break; 51 + } 52 + 53 + case "ArrowUp": { 54 + e.preventDefault(); 55 + navigateSelection(-1, e.shiftKey); 56 + break; 57 + } 58 + 59 + case "ArrowDown": { 60 + e.preventDefault(); 61 + navigateSelection(1, e.shiftKey); 62 + break; 63 + } 64 + 65 + case "KeyA": { 66 + if (!(e.ctrlKey || e.metaKey)) return; 67 + e.preventDefault(); 68 + if (state.queue.length > 0) { 69 + selectionManager?.setSelection( 70 + Array.from({ length: state.queue.length }, (_, i) => i), 71 + ); 72 + } 73 + break; 74 + } 75 + 76 + case "Enter": { 77 + e.preventDefault(); 78 + const selectedIndices = Array.from( 79 + selectionManager?.getSelected() || [], 80 + ); 81 + if (selectedIndices.length > 0) playQueueTrack(selectedIndices[0]); 82 + break; 83 + } 84 + 85 + case "Escape": { 86 + e.preventDefault(); 87 + cleanupContextMenu(); 88 + clearSelection(); 89 + break; 90 + } 91 + } 92 + }); 93 + }
+79
src/js/library.js
··· 1 + // library tree rendering and navigation 2 + 1 3 const playNextByType = { 2 4 artist: (id) => 3 5 addToQueue(() => state.api.getArtist(id), SONG_EXTRACTORS.artist, true), ··· 8 10 song: (song) => 9 11 addToQueue(() => Promise.resolve(song), SONG_EXTRACTORS.song, true), 10 12 }; 13 + 14 + // create tree item with cover art and action buttons 15 + function createTreeItem( 16 + name, 17 + coverArtId, 18 + onToggle, 19 + onAdd, 20 + onPlayNext, 21 + artType, 22 + itemId, 23 + ) { 24 + const li = createElement("li"); 25 + const div = createElement("div", { className: CLASSES.TREE_ITEM }); 26 + 27 + if (itemId) li.dataset.itemId = itemId; 28 + 29 + const linkChildren = []; 30 + if (coverArtId) { 31 + const imgEl = createElement("img", { 32 + attributes: { 33 + alt: "cover", 34 + loading: "lazy", 35 + }, 36 + }); 37 + loadCachedImage(imgEl, coverArtId, artType); 38 + linkChildren.push(imgEl); 39 + } 40 + linkChildren.push(createElement("span", { textContent: name })); 41 + 42 + const linkEl = createElement("a", { 43 + className: onToggle ? CLASSES.TREE_TOGGLE : CLASSES.TREE_NAME, 44 + listeners: { 45 + click: (e) => { 46 + e.preventDefault(); 47 + onToggle?.(li); 48 + }, 49 + }, 50 + children: linkChildren, 51 + }); 52 + 53 + div.appendChild(linkEl); 54 + if (onPlayNext) 55 + div.appendChild( 56 + createIconButton( 57 + "tree-action", 58 + "play next", 59 + ICONS.PLAY_NEXT, 60 + "play next", 61 + onPlayNext, 62 + ), 63 + ); 64 + if (onAdd) 65 + div.appendChild( 66 + createIconButton("tree-action", "add", ICONS.ADD, "add", onAdd), 67 + ); 68 + 69 + li.appendChild(div); 70 + return li; 71 + } 72 + 73 + // toggle library section expand/collapse 74 + function handleSectionToggle(e) { 75 + e.preventDefault(); 76 + const section = e.target.dataset.section; 77 + state.expanded[section] = !state.expanded[section]; 78 + if ( 79 + state.expanded[section] && 80 + typeof state.expanded.items[section] === "undefined" 81 + ) { 82 + state.expanded.items[section] = {}; 83 + } 84 + const renderers = { 85 + artists: renderLibraryTree, 86 + playlists: renderPlaylistsTree, 87 + }; 88 + renderers[section]?.(); 89 + } 11 90 12 91 async function loadData(config) { 13 92 const { fetcher, transformer, stateKey, renderFn } = config;
+2
src/js/lyrics.js
··· 1 + // lyrics parsing and display (synced lyrics only for now) 2 + 1 3 let currentLyrics = []; 2 4 let lyricIndex = 0; 3 5
+35
src/js/player.js
··· 1 + // audio playback control and player state management 2 + 1 3 // load and play a song 2 4 function playTrack(song) { 3 5 if (!song) { ··· 24 26 ui.coverArt.srcset = ""; 25 27 } 26 28 } 29 + 30 + // update play button icon based on playback state 31 + const updatePlayIcon = () => { 32 + const iconPath = ui.player.paused ? ICONS.PLAY : ICONS.PAUSE; 33 + const label = ui.player.paused ? "play" : "pause"; 34 + ui.playBtn.innerHTML = `<img src="${iconPath}" alt="${label}" />`; 35 + }; 27 36 28 37 // move to next or previous track in queue 29 38 function navigateTrack(offset) { ··· 44 53 45 54 const previousTrack = () => navigateTrack(-1); 46 55 const nextTrack = () => navigateTrack(1); 56 + 57 + // toggle playback or start queue if nothing playing 58 + const togglePlayback = () => { 59 + if (hasValidTrack()) { 60 + if (ui.player.src) { 61 + ui.player.paused ? ui.player.play() : ui.player.pause(); 62 + } else { 63 + playTrack(state.queue[state.queueIndex]); 64 + } 65 + } else if (state.queue.length > 0) { 66 + state.queueIndex = 0; 67 + playTrack(state.queue[0]); 68 + updateQueue(); 69 + } 70 + }; 71 + 72 + // check if queue has a valid current track 73 + const hasValidTrack = () => 74 + isValidQueueIndex(state.queueIndex, state.queue.length); 75 + 76 + // helper to play a track at given queue index 77 + const playQueueTrack = (idx) => { 78 + state.queueIndex = idx; 79 + saveQueue(); 80 + playTrack(state.queue[idx]); 81 + }; 47 82 48 83 // toggle favorite status of a song 49 84 const setFavoriteSong = async (song, force) => {
+98
src/js/queue.js
··· 1 + // queue management, sorting, virtual scrolling, and track display 2 + 1 3 // sort or shuffle queue, preserving current track position 2 4 // if items are selected, only sorts selected items, otherwise sorts entire queue 3 5 function sortQueue(field, ascending) { ··· 160 162 } 161 163 } 162 164 } 165 + } 166 + 167 + // move items within queue and maintain proper indices 168 + function moveQueueItems(queue, selectedIndices, insertPos, callbacks = {}) { 169 + const { onSelectionChange, onQueueChange } = callbacks; 170 + const sortedIndices = Array.from(selectedIndices).sort((a, b) => a - b); 171 + const movedItems = sortedIndices.map((i) => queue[i]); 172 + 173 + // remove items in reverse order to avoid index shifting 174 + for ( 175 + let i = sortedIndices[sortedIndices.length - 1]; 176 + i >= sortedIndices[0]; 177 + i-- 178 + ) { 179 + if (selectedIndices.includes(i)) queue.splice(i, 1); 180 + } 181 + 182 + // normalize insert position for removed items 183 + insertPos -= sortedIndices.filter((i) => i < insertPos).length; 184 + 185 + // insert moved items at target position 186 + queue.splice(insertPos, 0, ...movedItems); 187 + 188 + // update queue index for moved/removed items 189 + if (state.queueIndex >= 0) { 190 + if (selectedIndices.includes(state.queueIndex)) { 191 + const positionInMoved = sortedIndices.filter( 192 + (i) => i < state.queueIndex, 193 + ).length; 194 + state.queueIndex = insertPos + positionInMoved; 195 + } else { 196 + const newIndex = 197 + state.queueIndex - 198 + sortedIndices.filter((i) => i < state.queueIndex).length; 199 + state.queueIndex = 200 + insertPos <= newIndex ? newIndex + movedItems.length : newIndex; 201 + } 202 + } 203 + 204 + saveQueue(); 205 + 206 + // batch DOM updates together to avoid reflows 207 + if (onSelectionChange) 208 + onSelectionChange(sortedIndices.map((_, i) => insertPos + i)); 209 + if (onQueueChange) onQueueChange(); 163 210 } 164 211 165 212 // restore queue from localStorage ··· 408 455 tr.appendChild(cells); 409 456 return tr; 410 457 } 458 + 459 + // callbacks for queue operations 460 + const queueCallbacks = { 461 + onSelectionChange: (newIndices) => selectionManager?.setSelection(newIndices), 462 + onQueueChange: () => updateQueueDisplay(), 463 + }; 464 + 465 + // map button classes to queue action handlers 466 + const QUEUE_BUTTON_HANDLERS = { 467 + // play the selected track 468 + [CLASSES.QUEUE_PLAY]: (idx) => { 469 + playQueueTrack(idx); 470 + updateQueue(); 471 + }, 472 + // insert selected track after current track 473 + [CLASSES.QUEUE_PLAY_NEXT]: (idx) => { 474 + const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0; 475 + moveQueueItems(state.queue, [idx], insertPos, queueCallbacks); 476 + }, 477 + // move up one position 478 + [CLASSES.QUEUE_MOVE_UP]: (idx) => { 479 + if (idx > 0) { 480 + moveQueueItems(state.queue, [idx], idx - 1, queueCallbacks); 481 + } 482 + }, 483 + // move down one position 484 + [CLASSES.QUEUE_MOVE_DOWN]: (idx) => { 485 + if (idx < state.queue.length - 1) { 486 + moveQueueItems(state.queue, [idx], idx + 2, queueCallbacks); 487 + } 488 + }, 489 + // clear from queue 490 + [CLASSES.QUEUE_CLEAR]: (idx) => { 491 + const isCurrentTrack = removeFromQueue(idx); 492 + 493 + if (isCurrentTrack) { 494 + handleCurrentTrackDeleted(); 495 + highlightCurrentTrack(); 496 + } 497 + 498 + updateQueue(); 499 + }, 500 + // toggle favorite status 501 + [CLASSES.QUEUE_FAVORITE]: async (idx) => { 502 + const song = state.queue[idx]; 503 + if (song) { 504 + await setFavoriteSong(song); 505 + updateQueueDisplay(); 506 + } 507 + }, 508 + };
+1
src/js/selection-manager.js
··· 1 1 // manages row selection state for queue table (single, multi, range) 2 + 2 3 class SelectionManager { 3 4 constructor(container, options = {}) { 4 5 this.container = container;
+2
src/js/settings.js
··· 1 + // settings 2 + 1 3 // load settings from localStorage and apply to state 2 4 function loadSettings() { 3 5 const saved = localStorage.getItem("tinysub_settings");
+1
src/js/spark-md5.js
··· 1 1 /* https://www.npmjs.com/package/spark-md5 */ 2 + 2 3 (function (factory) { 3 4 if (typeof exports === "object") { 4 5 module.exports = factory();
+78 -76
src/js/ui.js
··· 1 - // update play button icon based on playback state 2 - const updatePlayIcon = () => { 3 - const iconPath = ui.player.paused ? ICONS.PLAY : ICONS.PAUSE; 4 - const label = ui.player.paused ? "play" : "pause"; 5 - ui.playBtn.innerHTML = `<img src="${iconPath}" alt="${label}" />`; 6 - }; 1 + // generic DOM utilities and element factories 7 2 8 - // create tree item with cover art and action buttons 9 - function createTreeItem( 10 - name, 11 - coverArtId, 12 - onToggle, 13 - onAdd, 14 - onPlayNext, 15 - artType, 16 - itemId, 17 - ) { 18 - const li = createElement("li"); 19 - const div = createElement("div", { className: CLASSES.TREE_ITEM }); 3 + // generic element creator 4 + function createElement(tag, config = {}) { 5 + const { 6 + className, 7 + textContent, 8 + html, 9 + attributes = {}, 10 + listeners = {}, 11 + children = [], 12 + } = config; 13 + const el = document.createElement(tag); 14 + if (className) el.className = className; 15 + if (textContent) el.textContent = textContent; 16 + if (html) el.innerHTML = html; 17 + Object.entries(attributes).forEach(([key, value]) => 18 + el.setAttribute(key, value), 19 + ); 20 + Object.entries(listeners).forEach(([event, handler]) => 21 + el.addEventListener(event, handler), 22 + ); 23 + children.forEach((child) => el.appendChild(child)); 24 + return el; 25 + } 20 26 21 - if (itemId) li.dataset.itemId = itemId; 27 + // create a button with icon 28 + function createIconButton(className, ariaLabel, iconPath, iconAlt, onCallback) { 29 + const btn = createElement("button", { 30 + className, 31 + attributes: { "aria-label": ariaLabel }, 32 + children: [ 33 + createElement("img", { attributes: { src: iconPath, alt: iconAlt } }), 34 + ], 35 + }); 22 36 23 - const linkChildren = []; 24 - if (coverArtId) { 25 - const imgEl = createElement("img", { 26 - attributes: { 27 - alt: "cover", 28 - loading: "lazy", 29 - }, 37 + if (onCallback) { 38 + btn.addEventListener("click", async (e) => { 39 + e.preventDefault(); 40 + btn.classList.add(CLASSES.DISABLED); 41 + try { 42 + await onCallback(); 43 + } finally { 44 + btn.classList.remove(CLASSES.DISABLED); 45 + } 30 46 }); 31 - loadCachedImage(imgEl, coverArtId, artType); 32 - linkChildren.push(imgEl); 33 47 } 34 - linkChildren.push(createElement("span", { textContent: name })); 35 48 36 - const linkEl = createElement("a", { 37 - className: onToggle ? CLASSES.TREE_TOGGLE : CLASSES.TREE_NAME, 38 - listeners: { 39 - click: (e) => { 40 - e.preventDefault(); 41 - onToggle?.(li); 42 - }, 43 - }, 44 - children: linkChildren, 45 - }); 49 + return btn; 50 + } 46 51 47 - div.appendChild(linkEl); 48 - if (onPlayNext) 49 - div.appendChild( 50 - createIconButton( 51 - "tree-action", 52 - "play next", 53 - ICONS.PLAY_NEXT, 54 - "play next", 55 - onPlayNext, 56 - ), 57 - ); 58 - if (onAdd) 59 - div.appendChild( 60 - createIconButton("tree-action", "add", ICONS.ADD, "add", onAdd), 61 - ); 52 + // find closest row element 53 + function getClosestRow(el, rowSelector = "tr") { 54 + return el.closest(rowSelector); 55 + } 56 + 57 + // format seconds as mm:ss string 58 + function formatDuration(seconds) { 59 + if (!seconds || !Number.isFinite(seconds)) return "0:00"; 60 + const minutes = Math.floor(seconds / 60); 61 + return `${minutes}:${Math.floor(seconds % 60) 62 + .toString() 63 + .padStart(2, "0")}`; 64 + } 62 65 63 - li.appendChild(div); 64 - return li; 66 + // get row index from data attribute 67 + function getRowIndex(rowEl, attrName = "data-index") { 68 + return parseInt(rowEl.getAttribute(attrName)); 65 69 } 66 70 67 - const SECTION_RENDERERS = { 68 - artists: renderLibraryTree, 69 - playlists: renderPlaylistsTree, 70 - }; 71 + // remove classes from only elements that have them 72 + function clearRowClasses(container, rowSelector, classNames) { 73 + const classes = Array.isArray(classNames) ? classNames : [classNames]; 74 + container.querySelectorAll(rowSelector).forEach((row) => { 75 + classes.forEach((cls) => row.classList.remove(cls)); 76 + }); 77 + } 71 78 72 - // toggle library section expand/collapse 73 - function handleSectionToggle(e) { 74 - e.preventDefault(); 75 - const section = e.target.dataset.section; 76 - state.expanded[section] = !state.expanded[section]; 77 - if ( 78 - state.expanded[section] && 79 - typeof state.expanded.items[section] === "undefined" 80 - ) { 81 - state.expanded.items[section] = {}; 82 - } 83 - SECTION_RENDERERS[section]?.(); 79 + // add or remove class from specific rows 80 + function updateRowClass(container, indices, className, add = true) { 81 + const indexSet = new Set(indices); 82 + container.querySelectorAll("tr").forEach((row) => { 83 + const idx = getRowIndex(row); 84 + row.classList.toggle(className, indexSet.has(idx) === add); 85 + }); 84 86 } 85 87 86 - // toggle auth modal 87 - const toggleAuthModal = (show) => { 88 - document.getElementById(DOM_IDS.AUTH_MODAL).classList.toggle("hidden", !show); 89 - }; 88 + // validate queue index 89 + function isValidQueueIndex(index, queueLength) { 90 + return index >= 0 && index < queueLength; 91 + }
-133
src/js/utils.js
··· 1 - // format seconds as mm:ss string 2 - function formatDuration(seconds) { 3 - if (!seconds || !Number.isFinite(seconds)) return "0:00"; 4 - const minutes = Math.floor(seconds / 60); 5 - return `${minutes}:${Math.floor(seconds % 60) 6 - .toString() 7 - .padStart(2, "0")}`; 8 - } 9 - 10 - // get row index from data attribute 11 - function getRowIndex(rowEl, attrName = "data-index") { 12 - return parseInt(rowEl.getAttribute(attrName)); 13 - } 14 - 15 - // find closest row element 16 - function getClosestRow(el, rowSelector = "tr") { 17 - return el.closest(rowSelector); 18 - } 19 - 20 - // generic element creator 21 - function createElement(tag, config = {}) { 22 - const { 23 - className, 24 - textContent, 25 - html, 26 - attributes = {}, 27 - listeners = {}, 28 - children = [], 29 - } = config; 30 - const el = document.createElement(tag); 31 - if (className) el.className = className; 32 - if (textContent) el.textContent = textContent; 33 - if (html) el.innerHTML = html; 34 - Object.entries(attributes).forEach(([key, value]) => 35 - el.setAttribute(key, value), 36 - ); 37 - Object.entries(listeners).forEach(([event, handler]) => 38 - el.addEventListener(event, handler), 39 - ); 40 - children.forEach((child) => el.appendChild(child)); 41 - return el; 42 - } 43 - 44 - // create a button with icon 45 - function createIconButton(className, ariaLabel, iconPath, iconAlt, onCallback) { 46 - const btn = createElement("button", { 47 - className, 48 - attributes: { "aria-label": ariaLabel }, 49 - children: [ 50 - createElement("img", { attributes: { src: iconPath, alt: iconAlt } }), 51 - ], 52 - }); 53 - 54 - if (onCallback) { 55 - btn.addEventListener("click", async (e) => { 56 - e.preventDefault(); 57 - btn.classList.add(CLASSES.DISABLED); 58 - try { 59 - await onCallback(); 60 - } finally { 61 - btn.classList.remove(CLASSES.DISABLED); 62 - } 63 - }); 64 - } 65 - 66 - return btn; 67 - } 68 - 69 - // remove classes from only elements that have them 70 - function clearRowClasses(container, rowSelector, classNames) { 71 - const classes = Array.isArray(classNames) ? classNames : [classNames]; 72 - container.querySelectorAll(rowSelector).forEach((row) => { 73 - classes.forEach((cls) => row.classList.remove(cls)); 74 - }); 75 - } 76 - 77 - // add or remove class from specific rows 78 - function updateRowClass(container, indices, className, add = true) { 79 - const indexSet = new Set(indices); 80 - container.querySelectorAll("tr").forEach((row) => { 81 - const idx = getRowIndex(row); 82 - row.classList.toggle(className, indexSet.has(idx) === add); 83 - }); 84 - } 85 - 86 - function isValidQueueIndex(index, queueLength) { 87 - return index >= 0 && index < queueLength; 88 - } 89 - 90 - // move items within queue and maintain proper indices 91 - function moveQueueItems(queue, selectedIndices, insertPos, callbacks = {}) { 92 - const { onSelectionChange, onQueueChange } = callbacks; 93 - const sortedIndices = Array.from(selectedIndices).sort((a, b) => a - b); 94 - const movedItems = sortedIndices.map((i) => queue[i]); 95 - 96 - // remove items in reverse order to avoid index shifting 97 - for ( 98 - let i = sortedIndices[sortedIndices.length - 1]; 99 - i >= sortedIndices[0]; 100 - i-- 101 - ) { 102 - if (selectedIndices.includes(i)) queue.splice(i, 1); 103 - } 104 - 105 - // normalize insert position for removed items 106 - insertPos -= sortedIndices.filter((i) => i < insertPos).length; 107 - 108 - // insert moved items at target position 109 - queue.splice(insertPos, 0, ...movedItems); 110 - 111 - // update queue index for moved/removed items 112 - if (state.queueIndex >= 0) { 113 - if (selectedIndices.includes(state.queueIndex)) { 114 - const positionInMoved = sortedIndices.filter( 115 - (i) => i < state.queueIndex, 116 - ).length; 117 - state.queueIndex = insertPos + positionInMoved; 118 - } else { 119 - const newIndex = 120 - state.queueIndex - 121 - sortedIndices.filter((i) => i < state.queueIndex).length; 122 - state.queueIndex = 123 - insertPos <= newIndex ? newIndex + movedItems.length : newIndex; 124 - } 125 - } 126 - 127 - saveQueue(); 128 - 129 - // batch DOM updates together to avoid reflows 130 - if (onSelectionChange) 131 - onSelectionChange(sortedIndices.map((_, i) => insertPos + i)); 132 - if (onQueueChange) onQueueChange(); 133 - }
+2
src/js/validation.js
··· 1 + // input validation for server URLs and credentials 2 + 1 3 // build validation response object 2 4 const buildValidation = (valid, value, error) => ({ 3 5 valid,
+2
src/js/virtual-scroll.js
··· 1 + // virtual scrolling for efficient rendering of massive queues 2 + 1 3 const STRIPE_CLASS = "stripe"; 2 4 3 5 class VirtualScroller {