feat(ui): warm album art ahead of the scroll
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m46s
CI / e2e (pull_request) Successful in 10m13s

Scrolling the albums grid pops art in: the cards already draw the
smallest adequate tier and are already lazy, so what was left is *when*
the request happens. The grids are virtualized, so the `<img>` — and
therefore the fetch — does not exist until the virtualizer renders its
card, which is about 1000px past the viewport, or two screens on the
reference device.

The issue asks for a larger overscan and that is not available:
`_overhang` is a hard-coded `protected` field on `BaseLayout` with no
configuration surface. So the request is issued ahead of the element
instead. `utils/image-prefetch.ts` warms a bounded window either side
of the rendered range, from `rangeChanged` rather than
`visibilityChanged` — the two report different ranges, and a window
measured from what is *visible* is spent on cards that already exist.

Cover and artist URLs are served under `Cache-Control: immutable`
(content-hashed filenames), so a prefetched image is a cache hit by the
time its card is drawn. The bytes are the browser's; what this holds is
the set of URLs asked for, capped and reported to `__yjCacheStats()`.

Measured on the bulk seed (4 988 albums), ten 2 400px jumps, covers in
the viewport with `naturalWidth === 0`: 254 of 258 blank one frame
after the jump and 214 two frames after, against 117 and 77 with the
prefetch.

Closes #65
This commit is contained in:
2026-08-25 13:55:43 -04:00
parent e23e6f9a54
commit 3479ae8d39
8 changed files with 618 additions and 1 deletions
@@ -7,6 +7,7 @@ import {
import '@lit-labs/virtualizer';
import type {
LitVirtualizer,
RangeChangedEvent,
VisibilityChangedEvent,
} from '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
@@ -30,6 +31,7 @@ import type { ContextMenuHost, MenuTarget } from '@utils/context-menu-controller
import { FavoritesController } from '@store/controllers/favorites-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { RovingGridController } from '@utils/roving-grid';
import { prefetchImageWindow } from '@utils/image-prefetch';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
@@ -582,6 +584,26 @@ export class ArtistsView
* Scroll position persistence
* ================================================================ */
/**
* Warm the avatars just past the rendered range (#65).
*
* `rangeChanged` is the rendered range and `visibilityChanged` is
* what is on screen; the virtualizer has already drawn about
* 1000px past the latter, so that is the wrong anchor to measure a
* prefetch window from. It is deliberately outside the
* `restoringScroll` guard below: a restored scroll lands in the
* middle of the grid, which is exactly when nothing around it is
* cached.
*/
private onRangeChanged = (e: RangeChangedEvent) => {
prefetchImageWindow(
this.cachedGridEntries,
e.first,
e.last,
(entry) => this.artistAvatarURL(entry.artist),
);
};
/**
* Save the first visible item index on scroll.
*/
@@ -1145,7 +1167,16 @@ export class ArtistsView
* Helpers
* ================================================================ */
private renderArtistAvatar(artist: library.Artist) {
/**
* The image this artist's card will draw, or `''` for the initial
* placeholder.
*
* Split out of `renderArtistAvatar` so the prefetch (#65) asks for
* exactly what the card is going to ask for — a second copy of the
* tier ladder would be a second thing to keep in step, and warming
* the wrong tier is a download that buys nothing.
*/
private artistAvatarURL(artist: library.Artist): string {
const needed = (this.imageSize ?? 176) * window.devicePixelRatio;
let imageURL = '';
@@ -1172,6 +1203,12 @@ export class ArtistsView
) ?? '';
}
return imageURL;
}
private renderArtistAvatar(artist: library.Artist) {
const imageURL = this.artistAvatarURL(artist);
if (imageURL) {
return html`<img
class="avatar-image"
@@ -1531,6 +1568,7 @@ export class ArtistsView
.keyFunction=${(entry: ArtistEntry) => entry.artist.ID}
.layout=${this.gridLayout}
@visibilityChanged=${this.onVisibilityChanged}
@rangeChanged=${this.onRangeChanged}
></lit-virtualizer>
</div>
${this.renderContextMenu()}
@@ -8,6 +8,7 @@ import {
import '@lit-labs/virtualizer';
import type {
LitVirtualizer,
RangeChangedEvent,
VisibilityChangedEvent,
} from '@lit-labs/virtualizer';
import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
@@ -30,6 +31,7 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/playlist-picker/playlist-picker.js';
import { loadTrackDetails } from '@utils/lazy-track-details.js';
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
import { prefetchImageWindow } from '@utils/image-prefetch.js';
import type { TrackDetails } from '@components/track-details/track-details.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
import { AlbumSelectionManager } from './album-selection.js';
@@ -910,6 +912,31 @@ export class CoverGrid
);
};
/**
* Warm the covers just past the rendered range (#65).
*
* `rangeChanged` rather than `visibilityChanged`, because the two
* report different ranges and only one of them is the right
* anchor: visibility is what is on screen, and the virtualizer has
* already rendered about 1000px past that. Measured from the
* visible range this would spend most of its window on cards that
* already exist and have already asked for their own art.
*
* The entry lists are memoized, so asking for one here costs a
* reference compare.
*/
private onRangeChanged = (e: RangeChangedEvent) => {
const entries = this.splitMode
? this.getBeforeEntries()
: this.buildGridEntries();
prefetchImageWindow(entries, e.first, e.last, (entry) =>
entry.album.CoverArtPath
? this.getCoverUrl(entry.album)
: '',
);
};
/* ====================================================================
* Virtualizer items
* ==================================================================== */
@@ -2003,6 +2030,7 @@ export class CoverGrid
@keydown=${this.onGridAlbumKeydown}
@contextmenu=${this.onGridAlbumContextMenu}
@visibilityChanged=${this.onVisibilityChanged}
@rangeChanged=${this.onRangeChanged}
></lit-virtualizer>
`;
}
@@ -2037,6 +2065,7 @@ export class CoverGrid
@keydown=${this.onGridAlbumKeydown}
@contextmenu=${this.onGridAlbumContextMenu}
@visibilityChanged=${this.onVisibilityChanged}
@rangeChanged=${this.onRangeChanged}
></lit-virtualizer>
<album-dropdown