From 5e5513bd349508f18fd2d60082db9656bc6a7d6a Mon Sep 17 00:00:00 2001 From: Finomosec <1665799+Finomosec@users.noreply.github.com> Date: Mon, 1 Jun 2026 16:26:23 +0200 Subject: [PATCH] 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) --- resources/i18n/de.json | 13 +- ui/src/common/AddToPlaylistButton.jsx | 6 +- ui/src/common/BatchRateButton.jsx | 194 ++++++++++++++++++++++++++ ui/src/common/SongBulkActions.jsx | 83 +++++++---- ui/src/i18n/en.json | 13 +- 5 files changed, 281 insertions(+), 28 deletions(-) create mode 100644 ui/src/common/BatchRateButton.jsx diff --git a/resources/i18n/de.json b/resources/i18n/de.json index c540dee05..e6d6111a1 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -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", diff --git a/ui/src/common/AddToPlaylistButton.jsx b/ui/src/common/AddToPlaylistButton.jsx index 9cddc7499..63e860ca6 100644 --- a/ui/src/common/AddToPlaylistButton.jsx +++ b/ui/src/common/AddToPlaylistButton.jsx @@ -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 ( diff --git a/ui/src/common/BatchRateButton.jsx b/ui/src/common/BatchRateButton.jsx new file mode 100644 index 000000000..37a0eceab --- /dev/null +++ b/ui/src/common/BatchRateButton.jsx @@ -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 ( + + + + + ) +} + +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 ( + <> + + setOpen(false)}> + {translate('resources.song.actions.batchRateTitle', { smart_count: selectedIds?.length || 0 })} + +
+ + setRating(rating === -1 ? 0 : -1)} + color={rating === -1 ? 'secondary' : 'default'} + > + + + + 0 ? rating : 0} + onChange={(_, val) => setRating(val || 0)} + emptyIcon={} + /> +
+ {config.enableFavourites && ( +
+ + setStarred(starred === false ? null : false)} + color={starred === false ? 'secondary' : 'default'} + > + + + + + setStarred(starred === true ? null : true)} + color={starred === true ? 'secondary' : 'default'} + > + + + +
+ )} +
+ + setOpen(false)}> + {translate('ra.action.cancel')} + + + {translate('ra.action.confirm')} + + +
+ + ) +} diff --git a/ui/src/common/SongBulkActions.jsx b/ui/src/common/SongBulkActions.jsx index 9210bb020..90867248f 100644 --- a/ui/src/common/SongBulkActions.jsx +++ b/ui/src/common/SongBulkActions.jsx @@ -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 ( + + {children} + + ) +} + export const SongBulkActions = (props) => { const classes = useStyles() const unselectAll = useUnselectAll() + useEffect(() => { unselectAll(props.resource) }, [unselectAll, props.resource]) return ( - } - className={classes.button} - /> - } - className={classes.button} - /> - } - className={classes.button} - /> + + } + className={classes.button} + /> + + + } + className={classes.button} + /> + + + } + className={classes.button} + /> + {config.enableSharing && ( - + + + + )} + + + + {config.enableStarRating && ( + + + )} - ) } diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index 74fb23ab9..2d156e1eb 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -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}'",