Something went wrong. Try again.
a simple but full-featured web player for OpenSubsonic music servers (Navidrome, Gonic, Ampache, etc) devins.page/tinysub
svelte typescript javascript subsonic gonic navidrome
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155// manages row selection state for queue table (single, multi, range)
class QueueSelection { constructor(container, options = {}) { this.container = container; this.selected = new Set(); this.lastSelected = null; this.shiftAnchor = null; this.rowSelector = options.rowSelector || "tr"; this.indexAttribute = options.indexAttribute || "data-index"; this.selectedClass = options.selectedClass || "selected"; this.listeners = []; this.virtualScroller = options.virtualScroller || null; }
getRowIndex(row) { // get index from row element return parseInt(row.getAttribute(this.indexAttribute)); }
select(index, options = {}) { const { multi = false, shift = false } = options;
if (shift && this.lastSelected !== null) { // range selection if (this.shiftAnchor === null) { this.shiftAnchor = this.lastSelected; } const start = Math.min(this.shiftAnchor, index); const end = Math.max(this.shiftAnchor, index); this.selected.clear(); for (let i = start; i <= end; i++) { this.selected.add(i); } } else if (multi) { // toggle selection with ctrl/cmd this.selected.has(index) ? this.selected.delete(index) : this.selected.add(index); this.shiftAnchor = null; } else { // single selection, clear others this.selected.clear(); this.selected.add(index); this.shiftAnchor = null; }
this.lastSelected = index; this.updateUI(); this.focusSelectedRow(); this.notifyListeners(); }
clear() { // clear selection this.selected.clear(); this.lastSelected = null; this.shiftAnchor = null; this.updateUI(); this.notifyListeners(); }
isSelected(index) { // check if index is in selection return this.selected.has(index); }
getSelected() { // return all selected indices sorted return Array.from(this.selected).sort((a, b) => a - b); }
updateUI() { // update dom to match selection state, only change what's different const rows = this.container.querySelectorAll(this.rowSelector); rows.forEach((row) => { const idx = this.getRowIndex(row); const isSelected = this.selected.has(idx); const hasClass = row.classList.contains(this.selectedClass);
// only update dom if state changed if (isSelected !== hasClass) { isSelected ? row.classList.add(this.selectedClass) : row.classList.remove(this.selectedClass); } }); }
notifyListeners() { // notify all listeners of selection changes this.listeners.forEach((callback) => { callback(this.getSelected()); }); }
setSelection(indices) { // set selection from external source this.selected.clear(); indices.forEach((idx) => this.selected.add(idx)); this.lastSelected = indices.length > 0 ? indices[indices.length - 1] : null; this.shiftAnchor = null; this.updateUI(); this.focusSelectedRow(); this.notifyListeners(); }
focusSelectedRow() { // scroll selected row into view without focusing individual rows // (focus stays on container for keyboard nav during virtual scrolling) if (this.lastSelected === null) return; const rows = this.container.querySelectorAll(this.rowSelector); const row = Array.from(rows).find( (r) => this.getRowIndex(r) === this.lastSelected, ); if (row) { row.scrollIntoView({ block: "nearest", behavior: "auto" }); } else if (this.virtualScroller && this.virtualScroller.rowHeight > 0) { // row is outside visible range, use actual measured row height const targetScrollTop = Math.max( 0, this.lastSelected * this.virtualScroller.rowHeight - this.virtualScroller.container.clientHeight / 2, ); this.virtualScroller.container.scrollTop = targetScrollTop; } }
navigateTo(index) { // navigate to a specific index const maxIdx = Math.max(0, (state?.queue?.length || 0) - 1); const boundedIdx = Math.min(Math.max(0, index), maxIdx); this.select(boundedIdx); }
navigatePageUp(pageSize = 10) { // navigate up by page size const currentIdx = this.lastSelected ?? 0; this.navigateTo(currentIdx - pageSize); }
navigatePageDown(pageSize = 10) { // navigate down by page size const currentIdx = this.lastSelected ?? 0; this.navigateTo(currentIdx + pageSize); }
executeAction(actionName, handlers = {}) { // execute an action with provided handler functions const selected = this.getSelected(); if (selected.length === 0) return; handlers[actionName]?.(selected); }}