diff --git a/ui/src/common/CoverImage.jsx b/ui/src/common/CoverImage.jsx
index e62fbcfcc..d4cd43b91 100644
--- a/ui/src/common/CoverImage.jsx
+++ b/ui/src/common/CoverImage.jsx
@@ -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)}
/>
)}
diff --git a/ui/src/common/CoverImage.test.jsx b/ui/src/common/CoverImage.test.jsx
index 9d244db63..93bd18f64 100644
--- a/ui/src/common/CoverImage.test.jsx
+++ b/ui/src/common/CoverImage.test.jsx
@@ -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()
+ vi.useFakeTimers()
+ try {
+ useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
+ const { container, rerender } = render()
- // Blob arrives: the image mounts transparent, with the blurhash still behind it.
- useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false })
- rerender()
- 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()
+ 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', () => {