feat(ui): add album image gallery carousel

This commit is contained in:
paddy 2026-06-11 23:06:04 +02:00
parent cbd56d7e46
commit c8f0f6f9dd
4 changed files with 97 additions and 5 deletions

View File

@ -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>

View File

@ -220,6 +220,11 @@ const wrapperDataProvider = {
data: json,
}))
},
getAlbumImages: (albumId) => {
return httpClient(`${REST_URL}/album/${albumId}/images`).then(
({ json }) => ({ data: json }),
)
},
}
export default wrapperDataProvider

View File

@ -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,

View File

@ -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