From f35c84a5074285a20012334f45614f15b7fd385b Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 6 Aug 2026 00:22:44 -0400 Subject: [PATCH] fix(ui): fade cover art consistently, and shorten the fade to 150ms The mounts with its blob URL already set, so onLoad often fires in the same frame the element is inserted. The opacity:0 start state never gets painted, the transition has no value to animate from, and the cover pops in instead of fading. Only tiles whose decode happened to straddle a paint boundary actually faded, which made a grid load look ragged: 4 to 6 of 18 covers faded, varying between runs. Deferring the decoded flag by two animation frames guarantees the hidden state is painted first. Measured on a live grid afterwards, 17 of 18 covers fade, the 18th having a cached blob and taking the intentional instant path, and the tail after the last byte arrives drops from ~440ms to ~163ms. The duration also drops from 500ms to 150ms. That 500ms mirrored master's fade, but master paints no placeholder behind the image and needs a slow blend to cover a blank tile. Here a thumbhash already fills the gap, so a long crossfade only delays the grid settling, measured at 145-190ms slower to full opacity than master. --- ui/src/common/Artwork.jsx | 17 +++++++++++++---- ui/src/common/Artwork.test.jsx | 7 ++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/ui/src/common/Artwork.jsx b/ui/src/common/Artwork.jsx index 94c45e011..21ac5e07b 100644 --- a/ui/src/common/Artwork.jsx +++ b/ui/src/common/Artwork.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import PropTypes from 'prop-types' import clsx from 'clsx' import { makeStyles } from '@material-ui/core/styles' @@ -8,7 +8,7 @@ import { useImageUrl } from './useImageUrl' import { ThumbHashCanvas } from './ThumbHashCanvas' // Drives both the CSS transition and the timer that retires the placeholder, so they cannot drift. -const fadeMs = 500 +const fadeMs = 150 const useStyles = makeStyles({ // className supplies the size and shape; overflow:hidden clips the fills to it. @@ -51,11 +51,21 @@ export const Artwork = ({ const [decoded, setDecoded] = useState(false) const [faded, setFaded] = useState(false) + const frame = useRef(0) useEffect(() => { setDecoded(false) setFaded(false) + return () => cancelAnimationFrame(frame.current) }, [url]) + // Two frames: a blob decodes from memory, so onLoad can beat the first paint of opacity 0, + // leaving the transition no start value and popping some tiles in while others fade. + const handleLoad = () => { + frame.current = requestAnimationFrame(() => { + frame.current = requestAnimationFrame(() => setDecoded(true)) + }) + } + // Timer, not transitionend: under prefers-reduced-motion there is no transition to end. useEffect(() => { if (!decoded || faded) return undefined @@ -99,8 +109,7 @@ export const Artwork = ({ decoded && classes.imgVisible, )} style={{ objectFit: effectiveFit }} - // Fading on decode, not on mount, keeps the image from ramping up before it can paint. - onLoad={() => setDecoded(true)} + onLoad={handleLoad} /> )} diff --git a/ui/src/common/Artwork.test.jsx b/ui/src/common/Artwork.test.jsx index 9240e8a9e..18b3c916f 100644 --- a/ui/src/common/Artwork.test.jsx +++ b/ui/src/common/Artwork.test.jsx @@ -140,8 +140,13 @@ describe('Artwork', () => { fireEvent.transitionEnd(img) }) expect(container.querySelector('canvas')).not.toBeNull() + // Two frames pass before the fade starts, so opacity 0 gets painted first. act(() => { - vi.advanceTimersByTime(500) + vi.advanceTimersByTime(32) + }) + expect(container.querySelector('canvas')).not.toBeNull() + act(() => { + vi.advanceTimersByTime(150) }) expect(container.querySelector('canvas')).toBeNull() } finally {