Skip to content

fix(library): fade artwork over its gradient placeholder instead of grey#62

Merged
InstaZDLL merged 3 commits into
mainfrom
fix/library-artwork-flicker
May 18, 2026
Merged

fix(library): fade artwork over its gradient placeholder instead of grey#62
InstaZDLL merged 3 commits into
mainfrom
fix/library-artwork-flicker

Conversation

@InstaZDLL
Copy link
Copy Markdown
Owner

@InstaZDLL InstaZDLL commented May 18, 2026

Summary

Two-part fix for the Library tab flicker reported by Daisy:

  1. Virtualize Albums and Artists grids β€” with an 800-item library, the previous flat-grid render mounted 800 React subtrees at once, each carrying an <Artwork>/<FadeInImage> (state + useMemo) and a popover button. Main thread stalled ~1 s before the first paint, then the <img> tags fanned out and filled in. No amount of fade-in could disguise the layout cost.

  2. Fade artwork in over its gradient placeholder β€” once virtualization keeps the DOM size sane, individual tile pop-in is the only remaining glitch. Each <img> now mounts at opacity-0 over the same coloured gradient already used as the "no image" fallback, then fades to 100 on onLoad. WebView-cached images skip the fade entirely (handled in the ref callback when complete && naturalWidth > 0).

What changed

src/components/views/LibraryView.tsx β€” AlbumGrid and ArtistList:

  • Wrap rendering in useVirtualizer keyed on row count (rows Γ— cols)
  • ResizeObserver recomputes colCount from the container width to match the original auto-fill,minmax(180px,1fr) Tailwind math
  • Each virtual row renders colCount tiles via inline grid styles
  • Both share usePageScroll() so the page-driven scrollbar stays (per the cross-cutting rule)
  • AlphabetIndex switches from querySelector + scrollIntoView to a scrollToIndexRef callback that drives virtualizer.scrollToIndex(floor(idx / cols)) β€” off-screen artists aren't in the DOM anymore

src/components/common/FadeInImage.tsx (new) β€” shared primitive:

  • Wrapper hosts the gradient placeholder + clip
  • Image mounts at opacity-0, fades to 100 on onLoad
  • ref callback pins the fade open when the WebView serves bytes synchronously
  • Resets the gate when src changes via the documented "compare prev prop in render" pattern (satisfies react-hooks/set-state-in-effect)

src/components/common/Artwork.tsx β€” delegates the image rendering to FadeInImage, keeps the existing API.

src/components/views/LibraryView.tsx β€” artist tile inside ArtistList uses FadeInImage directly with the violet gradient + first-letter as placeholder.

GenreList is intentionally untouched: ~30-50 items in practice, no per-tile state, virtualization would be all cost and no benefit.

Net effect

  • Switching to Albums or Artists tabs: only ~rowsVisible Γ— cols tiles in the DOM at any time
  • Each cover/avatar fades in from its coloured gradient over 200 ms instead of flashing from grey
  • Already-decoded covers (re-visit) appear at full opacity immediately

Test plan

  • bun run typecheck
  • bun run lint
  • Library with ~800 artists β†’ click Artistes tab β†’ first paint within frame budget, tiles fade in cleanly
  • Library with ~800 albums β†’ same expectation
  • Alphabet jump on Artistes (sort = name): clicking a letter scrolls to the first artist of that letter
  • Resize window β†’ tile count per row updates without breaking the virtual offset
  • Songs / Genres / Folders unaffected

Reported pain: opening Artists or Albums showed each thumbnail
flashing through a grey square before the bytes arrived, for 1-2 s
across the whole grid. Tabs themselves opened instantly (state
arrays persist between switches) β€” the perceived lag was entirely
the artwork pop-in.

Replace the `bg-zinc-100` placeholder behind each <img> with the
same coloured gradient already used as the "no image" fallback
(emerald for Artwork, violet for artist avatars). The <img> mounts
with opacity-0 and fades to 100 on `onLoad`, so the placeholder
gradient stays put underneath until the bytes are actually decoded.

WebView-cached images that resolve synchronously skip onLoad
entirely; the `ref` callback catches `complete && naturalWidth > 0`
and pins the fade open immediately so we don't get stuck at zero.

The shared logic lives in a new `<FadeInImage>` so both `Artwork`
(album covers, track thumbnails) and the bespoke round artist
avatars in LibraryView render through the same primitive. Falls
back to React's documented "compare prev prop in render" reset
pattern to satisfy react-hooks/set-state-in-effect.

Closes the gap the reverted #61 perf branch tried β€” and missed β€”
to address.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

Warning

Rate limit exceeded

@InstaZDLL has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 40 minutes and 56 seconds before requesting another review.

To continue reviewing without waiting, purchase usage credits in the billing tab.

βŒ› How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
βš™οΈ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5708fcb0-6ea4-4952-944a-59d07031c342

πŸ“₯ Commits

Reviewing files that changed from the base of the PR and between 77bad0a and 0e35055.

πŸ“’ Files selected for processing (3)
  • src/components/common/Artwork.tsx
  • src/components/common/FadeInImage.tsx
  • src/components/views/LibraryView.tsx
✨ Finishing Touches
πŸ§ͺ Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/library-artwork-flicker

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❀️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added scope: frontend React/Vite frontend (src/) type: fix Bug fix size: m 50-200 lines labels May 18, 2026
Reported pain: switching to Albums or Artists with an 800-item
library mounted 800 React subtrees at once. Each tile carries an
<Artwork>/<FadeInImage> (state + useMemo) plus a popover-trigger
button, so the main thread stalled for ~1 s before the first paint
and then the IMG tags fanned out and filled in. The fade-in pass
from the previous PR couldn't disguise the layout cost.

Apply the TrackTable pattern to both grids: a row-level
`useVirtualizer`, with column count derived from the container's
width via ResizeObserver to match the original
`grid-cols-[repeat(auto-fill,minmax(180px,1fr))]` math. Each
virtual row renders `colCount` tiles; everything off-screen stays
out of the DOM.

Both grids consume `usePageScroll()` so the single page-driven
scrollbar is preserved (per the existing cross-cutting rule).

AlphabetIndex side-effect: `querySelector('[data-artist-index]') +
scrollIntoView` no longer works because the target artist may not
be mounted yet. ArtistList now exposes a `scrollToIndexRef`
callback the parent installs once on mount; the alphabet jump
delegates to `virtualizer.scrollToIndex(floor(idx / cols))`.

GenreList isn't touched β€” it tops out around 30-50 items in
practice and the fixed-height row card has none of the per-tile
state Artwork carries, so it'd be all cost and no benefit.
@github-actions github-actions Bot added size: xl > 500 lines and removed size: m 50-200 lines labels May 18, 2026
The Artistes grid was still asking `resolveArtwork` for the 2x
(128 px) thumbnail variant β€” fine on a 64 px row icon, soft on the
180-220 px round tiles the virtualized grid actually paints on a
1080p / HiDPI screen. AlbumGrid had already switched to `size=full`
for the same reason; just bring artists in line.

Source artist images are the original Deezer 1000 px PNGs (or the
user's sidecar JPEGs in the same range), so decoding the full-res
copy at avatar size is cheap. No new fetch is introduced β€” the
`full` slot was already populated by `list_artists` since #60.
@InstaZDLL InstaZDLL self-assigned this May 18, 2026
@InstaZDLL InstaZDLL merged commit 8b2c922 into main May 18, 2026
13 checks passed
@InstaZDLL InstaZDLL deleted the fix/library-artwork-flicker branch May 18, 2026 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope: frontend React/Vite frontend (src/) size: xl > 500 lines type: fix Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant