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 })