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");