import type { LitElement } from 'lit'; import type { LitVirtualizer } from '@lit-labs/virtualizer'; import type * as library from '@go/library/models.js'; import type { LibraryController } from '@store/controllers/library-controller'; import type { GridEntry } from './cover-grid-types.js'; /** * Grid geometry, asked of the host rather than written down. * * These were two constants, `GRID_GAP` and `GRID_PADDING`, which stopped * describing anything the moment the grid's spacing became elastic: the * gap, the padding and the column count are all derived from the * container width now, and a scroll position rebuilt from a stale 8px * lands in the wrong row. */ export interface GridConstants { /** Columns that fit across `width`. */ columnsFor(width: number): number; /** The spacing `width` produces — between columns, between rows, * and around the outside, all the same number. */ spacingFor(width: number): number; } /** * Read-only interface into the cover-grid component * that the scroll manager needs. */ export interface ScrollManagerHost extends LitElement { readonly libraryCtrl: LibraryController; readonly cachedFilteredAlbums: library.Album[]; readonly expandedAlbumId: number | null; readonly expandedTracks: library.Track[]; readonly splitMode: boolean; readonly splitIndex: number; readonly cardWidth: number; readonly cardHeight: number; } /** * Manages scroll position persistence, resize-aware * scroll preservation, transition overlays, and * split/single mode geometry for the cover grid. * * This is a plain class (not a ReactiveController) * because scroll management is imperative and async, * not reactive. */ export class ScrollManager { private host: ScrollManagerHost; private gc: GridConstants; // RAF-throttled scroll position saving. private scrollRAFId: number | null = null; // Resize-aware scroll preservation. private resizeObserver: ResizeObserver | null = null; private resizeDebounceTimer: ReturnType< typeof setTimeout > | null = null; private pendingFocus: { albumIndex: number; viewportOffset: number; } | null = null; private currentColumnCount = 0; /** True while a resize reflow is in progress. */ isResizing = false; // Scroll restoration across single/split mode // transitions. savedScrollTop = 0; needsScrollRestore = false; showDropdownAfterRestore = false; /** * Monotonically increasing counter used to cancel * stale scroll-restore async blocks. */ private scrollRestoreGeneration = 0; /** * Set to the generation value when an async * scroll-restore block finishes or is cancelled. */ private scrollRestoreResolved = 0; /** * When switching albums, the pixel distance from * the newly-expanded album's top edge to the * viewport top. */ savedAlbumViewportOffset: number | null = null; /** Overlay element showing the old grid state * while a mode transition is in flight. */ private transitionOverlay: HTMLDivElement | null = null; /** Cached index of the expanded album in the * filtered list. -1 when no album is expanded * or the album isn't in the filtered list. */ private expandedAlbumIndex = -1; /** The expanded album ID that corresponds to the * cached index. Used to detect invalidation. */ private expandedAlbumIndexId: number | null = null; /** The filtered-albums reference used to compute * the cached index. Used to detect invalidation. */ private expandedAlbumIndexAlbums: library.Album[] = []; constructor( host: ScrollManagerHost, gc: GridConstants, ) { this.host = host; this.gc = gc; } // ================================================================ // Expanded album index cache (improvement 6c) // ================================================================ /** * Return the index of the expanded album in the * filtered list. Cached and invalidated when * `expandedAlbumId` or `cachedFilteredAlbums` * changes. */ getExpandedAlbumIndex(): number { const id = this.host.expandedAlbumId; const albums = this.host.cachedFilteredAlbums; if ( id === this.expandedAlbumIndexId && albums === this.expandedAlbumIndexAlbums ) { return this.expandedAlbumIndex; } this.expandedAlbumIndexId = id; this.expandedAlbumIndexAlbums = albums; if (id === null) { this.expandedAlbumIndex = -1; } else { this.expandedAlbumIndex = albums.findIndex( (a) => a.ID === id, ); } return this.expandedAlbumIndex; } // ================================================================ // Lifecycle // ================================================================ /** Clean up timers and observers. */ teardown(): void { if (this.scrollRAFId !== null) { cancelAnimationFrame(this.scrollRAFId); } if (this.resizeDebounceTimer !== null) { clearTimeout(this.resizeDebounceTimer); } this.resizeObserver?.disconnect(); this.resizeObserver = null; this.removeOverlay(); } // ================================================================ // Scroll position (index-based) // ================================================================ /** * Restore scroll position from the library store * after initial album load. */ restoreScrollPosition( virtualizer: LitVirtualizer | undefined, ): void { const saved = this.host.libraryCtrl.getScrollPosition( 'albums', ); if (saved <= 0 || !virtualizer) return; const safeIndex = Math.min( saved, this.host.cachedFilteredAlbums.length - 1, ); if (safeIndex <= 0) return; virtualizer.scrollToIndex(safeIndex, 'start'); } /** * 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, getEntries: () => GridEntry[], ): void { if (this.isResizing) return; if (this.scrollRAFId !== null) return; this.scrollRAFId = requestAnimationFrame( () => { this.scrollRAFId = null; const entries = getEntries(); const entry = entries[first]; if (entry) { this.host.libraryCtrl.setScrollPosition( 'albums', entry.albumIndex, ); } }, ); } // ================================================================ // Resize-aware scroll preservation // ================================================================ /** * Set up a ResizeObserver on the scroll container * to preserve scroll position across width changes. */ setupResizeObserver( container: HTMLElement, onSplitResize: () => Promise, ): void { // Guard against stacked observers. this.resizeObserver?.disconnect(); // An empty library renders no scroll container, so the caller's // query returns undefined and observe() throws — asynchronously, // out of loadAlbums, where nothing catches it. if (!container) return; this.currentColumnCount = this.getColumnCount(container); const restoreScroll = () => { const pending = this.pendingFocus; this.pendingFocus = null; this.isResizing = false; if (!pending) return; const newColumns = this.getColumnCount(container); this.currentColumnCount = newColumns; // If a dropdown is open, delegate to the // host for split recomputation. if ( this.host.splitMode && this.host.expandedAlbumId !== null ) { void onSplitResize(); return; } const gap = this.spacing(container); const pad = gap; const rowStep = this.host.cardHeight + gap; const newRow = Math.floor( pending.albumIndex / newColumns, ); const newY = pad + newRow * rowStep; container.scrollTop = newY - pending.viewportOffset; }; this.resizeObserver = new ResizeObserver( () => { const rowStep = this.host.cardHeight + this.spacing(container); if (this.pendingFocus === null) { this.isResizing = true; this.captureFocusPoint( container, rowStep, ); } const newColumns = this.getColumnCount(container); if ( newColumns !== this.currentColumnCount ) { if ( this.resizeDebounceTimer !== null ) { clearTimeout( this.resizeDebounceTimer, ); this.resizeDebounceTimer = null; } restoreScroll(); return; } if ( this.resizeDebounceTimer !== null ) { clearTimeout( this.resizeDebounceTimer, ); } this.resizeDebounceTimer = setTimeout( restoreScroll, 100, ); }, ); this.resizeObserver.observe(container); } /** * Determine the focus point for scroll restoration. */ private captureFocusPoint( container: HTMLElement, rowStep: number, ): void { const pad = this.spacing(container); const cols = this.currentColumnCount; const filtered = this.host.cachedFilteredAlbums; // Prefer the expanded album as focus. if (this.host.expandedAlbumId !== null) { const idx = this.getExpandedAlbumIndex(); if (idx >= 0) { const albumRow = Math.floor( idx / cols, ); const albumY = pad + albumRow * rowStep; this.pendingFocus = { albumIndex: idx, viewportOffset: albumY - container.scrollTop, }; return; } } const centerY = container.scrollTop + container.clientHeight / 2; const centerRow = Math.floor( Math.max(0, centerY - pad) / rowStep, ); const albumIndex = Math.min( centerRow * cols, Math.max(0, filtered.length - 1), ); const albumY = pad + centerRow * rowStep; this.pendingFocus = { albumIndex, viewportOffset: albumY - container.scrollTop, }; } // ================================================================ // Column count / geometry helpers // ================================================================ /** * Compute the number of columns that fit in the * given container. */ getColumnCount( container?: HTMLElement, ): number { if (!container) return 1; return this.gc.columnsFor( container.clientWidth, ); } /** The grid's current spacing, which is also its padding. */ private spacing(container?: HTMLElement): number { return this.gc.spacingFor( container?.clientWidth ?? 800, ); } /** Container width in pixels. */ getContainerWidth( container?: HTMLElement, ): number { return container?.clientWidth ?? 800; } /** * Width of the album row (left of leftmost card to * right of rightmost card). */ getGridRowWidth( container?: HTMLElement, ): number { const cols = this.getColumnCount(container); const gap = this.spacing(container); return ( cols * this.host.cardWidth + (cols - 1) * gap ); } /** * Horizontal offset of the carat so it points at * the center of the expanded album card. */ getCaratOffset( container?: HTMLElement, ): number { const idx = this.getExpandedAlbumIndex(); if (idx < 0) return 0; const cols = this.getColumnCount(container); const colIndex = idx % cols; const gap = this.spacing(container); return ( colIndex * (this.host.cardWidth + gap) + this.host.cardWidth / 2 ); } // ================================================================ // Split-mode helpers // ================================================================ /** * Compute the split point and return it. The * component assigns this to its `splitIndex` state. */ computeSplitIndex( container?: HTMLElement, ): number { const filtered = this.host.cachedFilteredAlbums; const idx = this.getExpandedAlbumIndex(); if (idx < 0) return filtered.length; const columns = this.getColumnCount(container); return Math.min( (Math.floor(idx / columns) + 1) * columns, filtered.length, ); } // ================================================================ // Transition overlay // ================================================================ /** * Capture the current scroll container as a static * overlay. */ captureOverlay( container: HTMLElement | undefined, shadowRoot: ShadowRoot | null, ): void { if (!container || this.transitionOverlay) { return; } const scrollY = container.scrollTop; const overlay = document.createElement('div'); overlay.style.cssText = 'position:absolute;inset:0;z-index:10;' + 'overflow:hidden;pointer-events:none;'; const inner = document.createElement('div'); inner.style.cssText = 'position:relative;height:100%;' + 'pointer-events:none;'; for (const child of Array.from( container.childNodes, )) { inner.appendChild(child.cloneNode(true)); } inner.style.transform = `translateY(-${scrollY}px)`; overlay.appendChild(inner); shadowRoot?.appendChild(overlay); this.transitionOverlay = overlay; container.style.visibility = 'hidden'; } /** * Remove the snapshot overlay and reveal the real * scroll container. */ removeOverlay(): void { if (this.transitionOverlay) { this.transitionOverlay.remove(); this.transitionOverlay = null; } } /** * Reveal the real scroll container (call separately * when the overlay has already been removed or was * never created). */ revealContainer( container: HTMLElement | undefined, ): void { if (container) { container.style.visibility = ''; } } // ================================================================ // Dropdown scroll positioning // ================================================================ /** * Wait for the "before" virtualizer to finish its * layout pass. */ async awaitBeforeLayout( shadowRoot: ShadowRoot | null, ): Promise { const virt = shadowRoot?.querySelector( '#grid-before', ) as LitVirtualizer | null; await virt?.layoutComplete; } /** * Return the current scrollTop converted to * single-mode (dropdown-free) coordinates. */ computeAdjustedScrollTop( container: HTMLElement | undefined, shadowRoot: ShadowRoot | null, ): number { if (!container) return 0; const raw = container.scrollTop; if (!this.host.splitMode) return raw; const gap = this.spacing(container); const pad = gap; const columns = this.getColumnCount(container); const rowStep = this.host.cardHeight + gap; const beforeRows = Math.ceil( this.host.splitIndex / columns, ); const dropdownTop = pad + beforeRows * rowStep; if (raw <= dropdownTop) return raw; const dropdown = shadowRoot?.querySelector( 'album-dropdown', ); const dropdownHeight = (dropdown as HTMLElement)?.offsetHeight ?? 0; return raw - dropdownHeight; } /** * Set scrollTop on the scroll container with * retry logic for virtualizer expansion. */ async restoreScrollTop( container: HTMLElement | undefined, target: number, ): Promise { if (!container) return; const maxAttempts = 10; for (let i = 0; i < maxAttempts; i++) { container.scrollTop = target; if ( container.scrollTop >= target || target <= 0 ) { return; } await new Promise((r) => requestAnimationFrame(() => r()), ); } console.warn( '[restoreScrollTop] gave up after max attempts', { target, actual: container.scrollTop, scrollHeight: container.scrollHeight, }, ); } /** * Scroll the container so the expanded album card * and its dropdown are visible with minimal movement. */ async scrollToShowDropdown( container: HTMLElement | undefined, shadowRoot: ShadowRoot | null, ): Promise { if ( !container || this.host.expandedAlbumId === null ) { return; } const expandedIndex = this.getExpandedAlbumIndex(); if (expandedIndex < 0) return; const gap = this.spacing(container); const pad = gap; const columns = this.getColumnCount(container); const rowStep = this.host.cardHeight + gap; const albumRow = Math.floor( expandedIndex / columns, ); const albumTop = pad + albumRow * rowStep - gap / 2; const dropdown = shadowRoot?.querySelector( 'album-dropdown', ); if (!dropdown) return; await (dropdown as LitElement).updateComplete; const beforeRows = Math.ceil( this.host.splitIndex / columns, ); const dropdownTop = pad + beforeRows * rowStep; const dropdownBottom = dropdownTop + (dropdown as HTMLElement).offsetHeight; const viewTop = container.scrollTop; const viewHeight = container.clientHeight; const minScroll = dropdownBottom - viewHeight; const maxScroll = albumTop; let newScrollTop: number; if (minScroll <= maxScroll) { newScrollTop = Math.max( minScroll, Math.min(viewTop, maxScroll), ); } else { newScrollTop = albumTop; } if (newScrollTop !== viewTop) { await this.restoreScrollTop( container, newScrollTop, ); } } // ================================================================ // willUpdate / updated helpers // // Called from the component's lifecycle methods to // compute scroll-related state transitions. // ================================================================ /** * Check whether a scroll-restore async block is * currently in flight. */ get restoreInFlight(): boolean { return ( this.scrollRestoreGeneration > this.scrollRestoreResolved ); } /** * Prepare the anchor capture for an exit-split * transition when switching albums (not closing). * Records the viewport offset of the newly-expanded * album in the old split layout. */ captureAnchorOffset( container: HTMLElement | undefined, shadowRoot: ShadowRoot | null, ): void { if (this.host.expandedAlbumId === null) { this.savedAlbumViewportOffset = null; return; } const rawScrollTop = container?.scrollTop ?? 0; const idx = this.getExpandedAlbumIndex(); if (idx < 0) return; const gap = this.spacing(container); const pad = gap; const cols = this.getColumnCount(container); const rowStep = this.host.cardHeight + gap; const row = Math.floor(idx / cols); const albumY = pad + row * rowStep; const oldBeforeRows = Math.ceil( this.host.splitIndex / cols, ); const oldDropdownTop = pad + oldBeforeRows * rowStep; const dropdown = shadowRoot?.querySelector( 'album-dropdown', ); const oldDropdownHeight = (dropdown as HTMLElement)?.offsetHeight ?? 0; const albumYOldSplit = albumY >= oldDropdownTop ? albumY + oldDropdownHeight : albumY; this.savedAlbumViewportOffset = albumYOldSplit - rawScrollTop; } /** * Run the async scroll-restore sequence from the * component's `updated()` callback. */ runScrollRestore( container: HTMLElement | undefined, shadowRoot: ShadowRoot | null, expandedAlbumId: number | null, updateComplete: Promise, ): void { this.needsScrollRestore = false; const saved = this.savedScrollTop; const showDropdown = this.showDropdownAfterRestore; const switching = !showDropdown && expandedAlbumId !== null; const gen = ++this.scrollRestoreGeneration; void (async () => { await updateComplete; if (gen !== this.scrollRestoreGeneration) { this.scrollRestoreResolved = gen; return; } await this.restoreScrollTop( container, saved, ); if (gen !== this.scrollRestoreGeneration) { this.scrollRestoreResolved = gen; return; } if (showDropdown) { if ( this.savedAlbumViewportOffset !== null && expandedAlbumId !== null ) { const idx = this.getExpandedAlbumIndex(); if (idx >= 0) { const gap = this.spacing(container); const pad = gap; const cols = this.getColumnCount( container, ); const rowStep = this.host.cardHeight + gap; const row = Math.floor( idx / cols, ); const albumY = pad + row * rowStep; const anchor = albumY - this .savedAlbumViewportOffset!; await this.restoreScrollTop( container, anchor, ); } this.savedAlbumViewportOffset = null; } if ( gen !== this.scrollRestoreGeneration ) { this.scrollRestoreResolved = gen; return; } await this.scrollToShowDropdown( container, shadowRoot, ); } if (gen !== this.scrollRestoreGeneration) { this.scrollRestoreResolved = gen; return; } if (!switching) { this.removeOverlay(); this.revealContainer(container); } this.scrollRestoreResolved = gen; })(); } }