quintodrome/ui/src/album/AlbumDetails.jsx
Deluan Quintão c87db92cee
fix(artwork): address WebP performance regression on low-power hardware (#5286)
* refactor(artwork): rename DevJpegCoverArt to EnableWebPEncoding

Replaced the internal DevJpegCoverArt flag with a user-facing
EnableWebPEncoding config option (defaults to true). When disabled, the
fallback encoding now preserves the original image format — PNG sources
stay PNG for non-square resizes, matching v0.60.3 behavior. The previous
implementation incorrectly re-encoded PNG sources as JPEG in non-square
mode. Also added EnableWebPEncoding to the insights data.

* feat: add configurable UICoverArtSize option

Converted the hardcoded UICoverArtSize constant (600px) into a
configurable option, allowing users to reduce the cover art size
requested by the UI to mitigate slow image encoding. The value is
served to the frontend via the app config and used by all components
that request cover art. Also simplified the cache warmer by removing
a single-iteration loop in favor of direct code.

* style: fix prettier formatting in subsonic test

* feat: log WebP encoder/decoder selection

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(artwork): address PR review feedback

- Add DevJpegCoverArt to logRemovedOptions so users with the old config
  key get a clear warning instead of a silent ignore.
- Include EnableWebPEncoding in the resized artwork cache key to prevent
  stale WebP responses after toggling the setting.
- Skip animated GIF to WebP conversion via ffmpeg when EnableWebPEncoding
  is false, so the setting is consistent across all image types.
- Fix data race in cache warmer by reading UICoverArtSize at construction
  time instead of per-image, avoiding concurrent access with config
  cleanup in tests.
- Clarify cache warmer docstring to accurately describe caching behavior.

* Revert "fix(artwork): address PR review feedback"

This reverts commit 3a213ef03e401930977138afe0e84c83290df683.

* fix(artwork): avoid data race in cache warmer config access

Capture UICoverArtSize at construction time instead of reading from
conf.Server on each doCacheImage call. The background goroutine could
race with test config cleanup, causing intermittent race detector
failures in CI.

* fix(configuration): clamp UICoverArtSize to be within 200 and 1200

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(artwork): preserve album cache key compatibility with v0.60.3

Restored the v0.60.3 hash input order for album artwork cache keys
(Agents + CoverArtPriority) so that existing caches remain valid on
upgrade when EnableExternalServices is true. Also ensures
CoverArtPriority is always part of the hash even when external services
are disabled, fixing a v0.60.3 bug where changing CoverArtPriority had
no effect on cache invalidation.

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: default EnableWebPEncoding to false and reduce artwork parallelism

Changed EnableWebPEncoding default to false so that upgrading users get
the same JPEG/PNG encoding behavior as v0.60.3 out of the box, avoiding
the WebP WASM overhead until native libwebp is available. Users can
opt in to WebP by setting EnableWebPEncoding=true. Also reduced the
default DevArtworkMaxRequests to half the CPU count (min 2) to lower
resource pressure during artwork processing.

* fix(configuration): update DefaultUICoverArtSize to 300

Signed-off-by: Deluan <deluan@navidrome.org>

* fix(Makefile): append EXTRA_BUILD_TAGS to GO_BUILD_TAGS

Signed-off-by: Deluan <deluan@navidrome.org>

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-04-04 15:17:01 -04:00

379 lines
10 KiB
JavaScript

import { useEffect, useState } from 'react'
import {
Card,
CardContent,
CardMedia,
Collapse,
makeStyles,
Typography,
useMediaQuery,
withWidth,
} from '@material-ui/core'
import {
ArrayField,
ChipField,
Link,
SingleFieldList,
useRecordContext,
useTranslate,
} from 'react-admin'
import Lightbox from 'react-image-lightbox'
import config from '../config'
import 'react-image-lightbox/style.css'
import subsonic from '../subsonic'
import {
ArtistLinkField,
CollapsibleComment,
DurationField,
formatRange,
LoveButton,
RatingField,
SizeField,
useAlbumsPerPage,
useImageLoadingState,
} from '../common'
import { formatFullDate, intersperse } from '../utils'
import AlbumExternalLinks from './AlbumExternalLinks'
import { SafeHTML } from '../common/SafeHTML'
const useStyles = makeStyles(
(theme) => ({
root: {
[theme.breakpoints.down('xs')]: {
padding: '0.7em',
minWidth: '20em',
},
[theme.breakpoints.up('sm')]: {
padding: '1em',
minWidth: '32em',
},
},
cardContents: {
display: 'flex',
},
details: {
display: 'flex',
flexDirection: 'column',
},
content: {
flex: '2 0 auto',
},
coverParent: {
[theme.breakpoints.down('xs')]: {
height: '8em',
width: '8em',
minWidth: '8em',
},
[theme.breakpoints.up('sm')]: {
height: '10em',
width: '10em',
minWidth: '10em',
},
[theme.breakpoints.up('lg')]: {
height: '15em',
width: '15em',
minWidth: '15em',
},
backgroundColor: 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
cover: {
objectFit: 'contain',
cursor: 'pointer',
display: 'block',
width: '100%',
height: '100%',
backgroundColor: 'transparent',
transition: 'opacity 0.3s ease-in-out',
},
coverLoading: {
opacity: 0.5,
},
loveButton: {
top: theme.spacing(-0.2),
left: theme.spacing(0.5),
},
notes: {
display: 'inline-block',
marginTop: '1em',
float: 'left',
wordBreak: 'break-word',
cursor: 'pointer',
},
recordName: {},
recordArtist: {},
recordMeta: {},
genreList: {
marginTop: theme.spacing(0.5),
},
externalLinks: {
marginTop: theme.spacing(1.5),
},
}),
{
name: 'NDAlbumDetails',
},
)
const useGetHandleGenreClick = (width) => {
const [perPage] = useAlbumsPerPage(width)
return (id) => {
return `/album?filter={"genre_id":["${id}"]}&order=ASC&sort=name&perPage=${perPage}`
}
}
const GenreChipField = withWidth()(({ width, ...rest }) => {
const record = useRecordContext(rest)
const genreLink = useGetHandleGenreClick(width)
return (
<Link to={genreLink(record.id)} onClick={(e) => e.stopPropagation()}>
<ChipField
source="name"
// Workaround to force ChipField to be clickable
onClick={() => {}}
/>
</Link>
)
})
const GenreList = () => {
const classes = useStyles()
return (
<ArrayField className={classes.genreList} source={'genres'}>
<SingleFieldList linkType={false}>
<GenreChipField />
</SingleFieldList>
</ArrayField>
)
}
export const Details = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const translate = useTranslate()
const record = useRecordContext(props)
// Create an array of detail elements
let details = []
const addDetail = (obj) => {
const id = details.length
details.push(<span key={`detail-${record.id}-${id}`}>{obj}</span>)
}
// Calculate date related fields
const yearRange = formatRange(record, 'year')
const date = record.date ? formatFullDate(record.date) : yearRange
const originalDate = record.originalDate
? formatFullDate(record.originalDate)
: formatRange(record, 'originalYear')
const releaseDate = record?.releaseDate && formatFullDate(record.releaseDate)
const dateToUse = originalDate || date
const isOriginalDate = originalDate && dateToUse !== date
const showDate = dateToUse && dateToUse !== releaseDate
// Get label for the main date display
const getDateLabel = () => {
if (isXsmall) return '♫'
if (isOriginalDate) return translate('resources.album.fields.originalDate')
return null
}
// Get label for release date display
const getReleaseDateLabel = () => {
if (!isXsmall) return translate('resources.album.fields.releaseDate')
if (showDate) return '○'
return null
}
// Display dates with appropriate labels
if (showDate) {
addDetail(<>{[getDateLabel(), dateToUse].filter(Boolean).join(' ')}</>)
}
if (releaseDate) {
addDetail(
<>{[getReleaseDateLabel(), releaseDate].filter(Boolean).join(' ')}</>,
)
}
addDetail(
<>
{record.songCount +
' ' +
translate('resources.song.name', {
smart_count: record.songCount,
})}
</>,
)
!isXsmall && addDetail(<DurationField source={'duration'} />)
!isXsmall && addDetail(<SizeField source="size" />)
// Return the details rendered with separators
return <>{intersperse(details, ' · ')}</>
}
const AlbumDetails = (props) => {
const record = useRecordContext(props)
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
const classes = useStyles()
const [expanded, setExpanded] = useState(false)
const [albumInfo, setAlbumInfo] = useState()
const {
imageLoading,
imageError,
isLightboxOpen,
handleImageLoad,
handleImageError,
handleOpenLightbox,
handleCloseLightbox,
} = useImageLoadingState(record.id)
let notes = albumInfo?.notes || record.notes
if (notes) {
notes += '..'
}
useEffect(() => {
subsonic
.getAlbumInfo(record.id)
.then((resp) => resp.json['subsonic-response'])
.then((data) => {
if (data.status === 'ok') {
setAlbumInfo(data.albumInfo)
}
})
.catch((e) => {
// eslint-disable-next-line no-console
console.error('error on album page', e)
})
}, [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}
title={record.name}
style={{
cursor: imageError ? 'default' : 'pointer',
}}
/>
</div>
<div className={classes.details}>
<CardContent className={classes.content}>
<Typography
variant={isDesktop ? 'h5' : 'h6'}
className={classes.recordName}
>
{record.name}
<LoveButton
className={classes.loveButton}
record={record}
resource={'album'}
size={isDesktop ? 'default' : 'small'}
aria-label="love"
color="primary"
/>
</Typography>
<Typography component={'h6'} className={classes.recordArtist}>
{record?.tags?.['albumversion']}
</Typography>
<Typography component={'h6'} className={classes.recordArtist}>
<ArtistLinkField record={record} />
</Typography>
<Typography component={'div'} className={classes.recordMeta}>
<Details />
</Typography>
{config.enableStarRating && (
<div>
<RatingField
record={record}
resource={'album'}
size={isDesktop ? 'medium' : 'small'}
/>
</div>
)}
{isDesktop ? (
<GenreList />
) : (
<Typography component={'p'}>{record.genre}</Typography>
)}
{!isXsmall && (
<Typography component={'div'} className={classes.recordMeta}>
{config.enableExternalServices && (
<AlbumExternalLinks className={classes.externalLinks} />
)}
</Typography>
)}
{isDesktop && notes && (
<Collapse
collapsedHeight={'2.75em'}
in={expanded}
timeout={'auto'}
className={classes.notes}
>
<Typography
variant={'body1'}
onClick={() => setExpanded(!expanded)}
>
<span>
<SafeHTML>{notes}</SafeHTML>
</span>
</Typography>
</Collapse>
)}
{isDesktop && record['comment'] && (
<CollapsibleComment record={record} />
)}
</CardContent>
</div>
</div>
{!isDesktop && record['comment'] && (
<CollapsibleComment record={record} />
)}
{!isDesktop && notes && (
<div className={classes.notes}>
<Collapse collapsedHeight={'1.5em'} in={expanded} timeout={'auto'}>
<Typography
variant={'body1'}
onClick={() => setExpanded(!expanded)}
>
<span>
<SafeHTML>{notes}</SafeHTML>
</span>
</Typography>
</Collapse>
</div>
)}
{isLightboxOpen && !imageError && (
<Lightbox
imagePadding={50}
animationDuration={200}
imageTitle={record.name}
mainSrc={fullImageUrl}
onCloseRequest={handleCloseLightbox}
/>
)}
</Card>
)
}
export default AlbumDetails