Add ability to reorder playlist items

This commit is contained in:
Deluan 2020-06-04 19:05:41 -04:00
parent b597a34cb4
commit 331fa1d952
8 changed files with 158 additions and 34 deletions

View file

@ -43,4 +43,5 @@ type PlaylistTrackRepository interface {
Add(mediaFileIds []string) error Add(mediaFileIds []string) error
Update(mediaFileIds []string) error Update(mediaFileIds []string) error
Delete(id string) error Delete(id string) error
Reorder(pos int, newPos int) error
} }

View file

@ -4,6 +4,7 @@ import (
. "github.com/Masterminds/squirrel" . "github.com/Masterminds/squirrel"
"github.com/deluan/navidrome/log" "github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model" "github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/utils"
"github.com/deluan/rest" "github.com/deluan/rest"
) )
@ -70,18 +71,10 @@ func (r *playlistTrackRepository) Add(mediaFileIds []string) error {
log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds) log.Debug(r.ctx, "Adding songs to playlist", "playlistId", r.playlistId, "mediaFileIds", mediaFileIds)
} }
// Get all current tracks ids, err := r.getTracks()
all := r.newSelect().Columns("media_file_id").Where(Eq{"playlist_id": r.playlistId}).OrderBy("id")
var tracks model.PlaylistTracks
err := r.queryAll(all, &tracks)
if err != nil { if err != nil {
log.Error("Error querying current tracks from playlist", "playlistId", r.playlistId, err)
return err return err
} }
ids := make([]string, len(tracks))
for i := range tracks {
ids[i] = tracks[i].MediaFileID
}
// Append new tracks // Append new tracks
ids = append(ids, mediaFileIds...) ids = append(ids, mediaFileIds...)
@ -90,6 +83,22 @@ func (r *playlistTrackRepository) Add(mediaFileIds []string) error {
return r.Update(ids) return r.Update(ids)
} }
func (r *playlistTrackRepository) getTracks() ([]string, error) {
// Get all current tracks
all := r.newSelect().Columns("media_file_id").Where(Eq{"playlist_id": r.playlistId}).OrderBy("id")
var tracks model.PlaylistTracks
err := r.queryAll(all, &tracks)
if err != nil {
log.Error("Error querying current tracks from playlist", "playlistId", r.playlistId, err)
return nil, err
}
ids := make([]string, len(tracks))
for i := range tracks {
ids[i] = tracks[i].MediaFileID
}
return ids, nil
}
func (r *playlistTrackRepository) Update(mediaFileIds []string) error { func (r *playlistTrackRepository) Update(mediaFileIds []string) error {
// Remove old tracks // Remove old tracks
del := Delete(r.tableName).Where(Eq{"playlist_id": r.playlistId}) del := Delete(r.tableName).Where(Eq{"playlist_id": r.playlistId})
@ -156,4 +165,13 @@ func (r *playlistTrackRepository) Delete(id string) error {
return r.updateStats() return r.updateStats()
} }
func (r *playlistTrackRepository) Reorder(pos int, newPos int) error {
ids, err := r.getTracks()
if err != nil {
return err
}
newOrder := utils.MoveString(ids, pos-1, newPos-1)
return r.Update(newOrder)
}
var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil) var _ model.PlaylistTrackRepository = (*playlistTrackRepository)(nil)

View file

