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

Configure Feed

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

feat: keyboard shortcuts

intergrav 562c0e3b 331365a2

+145 -44
+133 -41
src/js/events.js
··· 1 1 let selectionManager; 2 2 3 - // consolidated callbacks for queue operations 3 + // callbacks for queue operations 4 4 const queueCallbacks = { 5 5 onSelectionChange: (newIndices) => selectionManager.setSelection(newIndices), 6 6 onQueueChange: () => updateQueueDisplay(), 7 7 }; 8 8 9 - // helper to get row index from element 10 - const getRowIdx = (el) => getRowIndex(getClosestRow(el), DATA_ATTRS.INDEX); 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 + }; 11 23 12 24 // check if queue has a valid current track 13 25 const hasValidTrack = () => 14 26 isValidQueueIndex(state.queueIndex, state.queue.length); 15 27 16 - // helper to move queue item with bounds checking 17 - const moveQueueItem = (fromIdx, toIdx) => { 18 - if (toIdx >= 0 && toIdx <= state.queue.length) { 19 - moveQueueItems(state.queue, [fromIdx], toIdx, queueCallbacks); 20 - } 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]); 21 33 }; 22 34 23 35 // map button classes to handler functions 24 36 const QUEUE_BUTTON_HANDLERS = { 25 37 // play the selected track 26 38 [CLASSES.QUEUE_ACTION]: (idx) => { 27 - state.queueIndex = idx; 28 - playTrack(state.queue[idx]); 29 - updateQueue(false); 39 + playQueueTrack(idx); 40 + updateQueue(); 30 41 }, 31 42 // insert selected track after current track 32 43 [CLASSES.QUEUE_PLAY_NEXT]: (idx) => { ··· 34 45 moveQueueItems(state.queue, [idx], insertPos, queueCallbacks); 35 46 }, 36 47 // move up one position 37 - [CLASSES.QUEUE_MOVE_UP]: (idx) => moveQueueItem(idx, idx - 1), 48 + [CLASSES.QUEUE_MOVE_UP]: (idx) => { 49 + if (idx > 0) { 50 + moveQueueItems(state.queue, [idx], idx - 1, queueCallbacks); 51 + } 52 + }, 38 53 // move down one position 39 - [CLASSES.QUEUE_MOVE_DOWN]: (idx) => moveQueueItem(idx, idx + 2), 54 + [CLASSES.QUEUE_MOVE_DOWN]: (idx) => { 55 + if (idx < state.queue.length - 1) { 56 + moveQueueItems(state.queue, [idx], idx + 2, queueCallbacks); 57 + } 58 + }, 40 59 // remove from queue 41 60 [CLASSES.QUEUE_REMOVE]: (idx) => { 42 61 state.queue.splice(idx, 1); ··· 67 86 ); 68 87 } 69 88 89 + // navigate queue selection 90 + const navigateSelection = (offset, extend = false) => { 91 + const currentIdx = selectionManager.lastSelected ?? 0; 92 + const nextIdx = 93 + offset < 0 94 + ? Math.max(0, currentIdx - 1) 95 + : Math.min(state.queue.length - 1, currentIdx + 1); 96 + 97 + if (extend) { 98 + selectionManager.select(nextIdx, { shift: true }); 99 + } else { 100 + selectionManager.select(nextIdx); 101 + } 102 + 103 + ui.queueList 104 + .querySelector(`tr[${DATA_ATTRS.INDEX}="${nextIdx}"]`) 105 + ?.scrollIntoView({ block: "nearest" }); 106 + }; 107 + 108 + // keyboard shortcuts 109 + function setupKeyboardShortcuts() { 110 + document.addEventListener("keydown", (e) => { 111 + // skip if user is typing in an input field 112 + if (document.activeElement.matches("input, textarea")) return; 113 + 114 + switch (e.code) { 115 + case "Space": { 116 + e.preventDefault(); 117 + togglePlayback(); 118 + break; 119 + } 120 + 121 + case "Delete": 122 + case "Backspace": { 123 + e.preventDefault(); 124 + if (selectionManager.count() > 0) { 125 + const selected = selectionManager.getSelected(); 126 + const nextIdx = Math.min( 127 + selected[0], 128 + state.queue.length - selected.length - 1, 129 + ); 130 + removeSelectedRows(); 131 + if (nextIdx >= 0 && state.queue.length > 0) { 132 + selectionManager.select(nextIdx); 133 + } 134 + } 135 + break; 136 + } 137 + 138 + case "ArrowUp": { 139 + e.preventDefault(); 140 + navigateSelection(-1, e.shiftKey); 141 + break; 142 + } 143 + 144 + case "ArrowDown": { 145 + e.preventDefault(); 146 + navigateSelection(1, e.shiftKey); 147 + break; 148 + } 149 + 150 + case "KeyA": { 151 + if (!(e.ctrlKey || e.metaKey)) return; 152 + e.preventDefault(); 153 + if (state.queue.length > 0) { 154 + selectionManager.setSelection( 155 + Array.from({ length: state.queue.length }, (_, i) => i), 156 + ); 157 + } 158 + break; 159 + } 160 + 161 + case "Enter": { 162 + e.preventDefault(); 163 + const selectedIndices = Array.from(selectionManager.getSelected()); 164 + if (selectedIndices.length > 0) playQueueTrack(selectedIndices[0]); 165 + break; 166 + } 167 + 168 + case "Escape": { 169 + e.preventDefault(); 170 + clearSelection(); 171 + break; 172 + } 173 + } 174 + }); 175 + } 176 + 70 177 document.addEventListener("DOMContentLoaded", async () => { 71 178 // initialize selection manager for queue table 72 179 selectionManager = new SelectionManager(ui.queueList, { ··· 106 213 }); 107 214 108 215 // playback controls 109 - ui.playBtn.addEventListener("click", () => { 110 - if (hasValidTrack()) { 111 - if (ui.player.src) { 112 - ui.player.paused ? ui.player.play() : ui.player.pause(); 113 - } else { 114 - playTrack(state.queue[state.queueIndex]); 115 - } 116 - } else if (state.queue.length > 0) { 117 - state.queueIndex = 0; 118 - playTrack(state.queue[0]); 119 - updateQueue(false); 120 - } 121 - }); 216 + ui.playBtn.addEventListener("click", togglePlayback); 217 + ui.prevBtn.addEventListener("click", previousTrack); 218 + ui.nextBtn.addEventListener("click", nextTrack); 122 219 123 - [ui.prevBtn, ui.nextBtn].forEach((btn, i) => 124 - btn.addEventListener("click", i === 0 ? previousTrack : nextTrack), 125 - ); 126 - 127 - // progress slider (seek) 128 - ui.progress.addEventListener("input", (e) => { 220 + // progress slider (seek) - consolidate input/change handlers 221 + const seekHandler = (e) => { 129 222 ui.player.currentTime = parseFloat(e.target.value); 130 - }); 131 - ui.progress.addEventListener("change", (e) => { 132 - ui.player.currentTime = parseFloat(e.target.value); 133 - }); 223 + }; 224 + ui.progress.addEventListener("input", seekHandler); 225 + ui.progress.addEventListener("change", seekHandler); 134 226 135 227 // shuffle queue while preserving current track position 136 228 ui.shuffleBtn.addEventListener("click", () => { ··· 138 230 shuffleQueue(); 139 231 if (currentTrack) { 140 232 state.queueIndex = state.queue.indexOf(currentTrack); 141 - updateQueue(false); 233 + updateQueue(); 142 234 } 143 235 }); 144 236 ··· 160 252 return; 161 253 } 162 254 163 - const idx = getRowIdx(btn); 255 + const idx = getRowIndex(getClosestRow(btn), DATA_ATTRS.INDEX); 164 256 for (const [className, handler] of Object.entries(QUEUE_BUTTON_HANDLERS)) { 165 257 if (btn.classList.contains(className)) { 166 258 handler(idx); ··· 174 266 const row = getClosestRow(e.target); 175 267 if (row) { 176 268 const idx = getRowIndex(row, DATA_ATTRS.INDEX); 177 - state.queueIndex = idx; 178 - saveQueue(); 179 - playTrack(state.queue[idx]); 269 + playQueueTrack(idx); 270 + updateQueue(); 180 271 } 181 272 }); 182 273 ··· 198 289 // setup event handlers 199 290 setupDragAndDrop(); 200 291 setupQueueContextMenu(); 292 + setupKeyboardShortcuts(); 201 293 setupMediaSessionHandlers(); 202 294 203 295 // restore auto-login if credentials saved
+12 -3
src/js/selection-manager.js
··· 4 4 this.container = container; 5 5 this.selected = new Set(); 6 6 this.lastSelected = null; 7 + this.shiftAnchor = null; 7 8 this.rowSelector = options.rowSelector || "tr"; 8 9 this.indexAttribute = options.indexAttribute || "data-index"; 9 10 this.selectedClass = options.selectedClass || "selected"; ··· 19 20 const { multi = false, shift = false } = options; 20 21 21 22 if (shift && this.lastSelected !== null) { 22 - // range selection from last selected 23 - const start = Math.min(this.lastSelected, index); 24 - const end = Math.max(this.lastSelected, index); 23 + // range selection 24 + if (this.shiftAnchor === null) { 25 + this.shiftAnchor = this.lastSelected; 26 + } 27 + const start = Math.min(this.shiftAnchor, index); 28 + const end = Math.max(this.shiftAnchor, index); 29 + this.selected.clear(); 25 30 for (let i = start; i <= end; i++) { 26 31 this.selected.add(i); 27 32 } ··· 30 35 this.selected.has(index) 31 36 ? this.selected.delete(index) 32 37 : this.selected.add(index); 38 + this.shiftAnchor = null; 33 39 } else { 34 40 // single selection, clear others 35 41 this.selected.clear(); 36 42 this.selected.add(index); 43 + this.shiftAnchor = null; 37 44 } 38 45 39 46 this.lastSelected = index; ··· 45 52 // clear selection 46 53 this.selected.clear(); 47 54 this.lastSelected = null; 55 + this.shiftAnchor = null; 48 56 this.updateUI(); 49 57 this.notifyListeners(); 50 58 } ··· 98 106 this.selected.clear(); 99 107 indices.forEach((idx) => this.selected.add(idx)); 100 108 this.lastSelected = indices.length > 0 ? indices[indices.length - 1] : null; 109 + this.shiftAnchor = null; 101 110 this.updateUI(); 102 111 this.notifyListeners(); 103 112 }