feat(ui): increase cover art size to 600px and use CatmullRom scaling

Increased the UI cover art request size from 300px to 600px for sharper
images on high-DPI displays. Replaced BiLinear with CatmullRom (bicubic)
interpolation for higher quality image resizing. Extracted the hardcoded
size into a COVER_ART_SIZE constant in the frontend and consolidated
backend sizes into a CacheWarmerImageSizes slice. Removed the unused
UIThumbnailSize constant.

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan
2026-03-22 14:54:28 -04:00
parent 400a079fcd
commit cb396f3dba
15 changed files with 43 additions and 32 deletions
+6 -2
View File
@@ -70,8 +70,6 @@ const (
PlaceholderArtistArt = "artist-placeholder.webp" PlaceholderArtistArt = "artist-placeholder.webp"
PlaceholderAlbumArt = "album-placeholder.webp" PlaceholderAlbumArt = "album-placeholder.webp"
PlaceholderAvatar = "logo-192x192.png" PlaceholderAvatar = "logo-192x192.png"
UICoverArtSize = 300
UIThumbnailSize = 80
DefaultUIVolume = 100 DefaultUIVolume = 100
DefaultUISearchDebounceMs = 200 DefaultUISearchDebounceMs = 200
@@ -86,6 +84,12 @@ const (
Zwsp = string('\u200b') Zwsp = string('\u200b')
) )
const (
UICoverArtSize = 600
)
var CacheWarmerImageSizes = []int{UICoverArtSize}
// Prometheus options // Prometheus options
const ( const (
PrometheusDefaultPath = "/metrics" PrometheusDefaultPath = "/metrics"
+4 -5
View File
@@ -142,15 +142,14 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro
ctx, cancel := context.WithTimeout(ctx, 10*time.Second) ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel() defer cancel()
for _, size := range []int{consts.UICoverArtSize, consts.UIThumbnailSize} { for _, size := range consts.CacheWarmerImageSizes {
r, _, err := a.artwork.Get(ctx, id, size, true) r, _, err := a.artwork.Get(ctx, id, size, true)
if err != nil { if err != nil {
return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err) return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err)
} }
defer r.Close() _, err = io.Copy(io.Discard, r)
if _, err = io.Copy(io.Discard, r); err != nil { r.Close()
return err return err
}
} }
return nil return nil
} }
+2 -2
View File
@@ -176,13 +176,13 @@ var _ = Describe("CacheWarmer", func() {
}).Should(Equal(0)) }).Should(Equal(0))
}) })
It("pre-caches both UICoverArtSize and UIThumbnailSize", func() { It("pre-caches UICoverArtSize", func() {
cw := NewCacheWarmer(aw, fc).(*cacheWarmer) cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-1")) cw.PreCache(model.MustParseArtworkID("al-1"))
Eventually(func() []int { Eventually(func() []int {
return aw.getCachedSizes() return aw.getCachedSizes()
}).Should(ContainElements(consts.UICoverArtSize, consts.UIThumbnailSize)) }).Should(ContainElements(consts.UICoverArtSize))
}) })
}) })
}) })
+1 -1
View File
@@ -264,6 +264,6 @@ func fillCenter(src image.Image, dstW, dstH int) image.Image {
} }
dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) dst := image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
xdraw.BiLinear.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil) xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, cropRect, draw.Src, nil)
return dst return dst
} }
+1 -1
View File
@@ -155,7 +155,7 @@ func resizeStaticImage(data []byte, size int, square bool) (io.Reader, int, erro
dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH)) dst = image.NewNRGBA(image.Rect(0, 0, dstW, dstH))
dstRect = dst.Bounds() dstRect = dst.Bounds()
} }
xdraw.BiLinear.Scale(dst, dstRect, original, bounds, draw.Src, nil) xdraw.CatmullRom.Scale(dst, dstRect, original, bounds, draw.Src, nil)
buf := bufPool.Get().(*bytes.Buffer) buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() buf.Reset()
+2 -1
View File
@@ -18,6 +18,7 @@ import {
useTranslate, useTranslate,
} from 'react-admin' } from 'react-admin'
import Lightbox from 'react-image-lightbox' import Lightbox from 'react-image-lightbox'
import { COVER_ART_SIZE } from '../consts'
import 'react-image-lightbox/style.css' import 'react-image-lightbox/style.css'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { import {
@@ -254,7 +255,7 @@ const AlbumDetails = (props) => {
}) })
}, [record]) }, [record])
const imageUrl = subsonic.getCoverArtUrl(record, 300) const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE)
const fullImageUrl = subsonic.getCoverArtUrl(record) const fullImageUrl = subsonic.getCoverArtUrl(record)
return ( return (
+2 -2
View File
@@ -19,7 +19,7 @@ import {
ArtistLinkField, ArtistLinkField,
OverflowTooltip, OverflowTooltip,
} from '../common' } from '../common'
import { DraggableTypes } from '../consts' import { COVER_ART_SIZE, DraggableTypes } from '../consts'
import clsx from 'clsx' import clsx from 'clsx'
import { AlbumDatesField } from './AlbumDatesField.jsx' import { AlbumDatesField } from './AlbumDatesField.jsx'
@@ -157,7 +157,7 @@ const Cover = withContentRect('bounds')(({
<div ref={dragAlbumRef}> <div ref={dragAlbumRef}>
<img <img
key={record.id} // Force re-render when record changes key={record.id} // Force re-render when record changes
src={subsonic.getCoverArtUrl(record, 300, true)} src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)}
alt={record.name} alt={record.name}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onLoad={handleImageLoad} onLoad={handleImageLoad}
+2 -1
View File
@@ -15,6 +15,7 @@ import {
import Lightbox from 'react-image-lightbox' import Lightbox from 'react-image-lightbox'
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
import AlbumInfo from '../album/AlbumInfo' import AlbumInfo from '../album/AlbumInfo'
import { COVER_ART_SIZE } from '../consts'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML' import { SafeHTML } from '../common/SafeHTML'
@@ -109,7 +110,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
<CardMedia <CardMedia
key={record.id} key={record.id}
component="img" component="img"
src={subsonic.getCoverArtUrl(record, 300)} src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox} onClick={handleOpenLightbox}
onLoad={handleImageLoad} onLoad={handleImageLoad}
+2 -1
View File
@@ -11,6 +11,7 @@ import {
useImageLoadingState, useImageLoadingState,
} from '../common' } from '../common'
import Lightbox from 'react-image-lightbox' import Lightbox from 'react-image-lightbox'
import { COVER_ART_SIZE } from '../consts'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { SafeHTML } from '../common/SafeHTML' import { SafeHTML } from '../common/SafeHTML'
@@ -112,7 +113,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
<CardMedia <CardMedia
key={record.id} key={record.id}
component="img" component="img"
src={subsonic.getCoverArtUrl(record, 300)} src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onClick={handleOpenLightbox} onClick={handleOpenLightbox}
onLoad={handleImageLoad} onLoad={handleImageLoad}
+2 -1
View File
@@ -2,6 +2,7 @@ import { useRecordContext } from 'react-admin'
import { Avatar } from '@material-ui/core' import { Avatar } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import clsx from 'clsx' import clsx from 'clsx'
import { COVER_ART_SIZE } from '../consts'
import subsonic from '../subsonic' import subsonic from '../subsonic'
const useStyles = makeStyles({ const useStyles = makeStyles({
@@ -25,7 +26,7 @@ export const CoverArtAvatar = ({
const square = variant !== 'circular' const square = variant !== 'circular'
return ( return (
<Avatar <Avatar
src={subsonic.getCoverArtUrl(record, 80, square)} src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, square)}
variant={variant} variant={variant}
className={clsx(classes.avatar, square && classes.square)} className={clsx(classes.avatar, square && classes.square)}
alt={record.name} alt={record.name}
+2
View File
@@ -26,6 +26,8 @@ DraggableTypes.ALL.push(
export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg' export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg'
export const COVER_ART_SIZE = 600
export const DEFAULT_SHARE_BITRATE = 128 export const DEFAULT_SHARE_BITRATE = 128
export const BITRATE_CHOICES = [ export const BITRATE_CHOICES = [
+2 -1
View File
@@ -18,6 +18,7 @@ import {
OverflowTooltip, OverflowTooltip,
useImageLoadingState, useImageLoadingState,
} from '../common' } from '../common'
import { COVER_ART_SIZE } from '../consts'
import subsonic from '../subsonic' import subsonic from '../subsonic'
const useStyles = makeStyles( const useStyles = makeStyles(
@@ -106,7 +107,7 @@ const PlaylistDetails = (props) => {
handleCloseLightbox, handleCloseLightbox,
} = useImageLoadingState(record.id) } = useImageLoadingState(record.id)
const imageUrl = subsonic.getCoverArtUrl(record, 300, true) const imageUrl = subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)
const fullImageUrl = subsonic.getCoverArtUrl(record) const fullImageUrl = subsonic.getCoverArtUrl(record)
return ( return (
+2 -2
View File
@@ -11,7 +11,7 @@ import { makeStyles } from '@material-ui/core/styles'
import { urlValidate } from '../utils/validations' import { urlValidate } from '../utils/validations'
import { Title, ImageUploadOverlay, useImageLoadingState } from '../common' import { Title, ImageUploadOverlay, useImageLoadingState } from '../common'
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { RADIO_PLACEHOLDER_IMAGE } from '../consts' import { COVER_ART_SIZE, RADIO_PLACEHOLDER_IMAGE } from '../consts'
const useStyles = makeStyles({ const useStyles = makeStyles({
coverParent: { coverParent: {
@@ -83,7 +83,7 @@ const RadioCoverArt = ({ record }) => {
{record.uploadedImage ? ( {record.uploadedImage ? (
<CardMedia <CardMedia
component="img" component="img"
src={subsonic.getCoverArtUrl(record, 300, true)} src={subsonic.getCoverArtUrl(record, COVER_ART_SIZE, true)}
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
onLoad={handleImageLoad} onLoad={handleImageLoad}
onError={handleImageError} onError={handleImageError}
+2 -2
View File
@@ -1,5 +1,5 @@
import subsonic from '../subsonic' import subsonic from '../subsonic'
import { RADIO_PLACEHOLDER_IMAGE } from '../consts' import { COVER_ART_SIZE, RADIO_PLACEHOLDER_IMAGE } from '../consts'
export async function songFromRadio(radio) { export async function songFromRadio(radio) {
if (!radio) { if (!radio) {
@@ -8,7 +8,7 @@ export async function songFromRadio(radio) {
let cover = RADIO_PLACEHOLDER_IMAGE let cover = RADIO_PLACEHOLDER_IMAGE
if (radio.uploadedImage) { if (radio.uploadedImage) {
cover = subsonic.getCoverArtUrl(radio, 300, true) cover = subsonic.getCoverArtUrl(radio, COVER_ART_SIZE, true)
} else { } else {
// Try favicon as fallback // Try favicon as fallback
try { try {
+11 -10
View File
@@ -1,4 +1,5 @@
import { vi } from 'vitest' import { vi } from 'vitest'
import { COVER_ART_SIZE } from '../consts'
import subsonic from './index' import subsonic from './index'
describe('getCoverArtUrl', () => { describe('getCoverArtUrl', () => {
@@ -30,10 +31,10 @@ describe('getCoverArtUrl', () => {
updatedAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z',
} }
const url = subsonic.getCoverArtUrl(playlistRecord, 300, true) const url = subsonic.getCoverArtUrl(playlistRecord, COVER_ART_SIZE, true)
expect(url).toContain('pl-playlist-123') expect(url).toContain('pl-playlist-123')
expect(url).toContain('size=300') expect(url).toContain('size=600')
expect(url).toContain('square=true') expect(url).toContain('square=true')
expect(url).toContain('_=2023-01-01T00%3A00%3A00Z') expect(url).toContain('_=2023-01-01T00%3A00%3A00Z')
}) })
@@ -44,10 +45,10 @@ describe('getCoverArtUrl', () => {
sync: true, sync: true,
} }
const url = subsonic.getCoverArtUrl(playlistRecord, 300, true) const url = subsonic.getCoverArtUrl(playlistRecord, COVER_ART_SIZE, true)
expect(url).toContain('pl-playlist-123') expect(url).toContain('pl-playlist-123')
expect(url).toContain('size=300') expect(url).toContain('size=600')
expect(url).toContain('square=true') expect(url).toContain('square=true')
expect(url).not.toContain('_=') expect(url).not.toContain('_=')
}) })
@@ -59,10 +60,10 @@ describe('getCoverArtUrl', () => {
updatedAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z',
} }
const url = subsonic.getCoverArtUrl(albumRecord, 300, true) const url = subsonic.getCoverArtUrl(albumRecord, COVER_ART_SIZE, true)
expect(url).toContain('al-album-123') expect(url).toContain('al-album-123')
expect(url).toContain('size=300') expect(url).toContain('size=600')
expect(url).toContain('square=true') expect(url).toContain('square=true')
}) })
@@ -73,10 +74,10 @@ describe('getCoverArtUrl', () => {
updatedAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z',
} }
const url = subsonic.getCoverArtUrl(songRecord, 300, true) const url = subsonic.getCoverArtUrl(songRecord, COVER_ART_SIZE, true)
expect(url).toContain('mf-song-123') expect(url).toContain('mf-song-123')
expect(url).toContain('size=300') expect(url).toContain('size=600')
expect(url).toContain('square=true') expect(url).toContain('square=true')
}) })
@@ -86,10 +87,10 @@ describe('getCoverArtUrl', () => {
updatedAt: '2023-01-01T00:00:00Z', updatedAt: '2023-01-01T00:00:00Z',
} }
const url = subsonic.getCoverArtUrl(artistRecord, 300, true) const url = subsonic.getCoverArtUrl(artistRecord, COVER_ART_SIZE, true)
expect(url).toContain('ar-artist-123') expect(url).toContain('ar-artist-123')
expect(url).toContain('size=300') expect(url).toContain('size=600')
expect(url).toContain('square=true') expect(url).toContain('square=true')
}) })