refactor(ui): rename cover artwork components

This commit is contained in:
Deluan 2026-07-25 18:35:12 -04:00
parent 19d89143f7
commit 57a95c0dfd
14 changed files with 42 additions and 47 deletions

View File

@ -20,7 +20,7 @@ import Lightbox from 'react-image-lightbox'
import config from '../config' import config from '../config'
import 'react-image-lightbox/style.css' import 'react-image-lightbox/style.css'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { CoverImage } from '../common/CoverImage' import { Artwork } from '../common/Artwork'
import { import {
ArtistLinkField, ArtistLinkField,
CollapsibleComment, CollapsibleComment,
@ -248,7 +248,7 @@ const AlbumDetails = (props) => {
<Card className={classes.root}> <Card className={classes.root}>
<div className={classes.cardContents}> <div className={classes.cardContents}>
<div className={classes.coverParent}> <div className={classes.coverParent}>
<CoverImage <Artwork
record={record} record={record}
fit="contain" fit="contain"
className={classes.cover} className={classes.cover}

View File

@ -21,7 +21,7 @@ import {
import { DraggableTypes } from '../consts' import { DraggableTypes } from '../consts'
import clsx from 'clsx' import clsx from 'clsx'
import { AlbumDatesField } from './AlbumDatesField.jsx' import { AlbumDatesField } from './AlbumDatesField.jsx'
import { CoverImage } from '../common/CoverImage' import { Artwork } from '../common/Artwork'
const useStyles = makeStyles( const useStyles = makeStyles(
(theme) => ({ (theme) => ({
@ -136,7 +136,7 @@ const Cover = withContentRect('bounds')(({
return ( return (
<div ref={measureRef} className={classes.coverContainer}> <div ref={measureRef} className={classes.coverContainer}>
<div ref={dragAlbumRef}> <div ref={dragAlbumRef}>
<CoverImage <Artwork
record={record} record={record}
square square
className={classes.cover} className={classes.cover}

View File

@ -14,7 +14,7 @@ import { makeStyles } from '@material-ui/core/styles'
import { useDrag } from 'react-dnd' import { useDrag } from 'react-dnd'
import { import {
ArtistLinkField, ArtistLinkField,
CoverArtAvatar, ArtworkAvatar,
DurationField, DurationField,
RangeField, RangeField,
SimpleList, SimpleList,
@ -164,7 +164,7 @@ const AlbumTableView = ({
)} )}
leftIcon={(r) => ( leftIcon={(r) => (
<span style={{ marginRight: '8px' }}> <span style={{ marginRight: '8px' }}>
<CoverArtAvatar record={r} variant="square" /> <ArtworkAvatar record={r} variant="square" />
</span> </span>
)} )}
linkType={'show'} linkType={'show'}
@ -173,7 +173,7 @@ const AlbumTableView = ({
/> />
) : ( ) : (
<AlbumDatagrid rowClick={'show'} classes={{ row: classes.row }} {...rest}> <AlbumDatagrid rowClick={'show'} classes={{ row: classes.row }} {...rest}>
<CoverArtAvatar source="id" variant="square" /> <ArtworkAvatar source="id" variant="square" />
<TextField source="name" /> <TextField source="name" />
{columns} {columns}
<AlbumContextMenu <AlbumContextMenu

View File

@ -22,7 +22,7 @@ import { useDrag } from 'react-dnd'
import clsx from 'clsx' import clsx from 'clsx'
import { import {
ArtistContextMenu, ArtistContextMenu,
CoverArtAvatar, ArtworkAvatar,
List, List,
useGetHandleArtistClick, useGetHandleArtistClick,
RatingField, RatingField,
@ -173,7 +173,7 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
/> />
) : ( ) : (
<ArtistDatagrid rowClick={handleArtistLink} classes={{ row: classes.row }}> <ArtistDatagrid rowClick={handleArtistLink} classes={{ row: classes.row }}>
<CoverArtAvatar source="id" /> <ArtworkAvatar source="id" />
<TextField source="name" /> <TextField source="name" />
<FunctionField <FunctionField
source="albumCount" source="albumCount"

View File

@ -8,7 +8,7 @@ import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
import ListItemText from '@material-ui/core/ListItemText' import ListItemText from '@material-ui/core/ListItemText'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { sanitizeListRestProps } from 'react-admin' import { sanitizeListRestProps } from 'react-admin'
import { ArtistContextMenu, CoverArtAvatar, RatingField } from '../common' import { ArtistContextMenu, ArtworkAvatar, RatingField } from '../common'
import config from '../config' import config from '../config'
const useStyles = makeStyles( const useStyles = makeStyles(
@ -49,7 +49,7 @@ const ArtistSimpleList = ({
<span key={id} onClick={() => linkType(id)}> <span key={id} onClick={() => linkType(id)}>
<ListItem className={classes.listItem} button={true}> <ListItem className={classes.listItem} button={true}>
<ListItemAvatar> <ListItemAvatar>
<CoverArtAvatar record={data[id]} /> <ArtworkAvatar record={data[id]} />
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
style={{ marginLeft: '8px' }} style={{ marginLeft: '8px' }}

View File

@ -11,7 +11,7 @@ import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
import AlbumInfo from '../album/AlbumInfo' import AlbumInfo from '../album/AlbumInfo'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML' import { SafeHTML } from '../common/SafeHTML'
import { CoverImage } from '../common/CoverImage' import { Artwork } from '../common/Artwork'
const useStyles = makeStyles( const useStyles = makeStyles(
(theme) => ({ (theme) => ({
@ -90,7 +90,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
<Card className={classes.artistDetail}> <Card className={classes.artistDetail}>
<Card className={classes.artistImage}> <Card className={classes.artistImage}>
{artistInfo && ( {artistInfo && (
<CoverImage <Artwork
record={record} record={record}
className={classes.cover} className={classes.cover}
title={title} title={title}

View File

@ -7,7 +7,7 @@ import { LoveButton, RatingField, ImageUploadOverlay } from '../common'
import Lightbox from 'react-image-lightbox' import Lightbox from 'react-image-lightbox'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML' import { SafeHTML } from '../common/SafeHTML'
import { CoverImage } from '../common/CoverImage' import { Artwork } from '../common/Artwork'
const useStyles = makeStyles( const useStyles = makeStyles(
(theme) => ({ (theme) => ({
@ -93,7 +93,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
<div className={classes.bgContainer}> <div className={classes.bgContainer}>
<Card className={classes.artistImage}> <Card className={classes.artistImage}>
{artistInfo && ( {artistInfo && (
<CoverImage <Artwork
record={record} record={record}
className={classes.cover} className={classes.cover}
title={title} title={title}

View File

@ -34,10 +34,10 @@ const useStyles = makeStyles({
imgInstant: { opacity: 1, transition: 'none' }, imgInstant: { opacity: 1, transition: 'none' },
}) })
// CoverImage renders an entity's cover through the shared useImageUrl blob cache, so it survives // Artwork renders an entity's cover through the shared useImageUrl blob cache, so it survives
// React remounts without re-fetching. The blurhash is the loading placeholder; the image is only // React remounts without re-fetching. The blurhash is the loading placeholder; the image is only
// mounted once its blob is ready, so an unresolved cover never renders as a broken <img>. // mounted once its blob is ready, so an unresolved cover never renders as a broken <img>.
export const CoverImage = ({ export const Artwork = ({
record, record,
size = config.uiCoverArtSize, size = config.uiCoverArtSize,
square = false, square = false,
@ -107,7 +107,7 @@ export const CoverImage = ({
) )
} }
CoverImage.propTypes = { Artwork.propTypes = {
record: PropTypes.object, record: PropTypes.object,
size: PropTypes.number, size: PropTypes.number,
square: PropTypes.bool, square: PropTypes.bool,

View File

@ -8,7 +8,7 @@ vi.mock('../subsonic', () => ({
vi.mock('../config', () => ({ default: { uiCoverArtSize: 300 } })) vi.mock('../config', () => ({ default: { uiCoverArtSize: 300 } }))
import { useImageUrl } from './useImageUrl' import { useImageUrl } from './useImageUrl'
import { CoverImage } from './CoverImage' import { Artwork } from './Artwork'
const withArt = { const withArt = {
id: 'al-1', id: 'al-1',
@ -16,7 +16,7 @@ const withArt = {
blurHash: 'LEHV6nWB2yk8pyo0adR*.7kCMdnj', blurHash: 'LEHV6nWB2yk8pyo0adR*.7kCMdnj',
} }
describe('CoverImage', () => { describe('Artwork', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
// jsdom has no 2D context; stub it so BlurHashCanvas bails cleanly without console noise // jsdom has no 2D context; stub it so BlurHashCanvas bails cleanly without console noise
@ -25,29 +25,27 @@ describe('CoverImage', () => {
it('renders nothing without a record', () => { it('renders nothing without a record', () => {
useImageUrl.mockReturnValue({ imgUrl: null, loading: false }) useImageUrl.mockReturnValue({ imgUrl: null, loading: false })
const { container } = render(<CoverImage record={null} />) const { container } = render(<Artwork record={null} />)
expect(container.firstChild).toBeNull() expect(container.firstChild).toBeNull()
}) })
it('shows the blurhash and no <img> while loading', () => { it('shows the blurhash and no <img> while loading', () => {
useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
const { container } = render(<CoverImage record={withArt} title="Album" />) const { container } = render(<Artwork record={withArt} title="Album" />)
expect(container.querySelector('canvas')).not.toBeNull() expect(container.querySelector('canvas')).not.toBeNull()
expect(container.querySelector('img')).toBeNull() expect(container.querySelector('img')).toBeNull()
}) })
it('shows neither a broken <img> nor a canvas while loading a record with no blurhash', () => { it('shows neither a broken <img> nor a canvas while loading a record with no blurhash', () => {
useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
const { container } = render( const { container } = render(<Artwork record={{ id: 'al-2', name: 'X' }} />)
<CoverImage record={{ id: 'al-2', name: 'X' }} />,
)
expect(container.querySelector('img')).toBeNull() expect(container.querySelector('img')).toBeNull()
expect(container.querySelector('canvas')).toBeNull() expect(container.querySelector('canvas')).toBeNull()
}) })
it('mounts the image only once its blob is ready', () => { it('mounts the image only once its blob is ready', () => {
useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false }) useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false })
const { container } = render(<CoverImage record={withArt} title="Album" />) const { container } = render(<Artwork record={withArt} title="Album" />)
const img = container.querySelector('img') const img = container.querySelector('img')
expect(img).not.toBeNull() expect(img).not.toBeNull()
expect(img.getAttribute('src')).toBe('blob:abc') expect(img.getAttribute('src')).toBe('blob:abc')
@ -57,11 +55,11 @@ describe('CoverImage', () => {
vi.useFakeTimers() vi.useFakeTimers()
try { try {
useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
const { container, rerender } = render(<CoverImage record={withArt} />) const { container, rerender } = render(<Artwork record={withArt} />)
// Blob arrives: the image mounts transparent, with the blurhash still behind it. // Blob arrives: the image mounts transparent, with the blurhash still behind it.
useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false }) useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false })
rerender(<CoverImage record={withArt} />) rerender(<Artwork record={withArt} />)
const img = container.querySelector('img') const img = container.querySelector('img')
expect(img).not.toBeNull() expect(img).not.toBeNull()
expect(container.querySelector('canvas')).not.toBeNull() expect(container.querySelector('canvas')).not.toBeNull()
@ -87,7 +85,7 @@ describe('CoverImage', () => {
it('does not fade an image that was already cached on mount', () => { it('does not fade an image that was already cached on mount', () => {
useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false }) useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false })
const { container } = render(<CoverImage record={withArt} />) const { container } = render(<Artwork record={withArt} />)
// No placeholder to cross-fade from, so it paints at once. // No placeholder to cross-fade from, so it paints at once.
expect(container.querySelector('canvas')).toBeNull() expect(container.querySelector('canvas')).toBeNull()
expect(container.querySelector('img').className).toContain('imgInstant') expect(container.querySelector('img').className).toContain('imgInstant')
@ -95,9 +93,9 @@ describe('CoverImage', () => {
it('keeps the blurhash visible when the image never decodes', () => { it('keeps the blurhash visible when the image never decodes', () => {
useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
const { container, rerender } = render(<CoverImage record={withArt} />) const { container, rerender } = render(<Artwork record={withArt} />)
useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false }) useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false })
rerender(<CoverImage record={withArt} />) rerender(<Artwork record={withArt} />)
expect(container.querySelector('canvas')).not.toBeNull() expect(container.querySelector('canvas')).not.toBeNull()
}) })
@ -106,13 +104,13 @@ describe('CoverImage', () => {
const onClick = vi.fn() const onClick = vi.fn()
useImageUrl.mockReturnValue({ imgUrl: null, loading: true }) useImageUrl.mockReturnValue({ imgUrl: null, loading: true })
const { container, rerender } = render( const { container, rerender } = render(
<CoverImage record={withArt} onClick={onClick} />, <Artwork record={withArt} onClick={onClick} />,
) )
container.firstChild.click() container.firstChild.click()
expect(onClick).not.toHaveBeenCalled() expect(onClick).not.toHaveBeenCalled()
useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false }) useImageUrl.mockReturnValue({ imgUrl: 'blob:abc', loading: false })
rerender(<CoverImage record={withArt} onClick={onClick} />) rerender(<Artwork record={withArt} onClick={onClick} />)
container.firstChild.click() container.firstChild.click()
expect(onClick).toHaveBeenCalledTimes(1) expect(onClick).toHaveBeenCalledTimes(1)
}) })

View File

@ -2,7 +2,7 @@ import { useRecordContext } from 'react-admin'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import clsx from 'clsx' import clsx from 'clsx'
import config from '../config' import config from '../config'
import { CoverImage } from './CoverImage' import { Artwork } from './Artwork'
const useStyles = makeStyles({ const useStyles = makeStyles({
avatar: { avatar: {
@ -17,17 +17,14 @@ const useStyles = makeStyles({
}, },
}) })
export const CoverArtAvatar = ({ export const ArtworkAvatar = ({ record: recordProp, variant = 'circular' }) => {
record: recordProp,
variant = 'circular',
}) => {
const classes = useStyles() const classes = useStyles()
const recordContext = useRecordContext() const recordContext = useRecordContext()
const record = recordProp || recordContext const record = recordProp || recordContext
if (!record) return null if (!record) return null
const square = variant !== 'circular' const square = variant !== 'circular'
return ( return (
<CoverImage <Artwork
record={record} record={record}
size={config.uiCoverArtSize} size={config.uiCoverArtSize}
square={square} square={square}
@ -40,4 +37,4 @@ export const CoverArtAvatar = ({
) )
} }
CoverArtAvatar.defaultProps = { label: '', sortable: false } ArtworkAvatar.defaultProps = { label: '', sortable: false }

View File

@ -45,6 +45,6 @@ export * from './ParticipantsInfo'
export * from './OverflowTooltip' export * from './OverflowTooltip'
export * from './useSearchRefocus' export * from './useSearchRefocus'
export * from './ImageUploadOverlay' export * from './ImageUploadOverlay'
export * from './CoverArtAvatar' export * from './ArtworkAvatar'
export * from './useImageLoadingState' export * from './useImageLoadingState'
export * from './useImageUrl' export * from './useImageUrl'

View File

@ -14,7 +14,7 @@ import {
OverflowTooltip, OverflowTooltip,
} from '../common' } from '../common'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { CoverImage } from '../common/CoverImage' import { Artwork } from '../common/Artwork'
const useStyles = makeStyles( const useStyles = makeStyles(
(theme) => ({ (theme) => ({
@ -106,7 +106,7 @@ const PlaylistDetails = (props) => {
<Card className={classes.root}> <Card className={classes.root}>
<div className={classes.cardContents}> <div className={classes.cardContents}>
<div className={classes.coverParent}> <div className={classes.coverParent}>
<CoverImage <Artwork
record={record} record={record}
square square
fit="contain" fit="contain"

View File

@ -20,7 +20,7 @@ import Switch from '@material-ui/core/Switch'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { useMediaQuery } from '@material-ui/core' import { useMediaQuery } from '@material-ui/core'
import { import {
CoverArtAvatar, ArtworkAvatar,
DurationField, DurationField,
List, List,
LoveButton, LoveButton,
@ -197,7 +197,7 @@ const PlaylistList = (props) => {
bulkActionButtons={!isXsmall && <PlaylistListBulkActions />} bulkActionButtons={!isXsmall && <PlaylistListBulkActions />}
> >
<Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}> <Datagrid rowClick="show" isRowSelectable={(r) => isWritable(r?.ownerId)}>
<CoverArtAvatar source="id" variant="square" /> <ArtworkAvatar source="id" variant="square" />
<TextField source="name" /> <TextField source="name" />
{columns} {columns}
<Writable> <Writable>

View File

@ -21,7 +21,7 @@ import {
ToggleFieldsMenu, ToggleFieldsMenu,
useSelectedFields, useSelectedFields,
} from '../common' } from '../common'
import { CoverImage } from '../common/CoverImage' import { Artwork } from '../common/Artwork'
import { StreamField } from './StreamField' import { StreamField } from './StreamField'
import { setTrack } from '../actions' import { setTrack } from '../actions'
import { songFromRadio } from './helper' import { songFromRadio } from './helper'
@ -92,7 +92,7 @@ const CoverArtField = ({ record }) => {
// Radios resolve art only from an uploaded image; otherwise show the generic radio icon. // Radios resolve art only from an uploaded image; otherwise show the generic radio icon.
if (record.uploadedImage) { if (record.uploadedImage) {
return ( return (
<CoverImage <Artwork
record={record} record={record}
size={40} size={40}
square square