feat(ui): warm album art ahead of the scroll
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user