diff --git a/model/playlist.go b/model/playlist.go index ff0f1d7b3..cd20da632 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -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 } diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go index 92f652f52..6f53d0054 100644 --- a/persistence/playlist_track_repository.go +++ b/persistence/playlist_track_repository.go @@ -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) diff --git a/server/app/app.go b/server/app/app.go index 0ad730a47..5f317ce23 100644 --- a/server/app/app.go +++ b/server/app/app.go @@ -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) }) diff --git a/server/app/playlists.go b/server/app/playlists.go index 7df1a77e5..e1348efe3 100644 --- a/server/app/playlists.go +++ b/server/app/playlists.go @@ -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) + } + } +} diff --git a/ui/package.json b/ui/package.json index 9c3b5c2ec..64d7bfe7f 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/playlist/PlaylistSongs.js b/ui/src/playlist/PlaylistSongs.js index 5243aa82d..d8d8a76f9 100644 --- a/ui/src/playlist/PlaylistSongs.js +++ b/ui/src/playlist/PlaylistSongs.js @@ -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
+ 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 ( <>