Add 5-star rating system(#986)

* Added Star Rating functionality for Songs

* Added Star Rating functionality for Artists

* Added Star Rating functionality for AlbumListView

* Added Star Rating functionality for AlbumDetails and improved typography for title

* Added functionality to turn on/off Star Rating functionality for Songs

* Added functionality to turn on/off Star Rating functionality for Artists

* Added functionality to turn on/off Star Rating functionality for Albums

* Added enableStarRating to server config

* Resolved the bugs and improved the ratings functionality.

* synced repo and removed duplicate key

* changed the default rating size to small, and changed the color to match the theme.

* Added translations for ratings, and Top Rated tab in side menu.

* Changed rating translation to topRated in albumLists, and added has_rating filter to topRated.

* Added empty stars icon to RatingField.

* Added sortable=false in AlbumSongs and added sortByOrder=DESC in all List components.

* Added translations for rating, for artists and albums, and removed the sortByOrder=DESC from SimpleLists.
This commit is contained in:
Neil Chauhan 2021-04-08 01:32:52 +05:30 committed by GitHub
parent 840521ffe2
commit 48ae4f7479
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 277 additions and 16 deletions

View file

@ -41,6 +41,7 @@ type configOptions struct {
UIWelcomeMessage string UIWelcomeMessage string
EnableGravatar bool EnableGravatar bool
EnableFavourites bool EnableFavourites bool
EnableStarRating bool
GATrackingID string GATrackingID string
AuthRequestLimit int AuthRequestLimit int
AuthWindowLength time.Duration AuthWindowLength time.Duration
@ -145,6 +146,7 @@ func init() {
viper.SetDefault("uiwelcomemessage", "") viper.SetDefault("uiwelcomemessage", "")
viper.SetDefault("enablegravatar", false) viper.SetDefault("enablegravatar", false)
viper.SetDefault("enablefavourites", true) viper.SetDefault("enablefavourites", true)
viper.SetDefault("enablestarrating", true)
viper.SetDefault("gatrackingid", "") viper.SetDefault("gatrackingid", "")
viper.SetDefault("authrequestlimit", 5) viper.SetDefault("authrequestlimit", 5)
viper.SetDefault("authwindowlength", 20*time.Second) viper.SetDefault("authwindowlength", 20*time.Second)

View file

@ -44,6 +44,7 @@ func serveIndex(ds model.DataStore, fs fs.FS) http.HandlerFunc {
"enableDownloads": conf.Server.EnableDownloads, "enableDownloads": conf.Server.EnableDownloads,
"enableFavourites": conf.Server.EnableFavourites, "enableFavourites": conf.Server.EnableFavourites,
"losslessFormats": strings.ToUpper(strings.Join(consts.LosslessFormats, ",")), "losslessFormats": strings.ToUpper(strings.Join(consts.LosslessFormats, ",")),
"enableStarRating": conf.Server.EnableStarRating,
"devActivityPanel": conf.Server.DevActivityPanel, "devActivityPanel": conf.Server.DevActivityPanel,
"devFastAccessCoverArt": conf.Server.DevFastAccessCoverArt, "devFastAccessCoverArt": conf.Server.DevFastAccessCoverArt,
} }

View file

@ -136,6 +136,17 @@ var _ = Describe("serveIndex", func() {
Expect(config).To(HaveKeyWithValue("enableFavourites", true)) 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() { It("sets the gaTrackingId", func() {
conf.Server.GATrackingID = "UA-12345" conf.Server.GATrackingID = "UA-12345"
r := httptest.NewRequest("GET", "/index.html", nil) r := httptest.NewRequest("GET", "/index.html", nil)

View file

@ -19,6 +19,7 @@ import {
formatRange, formatRange,
SizeField, SizeField,
LoveButton, LoveButton,
RatingField,
} from '../common' } from '../common'
import config from '../config' import config from '../config'
@ -167,7 +168,10 @@ const AlbumDetails = ({ record }) => {
</div> </div>
<div className={classes.details}> <div className={classes.details}>
<CardContent className={classes.content}> <CardContent className={classes.content}>
<Typography variant="h5" className={classes.recordName}> <Typography
variant={isDesktop ? 'h5' : 'h6'}
className={classes.recordName}
>
{record.name} {record.name}
{config.enableFavourites && ( {config.enableFavourites && (
<LoveButton <LoveButton
@ -195,6 +199,13 @@ const AlbumDetails = ({ record }) => {
{' · '} {' · '}
<SizeField record={record} source="size" /> <SizeField record={record} source="size" />
</Typography> </Typography>
{config.enableStarRating && (
<RatingField
record={record}
resource={'album'}
size={isDesktop ? 'medium' : 'small'}
/>
)}
{isDesktop && record['comment'] && <AlbumComment record={record} />} {isDesktop && record['comment'] && <AlbumComment record={record} />}
</CardContent> </CardContent>
</div> </div>

View file

@ -25,6 +25,7 @@ import {
SimpleList, SimpleList,
MultiLineTextField, MultiLineTextField,
AlbumContextMenu, AlbumContextMenu,
RatingField,
} from '../common' } from '../common'
import config from '../config' import config from '../config'
@ -39,6 +40,9 @@ const useStyles = makeStyles({
'& $contextMenu': { '& $contextMenu': {
visibility: 'visible', visibility: 'visible',
}, },
'& $ratingField': {
visibility: 'visible',
},
}, },
}, },
tableCell: { tableCell: {
@ -47,6 +51,9 @@ const useStyles = makeStyles({
contextMenu: { contextMenu: {
visibility: 'hidden', visibility: 'hidden',
}, },
ratingField: {
visibility: 'hidden',
},
}) })
const AlbumDetails = (props) => { const AlbumDetails = (props) => {
@ -105,7 +112,23 @@ const AlbumListView = ({
return isXsmall ? ( return isXsmall ? (
<SimpleList <SimpleList
primaryText={(r) => r.name} primaryText={(r) => r.name}
secondaryText={(r) => r.albumArtist} secondaryText={(r) => (
<>
{r.albumArtist}
{config.enableStarRating && (
<>
<br />
<RatingField
record={r}
sortByOrder={'DESC'}
source={'rating'}
resource={'album'}
size={'small'}
/>
</>
)}
</>
)}
tertiaryText={(r) => ( tertiaryText={(r) => (
<> <>
<RangeField record={r} source={'year'} sortBy={'maxYear'} /> <RangeField record={r} source={'year'} sortBy={'maxYear'} />
@ -129,6 +152,14 @@ const AlbumListView = ({
{isDesktop && <NumberField source="playCount" sortByOrder={'DESC'} />} {isDesktop && <NumberField source="playCount" sortByOrder={'DESC'} />}
<RangeField source={'year'} sortBy={'maxYear'} sortByOrder={'DESC'} /> <RangeField source={'year'} sortBy={'maxYear'} sortByOrder={'DESC'} />
{isDesktop && <DurationField source="duration" />} {isDesktop && <DurationField source="duration" />}
{config.enableStarRating && (
<RatingField
source={'rating'}
resource={'album'}
sortByOrder={'DESC'}
className={classes.ratingField}
/>
)}
<AlbumContextMenu <AlbumContextMenu
source={'starred'} source={'starred'}
sortBy={'starred ASC, starredAt ASC'} sortBy={'starred ASC, starredAt ASC'}

View file

@ -19,6 +19,7 @@ import {
SongDatagrid, SongDatagrid,
SongDetails, SongDetails,
SongTitleField, SongTitleField,
RatingField,
} from '../common' } from '../common'
import { AddToPlaylistDialog } from '../dialogs' import { AddToPlaylistDialog } from '../dialogs'
import { QualityInfo } from '../common/QualityInfo' import { QualityInfo } from '../common/QualityInfo'
@ -63,11 +64,17 @@ const useStyles = makeStyles(
'& $contextMenu': { '& $contextMenu': {
visibility: 'visible', visibility: 'visible',
}, },
'& $ratingField': {
visibility: 'visible',
},
}, },
}, },
contextMenu: { contextMenu: {
visibility: (props) => (props.isDesktop ? 'hidden' : 'visible'), visibility: (props) => (props.isDesktop ? 'hidden' : 'visible'),
}, },
ratingField: {
visibility: 'hidden',
},
}), }),
{ name: 'RaList' } { name: 'RaList' }
) )
@ -121,6 +128,14 @@ const AlbumSongs = (props) => {
{isDesktop && <TextField source="artist" sortable={false} />} {isDesktop && <TextField source="artist" sortable={false} />}
<DurationField source="duration" sortable={false} /> <DurationField source="duration" sortable={false} />
<QualityInfo source="quality" sortable={false} /> <QualityInfo source="quality" sortable={false} />
{isDesktop && config.enableStarRating && (
<RatingField
source="rating"
resource={'albumSong'}
sortable={false}
className={classes.ratingField}
/>
)}
<SongContextMenu <SongContextMenu
source={'starred'} source={'starred'}
sortable={false} sortable={false}

View file

@ -4,6 +4,7 @@ import VideoLibraryIcon from '@material-ui/icons/VideoLibrary'
import RepeatIcon from '@material-ui/icons/Repeat' import RepeatIcon from '@material-ui/icons/Repeat'
import AlbumIcon from '@material-ui/icons/Album' import AlbumIcon from '@material-ui/icons/Album'
import FavoriteIcon from '@material-ui/icons/Favorite' import FavoriteIcon from '@material-ui/icons/Favorite'
import StarIcon from '@material-ui/icons/Star'
import config from '../config' import config from '../config'
export default { export default {
@ -18,6 +19,12 @@ export default {
params: 'sort=starred_at&order=DESC&filter={"starred":true}', params: 'sort=starred_at&order=DESC&filter={"starred":true}',
}, },
}), }),
...(config.enableStarRating && {
topRated: {
icon: StarIcon,
params: 'sort=rating&order=DESC&filter={"has_rating":true}',
},
}),
recentlyAdded: { recentlyAdded: {
icon: LibraryAddIcon, icon: LibraryAddIcon,
params: 'sort=recently_added&order=DESC', params: 'sort=recently_added&order=DESC',

View file

@ -17,6 +17,7 @@ import {
QuickFilter, QuickFilter,
useGetHandleArtistClick, useGetHandleArtistClick,
ArtistSimpleList, ArtistSimpleList,
RatingField,
} from '../common' } from '../common'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import config from '../config' import config from '../config'
@ -32,11 +33,17 @@ const useStyles = makeStyles({
'& $contextMenu': { '& $contextMenu': {
visibility: 'visible', visibility: 'visible',
}, },
'& $ratingField': {
visibility: 'visible',
},
}, },
}, },
contextMenu: { contextMenu: {
visibility: 'hidden', visibility: 'hidden',
}, },
ratingField: {
visibility: 'hidden',
},
}) })
const ArtistFilter = (props) => ( const ArtistFilter = (props) => (
@ -68,6 +75,14 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
<NumberField source="albumCount" sortByOrder={'DESC'} /> <NumberField source="albumCount" sortByOrder={'DESC'} />
<NumberField source="songCount" sortByOrder={'DESC'} /> <NumberField source="songCount" sortByOrder={'DESC'} />
<NumberField source="playCount" sortByOrder={'DESC'} /> <NumberField source="playCount" sortByOrder={'DESC'} />
{config.enableStarRating && (
<RatingField
source="rating"
sortByOrder={'DESC'}
resource={'artist'}
className={classes.ratingField}
/>
)}
<ArtistContextMenu <ArtistContextMenu
source={'starred'} source={'starred'}
sortBy={'starred ASC, starredAt ASC'} sortBy={'starred ASC, starredAt ASC'}

View file

@ -7,7 +7,8 @@ import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
import ListItemText from '@material-ui/core/ListItemText' import ListItemText from '@material-ui/core/ListItemText'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { sanitizeListRestProps } from 'ra-core' import { sanitizeListRestProps } from 'ra-core'
import { ArtistContextMenu } from './index' import { ArtistContextMenu, RatingField } from './index'
import config from '../config'
const useStyles = makeStyles( const useStyles = makeStyles(
{ {
@ -48,7 +49,17 @@ export const ArtistSimpleList = ({
<ListItem className={classes.listItem} button={true}> <ListItem className={classes.listItem} button={true}>
<ListItemText <ListItemText
primary={ primary={
<div className={classes.title}>{data[id].name}</div> <>
<div className={classes.title}>{data[id].name}</div>
{config.enableStarRating && (
<RatingField
record={data[id]}
source={'rating'}
resource={'artist'}
size={'small'}
/>
)}
</>
} }
/> />
<ListItemSecondaryAction className={classes.rightIcon}> <ListItemSecondaryAction className={classes.rightIcon}>

View file

@ -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 (
<span onClick={(e) => stopPropagation(e)}>
<Rating
name={record.id}
className={clsx(
className,
classes.rating,
rating > 0 ? classes.show : classes.hide
)}
value={rating}
size={size}
emptyIcon={<StarBorderIcon fontSize="inherit" />}
onChange={(e, newValue) => handleRating(e, newValue)}
/>
</span>
)
}
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',
}

View file

@ -7,9 +7,10 @@ import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'
import ListItemText from '@material-ui/core/ListItemText' import ListItemText from '@material-ui/core/ListItemText'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { sanitizeListRestProps } from 'ra-core' import { sanitizeListRestProps } from 'ra-core'
import { DurationField, SongContextMenu } from './index' import { DurationField, SongContextMenu, RatingField } from './index'
import { setTrack } from '../actions' import { setTrack } from '../actions'
import { useDispatch } from 'react-redux' import { useDispatch } from 'react-redux'
import config from '../config'
const useStyles = makeStyles( const useStyles = makeStyles(
{ {
@ -77,17 +78,27 @@ export const SongSimpleList = ({
<div className={classes.title}>{data[id].title}</div> <div className={classes.title}>{data[id].title}</div>
} }
secondary={ secondary={
<span className={classes.secondary}> <>
<span className={classes.artist}> <span className={classes.secondary}>
{data[id].artist} <span className={classes.artist}>
{data[id].artist}
</span>
<span className={classes.timeStamp}>
<DurationField
record={data[id]}
source={'duration'}
/>
</span>
</span> </span>
<span className={classes.timeStamp}> {config.enableStarRating && (
<DurationField <RatingField
record={data[id]} record={data[id]}
source={'duration'} source={'rating'}
resource={'song'}
size={'small'}
/> />
</span> )}
</span> </>
} }
/> />
<ListItemSecondaryAction className={classes.rightIcon}> <ListItemSecondaryAction className={classes.rightIcon}>

View file

@ -28,3 +28,5 @@ export * from './useTraceUpdate'
export * from './Writable' export * from './Writable'
export * from './SongSimpleList' export * from './SongSimpleList'
export * from './ArtistSimpleList' export * from './ArtistSimpleList'
export * from './RatingField'
export * from './useRating'

View file

@ -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]
}

View file

@ -15,6 +15,7 @@ const defaultConfig = {
gaTrackingId: '', gaTrackingId: '',
devActivityPanel: true, devActivityPanel: true,
devFastAccessCoverArt: false, devFastAccessCoverArt: false,
enableStarRating: true,
} }
let config let config

View file

@ -20,6 +20,7 @@
"bitRate": "Bit rate", "bitRate": "Bit rate",
"discSubtitle": "Disc Subtitle", "discSubtitle": "Disc Subtitle",
"starred": "Favourite", "starred": "Favourite",
"rating": "Rating",
"comment": "Comment", "comment": "Comment",
"quality": "Quality" "quality": "Quality"
}, },
@ -45,7 +46,8 @@
"compilation": "Compilation", "compilation": "Compilation",
"year": "Year", "year": "Year",
"updatedAt": "Updated at", "updatedAt": "Updated at",
"comment": "Comment" "comment": "Comment",
"rating": "Rating"
}, },
"actions": { "actions": {
"playAll": "Play", "playAll": "Play",
@ -61,7 +63,8 @@
"recentlyAdded": "Recently Added", "recentlyAdded": "Recently Added",
"recentlyPlayed": "Recently Played", "recentlyPlayed": "Recently Played",
"mostPlayed": "Most Played", "mostPlayed": "Most Played",
"starred": "Favourites" "starred": "Favourites",
"topRated": "Top Rated"
} }
}, },
"artist": { "artist": {
@ -70,7 +73,8 @@
"name": "Name", "name": "Name",
"albumCount": "Album Count", "albumCount": "Album Count",
"songCount": "Song Count", "songCount": "Song Count",
"playCount": "Plays" "playCount": "Plays",
"rating": "Rating"
} }
}, },
"user": { "user": {

View file

@ -17,6 +17,7 @@ import {
QuickFilter, QuickFilter,
SongTitleField, SongTitleField,
SongSimpleList, SongSimpleList,
RatingField,
} from '../common' } from '../common'
import { useDispatch } from 'react-redux' import { useDispatch } from 'react-redux'
import { setTrack } from '../actions' import { setTrack } from '../actions'
@ -40,11 +41,17 @@ const useStyles = makeStyles({
'& $contextMenu': { '& $contextMenu': {
visibility: 'visible', visibility: 'visible',
}, },
'& $ratingField': {
visibility: 'visible',
},
}, },
}, },
contextMenu: { contextMenu: {
visibility: 'hidden', visibility: 'hidden',
}, },
ratingField: {
visibility: 'hidden',
},
}) })
const SongFilter = (props) => ( const SongFilter = (props) => (
@ -114,6 +121,14 @@ const SongList = (props) => {
)} )}
<QualityInfo source="quality" sortable={false} /> <QualityInfo source="quality" sortable={false} />
<DurationField source="duration" /> <DurationField source="duration" />
{config.enableStarRating && (
<RatingField
source="rating"
sortByOrder={'DESC'}
resource={'song'}
className={classes.ratingField}
/>
)}
<SongContextMenu <SongContextMenu
source={'starred'} source={'starred'}
sortBy={'starred ASC, starredAt ASC'} sortBy={'starred ASC, starredAt ASC'}

View file

@ -40,6 +40,9 @@ const getCoverArtUrl = (record, size) => {
return url('getCoverArt', record.coverArtId || 'not_found', options) return url('getCoverArt', record.coverArtId || 'not_found', options)
} }
const setRating = (id, rating) =>
fetchUtils.fetchJson(url('setRating', id, { rating }))
export default { export default {
url, url,
getCoverArtUrl, getCoverArtUrl,
@ -47,4 +50,5 @@ export default {
download, download,
star, star,
unstar, unstar,
setRating,
} }