diff --git a/conf/configuration.go b/conf/configuration.go index f6a8a5bcb..9293c6f4a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -41,6 +41,7 @@ type configOptions struct { UIWelcomeMessage string EnableGravatar bool EnableFavourites bool + EnableStarRating bool GATrackingID string AuthRequestLimit int AuthWindowLength time.Duration @@ -145,6 +146,7 @@ func init() { viper.SetDefault("uiwelcomemessage", "") viper.SetDefault("enablegravatar", false) viper.SetDefault("enablefavourites", true) + viper.SetDefault("enablestarrating", true) viper.SetDefault("gatrackingid", "") viper.SetDefault("authrequestlimit", 5) viper.SetDefault("authwindowlength", 20*time.Second) diff --git a/server/app/serve_index.go b/server/app/serve_index.go index b1a5ac47d..4b36ae87e 100644 --- a/server/app/serve_index.go +++ b/server/app/serve_index.go @@ -44,6 +44,7 @@ func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc { "enableDownloads": conf.Server.EnableDownloads, "enableFavourites": conf.Server.EnableFavourites, "losslessFormats": strings.ToUpper(strings.Join(consts.LosslessFormats, ",")), + "enableStarRating": conf.Server.EnableStarRating, "devActivityPanel": conf.Server.DevActivityPanel, "devFastAccessCoverArt": conf.Server.DevFastAccessCoverArt, } diff --git a/server/app/serve_index_test.go b/server/app/serve_index_test.go index 90f47dab6..eae43cfd3 100644 --- a/server/app/serve_index_test.go +++ b/server/app/serve_index_test.go @@ -136,6 +136,17 @@ var _ = Describe("serveIndex", func() { Expect(config).To(HaveKeyWithValue("enableFavourites", true)) }) + It("sets the enableStarRating", func() { + conf.Server.EnableStarRating = true + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKeyWithValue("enableStarRating", true)) + }) + It("sets the gaTrackingId", func() { conf.Server.GATrackingID = "UA-12345" r := httptest.NewRequest("GET", "/index.html", nil) diff --git a/ui/src/album/AlbumDetails.js b/ui/src/album/AlbumDetails.js index 9cb8f6e16..f04e4b380 100644 --- a/ui/src/album/AlbumDetails.js +++ b/ui/src/album/AlbumDetails.js @@ -19,6 +19,7 @@ import { formatRange, SizeField, LoveButton, + RatingField, } from '../common' import config from '../config' @@ -167,7 +168,10 @@ const AlbumDetails = ({ record }) => {
- + {record.name} {config.enableFavourites && ( { {' ยท '} + {config.enableStarRating && ( + + )} {isDesktop && record['comment'] && }
diff --git a/ui/src/album/AlbumListView.js b/ui/src/album/AlbumListView.js index cecaac5cd..99ef1e5b4 100644 --- a/ui/src/album/AlbumListView.js +++ b/ui/src/album/AlbumListView.js @@ -25,6 +25,7 @@ import { SimpleList, MultiLineTextField, AlbumContextMenu, + RatingField, } from '../common' import config from '../config' @@ -39,6 +40,9 @@ const useStyles = makeStyles({ '& $contextMenu': { visibility: 'visible', }, + '& $ratingField': { + visibility: 'visible', + }, }, }, tableCell: { @@ -47,6 +51,9 @@ const useStyles = makeStyles({ contextMenu: { visibility: 'hidden', }, + ratingField: { + visibility: 'hidden', + }, }) const AlbumDetails = (props) => { @@ -105,7 +112,23 @@ const AlbumListView = ({ return isXsmall ? ( r.name} - secondaryText={(r) => r.albumArtist} + secondaryText={(r) => ( + <> + {r.albumArtist} + {config.enableStarRating && ( + <> +
+ + + )} + + )} tertiaryText={(r) => ( <> @@ -129,6 +152,14 @@ const AlbumListView = ({ {isDesktop && } {isDesktop && } + {config.enableStarRating && ( + + )} (props.isDesktop ? 'hidden' : 'visible'), }, + ratingField: { + visibility: 'hidden', + }, }), { name: 'RaList' } ) @@ -121,6 +128,14 @@ const AlbumSongs = (props) => { {isDesktop && } + {isDesktop && config.enableStarRating && ( + + )} ( @@ -68,6 +75,14 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => { + {config.enableStarRating && ( + + )} {data[id].name} + <> +
{data[id].name}
+ {config.enableStarRating && ( + + )} + } /> diff --git a/ui/src/common/RatingField.js b/ui/src/common/RatingField.js new file mode 100644 index 000000000..b22c535b8 --- /dev/null +++ b/ui/src/common/RatingField.js @@ -0,0 +1,73 @@ +import React, { useCallback } from 'react' +import PropTypes from 'prop-types' +import Rating from '@material-ui/lab/Rating' +import { makeStyles } from '@material-ui/core/styles' +import StarBorderIcon from '@material-ui/icons/StarBorder' +import clsx from 'clsx' +import { useRating } from './useRating' + +const useStyles = makeStyles({ + rating: { + color: (props) => props.color, + visibility: (props) => (props.visible === false ? 'hidden' : 'inherit'), + }, + show: { + visibility: 'visible !important', + }, + hide: { + visibility: 'hidden', + }, +}) + +export const RatingField = ({ + resource, + record, + visible, + className, + size, + color, +}) => { + const [rate, rating] = useRating(resource, record) + const classes = useStyles({ visible, rating: record.rating, color }) + + const stopPropagation = (e) => { + e.stopPropagation() + } + + const handleRating = useCallback( + (e, val) => { + rate(val, e.target.name) + }, + [rate] + ) + + return ( + stopPropagation(e)}> + 0 ? classes.show : classes.hide + )} + value={rating} + size={size} + emptyIcon={} + onChange={(e, newValue) => handleRating(e, newValue)} + /> + + ) +} +RatingField.propTypes = { + resource: PropTypes.string.isRequired, + record: PropTypes.object.isRequired, + visible: PropTypes.bool, + size: PropTypes.string, +} + +RatingField.defaultProps = { + record: {}, + visible: true, + size: 'small', + color: 'inherit', +} diff --git a/ui/src/common/SongSimpleList.js b/ui/src/common/SongSimpleList.js index ef3918a41..e6644dae1 100644 --- a/ui/src/common/SongSimpleList.js +++ b/ui/src/common/SongSimpleList.js @@ -7,9 +7,10 @@ import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction' import ListItemText from '@material-ui/core/ListItemText' import { makeStyles } from '@material-ui/core/styles' import { sanitizeListRestProps } from 'ra-core' -import { DurationField, SongContextMenu } from './index' +import { DurationField, SongContextMenu, RatingField } from './index' import { setTrack } from '../actions' import { useDispatch } from 'react-redux' +import config from '../config' const useStyles = makeStyles( { @@ -77,17 +78,27 @@ export const SongSimpleList = ({
{data[id].title}
} secondary={ - - - {data[id].artist} + <> + + + {data[id].artist} + + + + - - - - + )} + } /> diff --git a/ui/src/common/index.js b/ui/src/common/index.js index fa952af99..a7a8a50ee 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -28,3 +28,5 @@ export * from './useTraceUpdate' export * from './Writable' export * from './SongSimpleList' export * from './ArtistSimpleList' +export * from './RatingField' +export * from './useRating' diff --git a/ui/src/common/useRating.js b/ui/src/common/useRating.js new file mode 100644 index 000000000..43fc40579 --- /dev/null +++ b/ui/src/common/useRating.js @@ -0,0 +1,47 @@ +import { useState, useCallback, useEffect, useRef } from 'react' +import { useDataProvider, useNotify } from 'react-admin' +import subsonic from '../subsonic' + +export const useRating = (resource, record) => { + const [loading, setLoading] = useState(false) + const notify = useNotify() + const dataProvider = useDataProvider() + const mountedRef = useRef(false) + const rating = record.rating + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + const refreshRating = useCallback(() => { + dataProvider + .getOne(resource, { id: record.id }) + .then(() => { + if (mountedRef.current) { + setLoading(false) + } + }) + .catch((e) => { + console.log('Error encountered: ' + e) + }) + }, [dataProvider, record, resource]) + + const rate = (val, id) => { + setLoading(true) + subsonic + .setRating(id, val) + .then(refreshRating) + .catch((e) => { + console.log('Error setting star rating: ', e) + notify('ra.page.error', 'warning') + if (mountedRef.current) { + setLoading(false) + } + }) + } + + return [rate, rating, loading] +} diff --git a/ui/src/config.js b/ui/src/config.js index c2f9449c3..fcc491e8e 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -15,6 +15,7 @@ const defaultConfig = { gaTrackingId: '', devActivityPanel: true, devFastAccessCoverArt: false, + enableStarRating: true, } let config diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json index eef3fbed8..44300c1fa 100644 --- a/ui/src/i18n/en.json +++ b/ui/src/i18n/en.json @@ -20,6 +20,7 @@ "bitRate": "Bit rate", "discSubtitle": "Disc Subtitle", "starred": "Favourite", + "rating": "Rating", "comment": "Comment", "quality": "Quality" }, @@ -45,7 +46,8 @@ "compilation": "Compilation", "year": "Year", "updatedAt": "Updated at", - "comment": "Comment" + "comment": "Comment", + "rating": "Rating" }, "actions": { "playAll": "Play", @@ -61,7 +63,8 @@ "recentlyAdded": "Recently Added", "recentlyPlayed": "Recently Played", "mostPlayed": "Most Played", - "starred": "Favourites" + "starred": "Favourites", + "topRated": "Top Rated" } }, "artist": { @@ -70,7 +73,8 @@ "name": "Name", "albumCount": "Album Count", "songCount": "Song Count", - "playCount": "Plays" + "playCount": "Plays", + "rating": "Rating" } }, "user": { diff --git a/ui/src/song/SongList.js b/ui/src/song/SongList.js index a5efcf7bc..58e089b5e 100644 --- a/ui/src/song/SongList.js +++ b/ui/src/song/SongList.js @@ -17,6 +17,7 @@ import { QuickFilter, SongTitleField, SongSimpleList, + RatingField, } from '../common' import { useDispatch } from 'react-redux' import { setTrack } from '../actions' @@ -40,11 +41,17 @@ const useStyles = makeStyles({ '& $contextMenu': { visibility: 'visible', }, + '& $ratingField': { + visibility: 'visible', + }, }, }, contextMenu: { visibility: 'hidden', }, + ratingField: { + visibility: 'hidden', + }, }) const SongFilter = (props) => ( @@ -114,6 +121,14 @@ const SongList = (props) => { )} + {config.enableStarRating && ( + + )} { return url('getCoverArt', record.coverArtId || 'not_found', options) } +const setRating = (id, rating) => + fetchUtils.fetchJson(url('setRating', id, { rating })) + export default { url, getCoverArtUrl, @@ -47,4 +50,5 @@ export default { download, star, unstar, + setRating, }