// context menu let contextMenuEl = null; let currentKeyboardHandler = null; let currentClickHandler = null; let currentSystemContextmenuEventHandler = null; // remove context menu display and event listeners function removeContextMenuDisplay() { if (!contextMenuEl) return; contextMenuEl.remove(); contextMenuEl = null; if (currentKeyboardHandler) { document.removeEventListener("keydown", currentKeyboardHandler); currentKeyboardHandler = null; } if (currentClickHandler) { document.removeEventListener("click", currentClickHandler, { capture: true, }); currentClickHandler = null; } if (currentSystemContextmenuEventHandler) { document.removeEventListener( "contextmenu", currentSystemContextmenuEventHandler, { capture: true, }, ); currentSystemContextmenuEventHandler = null; } } // remove context menu and cleanup all listeners function cleanupContextMenu() { if (!contextMenuEl) return; removeContextMenuDisplay(); // restore focus to main immediately // 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 getMainEl().focus(); } // display context menu with given items at position function showContextMenu(x, y, items) { // close any existing menu removeContextMenuDisplay(); contextMenuEl = createElement("div", { attributes: { id: DOM_IDS.CONTEXT_MENU, role: "menu" }, }); const menuItems = []; Object.entries(items).forEach(([label, handler]) => { const item = createElement("button", { className: CLASSES.CONTEXT_MENU_ITEM, textContent: label, attributes: { role: "menuitem", tabindex: "-1" }, listeners: { mousedown: (e) => e.preventDefault(), // prevent focus on mousedown click: (e) => { e.stopPropagation(); handler(); }, }, }); menuItems.push(item); contextMenuEl.appendChild(item); }); // append to main element to keep focus within main // 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 getMainEl().appendChild(contextMenuEl); // position menu with bounds checking to keep within viewport const rect = contextMenuEl.getBoundingClientRect(); const clampPosition = (pos, size, limit) => Math.max(0, Math.min(pos, limit - size)); contextMenuEl.style.left = `${clampPosition(x, rect.width, window.innerWidth)}px`; contextMenuEl.style.top = `${clampPosition(y, rect.height, window.innerHeight)}px`; // keyboard navigation for context menu let focusedIndex = 0; const updateMenuItemFocus = () => { const focused = contextMenuEl.querySelector(".focused"); if (focused) focused.classList.remove("focused"); menuItems[focusedIndex].classList.add("focused"); }; currentKeyboardHandler = (e) => { if (!contextMenuEl) return; // menu was closed if (!["ArrowUp", "ArrowDown", "Enter", "Space", "Escape"].includes(e.code)) return; e.preventDefault(); e.stopPropagation(); switch (e.code) { case "ArrowUp": focusedIndex = (focusedIndex - 1 + menuItems.length) % menuItems.length; updateMenuItemFocus(); break; case "ArrowDown": focusedIndex = (focusedIndex + 1) % menuItems.length; updateMenuItemFocus(); break; case "Enter": case "Space": menuItems[focusedIndex].click(); break; case "Escape": cleanupContextMenu(); break; } }; document.addEventListener("keydown", currentKeyboardHandler); // close menu on clicks outside it currentClickHandler = (e) => { if (!contextMenuEl?.contains(e.target)) { cleanupContextMenu(); } }; document.addEventListener("click", currentClickHandler, { capture: true }); // prevent default browser context menu from appearing currentSystemContextmenuEventHandler = (e) => { if ( !contextMenuEl?.contains(e.target) && !e.target.closest(`#${DOM_IDS.QUEUE_LIST}`) ) { e.preventDefault(); e.stopImmediatePropagation(); } }; document.addEventListener( "contextmenu", currentSystemContextmenuEventHandler, { capture: true, }, ); } // wrap handler to auto-cleanup const withContextMenuCleanup = (handler) => async (...args) => { try { await handler(...args); } finally { cleanupContextMenu(); } }; // get sort menu items function getSortMenuItems() { return { [STRINGS.CONTEXT_SORT_SHUFFLE]: withContextMenuCleanup(() => { sortQueue("shuffle"); updateQueue(); }), [STRINGS.CONTEXT_SORT_SONG_AZ]: withContextMenuCleanup(() => { sortQueue("title", true); updateQueue(); }), [STRINGS.CONTEXT_SORT_SONG_ZA]: withContextMenuCleanup(() => { sortQueue("title", false); updateQueue(); }), [STRINGS.CONTEXT_SORT_ARTIST_AZ]: withContextMenuCleanup(() => { sortQueue("artist", true); updateQueue(); }), [STRINGS.CONTEXT_SORT_ARTIST_ZA]: withContextMenuCleanup(() => { sortQueue("artist", false); updateQueue(); }), [STRINGS.CONTEXT_SORT_ALBUM_AZ]: withContextMenuCleanup(() => { sortQueue("album", true); updateQueue(); }), [STRINGS.CONTEXT_SORT_ALBUM_ZA]: withContextMenuCleanup(() => { sortQueue("album", false); updateQueue(); }), [STRINGS.CONTEXT_SORT_DURATION_SHORT_LONG]: withContextMenuCleanup(() => { sortQueue("duration", true); updateQueue(); }), [STRINGS.CONTEXT_SORT_DURATION_LONG_SHORT]: withContextMenuCleanup(() => { sortQueue("duration", false); updateQueue(); }), [STRINGS.CONTEXT_SORT_FAVORITED_FIRST]: withContextMenuCleanup(() => { sortQueue("favorited", false); updateQueue(); }), [STRINGS.CONTEXT_SORT_FAVORITED_LAST]: withContextMenuCleanup(() => { sortQueue("favorited", true); updateQueue(); }), }; } // show context menu function showQueueContextMenuAtSelection(x, y, selectedIndices) { showContextMenu(x, y, { [STRINGS.CONTEXT_PLAY]: withContextMenuCleanup(() => { playQueueTrack(selectedIndices[0]); updateQueue(); }), [STRINGS.CONTEXT_PLAY_NEXT]: withContextMenuCleanup(() => { const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0; moveQueueItems(state.queue, selectedIndices, insertPos, queueCallbacks); }), [STRINGS.CONTEXT_SORT]: () => { showContextMenu(x, y, getSortMenuItems()); }, [STRINGS.CONTEXT_FAVORITE]: withContextMenuCleanup(async () => { await Promise.all( selectedIndices.map((i) => setFavoriteSong(state.queue[i], true)), ); updateQueueDisplay(); }), [STRINGS.CONTEXT_UNFAVORITE]: withContextMenuCleanup(async () => { await Promise.all( selectedIndices.map((i) => setFavoriteSong(state.queue[i], false)), ); updateQueueDisplay(); }), [STRINGS.CONTEXT_MOVE_UP]: withContextMenuCleanup(() => { const firstIdx = Math.min(...selectedIndices); if (firstIdx > 0) { moveQueueItems( state.queue, selectedIndices, firstIdx - 1, queueCallbacks, ); } }), [STRINGS.CONTEXT_MOVE_DOWN]: withContextMenuCleanup(() => { const lastIdx = Math.max(...selectedIndices); if (lastIdx < state.queue.length - 1) { moveQueueItems( state.queue, selectedIndices, lastIdx + 2, queueCallbacks, ); } }), [STRINGS.CONTEXT_CLEAR]: withContextMenuCleanup(() => { clearSelectedRows(); }), }); } function setupQueueContextMenu() { ui.queueList.addEventListener( "contextmenu", (e) => { const row = getClosestRow(e.target); if (!row) return; e.preventDefault(); e.stopPropagation(); const idx = getRowIndex(row, DATA_ATTRS.INDEX); if (!selectionManager.isSelected(idx)) { selectionManager.select(idx); } const selectedIndices = Array.from(selectionManager.getSelected()); showQueueContextMenuAtSelection(e.clientX, e.clientY, selectedIndices); }, true, ); }