// most keyboard and input control // TODO: kinda a monolith file, maybe should split into separate modules later? idk :p // tab order // selector for all interactive elements that should be non-tabbable const INTERACTIVE_SELECTOR = "button, a, input, select, textarea, [role='button'], tr, li, ul, .section-toggle"; // apply `tabIndex=-1` to ALL interactive elements, only queue and library get tabIndex=0 function lockTabOrder() { document.querySelectorAll(INTERACTIVE_SELECTOR).forEach((el) => { // skip queue and library which are always tabbable if (el.id === "queue" || el.id === "library") return; // skip if element is inside a modal const modal = el.closest(".modal:not(.hidden)"); if (modal) return; // lock all other interactive elements el.tabIndex = -1; }); } // watch for dynamically added elements and lock their tab order function setupTabOrderObserver() { let isUpdating = false; const observer = new MutationObserver((mutations) => { const hasAddedNodes = mutations.some( (m) => m.type === "childList" && m.addedNodes.length, ); if (hasAddedNodes && !isUpdating) lockTabOrder(); }); observer.observe(document.body, { childList: true, subtree: true, }); // expose flag so queue operations can pause observer during DOM updates return { pauseUpdates: () => { isUpdating = true; }, resumeUpdates: () => { isUpdating = false; }, }; } let tabOrderObserver; // queue navigation // navigate queue selection with arrow keys const navigateSelection = (offset, extend = false) => { if (!selectionManager) return; const currentIdx = selectionManager.lastSelected ?? 0; const nextIdx = offset < 0 ? Math.max(0, currentIdx - 1) : Math.min(state.queue.length - 1, currentIdx + 1); if (extend) { selectionManager.select(nextIdx, { shift: true }); } else { selectionManager.select(nextIdx); } // ensure queue container has focus for keyboard navigation ui.queueList.focus(); }; // cache element references at module scope to avoid repeated lookups const elementCache = { queue: null, library: null }; // get cached element by id, refresh if detached from DOM function getCachedElement(type) { const id = type === "queue" ? "queue" : "library"; if (!elementCache[type] || !document.body.contains(elementCache[type])) { elementCache[type] = document.getElementById(id); } return elementCache[type]; } function getMainEl() { return getCachedElement("queue"); } function getLibraryEl() { return getCachedElement("library"); } // refocus the container after an action to keep keyboard shortcuts working function refocusContext(isInMain, isInSidebar) { if (isInMain) { const mainEl = getMainEl(); if (mainEl && document.activeElement !== mainEl) { mainEl.focus(); } } else if (isInSidebar) { const libraryEl = getLibraryEl(); if (libraryEl && document.activeElement !== libraryEl) { libraryEl.focus(); } } } // action handlers for keyboard shortcuts and selection manager const keyboardActionHandlers = { play: (selectedIndices) => { if (!selectedIndices || selectedIndices.length === 0) return; playQueueTrack(selectedIndices[0]); updateQueue(); refocusContext(true, false); }, moveUp: (selectedIndices) => { const firstIdx = Math.min(...selectedIndices); if (firstIdx > 0) { moveQueueItems( state.queue, selectedIndices, firstIdx - 1, queueCallbacks, ); } refocusContext(true, false); }, moveDown: (selectedIndices) => { const lastIdx = Math.max(...selectedIndices); if (lastIdx < state.queue.length - 1) { moveQueueItems(state.queue, selectedIndices, lastIdx + 2, queueCallbacks); } refocusContext(true, false); }, showContextMenu: (selectedIndices) => { // show context menu at the last selected row's position const lastIdx = selectedIndices[selectedIndices.length - 1]; const row = ui.queueList.querySelector( `tr[${DATA_ATTRS.INDEX}="${lastIdx}"]`, ); if (row) { const rect = row.getBoundingClientRect(); showQueueContextMenuAtSelection( rect.left, rect.top + rect.height, selectedIndices, ); } refocusContext(true, false); }, }; // keyboard shortcuts // setup keyboard shortcuts function setupKeyboardShortcuts() { // setup keyboard help close button const closeKeyboardHelpBtn = document.getElementById( "close-keyboard-help-btn", ); if (closeKeyboardHelpBtn) { closeKeyboardHelpBtn.onclick = () => hideModal("keyboard-help-modal"); } document.addEventListener("keydown", (e) => { // skip if user is typing in an input field if (document.activeElement.matches("input, textarea")) return; // skip keyboard shortcuts if a modal is open (let modal handle it) if (isModalOpen()) return; // cache element lookups for this event to avoid repeated DOM queries const mainEl = getMainEl(); const libraryEl = getLibraryEl(); const isInMain = mainEl && mainEl.contains(document.activeElement); const isInSidebar = libraryEl && libraryEl.contains(document.activeElement); if (!isInMain && !isInSidebar) return; switch (e.code) { case "Space": { e.preventDefault(); togglePlayback(); break; } case "Delete": case "Backspace": { if (!isInMain) return; // queue only e.preventDefault(); if (selectionManager?.count() > 0) { const selected = selectionManager.getSelected(); const nextIdx = Math.min( selected[0], state.queue.length - selected.length - 1, ); clearSelectedRows(); if (nextIdx >= 0 && state.queue.length > 0) { selectionManager.select(nextIdx); } } refocusContext(true, false); break; } case "ArrowUp": { e.preventDefault(); if (isInSidebar) { LibraryNavigator.navigate(-1); } else if (e.altKey) { selectionManager?.executeAction("moveUp", keyboardActionHandlers); } else { navigateSelection(-1, e.shiftKey); } refocusContext(isInMain, isInSidebar); break; } case "ArrowDown": { e.preventDefault(); if (isInSidebar) { LibraryNavigator.navigate(1); } else if (e.altKey) { selectionManager?.executeAction("moveDown", keyboardActionHandlers); } else { navigateSelection(1, e.shiftKey); } refocusContext(isInMain, isInSidebar); break; } case "Home": { e.preventDefault(); if (isInSidebar) { LibraryNavigator.navigateFirst(); } else { if (e.shiftKey) { selectionManager?.select(0, { shift: true }); } else { selectionManager?.navigateFirst(); } } refocusContext(isInMain, isInSidebar); break; } case "End": { e.preventDefault(); if (isInSidebar) { LibraryNavigator.navigateLast(); } else { const lastIdx = (state?.queue?.length || 0) - 1; if (e.shiftKey) { selectionManager?.select(lastIdx, { shift: true }); } else { selectionManager?.navigateLast(); } } refocusContext(isInMain, isInSidebar); break; } case "PageUp": { e.preventDefault(); if (isInMain) { const currentIdx = selectionManager.lastSelected ?? 0; const nextIdx = Math.max(0, currentIdx - 10); if (e.shiftKey) { selectionManager?.select(nextIdx, { shift: true }); } else { selectionManager?.navigatePageUp(10); } } else if (isInSidebar) { LibraryNavigator.navigatePageUp(10); } refocusContext(isInMain, isInSidebar); break; } case "PageDown": { e.preventDefault(); if (isInMain) { const currentIdx = selectionManager.lastSelected ?? 0; const lastIdx = (state?.queue?.length || 0) - 1; const nextIdx = Math.min(lastIdx, currentIdx + 10); if (e.shiftKey) { selectionManager?.select(nextIdx, { shift: true }); } else { selectionManager?.navigatePageDown(10); } } else if (isInSidebar) { LibraryNavigator.navigatePageDown(10); } refocusContext(isInMain, isInSidebar); break; } case "KeyA": { if (!isInMain) return; // queue only if (!e.ctrlKey && !e.metaKey) return; // Ctrl+A (or Cmd+A on Mac) e.preventDefault(); if (state.queue.length > 0) { selectionManager?.setSelection( Array.from({ length: state.queue.length }, (_, i) => i), ); } refocusContext(true, false); break; } case "Enter": { e.preventDefault(); if (isInSidebar) { LibraryNavigator.toggleCurrent(); refocusContext(false, true); } else { selectionManager?.executeAction("play", keyboardActionHandlers); } break; } case "Escape": { e.preventDefault(); cleanupContextMenu(); // clear selection only in focused container if (isInMain) { selectionManager.clear(); } else if (isInSidebar) { if (LibraryNavigator.currentFocusedItem) { LibraryNavigator.currentFocusedItem.classList.remove( "library-focused", ); LibraryNavigator.currentFocusedItem = null; } } refocusContext(isInMain, isInSidebar); break; } case "KeyP": { if (!isInSidebar) return; // library only if (e.altKey) { // Alt+P for add next in library e.preventDefault(); addLibraryItem(true); updateQueue(); } else { // P for add to queue e.preventDefault(); addLibraryItem(false); updateQueue(); } refocusContext(false, true); break; } case "Comma": { if (!(e.ctrlKey || e.metaKey)) return; // Ctrl+, for settings e.preventDefault(); const settingsModal = document.getElementById("settings-modal"); if (settingsModal) { showModal(settingsModal, { focusSelector: "input, button", closeOnClickOutside: true, }); } break; } case "ContextMenu": { if (!isInMain) return; // queue only e.preventDefault(); selectionManager?.executeAction( "showContextMenu", keyboardActionHandlers, ); break; } case "Slash": { if (!e.shiftKey) return; // Shift+/ for keyboard help (?) e.preventDefault(); const helpModalEl = document.getElementById("keyboard-help-modal"); if (!helpModalEl) break; if (helpModalEl.classList.contains("hidden")) { showModal(helpModalEl, { focusSelector: "button, input", closeOnClickOutside: true, }); } else { hideModal("keyboard-help-modal"); } break; } case "KeyR": { if (!e.shiftKey) return; // Shift+R for toggle loop (was Ctrl+R) e.preventDefault(); state.loop = !state.loop; ui.loopBtn.classList.toggle("active", state.loop); break; } case "KeyJ": { if (!isInMain) return; // queue only e.preventDefault(); if (e.altKey) { // Alt+J for previous track navigateTrack(-1); } else { // J for seek -10s ui.player.currentTime = Math.max(0, ui.player.currentTime - 10); } break; } case "KeyL": { if (!isInMain) return; // queue only e.preventDefault(); if (e.altKey) { // Alt+L for next track navigateTrack(1); } else { // L for seek +10s ui.player.currentTime = Math.min( ui.player.duration, ui.player.currentTime + 10, ); } break; } } }); }