feat(ui): show the blurhash as the loading placeholder across cover surfaces

Add a shared CoverImage component (useImageUrl blob cache + blurhash + fade) and render the blurhash while a cover loads on the list thumbnails (CoverArtAvatar, radio) and the artist/album/playlist detail pages. The detail pages now go through CoverImage instead of a plain CardMedia, so their images come from the in-memory blob cache and survive React remounts without re-fetching. BlurHashCanvas gains an optional style prop.
This commit is contained in:
Deluan 2026-07-24 12:24:12 -04:00
parent 20557f2fb8
commit 44913df403
8 changed files with 155 additions and 119 deletions

View File

@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'
import {
Card,
CardContent,
CardMedia,
Collapse,
makeStyles,
Typography,
@ -21,6 +20,7 @@ import Lightbox from 'react-image-lightbox'
import config from '../config'
import 'react-image-lightbox/style.css'
import subsonic from '../subsonic'
import { CoverImage } from '../common/CoverImage'
import {
ArtistLinkField,
CollapsibleComment,
@ -30,7 +30,6 @@ import {
RatingField,
SizeField,
useAlbumsPerPage,
useImageLoadingState,
} from '../common'
import { formatFullDate, intersperse } from '../utils'
import AlbumExternalLinks from './AlbumExternalLinks'
@ -223,15 +222,7 @@ const AlbumDetails = (props) => {
const classes = useStyles()
const [expanded, setExpanded] = useState(false)
const [albumInfo, setAlbumInfo] = useState()
const {
imageLoading,
imageError,
isLightboxOpen,
handleImageLoad,
handleImageError,
handleOpenLightbox,
handleCloseLightbox,
} = useImageLoadingState(record.id)
const [isLightboxOpen, setLightboxOpen] = useState(false)
let notes = albumInfo?.notes || record.notes
@ -254,27 +245,17 @@ const AlbumDetails = (props) => {
})
}, [record])
const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize)
const fullImageUrl = subsonic.getCoverArtUrl(record)
return (
<Card className={classes.root}>
<div className={classes.cardContents}>
<div className={classes.coverParent}>
<CardMedia
key={record.id}
component={'img'}
src={imageUrl}
width="400"
height="400"
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox}
onLoad={handleImageLoad}
onError={handleImageError}
<CoverImage
record={record}
className={classes.cover}
title={record.name}
style={{
cursor: imageError ? 'default' : 'pointer',
}}
onClick={() => setLightboxOpen(true)}
/>
</div>
<div className={classes.details}>
@ -363,13 +344,13 @@ const AlbumDetails = (props) => {
</Collapse>
</div>
)}
{isLightboxOpen && !imageError && (
{isLightboxOpen && (
<Lightbox
imagePadding={50}
animationDuration={200}
imageTitle={record.name}
mainSrc={fullImageUrl}
onCloseRequest={handleCloseLightbox}
onCloseRequest={() => setLightboxOpen(false)}
/>
)}
</Card>

View File

@ -3,20 +3,15 @@ import { Typography, Collapse } from '@material-ui/core'
import { makeStyles } from '@material-ui/core'
import Card from '@material-ui/core/Card'
import CardContent from '@material-ui/core/CardContent'
import CardMedia from '@material-ui/core/CardMedia'
import ArtistExternalLinks from './ArtistExternalLink'
import config from '../config'
import {
LoveButton,
RatingField,
ImageUploadOverlay,
useImageLoadingState,
} from '../common'
import { LoveButton, RatingField, ImageUploadOverlay } from '../common'
import Lightbox from 'react-image-lightbox'
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
import AlbumInfo from '../album/AlbumInfo'
import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML'
import { CoverImage } from '../common/CoverImage'
const useStyles = makeStyles(
(theme) => ({
@ -91,33 +86,18 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
const [expanded, setExpanded] = useState(false)
const classes = useStyles()
const title = record.name
const {
imageLoading,
imageError,
isLightboxOpen,
handleImageLoad,
handleImageError,
handleOpenLightbox,
handleCloseLightbox,
} = useImageLoadingState(record.id)
const [isLightboxOpen, setLightboxOpen] = useState(false)
return (
<div className={classes.root}>
<Card className={classes.artistDetail}>
<Card className={classes.artistImage}>
{artistInfo && (
<CardMedia
key={record.id}
component="img"
src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox}
onLoad={handleImageLoad}
onError={handleImageError}
<CoverImage
record={record}
className={classes.cover}
title={title}
style={{
cursor: imageError ? 'default' : 'pointer',
}}
onClick={() => setLightboxOpen(true)}
/>
)}
<ImageUploadOverlay
@ -175,13 +155,13 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
)}
</Typography>
</div>
{isLightboxOpen && !imageError && (
{isLightboxOpen && (
<Lightbox
imagePadding={50}
animationDuration={200}
imageTitle={record.name}
mainSrc={subsonic.getCoverArtUrl(record)}
onCloseRequest={handleCloseLightbox}
onCloseRequest={() => setLightboxOpen(false)}
/>
)}
</Card>

View File

@ -2,17 +2,12 @@ import React, { useState } from 'react'
import { Typography, Collapse } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import Card from '@material-ui/core/Card'
import CardMedia from '@material-ui/core/CardMedia'
import config from '../config'
import {
LoveButton,
RatingField,
ImageUploadOverlay,
useImageLoadingState,
} from '../common'
import { LoveButton, RatingField, ImageUploadOverlay } from '../common'
import Lightbox from 'react-image-lightbox'
import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML'
import { CoverImage } from '../common/CoverImage'
const useStyles = makeStyles(
(theme) => ({
@ -93,15 +88,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
const [expanded, setExpanded] = useState(false)
const classes = useStyles({ img, expanded })
const title = record.name
const {
imageLoading,
imageError,
isLightboxOpen,
handleImageLoad,
handleImageError,
handleOpenLightbox,
handleCloseLightbox,
} = useImageLoadingState(record.id)
const [isLightboxOpen, setLightboxOpen] = useState(false)
return (
<>
@ -109,18 +96,11 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
<div className={classes.bgContainer}>
<Card className={classes.artistImage}>
{artistInfo && (
<CardMedia
key={record.id}
component="img"
src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox}
onLoad={handleImageLoad}
onError={handleImageError}
<CoverImage
record={record}
className={classes.cover}
title={title}
style={{
cursor: imageError ? 'default' : 'pointer',
}}
onClick={() => setLightboxOpen(true)}
/>
)}
<ImageUploadOverlay
@ -165,13 +145,13 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
</Typography>
</Collapse>
</div>
{isLightboxOpen && !imageError && (
{isLightboxOpen && (
<Lightbox
imagePadding={50}
animationDuration={200}
imageTitle={record.name}
mainSrc={img}
onCloseRequest={handleCloseLightbox}
onCloseRequest={() => setLightboxOpen(false)}
/>
)}
</>

View File

@ -5,7 +5,7 @@ import { decode } from 'blurhash'
// A blurhash carries no detail beyond a few dozen pixels; CSS upscales the canvas.
const DECODE_SIZE = 32
export const BlurHashCanvas = ({ hash, className }) => {
export const BlurHashCanvas = ({ hash, className, style }) => {
const canvasRef = useRef(null)
useEffect(() => {
@ -37,6 +37,7 @@ export const BlurHashCanvas = ({ hash, className }) => {
width={DECODE_SIZE}
height={DECODE_SIZE}
className={className}
style={style}
aria-hidden="true"
/>
)
@ -45,4 +46,5 @@ export const BlurHashCanvas = ({ hash, className }) => {
BlurHashCanvas.propTypes = {
hash: PropTypes.string,
className: PropTypes.string,
style: PropTypes.object,
}

View File

@ -5,8 +5,15 @@ import clsx from 'clsx'
import config from '../config'
import subsonic from '../subsonic'
import { useImageUrl } from './useImageUrl'
import { BlurHashCanvas } from './BlurHashCanvas'
const useStyles = makeStyles({
root: {
position: 'relative',
display: 'inline-flex',
width: '55px',
height: '55px',
},
avatar: {
width: '55px',
height: '55px',
@ -17,6 +24,16 @@ const useStyles = makeStyles({
square: {
borderRadius: '4px',
},
circular: {
borderRadius: '50%',
},
blur: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
},
})
export const CoverArtAvatar = ({
@ -30,9 +47,10 @@ export const CoverArtAvatar = ({
const url = record
? subsonic.getCoverArtUrl(record, config.uiCoverArtSize, square)
: null
const { imgUrl } = useImageUrl(url)
const { imgUrl, loading } = useImageUrl(url)
if (!record) return null
return (
const avatar = (
<Avatar
src={imgUrl || undefined}
variant={variant}
@ -47,6 +65,18 @@ export const CoverArtAvatar = ({
{!imgUrl && <span />}
</Avatar>
)
// Show the blurhash behind the transparent avatar until the real image loads.
if (!(loading && record.blurHash)) return avatar
return (
<div className={classes.root}>
<BlurHashCanvas
hash={record.blurHash}
className={clsx(classes.blur, square ? classes.square : classes.circular)}
/>
{avatar}
</div>
)
}
CoverArtAvatar.defaultProps = { label: '', sortable: false }

View File

@ -0,0 +1,63 @@
import PropTypes from 'prop-types'
import clsx from 'clsx'
import { makeStyles } from '@material-ui/core/styles'
import config from '../config'
import subsonic from '../subsonic'
import { useImageUrl } from './useImageUrl'
import { BlurHashCanvas } from './BlurHashCanvas'
const useStyles = makeStyles({
root: { position: 'relative', display: 'inline-flex' },
blur: { position: 'absolute', top: 0, left: 0, zIndex: 0 },
img: { position: 'relative', zIndex: 1 },
})
// CoverImage renders an entity's cover through the shared useImageUrl blob cache, so it survives
// React remounts without re-fetching, with the blurhash as the loading placeholder. `className`
// supplies the size (and any transition); the fade opacity is applied here.
export const CoverImage = ({
record,
size = config.uiCoverArtSize,
square = false,
className,
title,
onClick,
}) => {
const classes = useStyles()
const url = record ? subsonic.getCoverArtUrl(record, size, square) : ''
const { imgUrl, loading } = useImageUrl(url)
if (!record) return null
const showBlurHash = loading && record.blurHash
const handleClick = imgUrl && onClick ? onClick : undefined
return (
<div className={classes.root}>
{showBlurHash && (
<BlurHashCanvas
hash={record.blurHash}
className={clsx(className, classes.blur)}
/>
)}
<img
src={imgUrl || undefined}
alt={title}
title={title}
onClick={handleClick}
className={clsx(className, showBlurHash && classes.img)}
style={{
opacity: loading ? 0.5 : 1,
cursor: handleClick ? 'pointer' : 'default',
}}
/>
</div>
)
}
CoverImage.propTypes = {
record: PropTypes.object,
size: PropTypes.number,
square: PropTypes.bool,
className: PropTypes.string,
title: PropTypes.string,
onClick: PropTypes.func,
}

View File

@ -1,7 +1,7 @@
import { useState } from 'react'
import {
Card,
CardContent,
CardMedia,
Typography,
useMediaQuery,
} from '@material-ui/core'
@ -17,10 +17,9 @@ import {
SizeField,
isWritable,
OverflowTooltip,
useImageLoadingState,
} from '../common'
import config from '../config'
import subsonic from '../subsonic'
import { CoverImage } from '../common/CoverImage'
const useStyles = makeStyles(
(theme) => ({
@ -107,37 +106,20 @@ const PlaylistDetails = (props) => {
const translate = useTranslate()
const classes = useStyles()
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
const {
imageLoading,
imageError,
isLightboxOpen,
handleImageLoad,
handleImageError,
handleOpenLightbox,
handleCloseLightbox,
} = useImageLoadingState(record.id)
const [isLightboxOpen, setLightboxOpen] = useState(false)
const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)
const fullImageUrl = subsonic.getCoverArtUrl(record)
return (
<Card className={classes.root}>
<div className={classes.cardContents}>
<div className={classes.coverParent}>
<CardMedia
key={record.id} // Force re-render when playlist changes
component={'img'}
src={imageUrl}
width="400"
height="400"
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox}
onLoad={handleImageLoad}
onError={handleImageError}
<CoverImage
record={record}
square
className={classes.cover}
title={record.name}
style={{
cursor: imageError ? 'default' : 'pointer',
}}
onClick={() => setLightboxOpen(true)}
/>
{isWritable(record.ownerId) && (
<ImageUploadOverlay
@ -187,13 +169,13 @@ const PlaylistDetails = (props) => {
</CardContent>
</div>
</div>
{isLightboxOpen && !imageError && (
{isLightboxOpen && (
<Lightbox
imagePadding={50}
animationDuration={200}
imageTitle={record.name}
mainSrc={fullImageUrl}
onCloseRequest={handleCloseLightbox}
onCloseRequest={() => setLightboxOpen(false)}
/>
)}
</Card>

View File

@ -22,6 +22,7 @@ import {
ToggleFieldsMenu,
useSelectedFields,
} from '../common'
import { BlurHashCanvas } from '../common/BlurHashCanvas'
import subsonic from '../subsonic'
import { StreamField } from './StreamField'
import { setTrack } from '../actions'
@ -82,17 +83,34 @@ const RadioListActions = ({
}
const avatarStyle = { width: 40, height: 40 }
const blurStyle = {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
borderRadius: '4px',
}
const CoverArtField = ({ record }) => {
const directUrl = record?.uploadedImage
? subsonic.getCoverArtUrl(record, 40, true)
: null
const { imgUrl } = useImageUrl(directUrl)
const { imgUrl, loading } = useImageUrl(directUrl)
if (!record) return null
const src = imgUrl || RADIO_PLACEHOLDER_IMAGE
return (
const showBlurHash = loading && record.blurHash
// While the real image loads, prefer the blurhash over the generic radio icon.
const src = imgUrl || (showBlurHash ? undefined : RADIO_PLACEHOLDER_IMAGE)
const avatar = (
<Avatar src={src} variant="rounded" style={avatarStyle} alt={record.name} />
)
if (!showBlurHash) return avatar
return (
<div style={{ position: 'relative', display: 'inline-flex', ...avatarStyle }}>
<BlurHashCanvas hash={record.blurHash} style={blurStyle} />
{avatar}
</div>
)
}
CoverArtField.defaultProps = { label: '' }