diff --git a/frontend/src/components/cover-grid/scroll-manager.ts b/frontend/src/components/cover-grid/scroll-manager.ts index b3c4911..bd4a3a1 100644 --- a/frontend/src/components/cover-grid/scroll-manager.ts +++ b/frontend/src/components/cover-grid/scroll-manager.ts @@ -3,9 +3,6 @@ import type { LitVirtualizer } from '@lit-labs/virtualizer'; import type { library } from '@go/models'; import type { LibraryController } from '@store/controllers/library-controller'; -import { - SCROLL_DEBOUNCE_MS, -} from './cover-grid-types.js'; import type { GridEntry } from './cover-grid-types.js'; /** @@ -45,10 +42,8 @@ export class ScrollManager { private host: ScrollManagerHost; private gc: GridConstants; - // Scroll position debounce. - private scrollDebounceTimer: ReturnType< - typeof setTimeout - > | null = null; + // RAF-throttled scroll position saving. + private scrollRAFId: number | null = null; // Resize-aware scroll preservation. private resizeObserver: ResizeObserver | null = null; @@ -157,8 +152,8 @@ export class ScrollManager { /** Clean up timers and observers. */ teardown(): void { - if (this.scrollDebounceTimer !== null) { - clearTimeout(this.scrollDebounceTimer); + if (this.scrollRAFId !== null) { + cancelAnimationFrame(this.scrollRAFId); } if (this.resizeDebounceTimer !== null) { @@ -202,6 +197,12 @@ export class ScrollManager { * Save scroll position from the first visible album. * In split mode we use the before-entries; in single * mode we use the full grid entries. + * + * Uses requestAnimationFrame throttling: saves at most + * once per frame (~16ms at 60fps). Unlike debouncing, + * this captures position continuously during scrolling + * (not just after it stops) and naturally aligns with + * the browser's paint cycle. */ onVisibilityChanged( first: number, @@ -209,21 +210,23 @@ export class ScrollManager { ): void { if (this.isResizing) return; - if (this.scrollDebounceTimer !== null) { - clearTimeout(this.scrollDebounceTimer); - } + if (this.scrollRAFId !== null) return; - this.scrollDebounceTimer = setTimeout(() => { - const entries = getEntries(); - const entry = entries[first]; + this.scrollRAFId = requestAnimationFrame( + () => { + this.scrollRAFId = null; - if (entry) { - this.host.libraryCtrl.setScrollPosition( - 'albums', - entry.albumIndex, - ); - } - }, SCROLL_DEBOUNCE_MS); + const entries = getEntries(); + const entry = entries[first]; + + if (entry) { + this.host.libraryCtrl.setScrollPosition( + 'albums', + entry.albumIndex, + ); + } + }, + ); } // ================================================================ diff --git a/frontend/src/components/queue-panel/queue-panel.ts b/frontend/src/components/queue-panel/queue-panel.ts index 3573760..e204ec4 100644 --- a/frontend/src/components/queue-panel/queue-panel.ts +++ b/frontend/src/components/queue-panel/queue-panel.ts @@ -303,6 +303,7 @@ export class QueuePanel overflow-y: auto; contain: paint; will-change: transform; + overflow-anchor: none; } .track-item { @@ -490,6 +491,12 @@ export class QueuePanel // scrollTo() to "correct" sub-pixel estimation errors, which fights the // browser's native scrollbar drag gesture and causes the thumb to // desync from the mouse on large lists (20k+ items). + // + // NOTE: CSS `overflow-anchor: none` (set on lit-virtualizer above) + // disables the *browser's* native scroll anchoring, but does NOT + // affect lit-virtualizer's own _correctScrollError() method which + // calls scrollTo() internally. This monkey-patch is still needed + // to suppress that internal correction during scrollbar drag. // eslint-disable-next-line @typescript-eslint/no-explicit-any const virt = (this.virtualizer as any)?.[virtualizerRef]; if (virt) { @@ -509,6 +516,17 @@ export class QueuePanel 'mousedown', this.onVirtualizerMouseDown, ); + + // Event delegation: attach stable handlers to the virtualizer + // so renderTrackItem creates zero per-item closures. + const virtEl = this.virtualizer; + if (virtEl) { + virtEl.addEventListener('click', this.onDelegatedClick); + virtEl.addEventListener('dblclick', this.onDelegatedDblClick); + virtEl.addEventListener('contextmenu', this.onDelegatedContextMenu); + virtEl.addEventListener('dragstart', this.onDelegatedDragStart); + virtEl.addEventListener('dragend', this.onTrackDragEnd); + } } override connectedCallback() { @@ -581,6 +599,16 @@ export class QueuePanel 'mousedown', this.onVirtualizerMouseDown, ); + + // Remove delegated event handlers from virtualizer. + const virtEl = this.virtualizer; + if (virtEl) { + virtEl.removeEventListener('click', this.onDelegatedClick); + virtEl.removeEventListener('dblclick', this.onDelegatedDblClick); + virtEl.removeEventListener('contextmenu', this.onDelegatedContextMenu); + virtEl.removeEventListener('dragstart', this.onDelegatedDragStart); + virtEl.removeEventListener('dragend', this.onTrackDragEnd); + } } override updated() { @@ -655,6 +683,70 @@ export class QueuePanel this.closePlaylistPicker(); }; + // ================================================================= + // Delegated event handlers (stable references, zero per-item closures) + // ================================================================= + + /** + * Walk up from the event target to find the nearest + * `.track-item` and extract the index via `data-index`. + */ + private resolveTrackIndexFromEvent( + e: Event, + ): number | null { + const row = (e.target as HTMLElement).closest( + '.track-item', + ) as HTMLElement | null; + + if (!row) return null; + + const idx = Number(row.dataset.index); + + if (Number.isNaN(idx)) return null; + + return idx; + } + + private onDelegatedClick = (e: MouseEvent) => { + const idx = this.resolveTrackIndexFromEvent(e); + + if (idx === null) return; + + // Check if click was on the remove button + const removeBtn = (e.target as HTMLElement).closest( + '.remove-button', + ); + + if (removeBtn) { + e.stopPropagation(); + this.queue.removeFromQueue(idx); + + return; + } + + const track = this.queue.tracks[idx]; + + if (track) this.handleTrackClick(e, track, idx); + }; + + private onDelegatedDblClick = (e: MouseEvent) => { + const idx = this.resolveTrackIndexFromEvent(e); + + if (idx !== null) this.handleTrackDblClick(idx); + }; + + private onDelegatedContextMenu = (e: MouseEvent) => { + const idx = this.resolveTrackIndexFromEvent(e); + + if (idx !== null) this.handleTrackContextMenu(e, idx); + }; + + private onDelegatedDragStart = (e: DragEvent) => { + const idx = this.resolveTrackIndexFromEvent(e); + + if (idx !== null) this.onTrackDragStart(e, idx); + }; + // ================================================================= // Selection & click handlers // =================================================================