mirror of
https://github.com/navidrome/navidrome.git
synced 2025-08-10 00:52:20 +00:00
feat(ui): add playlist cover art display
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push Docker manifest (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
Some checks failed
Pipeline: Test, Lint, Build / Get version info (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test Go code (push) Has been cancelled
Pipeline: Test, Lint, Build / Test JS code (push) Has been cancelled
Pipeline: Test, Lint, Build / Lint i18n files (push) Has been cancelled
Pipeline: Test, Lint, Build / Check Docker configuration (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (darwin/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v5) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v6) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm/v7) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (linux/arm64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/386) (push) Has been cancelled
Pipeline: Test, Lint, Build / Build (windows/amd64) (push) Has been cancelled
Pipeline: Test, Lint, Build / Push Docker manifest (push) Has been cancelled
Pipeline: Test, Lint, Build / Build Windows installers (push) Has been cancelled
Pipeline: Test, Lint, Build / Package/Release (push) Has been cancelled
Pipeline: Test, Lint, Build / Upload Linux PKG (push) Has been cancelled
POEditor import / update-translations (push) Has been cancelled
Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
@@ -1,32 +1,72 @@
|
||||
import { Card, CardContent, Typography } from '@material-ui/core'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
} from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { useTranslate } from 'react-admin'
|
||||
import { useCallback, useState, useEffect } from 'react'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import 'react-image-lightbox/style.css'
|
||||
import { CollapsibleComment, DurationField, SizeField } from '../common'
|
||||
import subsonic from '../subsonic'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
container: {
|
||||
root: {
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
padding: '0.7em',
|
||||
minWidth: '24em',
|
||||
minWidth: '20em',
|
||||
},
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
padding: '1em',
|
||||
minWidth: '32em',
|
||||
},
|
||||
},
|
||||
cardContents: {
|
||||
display: 'flex',
|
||||
},
|
||||
details: {
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'top',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
content: {
|
||||
flex: '2 0 auto',
|
||||
},
|
||||
coverParent: {
|
||||
[theme.breakpoints.down('xs')]: {
|
||||
width: '14em',
|
||||
height: '8em',
|
||||
width: '8em',
|
||||
minWidth: '8em',
|
||||
},
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
width: '26em',
|
||||
height: '10em',
|
||||
width: '10em',
|
||||
minWidth: '10em',
|
||||
},
|
||||
[theme.breakpoints.up('lg')]: {
|
||||
width: '38em',
|
||||
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,
|
||||
},
|
||||
title: {
|
||||
whiteSpace: 'nowrap',
|
||||
@@ -43,31 +83,95 @@ const PlaylistDetails = (props) => {
|
||||
const { record = {} } = props
|
||||
const translate = useTranslate()
|
||||
const classes = useStyles()
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
|
||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
||||
const [imageLoading, setImageLoading] = useState(false)
|
||||
const [imageError, setImageError] = useState(false)
|
||||
|
||||
const imageUrl = subsonic.getCoverArtUrl(record, 300, true)
|
||||
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
||||
|
||||
// Reset image state when playlist changes
|
||||
useEffect(() => {
|
||||
setImageLoading(true)
|
||||
setImageError(false)
|
||||
}, [record.id])
|
||||
|
||||
const handleImageLoad = useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(false)
|
||||
}, [])
|
||||
|
||||
const handleImageError = useCallback(() => {
|
||||
setImageLoading(false)
|
||||
setImageError(true)
|
||||
}, [])
|
||||
|
||||
const handleOpenLightbox = useCallback(() => {
|
||||
if (!imageError) {
|
||||
setLightboxOpen(true)
|
||||
}
|
||||
}, [imageError])
|
||||
|
||||
const handleCloseLightbox = useCallback(() => setLightboxOpen(false), [])
|
||||
|
||||
return (
|
||||
<Card className={classes.container}>
|
||||
<CardContent className={classes.details}>
|
||||
<Typography variant="h5" className={classes.title}>
|
||||
{record.name || translate('ra.page.loading')}
|
||||
</Typography>
|
||||
<Typography component="p">
|
||||
{record.songCount ? (
|
||||
<span>
|
||||
{record.songCount}{' '}
|
||||
{translate('resources.song.name', {
|
||||
smart_count: record.songCount,
|
||||
})}
|
||||
{' · '}
|
||||
<DurationField record={record} source={'duration'} />
|
||||
{' · '}
|
||||
<SizeField record={record} source={'size'} />
|
||||
</span>
|
||||
) : (
|
||||
<span> </span>
|
||||
)}
|
||||
</Typography>
|
||||
<CollapsibleComment record={record} />
|
||||
</CardContent>
|
||||
<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}
|
||||
title={record.name}
|
||||
style={{
|
||||
cursor: imageError ? 'default' : 'pointer',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.details}>
|
||||
<CardContent className={classes.content}>
|
||||
<Typography
|
||||
variant={isDesktop ? 'h5' : 'h6'}
|
||||
className={classes.title}
|
||||
>
|
||||
{record.name || translate('ra.page.loading')}
|
||||
</Typography>
|
||||
<Typography component="p">
|
||||
{record.songCount ? (
|
||||
<span>
|
||||
{record.songCount}{' '}
|
||||
{translate('resources.song.name', {
|
||||
smart_count: record.songCount,
|
||||
})}
|
||||
{' · '}
|
||||
<DurationField record={record} source={'duration'} />
|
||||
{' · '}
|
||||
<SizeField record={record} source={'size'} />
|
||||
</span>
|
||||
) : (
|
||||
<span> </span>
|
||||
)}
|
||||
</Typography>
|
||||
<CollapsibleComment record={record} />
|
||||
</CardContent>
|
||||
</div>
|
||||
</div>
|
||||
{isLightboxOpen && !imageError && (
|
||||
<Lightbox
|
||||
imagePadding={50}
|
||||
animationDuration={200}
|
||||
imageTitle={record.name}
|
||||
mainSrc={fullImageUrl}
|
||||
onCloseRequest={handleCloseLightbox}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -61,11 +61,20 @@ const getCoverArtUrl = (record, size, square) => {
|
||||
...(square && { square }),
|
||||
}
|
||||
|
||||
// For playlists, add a timestamp to prevent caching issues when switching between playlists
|
||||
if (record.songCount !== undefined) {
|
||||
// Add current timestamp to ensure fresh requests for playlists
|
||||
options._ = record.updatedAt || new Date().getTime()
|
||||
}
|
||||
|
||||
// TODO Move this logic to server. `song` and `album` should have a CoverArtID
|
||||
if (record.album) {
|
||||
return baseUrl(url('getCoverArt', 'mf-' + record.id, options))
|
||||
} else if (record.albumArtist) {
|
||||
return baseUrl(url('getCoverArt', 'al-' + record.id, options))
|
||||
} else if (record.songCount !== undefined) {
|
||||
// This is a playlist
|
||||
return baseUrl(url('getCoverArt', 'pl-' + record.id, options))
|
||||
} else {
|
||||
return baseUrl(url('getCoverArt', 'ar-' + record.id, options))
|
||||
}
|
||||
|
||||
101
ui/src/subsonic/index.test.js
Normal file
101
ui/src/subsonic/index.test.js
Normal file
@@ -0,0 +1,101 @@
|
||||
import { vi } from 'vitest'
|
||||
import subsonic from './index'
|
||||
|
||||
describe('getCoverArtUrl', () => {
|
||||
beforeEach(() => {
|
||||
// Mock window.location
|
||||
delete window.location
|
||||
window.location = { href: 'http://localhost:3000/app' }
|
||||
|
||||
// Mock localStorage values required by subsonic
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn((key) => {
|
||||
const values = {
|
||||
username: 'testuser',
|
||||
'subsonic-token': 'testtoken',
|
||||
'subsonic-salt': 'testsalt',
|
||||
}
|
||||
return values[key] || null
|
||||
}),
|
||||
setItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
})
|
||||
|
||||
it('should return playlist cover art URL for records with songCount', () => {
|
||||
const playlistRecord = {
|
||||
id: 'playlist-123',
|
||||
songCount: 10,
|
||||
updatedAt: '2023-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const url = subsonic.getCoverArtUrl(playlistRecord, 300, true)
|
||||
|
||||
expect(url).toContain('pl-playlist-123')
|
||||
expect(url).toContain('size=300')
|
||||
expect(url).toContain('square=true')
|
||||
expect(url).toContain('_=2023-01-01T00%3A00%3A00Z')
|
||||
})
|
||||
|
||||
it('should add timestamp for playlists without updatedAt', () => {
|
||||
const playlistRecord = {
|
||||
id: 'playlist-123',
|
||||
songCount: 5,
|
||||
}
|
||||
|
||||
const url = subsonic.getCoverArtUrl(playlistRecord)
|
||||
|
||||
expect(url).toContain('pl-playlist-123')
|
||||
expect(url).toMatch(/_=\d+/)
|
||||
})
|
||||
|
||||
it('should return album cover art URL for records with albumArtist', () => {
|
||||
const albumRecord = {
|
||||
id: 'album-123',
|
||||
albumArtist: 'Test Artist',
|
||||
updatedAt: '2023-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const url = subsonic.getCoverArtUrl(albumRecord, 300)
|
||||
|
||||
expect(url).toContain('al-album-123')
|
||||
expect(url).toContain('size=300')
|
||||
})
|
||||
|
||||
it('should return media file cover art URL for records with album', () => {
|
||||
const mediaFileRecord = {
|
||||
id: 'mf-123',
|
||||
album: 'Test Album',
|
||||
updatedAt: '2023-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const url = subsonic.getCoverArtUrl(mediaFileRecord, 200)
|
||||
|
||||
expect(url).toContain('mf-mf-123')
|
||||
expect(url).toContain('size=200')
|
||||
})
|
||||
|
||||
it('should return artist cover art URL for other records', () => {
|
||||
const artistRecord = {
|
||||
id: 'artist-123',
|
||||
updatedAt: '2023-01-01T00:00:00Z',
|
||||
}
|
||||
|
||||
const url = subsonic.getCoverArtUrl(artistRecord, 150)
|
||||
|
||||
expect(url).toContain('ar-artist-123')
|
||||
expect(url).toContain('size=150')
|
||||
})
|
||||
|
||||
it('should handle records without updatedAt', () => {
|
||||
const record = {
|
||||
id: 'test-123',
|
||||
}
|
||||
|
||||
const url = subsonic.getCoverArtUrl(record)
|
||||
|
||||
expect(url).toContain('ar-test-123')
|
||||
expect(url).not.toContain('_=')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user