mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-31 07:30:32 +00:00
feat(ui): add album image gallery carousel
This commit is contained in:
parent
cbd56d7e46
commit
c8f0f6f9dd
@ -14,6 +14,7 @@ import {
|
||||
ChipField,
|
||||
Link,
|
||||
SingleFieldList,
|
||||
useDataProvider,
|
||||
useRecordContext,
|
||||
useTranslate,
|
||||
} from 'react-admin'
|
||||
@ -216,6 +217,9 @@ export const Details = (props) => {
|
||||
return <>{intersperse(details, ' · ')}</>
|
||||
}
|
||||
|
||||
// Bounded lightbox size: avoids transferring/caching multi-MB originals.
|
||||
const GALLERY_IMAGE_SIZE = 1920
|
||||
|
||||
const AlbumDetails = (props) => {
|
||||
const record = useRecordContext(props)
|
||||
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
|
||||
@ -233,6 +237,10 @@ const AlbumDetails = (props) => {
|
||||
handleCloseLightbox,
|
||||
} = useImageLoadingState(record.id)
|
||||
|
||||
const dataProvider = useDataProvider()
|
||||
const [images, setImages] = useState([])
|
||||
const [photoIndex, setPhotoIndex] = useState(0)
|
||||
|
||||
let notes = albumInfo?.notes || record.notes
|
||||
|
||||
if (notes) {
|
||||
@ -255,7 +263,30 @@ const AlbumDetails = (props) => {
|
||||
}, [record])
|
||||
|
||||
const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize)
|
||||
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
||||
const fullImageUrl = subsonic.getCoverArtUrl(record, GALLERY_IMAGE_SIZE)
|
||||
|
||||
const galleryCount = images.length || 1
|
||||
const imageSrcFor = (i) =>
|
||||
images.length
|
||||
? subsonic.getImageCoverArtUrl(images[i].coverArt, GALLERY_IMAGE_SIZE)
|
||||
: fullImageUrl
|
||||
|
||||
const openGallery = () => {
|
||||
if (imageError) return
|
||||
dataProvider
|
||||
.getAlbumImages(record.id)
|
||||
.then(({ data }) => setImages(Array.isArray(data) ? data : []))
|
||||
.catch(() => setImages([]))
|
||||
.finally(() => {
|
||||
setPhotoIndex(0)
|
||||
handleOpenLightbox()
|
||||
})
|
||||
}
|
||||
|
||||
const closeGallery = () => {
|
||||
handleCloseLightbox()
|
||||
setPhotoIndex(0)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={classes.root}>
|
||||
@ -268,7 +299,7 @@ const AlbumDetails = (props) => {
|
||||
width="400"
|
||||
height="400"
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onClick={handleOpenLightbox}
|
||||
onClick={openGallery}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
title={record.name}
|
||||
@ -367,9 +398,27 @@ const AlbumDetails = (props) => {
|
||||
<Lightbox
|
||||
imagePadding={50}
|
||||
animationDuration={200}
|
||||
imageTitle={record.name}
|
||||
mainSrc={fullImageUrl}
|
||||
onCloseRequest={handleCloseLightbox}
|
||||
imageTitle={
|
||||
images[photoIndex]?.type
|
||||
? `${record.name} — ${images[photoIndex].type}`
|
||||
: record.name
|
||||
}
|
||||
mainSrc={imageSrcFor(photoIndex)}
|
||||
nextSrc={
|
||||
galleryCount > 1
|
||||
? imageSrcFor((photoIndex + 1) % galleryCount)
|
||||
: undefined
|
||||
}
|
||||
prevSrc={
|
||||
galleryCount > 1
|
||||
? imageSrcFor((photoIndex + galleryCount - 1) % galleryCount)
|
||||
: undefined
|
||||
}
|
||||
onMoveNextRequest={() => setPhotoIndex((p) => (p + 1) % galleryCount)}
|
||||
onMovePrevRequest={() =>
|
||||
setPhotoIndex((p) => (p + galleryCount - 1) % galleryCount)
|
||||
}
|
||||
onCloseRequest={closeGallery}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@ -220,6 +220,11 @@ const wrapperDataProvider = {
|
||||
data: json,
|
||||
}))
|
||||
},
|
||||
getAlbumImages: (albumId) => {
|
||||
return httpClient(`${REST_URL}/album/${albumId}/images`).then(
|
||||
({ json }) => ({ data: json }),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
export default wrapperDataProvider
|
||||
|
||||
@ -113,6 +113,12 @@ const getDiscCoverArtUrl = (albumId, discNumber, updatedAt, size) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Builds a getCoverArt URL from a ready-made coverArt id (from /album/{id}/images).
|
||||
const getImageCoverArtUrl = (coverArtId, size) => {
|
||||
const options = { ...(size && { size }) }
|
||||
return baseUrl(url('getCoverArt', coverArtId, options))
|
||||
}
|
||||
|
||||
const getArtistInfo = (id) => {
|
||||
return httpClient(url('getArtistInfo', id))
|
||||
}
|
||||
@ -152,6 +158,7 @@ export default {
|
||||
getNowPlaying,
|
||||
getCoverArtUrl,
|
||||
getDiscCoverArtUrl,
|
||||
getImageCoverArtUrl,
|
||||
getAvatarUrl,
|
||||
streamUrl,
|
||||
getAlbumInfo,
|
||||
|
||||
@ -172,6 +172,37 @@ describe('getDiscCoverArtUrl', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getImageCoverArtUrl', () => {
|
||||
beforeEach(() => {
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn((key) => {
|
||||
const values = {
|
||||
username: 'testuser',
|
||||
'subsonic-token': 'testtoken',
|
||||
'subsonic-salt': 'testsalt',
|
||||
}
|
||||
return values[key] || null
|
||||
}),
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', { value: localStorageMock })
|
||||
})
|
||||
|
||||
it('builds a getCoverArt URL from a fully-formed indexed coverArt id', () => {
|
||||
const url = subsonic.getImageCoverArtUrl('al-album-123:1_0', 300)
|
||||
|
||||
expect(url).toContain('getCoverArt')
|
||||
expect(url).toContain('id=al-album-123%3A1_0')
|
||||
expect(url).toContain('size=300')
|
||||
})
|
||||
|
||||
it('omits size when not provided', () => {
|
||||
const url = subsonic.getImageCoverArtUrl('al-album-123_0')
|
||||
|
||||
expect(url).toContain('id=al-album-123_0')
|
||||
expect(url).not.toContain('size=')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getAvatarUrl', () => {
|
||||
beforeEach(() => {
|
||||
// Mock localStorage values required by subsonic
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user