fix(ui): fade cover art consistently, and shorten the fade to 150ms

The <img> 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.
This commit is contained in:
Deluan 2026-08-06 00:22:44 -04:00
parent b3f58184b9
commit f35c84a507
2 changed files with 19 additions and 5 deletions

View File

@ -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}
/>
)}
</div>

View File

@ -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 {