refactor(ui): unify list cover surfaces onto the shared CoverImage component

Route the album grid, CoverArtAvatar (artist/playlist lists) and the radio list's cover field through CoverImage instead of each carrying its own useImageUrl + blurhash-overlay wiring. CoverImage gains a default object-fit: cover. Radio keeps its uploaded-image gate and the generic radio placeholder for stations with no art.
This commit is contained in:
Deluan 2026-07-24 13:53:04 -04:00
parent 44913df403
commit 01cf2d2915
4 changed files with 37 additions and 99 deletions

View File

@ -12,19 +12,16 @@ import { Link } from 'react-router-dom'
import { linkToRecord, useListContext, Loading } from 'react-admin'
import { withContentRect } from 'react-measure'
import { useDrag } from 'react-dnd'
import subsonic from '../subsonic'
import {
AlbumContextMenu,
PlayButton,
ArtistLinkField,
OverflowTooltip,
useImageUrl,
} from '../common'
import config from '../config'
import { DraggableTypes } from '../consts'
import clsx from 'clsx'
import { AlbumDatesField } from './AlbumDatesField.jsx'
import { BlurHashCanvas } from '../common/BlurHashCanvas'
import { CoverImage } from '../common/CoverImage'
const useStyles = makeStyles(
(theme) => ({
@ -139,23 +136,14 @@ const Cover = withContentRect('bounds')(({
[record],
)
const url = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)
const { imgUrl, loading: imageLoading } = useImageUrl(url)
const showBlurHash = imageLoading && record.blurHash
return (
<div ref={measureRef} className={classes.coverContainer}>
<div ref={dragAlbumRef} style={{ position: 'relative' }}>
{showBlurHash && (
<BlurHashCanvas hash={record.blurHash} className={classes.cover} />
)}
<img
src={imgUrl || undefined}
alt={record.name}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
style={
showBlurHash ? { position: 'absolute', left: 0, top: 0 } : undefined
}
<div ref={dragAlbumRef}>
<CoverImage
record={record}
square
className={classes.cover}
title={record.name}
/>
</div>
</div>

View File

@ -1,81 +1,36 @@
import { useRecordContext } from 'react-admin'
import { Avatar } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import clsx from 'clsx'
import config from '../config'
import subsonic from '../subsonic'
import { useImageUrl } from './useImageUrl'
import { BlurHashCanvas } from './BlurHashCanvas'
import { CoverImage } from './CoverImage'
const useStyles = makeStyles({
root: {
position: 'relative',
display: 'inline-flex',
width: '55px',
height: '55px',
},
avatar: {
width: '55px',
height: '55px',
},
avatarEmpty: {
backgroundColor: 'transparent',
},
square: {
borderRadius: '4px',
},
circular: {
borderRadius: '50%',
},
blur: {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
},
})
export const CoverArtAvatar = ({
record: recordProp,
variant = 'circular',
}) => {
export const CoverArtAvatar = ({ record: recordProp, variant = 'circular' }) => {
const classes = useStyles()
const recordContext = useRecordContext()
const record = recordProp || recordContext
const square = variant !== 'circular'
const url = record
? subsonic.getCoverArtUrl(record, config.uiCoverArtSize, square)
: null
const { imgUrl, loading } = useImageUrl(url)
if (!record) return null
const avatar = (
<Avatar
src={imgUrl || undefined}
variant={variant}
className={clsx(
classes.avatar,
square && classes.square,
!imgUrl && classes.avatarEmpty,
)}
alt={record.name}
>
{/* Empty child prevents default person icon while loading */}
{!imgUrl && <span />}
</Avatar>
)
// Show the blurhash behind the transparent avatar until the real image loads.
if (!(loading && record.blurHash)) return avatar
const square = variant !== 'circular'
return (
<div className={classes.root}>
<BlurHashCanvas
hash={record.blurHash}
className={clsx(classes.blur, square ? classes.square : classes.circular)}
/>
{avatar}
</div>
<CoverImage
record={record}
size={config.uiCoverArtSize}
square={square}
className={clsx(classes.avatar, square ? classes.square : classes.circular)}
title={record.name}
/>
)
}

View File

@ -45,6 +45,7 @@ export const CoverImage = ({
onClick={handleClick}
className={clsx(className, showBlurHash && classes.img)}
style={{
objectFit: 'cover',
opacity: loading ? 0.5 : 1,
cursor: handleClick ? 'pointer' : 'default',
}}

View File

@ -18,11 +18,10 @@ import {
List,
defaultRowsPerPageOptions,
getStoredPerPage,
useImageUrl,
ToggleFieldsMenu,
useSelectedFields,
} from '../common'
import { BlurHashCanvas } from '../common/BlurHashCanvas'
import { CoverImage } from '../common/CoverImage'
import subsonic from '../subsonic'
import { StreamField } from './StreamField'
import { setTrack } from '../actions'
@ -83,33 +82,28 @@ const RadioListActions = ({
}
const avatarStyle = { width: 40, height: 40 }
const blurStyle = {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
borderRadius: '4px',
}
const useCoverStyles = makeStyles({
cover: { width: 40, height: 40, borderRadius: '4px' },
})
const CoverArtField = ({ record }) => {
const directUrl = record?.uploadedImage
? subsonic.getCoverArtUrl(record, 40, true)
: null
const { imgUrl, loading } = useImageUrl(directUrl)
const classes = useCoverStyles()
if (!record) return null
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
// Radios resolve art only from an uploaded image; otherwise show the generic radio icon.
if (record.uploadedImage) {
return (
<CoverImage
record={record}
size={40}
square
className={classes.cover}
title={record.name}
/>
)
}
return (
<div style={{ position: 'relative', display: 'inline-flex', ...avatarStyle }}>
<BlurHashCanvas hash={record.blurHash} style={blurStyle} />
{avatar}
</div>
<Avatar src={RADIO_PLACEHOLDER_IMAGE} variant="rounded" style={avatarStyle} alt={record.name} />
)
}
CoverArtField.defaultProps = { label: '' }