mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
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:
parent
318893c700
commit
d4381c696e
@ -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>
|
||||
|
||||
@ -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 })
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user