mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
feat(ui): show the blurhash as the loading placeholder across cover surfaces
Add a shared CoverImage component (useImageUrl blob cache + blurhash + fade) and render the blurhash while a cover loads on the list thumbnails (CoverArtAvatar, radio) and the artist/album/playlist detail pages. The detail pages now go through CoverImage instead of a plain CardMedia, so their images come from the in-memory blob cache and survive React remounts without re-fetching. BlurHashCanvas gains an optional style prop.
This commit is contained in:
parent
20557f2fb8
commit
44913df403
@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Collapse,
|
||||
makeStyles,
|
||||
Typography,
|
||||
@ -21,6 +20,7 @@ import Lightbox from 'react-image-lightbox'
|
||||
import config from '../config'
|
||||
import 'react-image-lightbox/style.css'
|
||||
import subsonic from '../subsonic'
|
||||
import { CoverImage } from '../common/CoverImage'
|
||||
import {
|
||||
ArtistLinkField,
|
||||
CollapsibleComment,
|
||||
@ -30,7 +30,6 @@ import {
|
||||
RatingField,
|
||||
SizeField,
|
||||
useAlbumsPerPage,
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import { formatFullDate, intersperse } from '../utils'
|
||||
import AlbumExternalLinks from './AlbumExternalLinks'
|
||||
@ -223,15 +222,7 @@ const AlbumDetails = (props) => {
|
||||
const classes = useStyles()
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [albumInfo, setAlbumInfo] = useState()
|
||||
const {
|
||||
imageLoading,
|
||||
imageError,
|
||||
isLightboxOpen,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
handleOpenLightbox,
|
||||
handleCloseLightbox,
|
||||
} = useImageLoadingState(record.id)
|
||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
||||
|
||||
let notes = albumInfo?.notes || record.notes
|
||||
|
||||
@ -254,27 +245,17 @@ const AlbumDetails = (props) => {
|
||||
})
|
||||
}, [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}
|
||||
<CoverImage
|
||||
record={record}
|
||||
className={classes.cover}
|
||||
title={record.name}
|
||||
style={{
|
||||
cursor: imageError ? 'default' : 'pointer',
|
||||
}}
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.details}>
|
||||
@ -363,13 +344,13 @@ const AlbumDetails = (props) => {
|
||||
</Collapse>
|
||||
</div>
|
||||
)}
|
||||
{isLightboxOpen && !imageError && (
|
||||
{isLightboxOpen && (
|
||||
<Lightbox
|
||||
imagePadding={50}
|
||||
animationDuration={200}
|
||||
imageTitle={record.name}
|
||||
mainSrc={fullImageUrl}
|
||||
onCloseRequest={handleCloseLightbox}
|
||||
onCloseRequest={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@ -3,20 +3,15 @@ import { Typography, Collapse } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core'
|
||||
import Card from '@material-ui/core/Card'
|
||||
import CardContent from '@material-ui/core/CardContent'
|
||||
import CardMedia from '@material-ui/core/CardMedia'
|
||||
import ArtistExternalLinks from './ArtistExternalLink'
|
||||
import config from '../config'
|
||||
import {
|
||||
LoveButton,
|
||||
RatingField,
|
||||
ImageUploadOverlay,
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import { LoveButton, RatingField, ImageUploadOverlay } from '../common'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import ExpandInfoDialog from '../dialogs/ExpandInfoDialog'
|
||||
import AlbumInfo from '../album/AlbumInfo'
|
||||
import subsonic from '../subsonic'
|
||||
import { SafeHTML } from '../common/SafeHTML'
|
||||
import { CoverImage } from '../common/CoverImage'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
@ -91,33 +86,18 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const classes = useStyles()
|
||||
const title = record.name
|
||||
const {
|
||||
imageLoading,
|
||||
imageError,
|
||||
isLightboxOpen,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
handleOpenLightbox,
|
||||
handleCloseLightbox,
|
||||
} = useImageLoadingState(record.id)
|
||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<Card className={classes.artistDetail}>
|
||||
<Card className={classes.artistImage}>
|
||||
{artistInfo && (
|
||||
<CardMedia
|
||||
key={record.id}
|
||||
component="img"
|
||||
src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)}
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onClick={handleOpenLightbox}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
<CoverImage
|
||||
record={record}
|
||||
className={classes.cover}
|
||||
title={title}
|
||||
style={{
|
||||
cursor: imageError ? 'default' : 'pointer',
|
||||
}}
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<ImageUploadOverlay
|
||||
@ -175,13 +155,13 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => {
|
||||
)}
|
||||
</Typography>
|
||||
</div>
|
||||
{isLightboxOpen && !imageError && (
|
||||
{isLightboxOpen && (
|
||||
<Lightbox
|
||||
imagePadding={50}
|
||||
animationDuration={200}
|
||||
imageTitle={record.name}
|
||||
mainSrc={subsonic.getCoverArtUrl(record)}
|
||||
onCloseRequest={handleCloseLightbox}
|
||||
onCloseRequest={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@ -2,17 +2,12 @@ import React, { useState } from 'react'
|
||||
import { Typography, Collapse } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import Card from '@material-ui/core/Card'
|
||||
import CardMedia from '@material-ui/core/CardMedia'
|
||||
import config from '../config'
|
||||
import {
|
||||
LoveButton,
|
||||
RatingField,
|
||||
ImageUploadOverlay,
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import { LoveButton, RatingField, ImageUploadOverlay } from '../common'
|
||||
import Lightbox from 'react-image-lightbox'
|
||||
import subsonic from '../subsonic'
|
||||
import { SafeHTML } from '../common/SafeHTML'
|
||||
import { CoverImage } from '../common/CoverImage'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
@ -93,15 +88,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const classes = useStyles({ img, expanded })
|
||||
const title = record.name
|
||||
const {
|
||||
imageLoading,
|
||||
imageError,
|
||||
isLightboxOpen,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
handleOpenLightbox,
|
||||
handleCloseLightbox,
|
||||
} = useImageLoadingState(record.id)
|
||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -109,18 +96,11 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
|
||||
<div className={classes.bgContainer}>
|
||||
<Card className={classes.artistImage}>
|
||||
{artistInfo && (
|
||||
<CardMedia
|
||||
key={record.id}
|
||||
component="img"
|
||||
src={subsonic.getCoverArtUrl(record, config.uiCoverArtSize)}
|
||||
className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`}
|
||||
onClick={handleOpenLightbox}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
<CoverImage
|
||||
record={record}
|
||||
className={classes.cover}
|
||||
title={title}
|
||||
style={{
|
||||
cursor: imageError ? 'default' : 'pointer',
|
||||
}}
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
/>
|
||||
)}
|
||||
<ImageUploadOverlay
|
||||
@ -165,13 +145,13 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => {
|
||||
</Typography>
|
||||
</Collapse>
|
||||
</div>
|
||||
{isLightboxOpen && !imageError && (
|
||||
{isLightboxOpen && (
|
||||
<Lightbox
|
||||
imagePadding={50}
|
||||
animationDuration={200}
|
||||
imageTitle={record.name}
|
||||
mainSrc={img}
|
||||
onCloseRequest={handleCloseLightbox}
|
||||
onCloseRequest={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -5,7 +5,7 @@ import { decode } from 'blurhash'
|
||||
// A blurhash carries no detail beyond a few dozen pixels; CSS upscales the canvas.
|
||||
const DECODE_SIZE = 32
|
||||
|
||||
export const BlurHashCanvas = ({ hash, className }) => {
|
||||
export const BlurHashCanvas = ({ hash, className, style }) => {
|
||||
const canvasRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
@ -37,6 +37,7 @@ export const BlurHashCanvas = ({ hash, className }) => {
|
||||
width={DECODE_SIZE}
|
||||
height={DECODE_SIZE}
|
||||
className={className}
|
||||
style={style}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)
|
||||
@ -45,4 +46,5 @@ export const BlurHashCanvas = ({ hash, className }) => {
|
||||
BlurHashCanvas.propTypes = {
|
||||
hash: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
style: PropTypes.object,
|
||||
}
|
||||
|
||||
@ -5,8 +5,15 @@ import clsx from 'clsx'
|
||||
import config from '../config'
|
||||
import subsonic from '../subsonic'
|
||||
import { useImageUrl } from './useImageUrl'
|
||||
import { BlurHashCanvas } from './BlurHashCanvas'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
width: '55px',
|
||||
height: '55px',
|
||||
},
|
||||
avatar: {
|
||||
width: '55px',
|
||||
height: '55px',
|
||||
@ -17,6 +24,16 @@ const useStyles = makeStyles({
|
||||
square: {
|
||||
borderRadius: '4px',
|
||||
},
|
||||
circular: {
|
||||
borderRadius: '50%',
|
||||
},
|
||||
blur: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
})
|
||||
|
||||
export const CoverArtAvatar = ({
|
||||
@ -30,9 +47,10 @@ export const CoverArtAvatar = ({
|
||||
const url = record
|
||||
? subsonic.getCoverArtUrl(record, config.uiCoverArtSize, square)
|
||||
: null
|
||||
const { imgUrl } = useImageUrl(url)
|
||||
const { imgUrl, loading } = useImageUrl(url)
|
||||
if (!record) return null
|
||||
return (
|
||||
|
||||
const avatar = (
|
||||
<Avatar
|
||||
src={imgUrl || undefined}
|
||||
variant={variant}
|
||||
@ -47,6 +65,18 @@ export const CoverArtAvatar = ({
|
||||
{!imgUrl && <span />}
|
||||
</Avatar>
|
||||
)
|
||||
|
||||
// Show the blurhash behind the transparent avatar until the real image loads.
|
||||
if (!(loading && record.blurHash)) return avatar
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<BlurHashCanvas
|
||||
hash={record.blurHash}
|
||||
className={clsx(classes.blur, square ? classes.square : classes.circular)}
|
||||
/>
|
||||
{avatar}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
CoverArtAvatar.defaultProps = { label: '', sortable: false }
|
||||
|
||||
63
ui/src/common/CoverImage.jsx
Normal file
63
ui/src/common/CoverImage.jsx
Normal file
@ -0,0 +1,63 @@
|
||||
import PropTypes from 'prop-types'
|
||||
import clsx from 'clsx'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import config from '../config'
|
||||
import subsonic from '../subsonic'
|
||||
import { useImageUrl } from './useImageUrl'
|
||||
import { BlurHashCanvas } from './BlurHashCanvas'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: { position: 'relative', display: 'inline-flex' },
|
||||
blur: { position: 'absolute', top: 0, left: 0, zIndex: 0 },
|
||||
img: { position: 'relative', zIndex: 1 },
|
||||
})
|
||||
|
||||
// CoverImage renders an entity's cover through the shared useImageUrl blob cache, so it survives
|
||||
// React remounts without re-fetching, with the blurhash as the loading placeholder. `className`
|
||||
// supplies the size (and any transition); the fade opacity is applied here.
|
||||
export const CoverImage = ({
|
||||
record,
|
||||
size = config.uiCoverArtSize,
|
||||
square = false,
|
||||
className,
|
||||
title,
|
||||
onClick,
|
||||
}) => {
|
||||
const classes = useStyles()
|
||||
const url = record ? subsonic.getCoverArtUrl(record, size, square) : ''
|
||||
const { imgUrl, loading } = useImageUrl(url)
|
||||
if (!record) return null
|
||||
|
||||
const showBlurHash = loading && record.blurHash
|
||||
const handleClick = imgUrl && onClick ? onClick : undefined
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
{showBlurHash && (
|
||||
<BlurHashCanvas
|
||||
hash={record.blurHash}
|
||||
className={clsx(className, classes.blur)}
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={imgUrl || undefined}
|
||||
alt={title}
|
||||
title={title}
|
||||
onClick={handleClick}
|
||||
className={clsx(className, showBlurHash && classes.img)}
|
||||
style={{
|
||||
opacity: loading ? 0.5 : 1,
|
||||
cursor: handleClick ? 'pointer' : 'default',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
CoverImage.propTypes = {
|
||||
record: PropTypes.object,
|
||||
size: PropTypes.number,
|
||||
square: PropTypes.bool,
|
||||
className: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
} from '@material-ui/core'
|
||||
@ -17,10 +17,9 @@ import {
|
||||
SizeField,
|
||||
isWritable,
|
||||
OverflowTooltip,
|
||||
useImageLoadingState,
|
||||
} from '../common'
|
||||
import config from '../config'
|
||||
import subsonic from '../subsonic'
|
||||
import { CoverImage } from '../common/CoverImage'
|
||||
|
||||
const useStyles = makeStyles(
|
||||
(theme) => ({
|
||||
@ -107,37 +106,20 @@ const PlaylistDetails = (props) => {
|
||||
const translate = useTranslate()
|
||||
const classes = useStyles()
|
||||
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg'))
|
||||
const {
|
||||
imageLoading,
|
||||
imageError,
|
||||
isLightboxOpen,
|
||||
handleImageLoad,
|
||||
handleImageError,
|
||||
handleOpenLightbox,
|
||||
handleCloseLightbox,
|
||||
} = useImageLoadingState(record.id)
|
||||
const [isLightboxOpen, setLightboxOpen] = useState(false)
|
||||
|
||||
const imageUrl = subsonic.getCoverArtUrl(record, config.uiCoverArtSize, true)
|
||||
const fullImageUrl = subsonic.getCoverArtUrl(record)
|
||||
|
||||
return (
|
||||
<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}
|
||||
<CoverImage
|
||||
record={record}
|
||||
square
|
||||
className={classes.cover}
|
||||
title={record.name}
|
||||
style={{
|
||||
cursor: imageError ? 'default' : 'pointer',
|
||||
}}
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
/>
|
||||
{isWritable(record.ownerId) && (
|
||||
<ImageUploadOverlay
|
||||
@ -187,13 +169,13 @@ const PlaylistDetails = (props) => {
|
||||
</CardContent>
|
||||
</div>
|
||||
</div>
|
||||
{isLightboxOpen && !imageError && (
|
||||
{isLightboxOpen && (
|
||||
<Lightbox
|
||||
imagePadding={50}
|
||||
animationDuration={200}
|
||||
imageTitle={record.name}
|
||||
mainSrc={fullImageUrl}
|
||||
onCloseRequest={handleCloseLightbox}
|
||||
onCloseRequest={() => setLightboxOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
ToggleFieldsMenu,
|
||||
useSelectedFields,
|
||||
} from '../common'
|
||||
import { BlurHashCanvas } from '../common/BlurHashCanvas'
|
||||
import subsonic from '../subsonic'
|
||||
import { StreamField } from './StreamField'
|
||||
import { setTrack } from '../actions'
|
||||
@ -82,17 +83,34 @@ const RadioListActions = ({
|
||||
}
|
||||
|
||||
const avatarStyle = { width: 40, height: 40 }
|
||||
const blurStyle = {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '4px',
|
||||
}
|
||||
|
||||
const CoverArtField = ({ record }) => {
|
||||
const directUrl = record?.uploadedImage
|
||||
? subsonic.getCoverArtUrl(record, 40, true)
|
||||
: null
|
||||
const { imgUrl } = useImageUrl(directUrl)
|
||||
const { imgUrl, loading } = useImageUrl(directUrl)
|
||||
if (!record) return null
|
||||
const src = imgUrl || RADIO_PLACEHOLDER_IMAGE
|
||||
return (
|
||||
const showBlurHash = loading && record.blurHash
|
||||
// While the real image loads, prefer the blurhash over the generic radio icon.
|
||||
const src = imgUrl || (showBlurHash ? undefined : RADIO_PLACEHOLDER_IMAGE)
|
||||
const avatar = (
|
||||
<Avatar src={src} variant="rounded" style={avatarStyle} alt={record.name} />
|
||||
)
|
||||
if (!showBlurHash) return avatar
|
||||
return (
|
||||
<div style={{ position: 'relative', display: 'inline-flex', ...avatarStyle }}>
|
||||
<BlurHashCanvas hash={record.blurHash} style={blurStyle} />
|
||||
{avatar}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
CoverArtField.defaultProps = { label: '' }
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user