@ -91,7 +91,7 @@ func (app *Router) RX(r chi.Router, pathPrefix string, constructor rest.Reposito
type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc type restHandler = func(rest.RepositoryConstructor, ...rest.Logger) http.HandlerFunc
func (app *Router) addPlaylistTrackRoute(r chi.Router) { func (app *Router) addPlaylistTrackRoute(r chi.Router) {
// Add a middleware to capture the playlisId // Add a middleware to capture the playlistId
wrapper := func(f restHandler) http.HandlerFunc { wrapper := func(f restHandler) http.HandlerFunc {
return func(res http.ResponseWriter, req *http.Request) { return func(res http.ResponseWriter, req *http.Request) {
c := func(ctx context.Context) rest.Repository { c := func(ctx context.Context) rest.Repository {
@ -109,6 +109,9 @@ func (app *Router) addPlaylistTrackRoute(r chi.Router) {
r.Route("/{id}", func(r chi.Router) { r.Route("/{id}", func(r chi.Router) {
r.Use(UrlParams) r.Use(UrlParams)
r.Get("/", wrapper(rest.Get)) r.Get("/", wrapper(rest.Get))
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
reorderItem(app.ds)(w, r)
})
r.Delete("/", func(w http.ResponseWriter, r *http.Request) { r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
deleteFromPlaylist(app.ds)(w, r) deleteFromPlaylist(app.ds)(w, r)
}) })

View file

@ -4,16 +4,13 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"github.com/deluan/navidrome/log" "github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model" "github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/utils" "github.com/deluan/navidrome/utils"
) )
type addTracksPayload struct {
Ids []string `json:"ids"`
}
func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc { func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
playlistId := utils.ParamString(r, ":playlistId") playlistId := utils.ParamString(r, ":playlistId")
@ -38,6 +35,10 @@ func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc {
} }
func addToPlaylist(ds model.DataStore) http.HandlerFunc { func addToPlaylist(ds model.DataStore) http.HandlerFunc {
type addTracksPayload struct {
Ids []string `json:"ids"`
}
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
playlistId := utils.ParamString(r, ":playlistId") playlistId := utils.ParamString(r, ":playlistId")
tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId) tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId)
@ -60,3 +61,40 @@ func addToPlaylist(ds model.DataStore) http.HandlerFunc {
} }
} }
} }
func reorderItem(ds model.DataStore) http.HandlerFunc {
type reorderPayload struct {
InsertBefore string `json:"insert_before"`
}
return func(w http.ResponseWriter, r *http.Request) {
playlistId := utils.ParamString(r, ":playlistId")
id := utils.ParamInt(r, ":id", 0)
if id == 0 {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
tracksRepo := ds.Playlist(r.Context()).Tracks(playlistId)
var payload reorderPayload
err := json.NewDecoder(r.Body).Decode(&payload)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
newPos, err := strconv.Atoi(payload.InsertBefore)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = tracksRepo.Reorder(id, newPos)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_, err = w.Write([]byte(fmt.Sprintf(`{"id":"%d"}`, id)))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}

View file

@ -13,6 +13,7 @@
"react": "^16.13.1", "react": "^16.13.1",
"react-admin": "^3.5.3", "react-admin": "^3.5.3",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"react-drag-listview": "^0.1.6",
"react-jinke-music-player": "^4.13.1", "react-jinke-music-player": "^4.13.1",
"react-measure": "^2.3.0", "react-measure": "^2.3.0",
"react-redux": "^7.2.0", "react-redux": "^7.2.0",

View file

@ -6,10 +6,13 @@ import {
TextField, TextField,
useListController, useListController,
useRefresh, useRefresh,
useDataProvider,
useNotify,
} from 'react-admin' } from 'react-admin'
import classnames from 'classnames' import classnames from 'classnames'
import { Card, useMediaQuery } from '@material-ui/core' import { Card, useMediaQuery } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import ReactDragListView from 'react-drag-listview'
import { import {
DurationField, DurationField,
SongDetails, SongDetails,
@ -45,6 +48,9 @@ const useStyles = makeStyles(
flexWrap: 'wrap', flexWrap: 'wrap',
}, },
noResults: { padding: 20 }, noResults: { padding: 20 },
draggable: {
cursor: 'move',
},
}), }),
{ name: 'RaList' } { name: 'RaList' }
) )
@ -61,16 +67,29 @@ const PlaylistSongs = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md')) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('md'))
const controllerProps = useListController(props) const controllerProps = useListController(props)
const dataProvider = useDataProvider()
const refresh = useRefresh() const refresh = useRefresh()
const notify = useNotify()
const { bulkActionButtons, expand, className, playlistId } = props const { bulkActionButtons, expand, className, playlistId } = props
const { data, ids, version, loaded } = controllerProps const { data, ids, version } = controllerProps
const anySong = data[ids[0]] const anySong = data[ids[0]]
const showPlaceholder = !anySong || anySong.playlistId !== playlistId const showPlaceholder = !anySong || anySong.playlistId !== playlistId
const hasBulkActions = props.bulkActionButtons !== false const hasBulkActions = props.bulkActionButtons !== false
if (loaded && ids.length === 0) { const reorder = (playlistId, id, newPos) => {
return <div /> dataProvider
.update('playlistTrack', {
id,
data: { insert_before: newPos },
filter: { playlist_id: playlistId },
})
.then(() => {
refresh()
})
.catch(() => {
notify('ra.page.error', 'warning')
})
} }
const onAddToPlaylist = (pls) => { const onAddToPlaylist = (pls) => {
@ -79,6 +98,12 @@ const PlaylistSongs = (props) => {
} }
} }
const handleDragEnd = (from, to) => {
const toId = ids[to]
const fromId = ids[from]
reorder(playlistId, fromId, toId)
}
return ( return (
<> <>
<ListToolbar <ListToolbar
@ -111,23 +136,36 @@ const PlaylistSongs = (props) => {
size={'small'} size={'small'}
/> />
) : ( ) : (
<SongDatagrid <ReactDragListView onDragEnd={handleDragEnd} nodeSelector={'tr'}>
expand={!isXsmall && <SongDetails />} <SongDatagrid
rowClick={null} expand={!isXsmall && <SongDetails />}
{...controllerProps} rowClick={null}
hasBulkActions={hasBulkActions} {...controllerProps}
contextAlwaysVisible={!isDesktop} hasBulkActions={hasBulkActions}
> contextAlwaysVisible={!isDesktop}
{isDesktop && <TextField source="id" label={'#'} />} >
<TextField source="title" /> {isDesktop && (
{isDesktop && <AlbumLinkField source="album" />} <TextField
{isDesktop && <TextField source="artist" />} source="id"
<DurationField source="duration" /> label={'#'}
<SongContextMenu className={classes.draggable}
onAddToPlaylist={onAddToPlaylist} />
showStar={false} )}
/> <TextField source="title" className={classes.draggable} />
</SongDatagrid> {isDesktop && <AlbumLinkField source="album" />}
{isDesktop && (
<TextField source="artist" className={classes.draggable} />
)}
<DurationField
source="duration"
className={classes.draggable}
/>
<SongContextMenu
onAddToPlaylist={onAddToPlaylist}
showStar={false}
/>
</SongDatagrid>
</ReactDragListView>
)} )}
</Card> </Card>
</div> </div>

