Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 19 additions & 11 deletions src/components/common/Artwork.tsx
Original file line number Diff line number Diff line change
@@ -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 {
/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 <img>.
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 = (
<Disc
size={iconSize}
className="text-emerald-500/70 dark:text-emerald-400/60"
/>
);

if (!src) {
return (
<div
className={`${className} ${radiusClass} 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 flex items-center justify-center overflow-hidden shrink-0`}
className={`${className} ${radiusClass} ${placeholderBg} flex items-center justify-center overflow-hidden shrink-0`}
aria-hidden={alt ? undefined : true}
aria-label={alt}
role={alt ? "img" : undefined}
>
<Disc
size={iconSize}
className="text-emerald-500/70 dark:text-emerald-400/60"
/>
{discIcon}
</div>
);
}

return (
<img
<FadeInImage
src={src}
alt={alt ?? ""}
loading="lazy"
decoding="async"
draggable={false}
className={`${className} ${radiusClass} object-cover shrink-0 bg-zinc-100 dark:bg-zinc-800`}
wrapperClassName={`${className} ${radiusClass} ${placeholderBg} shrink-0`}
placeholder={discIcon}
/>
);
}
83 changes: 83 additions & 0 deletions src/components/common/FadeInImage.tsx
Original file line number Diff line number Diff line change
@@ -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 `<img>` 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 `<img>` 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 (
<div className={`relative overflow-hidden ${wrapperClassName}`}>
{placeholder ? (
<div
aria-hidden
className={`absolute inset-0 flex items-center justify-center transition-opacity duration-200 ${
loaded ? "opacity-0" : "opacity-100"
}`}
>
{placeholder}
</div>
) : null}
<img
src={src}
alt={alt}
loading="lazy"
decoding="async"
draggable={false}
onLoad={() => setLoaded(true)}
ref={(el) => {
// The WebView cache can serve the bytes synchronously — the
// <img> 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}`}
/>
</div>
);
}
Loading
Loading