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..7c98385 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"; @@ -203,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, @@ -629,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" /> @@ -1341,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<{ @@ -1349,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); @@ -1384,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))}
); })} @@ -1510,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({ @@ -1521,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) => { @@ -1544,95 +1706,125 @@ function ArtistList({ }; }, [openMenuArtistId]); + 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, + x1: artist.artwork_path_1x ?? artist.picture_path_1x, + x2: artist.artwork_path_2x ?? artist.picture_path_2x, + remoteUrl: artist.picture_url, + }, + "full", + ); + 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} - ) : ( -
- - {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), )}
);