scroll restore on artists and genres pages

This commit is contained in:
2026-02-23 13:08:44 -05:00
parent 3349e785d7
commit 967816ccd2
4 changed files with 203 additions and 3 deletions
@@ -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}
<div
class="grid-scroll-container"
style=${this.restoringScroll
? 'visibility: hidden'
: ''}
@click=${this.onGridClick}
>
<lit-virtualizer
@@ -1193,6 +1291,7 @@ export class ArtistsView extends LitElement {
entry,
)}
.layout=${this.gridLayout}
@visibilityChanged=${this.onVisibilityChanged}
></lit-virtualizer>
</div>
${this.renderContextMenu()}
@@ -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}
<div
class="grid-scroll-container"
style=${this.restoringScroll
? 'visibility: hidden'
: ''}
@click=${this.onGridClick}
>
<lit-virtualizer
@@ -1196,6 +1294,7 @@ export class GenresView extends LitElement {
) =>
this.renderGenreCard(entry)}
.layout=${this.gridLayout}
@visibilityChanged=${this.onVisibilityChanged}
></lit-virtualizer>
</div>
${this.renderContextMenu()}
@@ -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.
+4 -2
View File
@@ -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<ViewName, number> = {
tracks: 0,
albums: 0,
artists: 0,
genres: 0,
};
private subscribers = new Set<Subscriber>();
@@ -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();
}