diff --git a/frontend/src/components/artists-view/artists-view.ts b/frontend/src/components/artists-view/artists-view.ts index d44c9b1..a3cfd24 100644 --- a/frontend/src/components/artists-view/artists-view.ts +++ b/frontend/src/components/artists-view/artists-view.ts @@ -6,6 +6,10 @@ import { } from 'lit/decorators.js'; import { EventsOn } from '@runtime/runtime'; import '@lit-labs/virtualizer'; +import type { + LitVirtualizer, + VisibilityChangedEvent, +} from '@lit-labs/virtualizer'; import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; import { GetAlbumsByArtist, @@ -33,6 +37,9 @@ const CARD_SIZE_MIN = 100; const CARD_SIZE_MAX = 350; const CARD_SIZE_DEFAULT = 176; +/** Debounce delay for saving scroll position. */ +const SCROLL_DEBOUNCE_MS = 100; + /** * Grid entry for the virtualized artist grid. */ @@ -48,6 +55,9 @@ export class ArtistsView extends LitElement { private cancelScanComplete?: () => void; private wheelListenerAttached = false; private lastSearchTerm = ''; + private scrollDebounceTimer: ReturnType< + typeof setTimeout + > | null = null; @state() private artists: library.Artist[] = []; @@ -55,6 +65,9 @@ export class ArtistsView extends LitElement { @state() private loading = true; + @state() + private restoringScroll = false; + @state() private cardSize: number = CARD_SIZE_DEFAULT; @@ -398,6 +411,11 @@ export class ArtistsView extends LitElement { super.disconnectedCallback(); this.cancelScanComplete?.(); this.detachWheelListener(); + + if (this.scrollDebounceTimer !== null) { + clearTimeout(this.scrollDebounceTimer); + } + document.removeEventListener( 'click', this.closeHandler, @@ -445,8 +463,85 @@ export class ArtistsView extends LitElement { ); this.artists = []; } finally { + const saved = + this.libraryCtrl.getScrollPosition( + 'artists', + ); + + this.restoringScroll = saved > 0; this.loading = false; } + + await this.updateComplete; + this.restoreScrollPosition(); + } + + /* ================================================================ + * Scroll position persistence + * ================================================================ */ + + /** + * Save the first visible item index on scroll. + */ + private onVisibilityChanged = ( + e: VisibilityChangedEvent, + ) => { + if (this.restoringScroll) return; + + if (this.scrollDebounceTimer !== null) { + clearTimeout(this.scrollDebounceTimer); + } + + this.scrollDebounceTimer = setTimeout( + () => { + this.libraryCtrl.setScrollPosition( + 'artists', + e.first, + ); + }, + SCROLL_DEBOUNCE_MS, + ); + }; + + /** + * Restore scroll position from the store. + */ + private restoreScrollPosition(): void { + const saved = + this.libraryCtrl.getScrollPosition( + 'artists', + ); + + if (saved <= 0) { + this.restoringScroll = false; + + return; + } + + const virt = + this.shadowRoot?.querySelector( + 'lit-virtualizer', + ) as LitVirtualizer | null; + + if (!virt) { + this.restoringScroll = false; + + return; + } + + const safeIndex = Math.min( + saved, + this.filteredArtists.length - 1, + ); + + if (safeIndex <= 0) { + this.restoringScroll = false; + + return; + } + + virt.scrollToIndex(safeIndex, 'start'); + this.restoringScroll = false; } /* ================================================================ @@ -1182,6 +1277,9 @@ export class ArtistsView extends LitElement { : nothing}
${this.renderContextMenu()} diff --git a/frontend/src/components/genres-view/genres-view.ts b/frontend/src/components/genres-view/genres-view.ts index 7ec358e..109dbe1 100644 --- a/frontend/src/components/genres-view/genres-view.ts +++ b/frontend/src/components/genres-view/genres-view.ts @@ -6,6 +6,10 @@ import { } from 'lit/decorators.js'; import { EventsOn } from '@runtime/runtime'; import '@lit-labs/virtualizer'; +import type { + LitVirtualizer, + VisibilityChangedEvent, +} from '@lit-labs/virtualizer'; import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; import { library } from '@go/models'; import { LibraryController } from '@store/controllers/library-controller'; @@ -29,6 +33,9 @@ const CARD_SIZE_MIN = 100; const CARD_SIZE_MAX = 350; const CARD_SIZE_DEFAULT = 176; +/** Debounce delay for saving scroll position. */ +const SCROLL_DEBOUNCE_MS = 100; + /** A genre extracted from the track library. */ interface Genre { name: string; @@ -48,6 +55,9 @@ export class GenresView extends LitElement { private cancelScanComplete?: () => void; private wheelListenerAttached = false; private lastSearchTerm = ''; + private scrollDebounceTimer: ReturnType< + typeof setTimeout + > | null = null; /** All tracks from the library (used to derive genres). */ private allTracks: library.Track[] = []; @@ -58,6 +68,9 @@ export class GenresView extends LitElement { @state() private loading = true; + @state() + private restoringScroll = false; + @state() private cardSize: number = CARD_SIZE_DEFAULT; @@ -404,6 +417,11 @@ export class GenresView extends LitElement { super.disconnectedCallback(); this.cancelScanComplete?.(); this.detachWheelListener(); + + if (this.scrollDebounceTimer !== null) { + clearTimeout(this.scrollDebounceTimer); + } + document.removeEventListener( 'click', this.closeHandler, @@ -454,8 +472,17 @@ export class GenresView extends LitElement { this.allTracks = []; this.genres = []; } finally { + const saved = + this.libraryCtrl.getScrollPosition( + 'genres', + ); + + this.restoringScroll = saved > 0; this.loading = false; } + + await this.updateComplete; + this.restoreScrollPosition(); } /** @@ -493,6 +520,74 @@ export class GenresView extends LitElement { return result; } + /* ================================================================ + * Scroll position persistence + * ================================================================ */ + + /** + * Save the first visible item index on scroll. + */ + private onVisibilityChanged = ( + e: VisibilityChangedEvent, + ) => { + if (this.restoringScroll) return; + + if (this.scrollDebounceTimer !== null) { + clearTimeout(this.scrollDebounceTimer); + } + + this.scrollDebounceTimer = setTimeout( + () => { + this.libraryCtrl.setScrollPosition( + 'genres', + e.first, + ); + }, + SCROLL_DEBOUNCE_MS, + ); + }; + + /** + * Restore scroll position from the store. + */ + private restoreScrollPosition(): void { + const saved = + this.libraryCtrl.getScrollPosition( + 'genres', + ); + + if (saved <= 0) { + this.restoringScroll = false; + + return; + } + + const virt = + this.shadowRoot?.querySelector( + 'lit-virtualizer', + ) as LitVirtualizer | null; + + if (!virt) { + this.restoringScroll = false; + + return; + } + + const safeIndex = Math.min( + saved, + this.filteredGenres.length - 1, + ); + + if (safeIndex <= 0) { + this.restoringScroll = false; + + return; + } + + virt.scrollToIndex(safeIndex, 'start'); + this.restoringScroll = false; + } + /* ================================================================ * Card size (zoom) * ================================================================ */ @@ -1187,6 +1282,9 @@ export class GenresView extends LitElement { : nothing}
this.renderGenreCard(entry)} .layout=${this.gridLayout} + @visibilityChanged=${this.onVisibilityChanged} >
${this.renderContextMenu()} diff --git a/frontend/src/store/controllers/library-controller.ts b/frontend/src/store/controllers/library-controller.ts index 5fa52bf..ea2a38f 100644 --- a/frontend/src/store/controllers/library-controller.ts +++ b/frontend/src/store/controllers/library-controller.ts @@ -2,7 +2,7 @@ import type { ReactiveController, ReactiveControllerHost } from 'lit'; import type { library } from '@go/models'; import { libraryStore } from '../library-store'; -type ViewName = 'tracks' | 'albums'; +type ViewName = 'tracks' | 'albums' | 'artists' | 'genres'; /** * LibraryController connects a Lit component to the LibraryStore. diff --git a/frontend/src/store/library-store.ts b/frontend/src/store/library-store.ts index 0ee7d7a..a888e82 100644 --- a/frontend/src/store/library-store.ts +++ b/frontend/src/store/library-store.ts @@ -8,7 +8,7 @@ import { import type { library } from '@go/models'; import { Events } from '../events'; -type ViewName = 'tracks' | 'albums'; +type ViewName = 'tracks' | 'albums' | 'artists' | 'genres'; type Subscriber = () => void; @@ -38,6 +38,8 @@ class LibraryStore { private scrollPositions: Record = { tracks: 0, albums: 0, + artists: 0, + genres: 0, }; private subscribers = new Set(); @@ -247,7 +249,7 @@ class LibraryStore { this.tracks = null; this.albums = null; this.artists = null; - this.scrollPositions = { tracks: 0, albums: 0 }; + this.scrollPositions = { tracks: 0, albums: 0, artists: 0, genres: 0 }; this.notify(); }