feat: batch rate/like songs and compact bulk action labels

- New BatchRateButton: select multiple songs, set rating (1-5 stars),
  clear rating, or toggle like/unlike in one dialog
- Combined star+heart icon for the button
- Short labels for all bulk actions (Now, Next, Later, Playlist, Rate)
  with full-text tooltips on hover
- Increased gap and hover effect on bulk action buttons
- i18n: English and German translations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Finomosec 2026-06-01 16:26:23 +02:00
parent 2a43c4683e
commit 5e5513bd34
5 changed files with 281 additions and 28 deletions

View File

@ -49,7 +49,17 @@
"playNext": "Als nächstes abspielen",
"info": "Mehr Informationen",
"showInPlaylist": "In Wiedergabeliste anzeigen",
"instantMix": "Sofort-Mix"
"instantMix": "Sofort-Mix",
"playNowShort": "Jetzt",
"playNextShort": "Nächstes",
"addToQueueShort": "Später",
"addToPlaylistShort": "Liste",
"batchRate": "Bewerten",
"batchRateShort": "Bewerten",
"batchRateTitle": "%{smart_count} Titel bewerten |||| %{smart_count} Titel bewerten",
"clearRating": "Bewertung löschen",
"like": "Favorisieren",
"unlike": "Nicht mehr favorisieren"
}
},
"album": {
@ -592,6 +602,7 @@
"noSimilarSongsFound": "Keine ähnlichen Titel gefunden",
"noTopSongsFound": "Keine beliebten Titel gefunden",
"startingInstantMix": "Lade Sofort-Mix...",
"batchRateSuccess": "Bewertung erfolgreich angewendet",
"uploadCover": "Cover hochladen",
"removeCover": "Cover entfernen",
"coverUploaded": "Cover aktualisiert",

View File

@ -5,7 +5,7 @@ import { Button, useTranslate, useUnselectAll } from 'react-admin'
import PlaylistAddIcon from '@material-ui/icons/PlaylistAdd'
import { openAddToPlaylist } from '../actions'
export const AddToPlaylistButton = ({ resource, selectedIds, className }) => {
export const AddToPlaylistButton = ({ resource, selectedIds, className, label }) => {
const translate = useTranslate()
const dispatch = useDispatch()
const unselectAll = useUnselectAll()
@ -19,13 +19,15 @@ export const AddToPlaylistButton = ({ resource, selectedIds, className }) => {
)
}
const caption = label ? translate(label) : translate('resources.song.actions.addToPlaylist')
return (
<Button
aria-controls="simple-menu"
aria-haspopup="true"
onClick={handleClick}
className={className}
label={translate('resources.song.actions.addToPlaylist')}
label={caption}
>
<PlaylistAddIcon />
</Button>

View File

@ -0,0 +1,194 @@
import React, { useState } from 'react'
import {
Button,
useDataProvider,
useTranslate,
useUnselectAll,
useNotify,
useRefresh,
} from 'react-admin'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button as MuiButton,
IconButton,
Tooltip,
} from '@material-ui/core'
import Rating from '@material-ui/lab/Rating'
import StarBorderIcon from '@material-ui/icons/StarBorder'
import FavoriteIcon from '@material-ui/icons/Favorite'
import FavoriteBorderIcon from '@material-ui/icons/FavoriteBorder'
import ClearIcon from '@material-ui/icons/Clear'
import { makeStyles } from '@material-ui/core/styles'
import subsonic from '../subsonic'
import config from '../config'
const useStyles = makeStyles({
comboIcon: {
position: 'relative',
display: 'inline-flex',
width: 24,
height: 24,
},
starPart: {
position: 'absolute',
top: -1,
left: 0,
fontSize: 20,
opacity: 0.9,
},
heartPart: {
position: 'absolute',
bottom: -1,
right: -2,
fontSize: 14,
opacity: 0.9,
},
ratingRow: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
marginBottom: 12,
},
loveRow: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
})
const ComboIcon = () => {
const classes = useStyles()
return (
<span className={classes.comboIcon}>
<StarBorderIcon className={classes.starPart} />
<FavoriteBorderIcon className={classes.heartPart} />
</span>
)
}
export const BatchRateButton = ({
resource,
selectedIds,
className,
label: labelOverride,
}) => {
const [open, setOpen] = useState(false)
const [rating, setRating] = useState(0)
const [starred, setStarred] = useState(null) // null = don't change, true/false = set
const translate = useTranslate()
const dataProvider = useDataProvider()
const unselectAll = useUnselectAll()
const notify = useNotify()
const refresh = useRefresh()
const classes = useStyles()
const handleOpen = () => {
setRating(0)
setStarred(null)
setOpen(true)
}
const handleApply = async () => {
setOpen(false)
try {
for (const id of selectedIds) {
if (rating > 0) {
await subsonic.setRating(id, rating)
}
if (starred === true) {
await subsonic.star(id)
} else if (starred === false) {
await subsonic.unstar(id)
}
}
// Clear rating if "delete" was chosen (rating === -1)
if (rating === -1) {
for (const id of selectedIds) {
await subsonic.setRating(id, 0)
}
}
// Force React-Admin to re-fetch the records, then refresh the view
await dataProvider.getMany(resource, { ids: selectedIds })
notify('message.batchRateSuccess', { type: 'info' })
refresh()
} catch (e) {
notify('ra.page.error', { type: 'warning' })
}
unselectAll(resource)
}
const caption = labelOverride || translate('resources.song.actions.batchRate')
return (
<>
<Button
aria-label={caption}
onClick={handleOpen}
label={caption}
className={className}
>
<ComboIcon />
</Button>
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogTitle style={{ textAlign: 'center' }}>{translate('resources.song.actions.batchRateTitle', { smart_count: selectedIds?.length || 0 })}</DialogTitle>
<DialogContent>
<div className={classes.ratingRow}>
<Tooltip title={translate('resources.song.actions.clearRating')}>
<IconButton
size="small"
onClick={() => setRating(rating === -1 ? 0 : -1)}
color={rating === -1 ? 'secondary' : 'default'}
>
<ClearIcon />
</IconButton>
</Tooltip>
<Rating
value={rating > 0 ? rating : 0}
onChange={(_, val) => setRating(val || 0)}
emptyIcon={<StarBorderIcon fontSize="inherit" />}
/>
</div>
{config.enableFavourites && (
<div className={classes.loveRow}>
<Tooltip title={translate('resources.song.actions.unlike')}>
<IconButton
size="small"
onClick={() => setStarred(starred === false ? null : false)}
color={starred === false ? 'secondary' : 'default'}
>
<FavoriteBorderIcon />
</IconButton>
</Tooltip>
<Tooltip title={translate('resources.song.actions.like')}>
<IconButton
size="small"
onClick={() => setStarred(starred === true ? null : true)}
color={starred === true ? 'secondary' : 'default'}
>
<FavoriteIcon />
</IconButton>
</Tooltip>
</div>
)}
</DialogContent>
<DialogActions>
<MuiButton onClick={() => setOpen(false)}>
{translate('ra.action.cancel')}
</MuiButton>
<MuiButton
onClick={handleApply}
color="primary"
disabled={rating === 0 && starred === null}
>
{translate('ra.action.confirm')}
</MuiButton>
</DialogActions>
</Dialog>
</>
)
}

View File

@ -1,53 +1,88 @@
import React, { Fragment, useEffect } from 'react'
import { useUnselectAll } from 'react-admin'
import { useTranslate, useUnselectAll } from 'react-admin'
import { addTracks, playNext, playTracks } from '../actions'
import { RiPlayList2Fill, RiPlayListAddFill } from 'react-icons/ri'
import PlayArrowIcon from '@material-ui/icons/PlayArrow'
import { BatchPlayButton } from './index'
import { AddToPlaylistButton } from './AddToPlaylistButton'
import { makeStyles } from '@material-ui/core/styles'
import { Tooltip } from '@material-ui/core'
import { BatchShareButton } from './BatchShareButton'
import { BatchRateButton } from './BatchRateButton'
import config from '../config'
const useStyles = makeStyles((theme) => ({
button: {
color: theme.palette.type === 'dark' ? 'white' : undefined,
marginRight: 5,
'&:hover': {
backgroundColor: 'rgba(255, 255, 255, 0.15)',
},
},
}))
const TipButton = ({ labelKey, children }) => {
const translate = useTranslate()
return (
<Tooltip title={translate(labelKey)}>
<span>{children}</span>
</Tooltip>
)
}
export const SongBulkActions = (props) => {
const classes = useStyles()
const unselectAll = useUnselectAll()
useEffect(() => {
unselectAll(props.resource)
}, [unselectAll, props.resource])
return (
<Fragment>
<BatchPlayButton
{...props}
action={playTracks}
label={'resources.song.actions.playNow'}
icon={<PlayArrowIcon />}
className={classes.button}
/>
<BatchPlayButton
{...props}
action={playNext}
label={'resources.song.actions.playNext'}
icon={<RiPlayList2Fill />}
className={classes.button}
/>
<BatchPlayButton
{...props}
action={addTracks}
label={'resources.song.actions.addToQueue'}
icon={<RiPlayListAddFill />}
className={classes.button}
/>
<TipButton labelKey={'resources.song.actions.playNow'}>
<BatchPlayButton
{...props}
action={playTracks}
label={'resources.song.actions.playNowShort'}
icon={<PlayArrowIcon />}
className={classes.button}
/>
</TipButton>
<TipButton labelKey={'resources.song.actions.playNext'}>
<BatchPlayButton
{...props}
action={playNext}
label={'resources.song.actions.playNextShort'}
icon={<RiPlayList2Fill />}
className={classes.button}
/>
</TipButton>
<TipButton labelKey={'resources.song.actions.addToQueue'}>
<BatchPlayButton
{...props}
action={addTracks}
label={'resources.song.actions.addToQueueShort'}
icon={<RiPlayListAddFill />}
className={classes.button}
/>
</TipButton>
{config.enableSharing && (
<BatchShareButton {...props} className={classes.button} />
<TipButton labelKey={'resources.song.actions.share'}>
<BatchShareButton {...props} className={classes.button} />
</TipButton>
)}
<TipButton labelKey={'resources.song.actions.addToPlaylist'}>
<AddToPlaylistButton
{...props}
className={classes.button}
label={'resources.song.actions.addToPlaylistShort'}
/>
</TipButton>
{config.enableStarRating && (
<TipButton labelKey={'resources.song.actions.batchRate'}>
<BatchRateButton {...props} className={classes.button} />
</TipButton>
)}
<AddToPlaylistButton {...props} className={classes.button} />
</Fragment>
)
}

View File

@ -51,7 +51,17 @@
"download": "Download",
"playNext": "Play Next",
"info": "Get Info",
"instantMix": "Instant Mix"
"instantMix": "Instant Mix",
"playNowShort": "Now",
"playNextShort": "Next",
"addToQueueShort": "Later",
"addToPlaylistShort": "Playlist",
"batchRate": "Rate",
"batchRateShort": "Rate",
"batchRateTitle": "Rate %{smart_count} song |||| Rate %{smart_count} songs",
"clearRating": "Clear Rating",
"like": "Like",
"unlike": "Unlike"
}
},
"album": {
@ -571,6 +581,7 @@
"songsAddedToPlaylist": "Added 1 song to playlist |||| Added %{smart_count} songs to playlist",
"noSimilarSongsFound": "No similar songs found",
"startingInstantMix": "Loading Instant Mix...",
"batchRateSuccess": "Rating applied successfully",
"noTopSongsFound": "No top songs found",
"noPlaylistsAvailable": "None available",
"delete_user_title": "Delete user '%{name}'",