diff --git a/ui/src/common/CoverImage.jsx b/ui/src/common/CoverImage.jsx
index 1de52bce1..e62fbcfcc 100644
--- a/ui/src/common/CoverImage.jsx
+++ b/ui/src/common/CoverImage.jsx
@@ -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 (
setDecoded(true)}
+ onTransitionEnd={() => setFaded(true)}
/>
)}
diff --git a/ui/src/common/CoverImage.test.jsx b/ui/src/common/CoverImage.test.jsx
index 0cc44223e..9d244db63 100644
--- a/ui/src/common/CoverImage.test.jsx
+++ b/ui/src/common/CoverImage.test.jsx
@@ -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()
+
+ // 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()
+ })
+
+ it('does not fade an image that was already cached on mount', () => {
+ useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false })
+ const { container } = render()
+ // 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()
+ useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false })
+ rerender()
+
+ 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 })