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
Update(mediaFileIds []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/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/utils"
"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)
}
// 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)
ids, err := r.getTracks()
if err != nil {
log.Error("Error querying current tracks from playlist", "playlistId", r.playlistId, err)
return err
}
ids := make([]string, len(tracks))
for i := range tracks {
ids[i] = tracks[i].MediaFileID
}
// Append new tracks
ids = append(ids, mediaFileIds...)
@ -90,6 +83,22 @@ func (r *playlistTrackRepository) Add(mediaFileIds []string) error {
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 {
// Remove old tracks
del := Delete(r.tableName).Where(Eq{"playlist_id": r.playlistId})
@ -156,4 +165,13 @@ func (r *playlistTrackRepository) Delete(id string) error {
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)

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
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 {
return func(res http.ResponseWriter, req *http.Request) {
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.Use(UrlParams)
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) {
deleteFromPlaylist(app.ds)(w, r)
})

View file

@ -4,16 +4,13 @@ import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"github.com/deluan/navidrome/log"
"github.com/deluan/navidrome/model"
"github.com/deluan/navidrome/utils"
)
type addTracksPayload struct {
Ids []string `json:"ids"`
}
func deleteFromPlaylist(ds model.DataStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
playlistId := utils.ParamString(r, ":playlistId")
@ -38,6 +35,10 @@ func deleteFromPlaylist(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) {
playlistId := utils.ParamString(r, ":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-admin": "^3.5.3",
"react-dom": "^16.13.1",
"react-drag-listview": "^0.1.6",
"react-jinke-music-player": "^4.13.1",
"react-measure": "^2.3.0",
"react-redux": "^7.2.0",

View file

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

View file

@ -25,3 +25,16 @@ func StringInSlice(a string, list []string) bool {
}
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())
})
})
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"))
})
})
})