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
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)

View file

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

View file

@ -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)

View file

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

View file

@ -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 ? (
<SimpleList
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) => (
<>
<RangeField record={r} source={'year'} sortBy={'maxYear'} />
@ -129,6 +152,14 @@ const AlbumListView = ({
{isDesktop && <NumberField source="playCount" sortByOrder={'DESC'} />}
<RangeField source={'year'} sortBy={'maxYear'} sortByOrder={'DESC'} />
{isDesktop && <DurationField source="duration" />}
{config.enableStarRating && (
<RatingField
source={'rating'}
resource={'album'}
sortByOrder={'DESC'}
className={classes.ratingField}
/>
)}
<AlbumContextMenu
source={'starred'}
sortBy={'starred ASC, starredAt ASC'}

View file

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

View file

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

View file

@ -17,6 +17,7 @@ import {
QuickFilter,
useGetHandleArtistClick,
ArtistSimpleList,
RatingField,
} from '../common'
import { makeStyles } from '@material-ui/core/styles'
import config from '../config'
@ -32,11 +33,17 @@ const useStyles = makeStyles({
'& $contextMenu': {
visibility: 'visible',
},
'& $ratingField': {
visibility: 'visible',
},
},
},
contextMenu: {
visibility: 'hidden',
},
ratingField: {
visibility: 'hidden',
},
})
const ArtistFilter = (props) => (
@ -68,6 +75,14 @@ const ArtistListView = ({ hasShow, hasEdit, hasList, width, ...rest }) => {
<NumberField source="albumCount" sortByOrder={'DESC'} />
<NumberField source="songCount" sortByOrder={'DESC'} />
<NumberField source="playCount" sortByOrder={'DESC'} />
{config.enableStarRating && (
<RatingField
source="rating"
sortByOrder={'DESC'}
resource={'artist'}
className={classes.ratingField}
/>
)}
<ArtistContextMenu
source={'starred'}
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 { makeStyles } from '@material-ui/core/styles'
import { sanitizeListRestProps } from 'ra-core'
import { ArtistContextMenu } from './index'
import { ArtistContextMenu, RatingField } from './index'
import config from '../config'
const useStyles = makeStyles(
{
@ -48,7 +49,17 @@ export const ArtistSimpleList = ({
<ListItem className={classes.listItem} button={true}>
<ListItemText
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}>

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 { 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 = ({
<div className={classes.title}>{data[id].title}</div>
}
secondary={
<span className={classes.secondary}>
<span className={classes.artist}>
{data[id].artist}
<>
<span className={classes.secondary}>
<span className={classes.artist}>
{data[id].artist}
</span>
<span className={classes.timeStamp}>
<DurationField
record={data[id]}
source={'duration'}
/>
</span>
</span>
<span className={classes.timeStamp}>
<DurationField
{config.enableStarRating && (
<RatingField
record={data[id]}
source={'duration'}
source={'rating'}
resource={'song'}
size={'small'}
/>
</span>
</span>
)}
</>
}
/>
<ListItemSecondaryAction className={classes.rightIcon}>

View file

@ -28,3 +28,5 @@ export * from './useTraceUpdate'
export * from './Writable'
export * from './SongSimpleList'
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: '',
devActivityPanel: true,
devFastAccessCoverArt: false,
enableStarRating: true,
}
let config

View file

@ -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": {

View file

@ -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) => {
)}
<QualityInfo source="quality" sortable={false} />
<DurationField source="duration" />
{config.enableStarRating && (
<RatingField
source="rating"
sortByOrder={'DESC'}
resource={'song'}
className={classes.ratingField}
/>
)}
<SongContextMenu
source={'starred'}
sortBy={'starred ASC, starredAt ASC'}

View file

@ -40,6 +40,9 @@ const getCoverArtUrl = (record, size) => {
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,
}