mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
fix(ui): retire the blurhash on a timer, not transitionend
Under prefers-reduced-motion the img rule sets transition:none, so toggling opacity fires no transitionend and the handler that unmounts the blurhash never ran. The placeholder stayed mounted for the life of the component — visible in the letterbox bars wherever the cover is rendered with fit="contain", and a live canvas per tile everywhere else. One duration constant now drives both the CSS transition and the timer, so they cannot drift.
This commit is contained in:
parent
d4381c696e
commit
c66ef971cf
@ -7,6 +7,9 @@ import subsonic from '../subsonic'
|
||||
import { useImageUrl } from './useImageUrl'
|
||||
import { BlurHashCanvas } from './BlurHashCanvas'
|
||||
|
||||
// Drives both the CSS transition and the timer that retires the blurhash, so they cannot drift.
|
||||
const fadeMs = 500
|
||||
|
||||
const useStyles = makeStyles({
|
||||
// className supplies the size and shape; overflow:hidden clips the fills to a rounded shape.
|
||||
root: {
|
||||
@ -23,7 +26,7 @@ const useStyles = makeStyles({
|
||||
},
|
||||
img: {
|
||||
opacity: 0,
|
||||
transition: 'opacity 500ms ease-out',
|
||||
transition: `opacity ${fadeMs}ms ease-out`,
|
||||
'@media (prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||
},
|
||||
imgVisible: { opacity: 1 },
|
||||
@ -60,6 +63,14 @@ export const CoverImage = ({
|
||||
setFaded(false)
|
||||
}, [url])
|
||||
|
||||
// Retire the blurhash on a timer rather than transitionend: under prefers-reduced-motion the
|
||||
// transition is none, so the event never fires and the placeholder would stay up forever.
|
||||
useEffect(() => {
|
||||
if (!decoded || faded) return undefined
|
||||
const timer = setTimeout(() => setFaded(true), fadeMs)
|
||||
return () => clearTimeout(timer)
|
||||
}, [decoded, faded])
|
||||
|
||||
if (!record) return null
|
||||
|
||||
const instant = cachedOnMount.current
|
||||
@ -90,7 +101,6 @@ export const CoverImage = ({
|
||||
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, fireEvent } from '@testing-library/react'
|
||||
import { render, fireEvent, act } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
vi.mock('./useImageUrl', () => ({ useImageUrl: vi.fn() }))
|
||||
@ -54,21 +54,35 @@ describe('CoverImage', () => {
|
||||
})
|
||||
|
||||
it('keeps the blurhash under the image until the fade ends', () => {
|
||||
useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
|
||||
const { container, rerender } = render(<CoverImage record={withArt} />)
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
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()
|
||||
// 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()
|
||||
// Decoding starts the cross-fade; the blurhash only goes away once it finishes. The clock
|
||||
// drives it, not transitionend, which never fires under prefers-reduced-motion.
|
||||
act(() => {
|
||||
fireEvent.load(img)
|
||||
})
|
||||
expect(container.querySelector('canvas')).not.toBeNull()
|
||||
act(() => {
|
||||
fireEvent.transitionEnd(img)
|
||||
})
|
||||
expect(container.querySelector('canvas')).not.toBeNull()
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
expect(container.querySelector('canvas')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not fade an image that was already cached on mount', () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user