a simple web player for subsonic tinysub.devins.page
subsonic navidrome javascript
at main 290 lines 7.7 kB view raw
1// context menu 2 3let contextMenuEl = null; 4let currentKeyboardHandler = null; 5let currentClickHandler = null; 6let currentSystemContextmenuEventHandler = null; 7 8// remove context menu display and event listeners 9function removeContextMenuDisplay() { 10 if (!contextMenuEl) return; 11 contextMenuEl.remove(); 12 contextMenuEl = null; 13 14 if (currentKeyboardHandler) { 15 document.removeEventListener("keydown", currentKeyboardHandler); 16 currentKeyboardHandler = null; 17 } 18 19 if (currentClickHandler) { 20 document.removeEventListener("click", currentClickHandler, { 21 capture: true, 22 }); 23 currentClickHandler = null; 24 } 25 26 if (currentSystemContextmenuEventHandler) { 27 document.removeEventListener( 28 "contextmenu", 29 currentSystemContextmenuEventHandler, 30 { 31 capture: true, 32 }, 33 ); 34 currentSystemContextmenuEventHandler = null; 35 } 36} 37 38// remove context menu and cleanup all listeners 39function cleanupContextMenu() { 40 if (!contextMenuEl) return; 41 42 removeContextMenuDisplay(); 43 44 // restore focus to main immediately 45 // 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 46 getMainEl().focus(); 47} 48 49// display context menu with given items at position 50function showContextMenu(x, y, items) { 51 // close any existing menu 52 removeContextMenuDisplay(); 53 54 contextMenuEl = createElement("div", { 55 attributes: { id: DOM_IDS.CONTEXT_MENU, role: "menu" }, 56 }); 57 58 const menuItems = []; 59 Object.entries(items).forEach(([label, handler]) => { 60 const item = createElement("button", { 61 className: CLASSES.CONTEXT_MENU_ITEM, 62 textContent: label, 63 attributes: { role: "menuitem", tabindex: "-1" }, 64 listeners: { 65 mousedown: (e) => e.preventDefault(), // prevent focus on mousedown 66 click: (e) => { 67 e.stopPropagation(); 68 handler(); 69 }, 70 }, 71 }); 72 menuItems.push(item); 73 contextMenuEl.appendChild(item); 74 }); 75 76 // append to main element to keep focus within main 77 // 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 getMainEl().appendChild(contextMenuEl); 79 80 // position menu with bounds checking to keep within viewport 81 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 87 // keyboard navigation for context menu 88 let focusedIndex = 0; 89 90 const updateMenuItemFocus = () => { 91 const focused = contextMenuEl.querySelector(".focused"); 92 if (focused) focused.classList.remove("focused"); 93 menuItems[focusedIndex].classList.add("focused"); 94 }; 95 96 currentKeyboardHandler = (e) => { 97 if (!contextMenuEl) return; // menu was closed 98 if (!["ArrowUp", "ArrowDown", "Enter", "Space", "Escape"].includes(e.code)) 99 return; 100 101 e.preventDefault(); 102 e.stopPropagation(); 103 104 switch (e.code) { 105 case "ArrowUp": 106 focusedIndex = (focusedIndex - 1 + menuItems.length) % menuItems.length; 107 updateMenuItemFocus(); 108 break; 109 110 case "ArrowDown": 111 focusedIndex = (focusedIndex + 1) % menuItems.length; 112 updateMenuItemFocus(); 113 break; 114 115 case "Enter": 116 case "Space": 117 menuItems[focusedIndex].click(); 118 break; 119 120 case "Escape": 121 cleanupContextMenu(); 122 break; 123 } 124 }; 125 126 document.addEventListener("keydown", currentKeyboardHandler); 127 128 // close menu on clicks outside it 129 currentClickHandler = (e) => { 130 if (!contextMenuEl?.contains(e.target)) { 131 cleanupContextMenu(); 132 } 133 }; 134 document.addEventListener("click", currentClickHandler, { capture: true }); 135 136 // prevent default browser context menu from appearing 137 currentSystemContextmenuEventHandler = (e) => { 138 if ( 139 !contextMenuEl?.contains(e.target) && 140 !e.target.closest(`#${DOM_IDS.QUEUE_LIST}`) 141 ) { 142 e.preventDefault(); 143 e.stopImmediatePropagation(); 144 } 145 }; 146 document.addEventListener( 147 "contextmenu", 148 currentSystemContextmenuEventHandler, 149 { 150 capture: true, 151 }, 152 ); 153} 154 155// wrap handler to auto-cleanup 156const withContextMenuCleanup = 157 (handler) => 158 async (...args) => { 159 try { 160 await handler(...args); 161 } finally { 162 cleanupContextMenu(); 163 } 164 }; 165 166// get sort menu items 167function getSortMenuItems() { 168 return { 169 [STRINGS.CONTEXT_SORT_SHUFFLE]: withContextMenuCleanup(() => { 170 sortQueue("shuffle"); 171 updateQueue(); 172 }), 173 [STRINGS.CONTEXT_SORT_SONG_AZ]: withContextMenuCleanup(() => { 174 sortQueue("title", true); 175 updateQueue(); 176 }), 177 [STRINGS.CONTEXT_SORT_SONG_ZA]: withContextMenuCleanup(() => { 178 sortQueue("title", false); 179 updateQueue(); 180 }), 181 [STRINGS.CONTEXT_SORT_ARTIST_AZ]: withContextMenuCleanup(() => { 182 sortQueue("artist", true); 183 updateQueue(); 184 }), 185 [STRINGS.CONTEXT_SORT_ARTIST_ZA]: withContextMenuCleanup(() => { 186 sortQueue("artist", false); 187 updateQueue(); 188 }), 189 [STRINGS.CONTEXT_SORT_ALBUM_AZ]: withContextMenuCleanup(() => { 190 sortQueue("album", true); 191 updateQueue(); 192 }), 193 [STRINGS.CONTEXT_SORT_ALBUM_ZA]: withContextMenuCleanup(() => { 194 sortQueue("album", false); 195 updateQueue(); 196 }), 197 [STRINGS.CONTEXT_SORT_DURATION_SHORT_LONG]: withContextMenuCleanup(() => { 198 sortQueue("duration", true); 199 updateQueue(); 200 }), 201 [STRINGS.CONTEXT_SORT_DURATION_LONG_SHORT]: withContextMenuCleanup(() => { 202 sortQueue("duration", false); 203 updateQueue(); 204 }), 205 [STRINGS.CONTEXT_SORT_FAVORITED_FIRST]: withContextMenuCleanup(() => { 206 sortQueue("favorited", false); 207 updateQueue(); 208 }), 209 [STRINGS.CONTEXT_SORT_FAVORITED_LAST]: withContextMenuCleanup(() => { 210 sortQueue("favorited", true); 211 updateQueue(); 212 }), 213 }; 214} 215 216// show context menu 217function showQueueContextMenuAtSelection(x, y, selectedIndices) { 218 showContextMenu(x, y, { 219 [STRINGS.CONTEXT_PLAY]: withContextMenuCleanup(() => { 220 playQueueTrack(selectedIndices[0]); 221 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 }), 227 [STRINGS.CONTEXT_SORT]: () => { 228 showContextMenu(x, y, getSortMenuItems()); 229 }, 230 [STRINGS.CONTEXT_FAVORITE]: withContextMenuCleanup(async () => { 231 await Promise.all( 232 selectedIndices.map((i) => setFavoriteSong(state.queue[i], true)), 233 ); 234 updateQueueDisplay(); 235 }), 236 [STRINGS.CONTEXT_UNFAVORITE]: withContextMenuCleanup(async () => { 237 await Promise.all( 238 selectedIndices.map((i) => setFavoriteSong(state.queue[i], false)), 239 ); 240 updateQueueDisplay(); 241 }), 242 [STRINGS.CONTEXT_MOVE_UP]: withContextMenuCleanup(() => { 243 const firstIdx = Math.min(...selectedIndices); 244 if (firstIdx > 0) { 245 moveQueueItems( 246 state.queue, 247 selectedIndices, 248 firstIdx - 1, 249 queueCallbacks, 250 ); 251 } 252 }), 253 [STRINGS.CONTEXT_MOVE_DOWN]: withContextMenuCleanup(() => { 254 const lastIdx = Math.max(...selectedIndices); 255 if (lastIdx < state.queue.length - 1) { 256 moveQueueItems( 257 state.queue, 258 selectedIndices, 259 lastIdx + 2, 260 queueCallbacks, 261 ); 262 } 263 }), 264 [STRINGS.CONTEXT_CLEAR]: withContextMenuCleanup(() => { 265 clearSelectedRows(); 266 }), 267 }); 268} 269 270function setupQueueContextMenu() { 271 ui.queueList.addEventListener( 272 "contextmenu", 273 (e) => { 274 const row = getClosestRow(e.target); 275 if (!row) return; 276 277 e.preventDefault(); 278 e.stopPropagation(); 279 280 const idx = getRowIndex(row, DATA_ATTRS.INDEX); 281 if (!selectionManager.isSelected(idx)) { 282 selectionManager.select(idx); 283 } 284 285 const selectedIndices = Array.from(selectionManager.getSelected()); 286 showQueueContextMenuAtSelection(e.clientX, e.clientY, selectedIndices); 287 }, 288 true, 289 ); 290}