Files
navidrome/ui/src/radio/RadioList.jsx
T
Deluan Quintão ba8d427890 feat(ui): add cover art support for internet radio stations (#5229)
* feat(artwork): add KindRadioArtwork and EntityRadio constant

* feat(model): add UploadedImage field and artwork methods to Radio

* feat(model): add Radio to GetEntityByID lookup chain

* feat(db): add uploaded_image column to radio table

* feat(artwork): add radio artwork reader with uploaded image fallback

* feat(api): add radio image upload/delete endpoints

* feat(ui): add radio artwork ID prefix to getCoverArtUrl

* feat(ui): add cover art display and upload to RadioEdit

* feat(ui): add cover art thumbnails to radio list

* feat(ui): prefer artwork URL in radio player helper

* refactor: remove redundant code in radio artwork

- Remove duplicate Avatar rendering in RadioList by reusing CoverArtField
- Remove redundant UpdatedAt assignment in radio image handlers (already set by repository Put)

* refactor(ui): extract shared useImageLoadingState hook

Move image loading/error/lightbox state management into a shared
useImageLoadingState hook in common/. Consolidates duplicated logic
from AlbumDetails, PlaylistDetails, RadioEdit, and artist detail views.

* feat(ui): use radio placeholder icon when no uploaded image

Remove album placeholder fallback from radio artwork reader so radios
without an uploaded image return ErrUnavailable. On the frontend, show
the internet-radio-icon.svg placeholder instead of requesting server
artwork when no image is uploaded, allowing favicon fallback in the
player.

* refactor(ui): update defaultOff fields in useSelectedFields for RadioList

Signed-off-by: Deluan <deluan@navidrome.org>

* fix: address code review feedback

- Add missing alt attribute to CardMedia in RadioEdit for accessibility
- Fix UpdateInternetRadio to preserve UploadedImage field by fetching
  existing radio before updating (prevents Subsonic API from clearing
  custom artwork)
- Add Reader() level tests to verify ErrUnavailable is returned when
  radio has no uploaded image

* refactor: add colsToUpdate to RadioRepository.Put

Use the base sqlRepository.put with column filtering instead of
hand-rolled SQL. UpdateInternetRadio now specifies only the Subsonic API
fields, preventing UploadedImage from being cleared. Image upload/delete
handlers specify only UploadedImage.

* fix: ensure UpdatedAt is included in colsToUpdate for radio Put

---------

Signed-off-by: Deluan <deluan@navidrome.org>
2026-03-18 18:57:33 -04:00

162 lines
4.0 KiB
React

import { Avatar, makeStyles, useMediaQuery } from '@material-ui/core'
import React, { cloneElement } from 'react'
import {
CreateButton,
Datagrid,
DateField,
EditButton,
Filter,
sanitizeListRestProps,
SearchInput,
SimpleList,
TextField,
TopToolbar,
UrlField,
useTranslate,
} from 'react-admin'
import { List } from '../common'
import { ToggleFieldsMenu, useSelectedFields } from '../common'
import subsonic from '../subsonic'
import { StreamField } from './StreamField'
import { setTrack } from '../actions'
import { songFromRadio } from './helper'
import { RADIO_PLACEHOLDER_IMAGE } from '../consts'
import { useDispatch } from 'react-redux'
const useStyles = makeStyles({
row: {
'&:hover': {
'& $contextMenu': {
visibility: 'visible',
},
},
},
contextMenu: {
visibility: 'hidden',
},
})
const RadioFilter = (props) => (
<Filter {...props} variant={'outlined'}>
<SearchInput id="search" source="name" alwaysOn />
</Filter>
)
const RadioListActions = ({
className,
filters,
resource,
showFilter,
displayedFilters,
filterValues,
isAdmin,
...rest
}) => {
const isNotSmall = useMediaQuery((theme) => theme.breakpoints.up('sm'))
const translate = useTranslate()
return (
<TopToolbar className={className} {...sanitizeListRestProps(rest)}>
{isAdmin && (
<CreateButton basePath="/radio">
{translate('ra.action.create')}
</CreateButton>
)}
{filters &&
cloneElement(filters, {
resource,
showFilter,
displayedFilters,
filterValues,
context: 'button',
})}
{isNotSmall && <ToggleFieldsMenu resource="radio" />}
</TopToolbar>
)
}
const avatarStyle = { width: 40, height: 40 }
const CoverArtField = ({ record }) => {
if (!record) return null
const src = record.uploadedImage
? subsonic.getCoverArtUrl(record, 40, true)
: RADIO_PLACEHOLDER_IMAGE
return (
<Avatar src={src} variant="rounded" style={avatarStyle} alt={record.name} />
)
}
CoverArtField.defaultProps = { label: '' }
const RadioList = ({ permissions, ...props }) => {
const classes = useStyles()
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const dispatch = useDispatch()
const isAdmin = permissions === 'admin'
const toggleableFields = {
coverArt: <CoverArtField source="id" sortable={false} />,
name: <TextField source="name" />,
homePageUrl: (
<UrlField
source="homePageUrl"
onClick={(e) => e.stopPropagation()}
target="_blank"
rel="noopener noreferrer"
/>
),
streamUrl: <TextField source="streamUrl" />,
updatedAt: <DateField source="updatedAt" showTime />,
createdAt: <DateField source="createdAt" showTime />,
}
const columns = useSelectedFields({
resource: 'radio',
columns: toggleableFields,
defaultOff: ['streamUrl', 'createdAt'],
})
const handleRowClick = async (id, basePath, record) => {
dispatch(setTrack(await songFromRadio(record)))
}
return (
<List
{...props}
exporter={false}
sort={{ field: 'name', order: 'ASC' }}
bulkActionButtons={isAdmin ? undefined : false}
hasCreate={isAdmin}
actions={<RadioListActions isAdmin={isAdmin} />}
filters={<RadioFilter />}
perPage={isXsmall ? 25 : 10}
>
{isXsmall ? (
<SimpleList
leftAvatar={(r) => <CoverArtField record={r} />}
leftIcon={(r) => (
<StreamField
record={r}
source={'streamUrl'}
hideUrl
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
/>
)}
primaryText={(r) => r.name}
secondaryText={(r) => r.homePageUrl}
/>
) : (
<Datagrid rowClick={handleRowClick} classes={{ row: classes.row }}>
{columns}
{isAdmin && <EditButton />}
</Datagrid>
)}
</List>
)
}
export default RadioList