diff --git a/src/css/components.css b/src/css/components.css index dff853e..479244a 100644 --- a/src/css/components.css +++ b/src/css/components.css @@ -144,16 +144,16 @@ button { margin-block-start: 0.25rem; } -#sidebar #library .tree-toggle.library-focused, -#sidebar #library .tree-name.library-focused, -#sidebar #library .section-toggle.library-focused { +#sidebar #library .tree-toggle.focused, +#sidebar #library .tree-name.focused, +#sidebar #library .section-toggle.focused { background: Highlight; color: HighlightText; } -#sidebar #library:not(:focus-within) .tree-toggle.library-focused, -#sidebar #library:not(:focus-within) .tree-name.library-focused, -#sidebar #library:not(:focus-within) .section-toggle.library-focused { +#sidebar #library:not(:focus-within) .tree-toggle.focused, +#sidebar #library:not(:focus-within) .tree-name.focused, +#sidebar #library:not(:focus-within) .section-toggle.focused { background: GrayText; } diff --git a/src/index.html b/src/index.html index 885f019..8a2740f 100644 --- a/src/index.html +++ b/src/index.html @@ -299,7 +299,7 @@ - + diff --git a/src/js/constants.js b/src/js/constants.js index 0341f4a..dd02f15 100644 --- a/src/js/constants.js +++ b/src/js/constants.js @@ -1,6 +1,7 @@ // css classes and ui constants const CLASSES = { + ACTIVE: "active", CONTEXT_MENU_ITEM: "context-menu-item", CURRENTLY_PLAYING: "currently-playing", DISABLED: "disabled", @@ -9,6 +10,8 @@ const CLASSES = { DRAGGING: "dragging", ERROR: "error", FAVORITED: "favorited", + FOCUSED: "focused", + HIDDEN: "hidden", NESTED_SONGS: "nested-songs", NESTED: "nested", QUEUE_CLEAR: "queue-clear", @@ -19,22 +22,22 @@ const CLASSES = { QUEUE_PLAY_NEXT: "queue-play-next", QUEUE_PLAY: "queue-play", SELECTED: "selected", + STRIPE: "stripe", TREE_ITEM: "tree-item", TREE_NAME: "tree-name", TREE_TOGGLE: "tree-toggle", }; -const DATA_ATTRS = { - INDEX: "data-index", -}; - const DOM_IDS = { AUTH_ERROR: "auth-error", AUTH_MODAL: "auth-modal", CLEAR_BTN: "clear-btn", + CLOSE_KEYBOARD_HELP_BTN: "close-keyboard-help-btn", CONTEXT_MENU: "context-menu", COVER_ART: "cover-art", + KEYBOARD_HELP_MODAL: "keyboard-help-modal", LIBRARY_TREE: "artists-tree", + LIBRARY: "library", LOGIN_FORM: "login-form", LOOP_BTN: "loop-btn", NEXT_BTN: "next-btn", @@ -47,6 +50,7 @@ const DOM_IDS = { QUEUE_COUNT: "queue-count", QUEUE_LIST: "queue-list", QUEUE_TABLE: "queue-table", + QUEUE: "queue", SECTION_TOGGLE: "section-toggle", SERVER_INPUT: "server", SETTINGS_BTN: "settings-btn", diff --git a/src/js/contextmenu.js b/src/js/contextmenu.js index c00dbf8..31be07d 100644 --- a/src/js/contextmenu.js +++ b/src/js/contextmenu.js @@ -94,8 +94,8 @@ function showContextMenu(x, y, items) { const updateMenuItemFocus = () => { const focused = contextMenuEl.querySelector(".focused"); - if (focused) focused.classList.remove("focused"); - menuItems[focusedIndex].classList.add("focused"); + if (focused) focused.classList.remove(CLASSES.FOCUSED); + menuItems[focusedIndex].classList.add(CLASSES.FOCUSED); }; currentKeyboardHandler = (e) => { @@ -278,7 +278,7 @@ function setupQueueContextMenu() { e.preventDefault(); e.stopPropagation(); - const idx = parseInt(row.getAttribute(DATA_ATTRS.INDEX)); + const idx = parseInt(row.getAttribute("data-index")); if (!queueSelection.isSelected(idx)) { queueSelection.select(idx); } diff --git a/src/js/events.js b/src/js/events.js index 861043b..07bc85e 100644 --- a/src/js/events.js +++ b/src/js/events.js @@ -25,13 +25,13 @@ document.addEventListener("DOMContentLoaded", async () => { // initialize queueselection queueSelection = new QueueSelection(ui.queueList, { rowSelector: "tr", - indexAttribute: DATA_ATTRS.INDEX, + indexAttribute: "data-index", selectedClass: CLASSES.SELECTED, }); // make only #queue main and #library div tabbable - const mainEl = document.getElementById("queue"); - const libraryEl = document.getElementById("library"); + const mainEl = document.getElementById(DOM_IDS.QUEUE); + const libraryEl = document.getElementById(DOM_IDS.LIBRARY); if (mainEl) { mainEl.tabIndex = 0; @@ -100,7 +100,7 @@ document.addEventListener("DOMContentLoaded", async () => { // loop toggle ui.loopBtn.addEventListener("click", () => { state.loop = !state.loop; - ui.loopBtn.classList.toggle("active", state.loop); + ui.loopBtn.classList.toggle(CLASSES.ACTIVE, state.loop); }); // sort queue @@ -111,9 +111,9 @@ document.addEventListener("DOMContentLoaded", async () => { // clear queue ui.clearBtn.addEventListener("click", () => { - (queueSelection?.getSelected()?.length > 0 - ? clearSelectedRows - : clearQueue)(); + queueSelection?.getSelected()?.length > 0 + ? clearSelectedRows() + : clearQueue(); }); // queue table: row selection and action button handlers diff --git a/src/js/input.js b/src/js/input.js index 0af8b16..4577dff 100644 --- a/src/js/input.js +++ b/src/js/input.js @@ -11,7 +11,7 @@ const INTERACTIVE_SELECTOR = function lockTabOrder() { document.querySelectorAll(INTERACTIVE_SELECTOR).forEach((el) => { // skip queue and library which are always tabbable - if (el.id === "queue" || el.id === "library") return; + if (el.id === DOM_IDS.QUEUE || el.id === DOM_IDS.LIBRARY) return; // skip if element is inside a modal const modal = el.closest(".modal:not(.hidden)"); @@ -77,7 +77,7 @@ 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"; + const id = type === "queue" ? DOM_IDS.QUEUE : DOM_IDS.LIBRARY; if (!elementCache[type] || !document.body.contains(elementCache[type])) { elementCache[type] = document.getElementById(id); } @@ -139,9 +139,7 @@ const keyboardActionHandlers = { 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}"]`, - ); + const row = ui.queueList.querySelector(`tr[data-index="${lastIdx}"]`); if (row) { const rect = row.getBoundingClientRect(); showQueueContextMenu(rect.left, rect.top + rect.height, selectedIndices); @@ -156,10 +154,10 @@ const keyboardActionHandlers = { function setupKeyboardShortcuts() { // setup keyboard help close button const closeKeyboardHelpBtn = document.getElementById( - "close-keyboard-help-btn", + DOM_IDS.CLOSE_KEYBOARD_HELP_BTN, ); if (closeKeyboardHelpBtn) { - closeKeyboardHelpBtn.onclick = () => hideModal("keyboard-help-modal"); + closeKeyboardHelpBtn.onclick = () => hideModal(DOM_IDS.KEYBOARD_HELP_MODAL); } document.addEventListener("keydown", (e) => { @@ -169,8 +167,8 @@ function setupKeyboardShortcuts() { // skip keyboard shortcuts if a modal is open (let modal handle it) if ( modalRegistry.size > 0 || - (document.getElementById("context-menu") && - document.body.contains(document.getElementById("context-menu"))) + (document.getElementById(DOM_IDS.CONTEXT_MENU) && + document.body.contains(document.getElementById(DOM_IDS.CONTEXT_MENU))) ) return; @@ -415,7 +413,7 @@ function setupKeyboardShortcuts() { if (!e.shiftKey) return; // Shift+R for toggle loop e.preventDefault(); state.loop = !state.loop; - ui.loopBtn.classList.toggle("active", state.loop); + ui.loopBtn.classList.toggle(CLASSES.ACTIVE, state.loop); break; } diff --git a/src/js/library-selection.js b/src/js/library-selection.js index 2533c0a..d8d05ee 100644 --- a/src/js/library-selection.js +++ b/src/js/library-selection.js @@ -39,10 +39,10 @@ class LibrarySelection { focusItem(element) { // set focus to specific item if (this.currentFocusedItem) { - this.currentFocusedItem.classList.remove("library-focused"); + this.currentFocusedItem.classList.remove(CLASSES.FOCUSED); } if (element) { - element.classList.add("library-focused"); + element.classList.add(CLASSES.FOCUSED); element.scrollIntoView({ block: "nearest" }); this.currentFocusedItem = element; } diff --git a/src/js/library.js b/src/js/library.js index d893953..0ce27af 100644 --- a/src/js/library.js +++ b/src/js/library.js @@ -58,10 +58,10 @@ function addLibraryItem(addNext = false) { } const addByType = { - artist: (id) => addArtistToQueue(id), - album: (id) => addAlbumToQueue(id), - playlist: (id) => addPlaylistToQueue(id), - song: (song) => addSongToQueue(song), + artist: addArtistToQueue, + album: addAlbumToQueue, + playlist: addPlaylistToQueue, + song: addSongToQueue, }; const addNextByType = { diff --git a/src/js/modal.js b/src/js/modal.js index 79f49d6..854da27 100644 --- a/src/js/modal.js +++ b/src/js/modal.js @@ -24,7 +24,7 @@ function showModal(modalEl, options = {}) { clickHandler: null, }; - modalEl.classList.remove("hidden"); + modalEl.classList.remove(CLASSES.HIDDEN); cleanup.focusTrap = trapModalFocus(modalEl); cleanup.escapeHandler = (e) => { @@ -71,7 +71,7 @@ function hideModal(modalId) { document.removeEventListener("click", cleanup.clickHandler); } - modalEl.classList.add("hidden"); + modalEl.classList.add(CLASSES.HIDDEN); if (focusedBeforeModal && document.body.contains(focusedBeforeModal)) { focusedBeforeModal.focus(); diff --git a/src/js/player.js b/src/js/player.js index 92e6582..9313489 100644 --- a/src/js/player.js +++ b/src/js/player.js @@ -116,7 +116,7 @@ function highlightCurrentTrack() { const row = ui.queueList.querySelector( `tr[data-index="${state.queueIndex}"]`, ); - if (row) row.classList.add("currently-playing"); + if (row) row.classList.add(CLASSES.CURRENTLY_PLAYING); } } diff --git a/src/js/queue-drag.js b/src/js/queue-drag.js index 437311c..90cc0c8 100644 --- a/src/js/queue-drag.js +++ b/src/js/queue-drag.js @@ -12,12 +12,12 @@ function setupQueueDragAndDrop() { if ( !row || row.classList.contains(CLASSES.DRAGGING) || - queueSelection.isSelected(parseInt(row.getAttribute(DATA_ATTRS.INDEX))) + queueSelection.isSelected(parseInt(row.getAttribute("data-index"))) ) return; queueSelection.clear(); - queueSelection.select(parseInt(row.getAttribute(DATA_ATTRS.INDEX))); + queueSelection.select(parseInt(row.getAttribute("data-index"))); updateRowClass( ui.queueList, queueSelection.getSelected(), @@ -77,12 +77,16 @@ function setupQueueDragAndDrop() { } clearRowClasses(ui.queueList, CLASSES.DRAGGING); - const draggedIdx = parseInt(row.getAttribute(DATA_ATTRS.INDEX)); + const draggedIdx = parseInt(row.getAttribute("data-index")); moveQueueItems( state.queue, queueSelection.getSelected(), isDropBelowCenter(e, row) ? draggedIdx + 1 : draggedIdx, - queueCallbacks, + { + onSelectionChange: (newIndices) => + queueSelection?.setSelection(newIndices), + onQueueChange: () => updateQueueDisplay(), + }, ); // refocus after DOM render cycle completes diff --git a/src/js/virtual-scroll.js b/src/js/queue-virtualscroll.js similarity index 71% rename from src/js/virtual-scroll.js rename to src/js/queue-virtualscroll.js index cecda9a..318fcec 100644 --- a/src/js/virtual-scroll.js +++ b/src/js/queue-virtualscroll.js @@ -1,8 +1,6 @@ // virtual scrolling for efficient rendering of massive queues -const STRIPE_CLASS = "stripe"; - -class VirtualScroller { +class QueueVirtualScroller { // initialize virtual scroller with container, tbody, and row factory constructor( container, @@ -11,26 +9,23 @@ class VirtualScroller { createRow, { buffer = 32, onScroll } = {}, ) { - Object.assign(this, { - container, - tbody, - itemCount, - createRow, - buffer, - onScroll, - }); + this.container = container; + this.tbody = tbody; + this.itemCount = itemCount; + this.createRow = createRow; + this.buffer = buffer; + this.onScroll = onScroll; this.visibleStart = this.visibleEnd = 0; this.rafId = null; this.firstRender = true; this.rowHeight = 0; - this.handleScroll = () => this.scheduleRender(); - this.handleResize = () => this.scheduleRender(); + this.handleScheduleRender = () => this.scheduleRender(); - this.container.addEventListener("scroll", this.handleScroll, { + this.container.addEventListener("scroll", this.handleScheduleRender, { passive: true, }); - window.addEventListener("resize", this.handleResize); + window.addEventListener("resize", this.handleScheduleRender); this.render(); } @@ -80,30 +75,31 @@ class VirtualScroller { this.visibleStart = start; this.visibleEnd = end; - const rows = []; + const frag = document.createDocumentFragment(); - // top spacer for rows before visible range + // spacer for hidden top rows if (start > 0) { const tr = document.createElement("tr"); tr.style.height = `${start * this.rowHeight}px`; - rows.push(tr); + frag.appendChild(tr); } - // visible rows with alternating stripe class + // visible rows with stripe pattern for (let i = start; i < end; i++) { const row = this.createRow(i); - if (i % 2 === 1) row.classList.add(STRIPE_CLASS); - rows.push(row); + if (i % 2 === 1) row.classList.add(CLASSES.STRIPE); + frag.appendChild(row); } - // bottom spacer for rows after visible range + // spacer for hidden bottom rows if (end < this.itemCount) { const tr = document.createElement("tr"); tr.style.height = `${(this.itemCount - end) * this.rowHeight}px`; - rows.push(tr); + frag.appendChild(tr); } - this.tbody.replaceChildren(...rows); + this.tbody.replaceChildren(); + this.tbody.appendChild(frag); this.onScroll?.(); } @@ -116,7 +112,7 @@ class VirtualScroller { // clean up event listeners and cancel pending renders destroy() { cancelAnimationFrame(this.rafId); - this.container.removeEventListener("scroll", this.handleScroll); - window.removeEventListener("resize", this.handleResize); + this.container.removeEventListener("scroll", this.handleScheduleRender); + window.removeEventListener("resize", this.handleScheduleRender); } } diff --git a/src/js/queue.js b/src/js/queue.js index f31fff7..ca28f9c 100644 --- a/src/js/queue.js +++ b/src/js/queue.js @@ -255,7 +255,7 @@ function initVirtualScroller() { const container = document.querySelector("main"); if (!container) return; - virtualScroller = new VirtualScroller( + virtualScroller = new QueueVirtualScroller( container, ui.queueList, state.queue.length, @@ -273,9 +273,8 @@ function initVirtualScroller() { // update queue display using virtual scroller function updateQueueDisplay() { ui.queueCount.textContent = state.queue.length; - ( - virtualScroller || (initVirtualScroller(), virtualScroller) - )?.updateItemCount(state.queue.length); + if (!virtualScroller) initVirtualScroller(); + virtualScroller?.updateItemCount(state.queue.length); } // clear selected rows @@ -407,7 +406,7 @@ const ROW_BUTTON_CONFIG = [ function createQueueRow(song, idx) { const tr = document.createElement("tr"); tr.draggable = true; - tr.setAttribute(DATA_ATTRS.INDEX, idx); + tr.setAttribute("data-index", idx); tr.dataset.songId = song.id; // batch all cells in fragment for efficient DOM insertion @@ -463,12 +462,6 @@ function createQueueRow(song, idx) { return tr; } -// callbacks for queue operations -const queueCallbacks = { - onSelectionChange: (newIndices) => queueSelection?.setSelection(newIndices), - onQueueChange: () => updateQueueDisplay(), -}; - // map button classes to queue action handlers const QUEUE_BUTTON_HANDLERS = { // play the selected track @@ -481,18 +474,30 @@ const QUEUE_BUTTON_HANDLERS = { // insert selected track after current track [CLASSES.QUEUE_PLAY_NEXT]: (idx) => { const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0; - moveQueueItems(state.queue, [idx], insertPos, queueCallbacks); + moveQueueItems(state.queue, [idx], insertPos, { + onSelectionChange: (newIndices) => + queueSelection?.setSelection(newIndices), + onQueueChange: () => updateQueueDisplay(), + }); }, // move up one position [CLASSES.QUEUE_MOVE_UP]: (idx) => { if (idx > 0) { - moveQueueItems(state.queue, [idx], idx - 1, queueCallbacks); + moveQueueItems(state.queue, [idx], idx - 1, { + onSelectionChange: (newIndices) => + queueSelection?.setSelection(newIndices), + onQueueChange: () => updateQueueDisplay(), + }); } }, // move down one position [CLASSES.QUEUE_MOVE_DOWN]: (idx) => { if (idx < state.queue.length - 1) { - moveQueueItems(state.queue, [idx], idx + 2, queueCallbacks); + moveQueueItems(state.queue, [idx], idx + 2, { + onSelectionChange: (newIndices) => + queueSelection?.setSelection(newIndices), + onQueueChange: () => updateQueueDisplay(), + }); } }, // clear from queue diff --git a/src/js/settings.js b/src/js/settings.js index dd7c507..f5e6d38 100644 --- a/src/js/settings.js +++ b/src/js/settings.js @@ -8,7 +8,7 @@ const ART_SETTINGS = { "now-playing": "artNowPlaying", }; -// apply settings to CSS custom properties +// apply settings function applySettings() { Object.entries(ART_SETTINGS).forEach(([name, key]) => { const size = state.settings[key]; @@ -20,7 +20,7 @@ function applySettings() { ui.coverArt.style.display = state.settings.artNowPlaying === 0 ? "none" : ""; } -// setup settings modal and controls +// setup settings function setupSettings() { const modal = document.getElementById("settings-modal"); const settingsBtn = document.getElementById("settings-btn");