View file

@ -25,3 +25,16 @@ func StringInSlice(a string, list []string) bool {
} }
return false return false
} }
func InsertString(array []string, value string, index int) []string {
return append(array[:index], append([]string{value}, array[index:]...)...)
}
func RemoveString(array []string, index int) []string {
return append(array[:index], array[index+1:]...)
}
func MoveString(array []string, srcIndex int, dstIndex int) []string {
value := array[srcIndex]
return InsertString(RemoveString(array, srcIndex), value, dstIndex)
}

View file

@ -48,4 +48,16 @@ var _ = Describe("Strings", func() {
Expect(StringInSlice("bbb", []string{"bbb", "aaa", "ccc"})).To(BeTrue()) Expect(StringInSlice("bbb", []string{"bbb", "aaa", "ccc"})).To(BeTrue())
}) })
}) })
Describe("MoveString", func() {
It("moves item to end of slice", func() {
Expect(MoveString([]string{"1", "2", "3"}, 0, 2)).To(ConsistOf("2", "3", "1"))
})
It("moves item to beginning of slice", func() {
Expect(MoveString([]string{"1", "2", "3"}, 2, 0)).To(ConsistOf("3", "1", "2"))
})
It("keeps item in same position if srcIndex == dstIndex", func() {
Expect(MoveString([]string{"1", "2", "3"}, 1, 1)).To(ConsistOf("1", "2", "3"))
})
})
}) })