feat(ui): cross-fade the cover over its blurhash

The blurhash unmounted the moment the blob arrived, so the placeholder
vanished a frame before the image painted. The image now mounts
transparent and fades in over the blurhash, which stays behind it until
the fade completes. A blob already cached when the instance mounts
skips the fade, so a remount does not re-animate.
This commit is contained in:
Deluan 2026-07-25 10:20:36 -04:00
parent 318893c700
commit d4381c696e
2 changed files with 73 additions and 6 deletions

View File

@ -1,3 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import PropTypes from 'prop-types'
import clsx from 'clsx'
import { makeStyles } from '@material-ui/core/styles'
@ -20,8 +21,14 @@ const useStyles = makeStyles({
width: '100%',
height: '100%',
},
'@keyframes fadeIn': { from: { opacity: 0 }, to: { opacity: 1 } },
img: { animation: '$fadeIn 0.3s ease-in-out' },
img: {
opacity: 0,
transition: 'opacity 500ms ease-out',
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
},
imgVisible: { opacity: 1 },
// Already-decoded blobs appear at once: fading them in would re-animate on every remount.
imgInstant: { opacity: 1, transition: 'none' },
})
// CoverImage renders an entity's cover through the shared useImageUrl blob cache, so it survives
@ -38,10 +45,27 @@ export const CoverImage = ({
}) => {
const classes = useStyles()
const url = record ? subsonic.getCoverArtUrl(record, size, square) : ''
const { imgUrl, loading } = useImageUrl(url)
const { imgUrl } = useImageUrl(url)
// A blob already cached when this instance mounted paints on the first frame, so it skips the
// fade; anything fetched later cross-fades over the blurhash.
const cachedOnMount = useRef(null)
if (cachedOnMount.current === null) {
cachedOnMount.current = !!imgUrl
}
const [decoded, setDecoded] = useState(false)
const [faded, setFaded] = useState(false)
useEffect(() => {
setDecoded(false)
setFaded(false)
}, [url])
if (!record) return null
const showBlurHash = loading && record.blurHash
const instant = cachedOnMount.current
// The blurhash stays mounted under the image until the fade ends. Swapping them the moment the
// blob arrives would expose the empty container for the length of the fade.
const showBlurHash = !!record.blurHash && !instant && !faded
const handleClick = imgUrl && onClick ? onClick : undefined
return (
<div
@ -57,8 +81,16 @@ export const CoverImage = ({
src={imgUrl}
alt={title}
title={title}
className={clsx(classes.fill, classes.img)}
className={clsx(
classes.fill,
classes.img,
instant && classes.imgInstant,
decoded && classes.imgVisible,
)}
style={{ objectFit: fit }}
// Fading on decode, not on mount, keeps the image from ramping up before it can paint.
onLoad={() => setDecoded(true)}
onTransitionEnd={() => setFaded(true)}
/>
)}
</div>

View File

@ -1,4 +1,4 @@
import { render } from '@testing-library/react'
import { render, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('./useImageUrl', () => ({ useImageUrl: vi.fn() }))
@ -53,6 +53,41 @@ describe('CoverImage', () => {
expect(img.getAttribute('src')).toBe('blob:abc')
})
it('keeps the blurhash under the image until the fade ends', () => {
useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
const { container, rerender } = render(<CoverImage record={withArt} />)
// Blob arrives: the image mounts transparent, with the blurhash still behind it.
useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false })
rerender(<CoverImage record={withArt} />)
const img = container.querySelector('img')
expect(img).not.toBeNull()
expect(container.querySelector('canvas')).not.toBeNull()
// Decoding starts the cross-fade; the blurhash only goes away once it finishes.
fireEvent.load(img)
expect(container.querySelector('canvas')).not.toBeNull()
fireEvent.transitionEnd(img)
expect(container.querySelector('canvas')).toBeNull()
})
it('does not fade an image that was already cached on mount', () => {
useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false })
const { container } = render(<CoverImage record={withArt} />)
// No placeholder to cross-fade from, so it paints at once.
expect(container.querySelector('canvas')).toBeNull()
expect(container.querySelector('img').className).toContain('imgInstant')
})
it('keeps the blurhash visible when the image never decodes', () => {
useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
const { container, rerender } = render(<CoverImage record={withArt} />)
useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false })
rerender(<CoverImage record={withArt} />)
expect(container.querySelector('canvas')).not.toBeNull()
})
it('fires onClick only when the image is loaded', () => {
const onClick = vi.fn()
useImageUrl.mockReturnValue({ imgUrl: null, loading: true })