From 75a8eabbf92ed50b6645e940ef1f13b6dfc5a128 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 18 May 2026 23:29:27 +0200 Subject: [PATCH 1/3] fix(library): fade artwork over its gradient placeholder instead of grey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 with the same coloured gradient already used as the "no image" fallback (emerald for Artwork, violet for artist avatars). The 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 `` 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. --- src/components/common/Artwork.tsx | 30 ++++++---- src/components/common/FadeInImage.tsx | 83 +++++++++++++++++++++++++++ src/components/views/LibraryView.tsx | 11 +++- 3 files changed, 110 insertions(+), 14 deletions(-) create mode 100644 src/components/common/FadeInImage.tsx diff --git a/src/components/common/Artwork.tsx b/src/components/common/Artwork.tsx index 45a4fcb..fd5118f 100644 --- a/src/components/common/Artwork.tsx +++ b/src/components/common/Artwork.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { Disc } from "lucide-react"; import { resolveArtwork, type ArtworkSize } from "../../lib/tauri/artwork"; +import { FadeInImage } from "./FadeInImage"; interface ArtworkProps { /** @@ -36,7 +37,9 @@ interface ArtworkProps { /** * Render a hash-addressed cover image served via Tauri's `asset://` * protocol, picking the closest pre-resized variant for the requested - * display `size`. + * display `size`. The image fades in over a gradient placeholder via + * [`FadeInImage`] so a tab full of fresh thumbnails never flashes + * through grey skeleton squares. */ export function Artwork({ path, @@ -68,31 +71,36 @@ export function Artwork({ xl: "rounded-xl", "2xl": "rounded-2xl", }[rounded]; + // Gradient + border combo reused as the placeholder background, both + // when no image is available and behind the fading . + const placeholderBg = + "bg-linear-to-br from-emerald-100 to-emerald-200 dark:from-emerald-900/40 dark:to-emerald-800/30 border border-emerald-200/60 dark:border-emerald-800/40"; + const discIcon = ( + + ); if (!src) { return (
- + {discIcon}
); } return ( - {alt ); } diff --git a/src/components/common/FadeInImage.tsx b/src/components/common/FadeInImage.tsx new file mode 100644 index 0000000..a414a39 --- /dev/null +++ b/src/components/common/FadeInImage.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; + +interface FadeInImageProps { + /** Resolved `asset://` (or http) URL. */ + src: string; + alt: string; + /** Tailwind classes for the wrapper that hosts the placeholder + * gradient and clips the image. */ + wrapperClassName: string; + /** Tailwind classes for the `` itself. Should NOT carry sizing + * — the wrapper handles that. */ + imgClassName?: string; + /** Optional placeholder children rendered behind the image (e.g. a + * fallback initial or a Disc icon). Hidden once the image fades in. */ + placeholder?: React.ReactNode; +} + +/** + * Render an `` that fades in over an underlying placeholder once + * the browser has decoded it. Replaces the classic "flash from grey + * to image" you get when a whole grid of fresh thumbnails mounts and + * each tile pops in independently. + * + * Why a wrapper instead of just a CSS transition: the image hasn't + * decoded yet on first paint, so we have to gate `opacity` on the + * `onLoad` event. We also handle the WebView-cached case via `ref`, + * where `complete` is already true and `onLoad` would never fire. + */ +export function FadeInImage({ + src, + alt, + wrapperClassName, + imgClassName = "", + placeholder, +}: FadeInImageProps) { + // Reset the fade gate when the underlying URL changes (e.g. a tile + // gets recycled by a virtualized list, or the same component + // re-renders with a different artist). React's documented pattern + // for "reset state when prop changes" is to compare against a prev + // ref in render rather than chaining a useEffect — avoids the + // double-render that eslint-plugin-react-hooks flags. + const [loaded, setLoaded] = useState(false); + const [prevSrc, setPrevSrc] = useState(src); + if (prevSrc !== src) { + setPrevSrc(src); + setLoaded(false); + } + + return ( +
+ {placeholder ? ( +
+ {placeholder} +
+ ) : null} + {alt} setLoaded(true)} + ref={(el) => { + // The WebView cache can serve the bytes synchronously — the + // is already `complete` before React even attaches an + // onLoad handler. Pin the fade open from the ref callback in + // that case so we don't stay stuck at opacity-0. + if (el && el.complete && el.naturalWidth > 0) { + setLoaded(true); + } + }} + className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-200 ${ + loaded ? "opacity-100" : "opacity-0" + } ${imgClassName}`} + /> +
+ ); +} diff --git a/src/components/views/LibraryView.tsx b/src/components/views/LibraryView.tsx index ac3ecb5..7d362e7 100644 --- a/src/components/views/LibraryView.tsx +++ b/src/components/views/LibraryView.tsx @@ -53,6 +53,7 @@ import { useTrackUpdated } from "../../hooks/useTrackUpdated"; import { useMultiSelect } from "../../hooks/useMultiSelect"; import { resolvePlaylistColor } from "../../lib/playlistVisuals"; import { resolveArtwork } from "../../lib/tauri/artwork"; +import { FadeInImage } from "../common/FadeInImage"; import { PlaylistIcon } from "../../lib/PlaylistIcon"; import type { Playlist } from "../../lib/tauri/playlist"; import { pickFolder } from "../../lib/tauri/dialog"; @@ -1576,11 +1577,15 @@ function ArtistList({ >
{artistPictureSrc ? ( - {artist.name} + {artist.name.trim().charAt(0).toUpperCase() || "?"} + + } /> ) : (
From 4ce7254fa9ea85ea53dcab5ffe89764499e45813 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 18 May 2026 23:36:27 +0200 Subject: [PATCH 2/3] perf(library): virtualize Albums and Artists grids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported pain: switching to Albums or Artists with an 800-item library mounted 800 React subtrees at once. Each tile carries an / (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. --- src/components/views/LibraryView.tsx | 547 ++++++++++++++++++--------- 1 file changed, 365 insertions(+), 182 deletions(-) diff --git a/src/components/views/LibraryView.tsx b/src/components/views/LibraryView.tsx index 7d362e7..28e3c1b 100644 --- a/src/components/views/LibraryView.tsx +++ b/src/components/views/LibraryView.tsx @@ -204,7 +204,10 @@ export function LibraryView({ }, []); // Scroll target for the AlphabetIndex (artists tab uses it). - const artistGridRef = useRef(null); + // Callback ref populated by the virtualized ArtistList so the + // alphabet jump index can scroll a specific artist into view without + // relying on a `querySelector` that can't reach off-screen rows. + const artistScrollToIndexRef = useRef<((idx: number) => void) | null>(null); const trackContextMenu = useTrackContextMenu({ likedIds, @@ -630,23 +633,13 @@ export function LibraryView({ setIsCreatePlaylistModalOpen(true); }} onArtistClick={onNavigateToArtist} - gridRef={artistGridRef} + scrollToIndexRef={artistScrollToIndexRef} /> {artistsSort.sort.orderBy === "name" && artists.length > 0 && ( { - const grid = artistGridRef.current; - if (!grid) return; - const target = grid.querySelector( - `[data-artist-index="${idx}"]`, - ); - if (target) { - target.scrollIntoView({ - behavior: "smooth", - block: "start", - }); - } + artistScrollToIndexRef.current?.(idx); }} className="hidden md:flex fixed right-6 top-1/2 -translate-y-1/2 z-30 bg-white/80 dark:bg-zinc-900/70 backdrop-blur-sm rounded-full py-2 px-1.5 shadow-sm" /> @@ -1342,6 +1335,7 @@ function AlbumGrid({ onAlbumClick, onChangeCover, }: AlbumGridProps) { + "use no memo"; const unknown = t("library.table.unknown"); const [openMenuAlbumId, setOpenMenuAlbumId] = useState(null); const [contextMenu, setContextMenu] = useState<{ @@ -1350,6 +1344,68 @@ function AlbumGrid({ y: number; } | null>(null); + // Virtual-grid plumbing — without this a 800-album library mounts 800 + // components on every tab switch, blowing the main thread + // for ~1 s before the first paint. + const pageScrollRef = usePageScroll(); + const parentRef = useRef(null); + const [colCount, setColCount] = useState(1); + const [tileWidth, setTileWidth] = useState(180); + const [scrollMargin, setScrollMargin] = useState(0); + + // Match the original Tailwind grid: `auto-fill,minmax(180px,1fr)` + gap-5. + const MIN_TILE = 180; + const GAP = 20; + // Tile = aspect-square cover (width = column width) + ~70 px of text + // beneath it (title + artist + meta + the space-y-2 separator). + const tileHeight = tileWidth + 70; + + useLayoutEffect(() => { + const el = parentRef.current; + if (!el) return; + const recompute = () => { + const width = el.getBoundingClientRect().width; + if (width === 0) return; + const n = Math.max(1, Math.floor((width + GAP) / (MIN_TILE + GAP))); + const actual = (width - (n - 1) * GAP) / n; + setColCount(n); + setTileWidth(actual); + }; + recompute(); + const ro = new ResizeObserver(recompute); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Mirror TrackTable's scrollMargin trick so the virtual row offsets + // line up with the actual position of this grid inside the page + // scroller. + useLayoutEffect(() => { + const parent = parentRef.current; + const scroller = pageScrollRef?.current; + if (!parent || !scroller) return; + const recompute = () => { + const pr = parent.getBoundingClientRect(); + const sr = scroller.getBoundingClientRect(); + setScrollMargin(pr.top - sr.top + scroller.scrollTop); + }; + recompute(); + const ro = new ResizeObserver(recompute); + ro.observe(parent); + ro.observe(scroller); + return () => ro.disconnect(); + }, [pageScrollRef, albums.length]); + + const rowCount = Math.ceil(albums.length / colCount); + // eslint-disable-next-line react-hooks/incompatible-library + const virtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => pageScrollRef?.current ?? null, + estimateSize: () => tileHeight + GAP, + overscan: 2, + scrollMargin, + }); + useEffect(() => { if (contextMenu == null) return; const handleClick = () => setContextMenu(null); @@ -1385,94 +1441,120 @@ function AlbumGrid({ }; }, [openMenuAlbumId]); + const renderAlbumCard = (album: AlbumRow) => { + const isMenuOpen = openMenuAlbumId === album.id; + return ( +
onAlbumClick(album.id)} + onContextMenu={(e) => { + e.preventDefault(); + setContextMenu({ + albumId: album.id, + x: e.clientX, + y: e.clientY, + }); + }} + className="group flex flex-col space-y-2 cursor-pointer relative" + > +
+ + + +
+
+
+ {album.title} +
+
+ {album.artist_name ?? unknown} +
+
+ {t("library.albumGrid.trackCount", { + count: album.track_count, + })} + {album.year ? ` · ${album.year}` : ""} +
+
+ {isMenuOpen && ( + { + onAddToPlaylist(playlistId, album.id); + setOpenMenuAlbumId(null); + }} + onCreate={() => { + setOpenMenuAlbumId(null); + onCreatePlaylist(album.id); + }} + t={t} + /> + )} +
+ ); + }; + return ( <>
- {albums.map((album) => { - const isMenuOpen = openMenuAlbumId === album.id; + {virtualizer.getVirtualItems().map((row) => { + const startIdx = row.index * colCount; + const rowItems = albums.slice(startIdx, startIdx + colCount); return (
onAlbumClick(album.id)} - onContextMenu={(e) => { - e.preventDefault(); - setContextMenu({ - albumId: album.id, - x: e.clientX, - y: e.clientY, - }); + key={row.key} + style={{ + position: "absolute", + top: 0, + left: 0, + width: "100%", + transform: `translateY(${row.start - scrollMargin}px)`, + display: "grid", + gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`, + gap: `${GAP}px`, + paddingBottom: `${GAP}px`, }} - className="group flex flex-col space-y-2 cursor-pointer relative" > -
- - - -
-
-
- {album.title} -
-
- {album.artist_name ?? unknown} -
-
- {t("library.albumGrid.trackCount", { - count: album.track_count, - })} - {album.year ? ` · ${album.year}` : ""} -
-
- {isMenuOpen && ( - { - onAddToPlaylist(playlistId, album.id); - setOpenMenuAlbumId(null); - }} - onCreate={() => { - setOpenMenuAlbumId(null); - onCreatePlaylist(album.id); - }} - t={t} - /> - )} + {rowItems.map((album) => renderAlbumCard(album))}
); })} @@ -1511,7 +1593,13 @@ interface ArtistListProps { onAddToPlaylist: (playlistId: number, artistId: number) => void; onCreatePlaylist: (artistId: number) => void; onArtistClick: (artistId: number) => void; - gridRef?: React.RefObject; + /** + * Mutable ref the grid populates with a `(idx) => void` callback that + * scrolls a specific artist into view. Used by the alphabet jump + * index — `scrollIntoView` on the DOM no longer works because + * off-screen rows aren't rendered. + */ + scrollToIndexRef?: React.MutableRefObject<((idx: number) => void) | null>; } function ArtistList({ @@ -1522,10 +1610,83 @@ function ArtistList({ onAddToPlaylist, onCreatePlaylist, onArtistClick, - gridRef, + scrollToIndexRef, }: ArtistListProps) { + "use no memo"; const [openMenuArtistId, setOpenMenuArtistId] = useState(null); + // Virtual-grid plumbing — see AlbumGrid for the rationale; same math + // applies to the artist tiles (same `minmax(180px,1fr)` + gap-5). + const pageScrollRef = usePageScroll(); + const parentRef = useRef(null); + const [colCount, setColCount] = useState(1); + const [tileWidth, setTileWidth] = useState(180); + const [scrollMargin, setScrollMargin] = useState(0); + + const MIN_TILE = 180; + const GAP = 20; + // Round avatar (width = column width) + space-y-3 (12 px) + 2 lines + // of text underneath (~40 px) → ~ width + 52. + const tileHeight = tileWidth + 52; + + useLayoutEffect(() => { + const el = parentRef.current; + if (!el) return; + const recompute = () => { + const width = el.getBoundingClientRect().width; + if (width === 0) return; + const n = Math.max(1, Math.floor((width + GAP) / (MIN_TILE + GAP))); + const actual = (width - (n - 1) * GAP) / n; + setColCount(n); + setTileWidth(actual); + }; + recompute(); + const ro = new ResizeObserver(recompute); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + useLayoutEffect(() => { + const parent = parentRef.current; + const scroller = pageScrollRef?.current; + if (!parent || !scroller) return; + const recompute = () => { + const pr = parent.getBoundingClientRect(); + const sr = scroller.getBoundingClientRect(); + setScrollMargin(pr.top - sr.top + scroller.scrollTop); + }; + recompute(); + const ro = new ResizeObserver(recompute); + ro.observe(parent); + ro.observe(scroller); + return () => ro.disconnect(); + }, [pageScrollRef, artists.length]); + + const rowCount = Math.ceil(artists.length / colCount); + // eslint-disable-next-line react-hooks/incompatible-library + const virtualizer = useVirtualizer({ + count: rowCount, + getScrollElement: () => pageScrollRef?.current ?? null, + estimateSize: () => tileHeight + GAP, + overscan: 2, + scrollMargin, + }); + + // Expose a scroll-to-artist-index method for the AlphabetIndex (the + // off-screen rows aren't in the DOM anymore, so the previous + // `querySelector + scrollIntoView` path can't see them). + useEffect(() => { + if (!scrollToIndexRef) return; + scrollToIndexRef.current = (idx) => { + virtualizer.scrollToIndex(Math.floor(idx / Math.max(colCount, 1)), { + align: "start", + }); + }; + return () => { + scrollToIndexRef.current = null; + }; + }, [scrollToIndexRef, virtualizer, colCount]); + useEffect(() => { if (openMenuArtistId == null) return; const handleMouseDown = (event: MouseEvent) => { @@ -1545,99 +1706,121 @@ function ArtistList({ }; }, [openMenuArtistId]); + const renderArtistTile = (artist: ArtistRow, idx: number) => { + const isMenuOpen = openMenuArtistId === artist.id; + const artistPictureSrc = resolveArtwork( + { + full: artist.artwork_path ?? artist.picture_path, + x1: artist.artwork_path_1x ?? artist.picture_path_1x, + x2: artist.artwork_path_2x ?? artist.picture_path_2x, + remoteUrl: artist.picture_url, + }, + "2x", + ); + return ( +
onArtistClick(artist.id)} + className="group flex flex-col items-center space-y-3 cursor-pointer relative" + > +
+ {artistPictureSrc ? ( + + {artist.name.trim().charAt(0).toUpperCase() || "?"} + + } + /> + ) : ( +
+ + {artist.name.trim().charAt(0).toUpperCase() || "?"} + +
+ )} + +
+
+
+ {artist.name} +
+
+ {t("library.artistList.trackCount", { + count: artist.track_count, + })} + {artist.album_count > 0 + ? ` · ${t("library.artistList.albumCount", { count: artist.album_count })}` + : ""} +
+
+ {isMenuOpen && ( + { + onAddToPlaylist(playlistId, artist.id); + setOpenMenuArtistId(null); + }} + onCreate={() => { + setOpenMenuArtistId(null); + onCreatePlaylist(artist.id); + }} + t={t} + /> + )} +
+ ); + }; + return (
- {artists.map((artist, idx) => { - const isMenuOpen = openMenuArtistId === artist.id; - // Local artwork wins over the Deezer cache so the user's own - // sidecar JPEGs surface here exactly like they do on the - // per-artist page (`ArtistDetailView`). When no local image - // exists we fall back to the Deezer picture, then to the - // remote URL — same precedence as the detail view. - const artistPictureSrc = resolveArtwork( - { - full: artist.artwork_path ?? artist.picture_path, - x1: artist.artwork_path_1x ?? artist.picture_path_1x, - x2: artist.artwork_path_2x ?? artist.picture_path_2x, - remoteUrl: artist.picture_url, - }, - "2x", - ); + {virtualizer.getVirtualItems().map((row) => { + const startIdx = row.index * colCount; + const rowItems = artists.slice(startIdx, startIdx + colCount); return (
onArtistClick(artist.id)} - className="group flex flex-col items-center space-y-3 cursor-pointer relative" + key={row.key} + style={{ + position: "absolute", + top: 0, + left: 0, + width: "100%", + transform: `translateY(${row.start - scrollMargin}px)`, + display: "grid", + gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`, + gap: `${GAP}px`, + paddingBottom: `${GAP}px`, + }} > -
- {artistPictureSrc ? ( - - {artist.name.trim().charAt(0).toUpperCase() || "?"} - - } - /> - ) : ( -
- - {artist.name.trim().charAt(0).toUpperCase() || "?"} - -
- )} - -
-
-
- {artist.name} -
-
- {t("library.artistList.trackCount", { - count: artist.track_count, - })} - {artist.album_count > 0 - ? ` · ${t("library.artistList.albumCount", { count: artist.album_count })}` - : ""} -
-
- {isMenuOpen && ( - { - onAddToPlaylist(playlistId, artist.id); - setOpenMenuArtistId(null); - }} - onCreate={() => { - setOpenMenuArtistId(null); - onCreatePlaylist(artist.id); - }} - t={t} - /> + {rowItems.map((artist, i) => + renderArtistTile(artist, startIdx + i), )}
); From 0e35055aa06a7b656d2058496047fdcd4e54d2ef Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 18 May 2026 23:39:04 +0200 Subject: [PATCH 3/3] fix(library): render artist avatars from the full-res source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/components/views/LibraryView.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/views/LibraryView.tsx b/src/components/views/LibraryView.tsx index 28e3c1b..7c98385 100644 --- a/src/components/views/LibraryView.tsx +++ b/src/components/views/LibraryView.tsx @@ -1708,6 +1708,10 @@ function ArtistList({ const renderArtistTile = (artist: ArtistRow, idx: number) => { const isMenuOpen = openMenuArtistId === artist.id; + // Use the full-resolution source so HiDPI screens render the + // avatar crisp at any column width — same trade-off documented on + // `AlbumGrid`'s Artwork usage. The 128 px 2x thumbnail upscaled + // soft on the 180–220 px artist tiles users actually see. const artistPictureSrc = resolveArtwork( { full: artist.artwork_path ?? artist.picture_path, @@ -1715,7 +1719,7 @@ function ArtistList({ x2: artist.artwork_path_2x ?? artist.picture_path_2x, remoteUrl: artist.picture_url, }, - "2x", + "full", ); return (