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'; } from 'lit/decorators.js';
import { EventsOn } from '@runtime/runtime'; import { EventsOn } from '@runtime/runtime';
import '@lit-labs/virtualizer'; import '@lit-labs/virtualizer';
import type {
LitVirtualizer,
VisibilityChangedEvent,
} from '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import { import {
GetAlbumsByArtist, GetAlbumsByArtist,
@@ -33,6 +37,9 @@ const CARD_SIZE_MIN = 100;
const CARD_SIZE_MAX = 350; const CARD_SIZE_MAX = 350;
const CARD_SIZE_DEFAULT = 176; const CARD_SIZE_DEFAULT = 176;
/** Debounce delay for saving scroll position. */
const SCROLL_DEBOUNCE_MS = 100;
/** /**
* Grid entry for the virtualized artist grid. * Grid entry for the virtualized artist grid.
*/ */
@@ -48,6 +55,9 @@ export class ArtistsView extends LitElement {
private cancelScanComplete?: () => void; private cancelScanComplete?: () => void;
private wheelListenerAttached = false; private wheelListenerAttached = false;
private lastSearchTerm = ''; private lastSearchTerm = '';
private scrollDebounceTimer: ReturnType<
typeof setTimeout
> | null = null;
@state() @state()
private artists: library.Artist[] = []; private artists: library.Artist[] = [];
@@ -55,6 +65,9 @@ export class ArtistsView extends LitElement {
@state() @state()
private loading = true; private loading = true;
@state()
private restoringScroll = false;
@state() @state()
private cardSize: number = CARD_SIZE_DEFAULT; private cardSize: number = CARD_SIZE_DEFAULT;
@@ -398,6 +411,11 @@ export class ArtistsView extends LitElement {
super.disconnectedCallback(); super.disconnectedCallback();
this.cancelScanComplete?.(); this.cancelScanComplete?.();
this.detachWheelListener(); this.detachWheelListener();
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
}
document.removeEventListener( document.removeEventListener(
'click', 'click',
this.closeHandler, this.closeHandler,
@@ -445,8 +463,85 @@ export class ArtistsView extends LitElement {
); );
this.artists = []; this.artists = [];
} finally { } finally {
const saved =
this.libraryCtrl.getScrollPosition(
'artists',
);
this.restoringScroll = saved > 0;
this.loading = false; 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} : nothing}
<div <div
class="grid-scroll-container" class="grid-scroll-container"
style=${this.restoringScroll
? 'visibility: hidden'
: ''}
@click=${this.onGridClick} @click=${this.onGridClick}
> >
<lit-virtualizer <lit-virtualizer
@@ -1193,6 +1291,7 @@ export class ArtistsView extends LitElement {
entry, entry,
)} )}
.layout=${this.gridLayout} .layout=${this.gridLayout}
@visibilityChanged=${this.onVisibilityChanged}
></lit-virtualizer> ></lit-virtualizer>
</div> </div>
${this.renderContextMenu()} ${this.renderContextMenu()}
@@ -6,6 +6,10 @@ import {
} from 'lit/decorators.js'; } from 'lit/decorators.js';
import { EventsOn } from '@runtime/runtime'; import { EventsOn } from '@runtime/runtime';
import '@lit-labs/virtualizer'; import '@lit-labs/virtualizer';
import type {
LitVirtualizer,
VisibilityChangedEvent,
} from '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js'; import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import { library } from '@go/models'; import { library } from '@go/models';
import { LibraryController } from '@store/controllers/library-controller'; import { LibraryController } from '@store/controllers/library-controller';
@@ -29,6 +33,9 @@ const CARD_SIZE_MIN = 100;
const CARD_SIZE_MAX = 350; const CARD_SIZE_MAX = 350;
const CARD_SIZE_DEFAULT = 176; const CARD_SIZE_DEFAULT = 176;
/** Debounce delay for saving scroll position. */
const SCROLL_DEBOUNCE_MS = 100;
/** A genre extracted from the track library. */ /** A genre extracted from the track library. */
interface Genre { interface Genre {
name: string; name: string;
@@ -48,6 +55,9 @@ export class GenresView extends LitElement {
private cancelScanComplete?: () => void; private cancelScanComplete?: () => void;
private wheelListenerAttached = false; private wheelListenerAttached = false;
private lastSearchTerm = ''; private lastSearchTerm = '';
private scrollDebounceTimer: ReturnType<
typeof setTimeout
> | null = null;
/** All tracks from the library (used to derive genres). */ /** All tracks from the library (used to derive genres). */
private allTracks: library.Track[] = []; private allTracks: library.Track[] = [];
@@ -58,6 +68,9 @@ export class GenresView extends LitElement {
@state() @state()
private loading = true; private loading = true;
@state()
private restoringScroll = false;
@state() @state()
private cardSize: number = CARD_SIZE_DEFAULT; private cardSize: number = CARD_SIZE_DEFAULT;
@@ -404,6 +417,11 @@ export class GenresView extends LitElement {
super.disconnectedCallback(); super.disconnectedCallback();
this.cancelScanComplete?.(); this.cancelScanComplete?.();
this.detachWheelListener(); this.detachWheelListener();
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
}
document.removeEventListener( document.removeEventListener(
'click', 'click',
this.closeHandler, this.closeHandler,
@@ -454,8 +472,17 @@ export class GenresView extends LitElement {
this.allTracks = []; this.allTracks = [];
this.genres = []; this.genres = [];
} finally { } finally {
const saved =
this.libraryCtrl.getScrollPosition(
'genres',
);
this.restoringScroll = saved > 0;
this.loading = false; this.loading = false;
} }
await this.updateComplete;
this.restoreScrollPosition();
} }
/** /**
@@ -493,6 +520,74 @@ export class GenresView extends LitElement {
return result; 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) * Card size (zoom)
* ================================================================ */ * ================================================================ */
@@ -1187,6 +1282,9 @@ export class GenresView extends LitElement {
: nothing} : nothing}
<div <div
class="grid-scroll-container" class="grid-scroll-container"
style=${this.restoringScroll
? 'visibility: hidden'
: ''}
@click=${this.onGridClick} @click=${this.onGridClick}
> >
<lit-virtualizer <lit-virtualizer
@@ -1196,6 +1294,7 @@ export class GenresView extends LitElement {
) => ) =>
this.renderGenreCard(entry)} this.renderGenreCard(entry)}
.layout=${this.gridLayout} .layout=${this.gridLayout}
@visibilityChanged=${this.onVisibilityChanged}
></lit-virtualizer> ></lit-virtualizer>
</div> </div>
${this.renderContextMenu()} ${this.renderContextMenu()}
@@ -2,7 +2,7 @@ import type { ReactiveController, ReactiveControllerHost } from 'lit';
import type { library } from '@go/models'; import type { library } from '@go/models';
import { libraryStore } from '../library-store'; import { libraryStore } from '../library-store';
type ViewName = 'tracks' | 'albums'; type ViewName = 'tracks' | 'albums' | 'artists' | 'genres';
/** /**
* LibraryController connects a Lit component to the LibraryStore. * LibraryController connects a Lit component to the LibraryStore.
+4 -2
View File
@@ -8,7 +8,7 @@ import {
import type { library } from '@go/models'; import type { library } from '@go/models';
import { Events } from '../events'; import { Events } from '../events';
type ViewName = 'tracks' | 'albums'; type ViewName = 'tracks' | 'albums' | 'artists' | 'genres';
type Subscriber = () => void; type Subscriber = () => void;
@@ -38,6 +38,8 @@ class LibraryStore {
private scrollPositions: Record<ViewName, number> = { private scrollPositions: Record<ViewName, number> = {
tracks: 0, tracks: 0,
albums: 0, albums: 0,
artists: 0,
genres: 0,
}; };
private subscribers = new Set<Subscriber>(); private subscribers = new Set<Subscriber>();
@@ -247,7 +249,7 @@ class LibraryStore {
this.tracks = null; this.tracks = null;
this.albums = null; this.albums = null;
this.artists = null; this.artists = null;
this.scrollPositions = { tracks: 0, albums: 0 }; this.scrollPositions = { tracks: 0, albums: 0, artists: 0, genres: 0 };
this.notify(); this.notify();
} }