Android: album art pops in while scrolling — preload ahead of the viewport #65

Closed
opened 2026-08-18 06:00:07 +00:00 by logan · 2 comments
Collaborator

Report

Scrolling through albums, the art pops in. It should be preloaded so scrolling looks smooth.

Findings

  • Row/card images are loading="lazy" decoding="async" by rule, which is what produces the pop-in at speed.
  • Pick the tier for the box you are drawing (cover-grid's getCoverUrl() is the model) — a card drawing the _lg tier into a small box will pop in far more than one drawing _sm.
  • Art is fetched over the IPC as base64 data URLs (a cover thumbnail is ~27kB, an artist photo ~128kB) and cached in LRUMaps with measured ceilings; any prefetch must go through those caches and respect the caps, or it becomes an unbounded prefetch of the whole library.
  • The grids are virtualized, so "ahead of the viewport" means asking the virtualizer for a larger overscan rather than hand-rolling a window.

Direction

Increase the virtualizer's overscan on the grids, make sure the smallest adequate tier is used, and consider fetchpriority/eager loading for the first screenful. Measure with window.__yjCacheStats() before and after so the ceiling is shown to still hold — the caps exist because twenty-four searches retained 20.58MB and were still accelerating.

**Report** Scrolling through albums, the art pops in. It should be preloaded so scrolling looks smooth. **Findings** - Row/card images are `loading="lazy" decoding="async"` by rule, which is what produces the pop-in at speed. - Pick the tier for the box you are drawing (`cover-grid`'s `getCoverUrl()` is the model) — a card drawing the `_lg` tier into a small box will pop in far more than one drawing `_sm`. - Art is fetched over the IPC as base64 data URLs (a cover thumbnail is ~27kB, an artist photo ~128kB) and cached in `LRUMap`s with measured ceilings; any prefetch must go through those caches and respect the caps, or it becomes an unbounded prefetch of the whole library. - The grids are virtualized, so "ahead of the viewport" means asking the virtualizer for a larger overscan rather than hand-rolling a window. **Direction** Increase the virtualizer's overscan on the grids, make sure the smallest adequate tier is used, and consider `fetchpriority`/eager loading for the first screenful. Measure with `window.__yjCacheStats()` before and after so the ceiling is shown to still hold — the caps exist because twenty-four searches retained 20.58MB and were still accelerating.
logan added the Area/Library-UIKind/EnhancementPlatform/Android
Priority
Medium
3
labels 2026-08-18 14:36:34 +00:00
logan self-assigned this 2026-08-24 08:35:26 +00:00
logan added the
Status
In Progress
label 2026-08-24 08:35:26 +00:00
Author
Collaborator

Picking this up on branch feat/65-art-prefetch-ahead.

Taking it as Phase 4's remaining independent item in #73 (#66 and #64
are closed; #63/#67/#71/#54 are merged or in flight). Everything at
Priority/High is unactionable from here — #212, #203 and #194 end at a
device I do not have, #183 is claimed, #53 sits at Status/Need More Info behind an existing branch, and #73 is this list itself.

Skipping ahead of two Medium items deliberately, and saying why.
Six PRs from earlier runs are open and unmerged against a main that
has not moved, so this run is asked to avoid another branch over the
same files. #200 (dismissible surfaces answer back) is queue-panel,
menu-surface and index.ts — all three already edited by #208/#209/
#211/#214 — and #39 (column config from the track list) is
track-list, same problem. #85 opens with "re-measure before building",
which needs a real tagged library and MusicBrainz, not a scheduled run.

Direction I intend to take, since the issue offers three.

The tier work is already done: cover-grid's getCoverUrl() picks
_sm/_md/_lg from the card size and DPR, and artists-view does
the same for its avatars. So that half of the Direction is a check, not
a change.

The overscan half cannot be done as written: @lit-labs/virtualizer's
_overhang is a hard-coded 1000px protected field on BaseLayout
with no config surface, so "ask the virtualizer for a larger overscan"
would mean monkey-patching a private. That leaves the thing the
overhang is actually standing in for — the image is not fetched until
the card exists, and 1000px is about two screens on a 439px viewport.

So: prefetch the covers for the entries just past the rendered window,
off the visibilityChanged event both grids already listen to,
bounded and de-duplicated. Cover URLs are served by
coverart.Handler with Cache-Control: public, max-age=31536000, immutable (the filenames are content hashes), so a prefetched image is
a browser-cache hit when the card is finally drawn and costs nothing on
the second pass. Note that this is not the LRUMap path the Findings
warn about — that ceiling is Explore's base64 data URLs; library covers
are plain URLs and never enter it — so the bound here is the request
window and the dedup set, and I will say what both measure.

Picking this up on branch `feat/65-art-prefetch-ahead`. Taking it as Phase 4's remaining independent item in #73 (#66 and #64 are closed; #63/#67/#71/#54 are merged or in flight). Everything at `Priority/High` is unactionable from here — #212, #203 and #194 end at a device I do not have, #183 is claimed, #53 sits at `Status/Need More Info` behind an existing branch, and #73 is this list itself. **Skipping ahead of two Medium items deliberately, and saying why.** Six PRs from earlier runs are open and unmerged against a `main` that has not moved, so this run is asked to avoid another branch over the same files. #200 (dismissible surfaces answer back) is `queue-panel`, `menu-surface` and `index.ts` — all three already edited by #208/#209/ #211/#214 — and #39 (column config from the track list) is `track-list`, same problem. #85 opens with "re-measure before building", which needs a real tagged library and MusicBrainz, not a scheduled run. **Direction I intend to take, since the issue offers three.** The tier work is already done: `cover-grid`'s `getCoverUrl()` picks `_sm`/`_md`/`_lg` from the card size and DPR, and `artists-view` does the same for its avatars. So that half of the Direction is a check, not a change. The overscan half cannot be done as written: `@lit-labs/virtualizer`'s `_overhang` is a hard-coded 1000px `protected` field on `BaseLayout` with no config surface, so "ask the virtualizer for a larger overscan" would mean monkey-patching a private. That leaves the thing the overhang is actually standing in for — the *image* is not fetched until the card exists, and 1000px is about two screens on a 439px viewport. So: prefetch the covers for the entries just past the rendered window, off the `visibilityChanged` event both grids already listen to, bounded and de-duplicated. Cover URLs are served by `coverart.Handler` with `Cache-Control: public, max-age=31536000, immutable` (the filenames are content hashes), so a prefetched image is a browser-cache hit when the card is finally drawn and costs nothing on the second pass. Note that this is *not* the `LRUMap` path the Findings warn about — that ceiling is Explore's base64 data URLs; library covers are plain URLs and never enter it — so the bound here is the request window and the dedup set, and I will say what both measure.
Author
Collaborator

PR #215feat/65-art-prefetch-ahead, CI green (check and e2e,
the latter carrying WebKit).

Three notes on how the Direction's three suggestions came out, since
two of them changed shape:

  • The tier work was already done and is a check rather than a
    change: cover-grid.getCoverUrl() and artists-view's avatar both
    pick _sm/_md/_lg from the card size and the device pixel ratio,
    and both images are already loading="lazy" decoding="async".
  • The overscan cannot be raised. @lit-labs/virtualizer's
    _overhang is a hard-coded 1000px protected field on BaseLayout,
    read by every layout, with no option on grid() and no property on
    the element — so this would be monkey-patching a private. The
    request is issued ahead of the element instead, which is what that
    overhang was standing in for.
  • fetchpriority/eager for the first screenful is not done. That
    screenful renders on mount and asks for its art immediately, so it is
    a different question from the one reported. Saying so rather than
    leaving it ambiguous.

Measured on make dev-headless SEED=bulk (4 988 albums), ten 2 400px
jumps, counting covers in the viewport with naturalWidth === 0:

build blank at frame 1 at frame 2 at 50 ms
main 254 / 258 214 / 258 0
branch 117 / 258 77 / 258 0

window.__yjCacheStats().imagePrefetch after that run is 497
entries, 15 407 chars, cap 512
— the ceiling this issue asks to be
shown still holding. Worth recording that it is a different ceiling
from the one the Findings name: the LRUMap caps bound Explore's
base64 data URLs, whereas a library cover is a plain URL under
Cache-Control: immutable, so the bytes are the browser's cache and
what is capped here is the record of URLs already asked for.

Not verified on the device: the numbers above are a desktop engine
against the bulk library's 3.7 kB covers, which is why both builds are
clean by 50 ms there.

PR **#215** — `feat/65-art-prefetch-ahead`, CI green (`check` and `e2e`, the latter carrying WebKit). Three notes on how the Direction's three suggestions came out, since two of them changed shape: - **The tier work was already done** and is a check rather than a change: `cover-grid.getCoverUrl()` and `artists-view`'s avatar both pick `_sm`/`_md`/`_lg` from the card size and the device pixel ratio, and both images are already `loading="lazy" decoding="async"`. - **The overscan cannot be raised.** `@lit-labs/virtualizer`'s `_overhang` is a hard-coded 1000px `protected` field on `BaseLayout`, read by every layout, with no option on `grid()` and no property on the element — so this would be monkey-patching a private. The request is issued ahead of the element instead, which is what that overhang was standing in for. - **`fetchpriority`/eager for the first screenful is not done.** That screenful renders on mount and asks for its art immediately, so it is a different question from the one reported. Saying so rather than leaving it ambiguous. Measured on `make dev-headless SEED=bulk` (4 988 albums), ten 2 400px jumps, counting covers in the viewport with `naturalWidth === 0`: | build | blank at frame 1 | at frame 2 | at 50 ms | |---|---|---|---| | `main` | 254 / 258 | 214 / 258 | 0 | | branch | 117 / 258 | 77 / 258 | 0 | `window.__yjCacheStats().imagePrefetch` after that run is **497 entries, 15 407 chars, cap 512** — the ceiling this issue asks to be shown still holding. Worth recording that it is a *different* ceiling from the one the Findings name: the `LRUMap` caps bound Explore's base64 data URLs, whereas a library cover is a plain URL under `Cache-Control: immutable`, so the bytes are the browser's cache and what is capped here is the record of URLs already asked for. Not verified on the device: the numbers above are a desktop engine against the bulk library's 3.7 kB covers, which is why both builds are clean by 50 ms there.
logan closed this issue 2026-08-25 17:58:43 +00:00
gitea-actions bot removed the
Status
In Progress
label 2026-08-25 18:10:24 +00:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: yonlu/yellowjacket#65