mirror of
https://github.com/navidrome/navidrome.git
synced 2026-10-18 07:30:29 +00:00
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:
parent
b3f58184b9
commit
f35c84a507
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user