Sfoglia il codice sorgente

feat(Playlists): added drag and drop to songs

Signed-off-by: Jonathan <theflametrooper@gmail.com>
Jonathan 4 anni fa
parent
commit
4f498292ca

+ 240 - 275
backend/logic/actions/playlists.js

@@ -47,30 +47,16 @@ CacheModule.runJob("SUB", {
 });
 
 CacheModule.runJob("SUB", {
-	channel: "playlist.moveSongToTop",
+	channel: "playlist.repositionSongs",
 	cb: res => {
-		IOModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(response => {
-			response.sockets.forEach(socket => {
-				socket.emit("event:playlist.moveSongToTop", {
+		IOModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(response =>
+			response.sockets.forEach(socket =>
+				socket.emit("event:playlist.repositionSongs", {
 					playlistId: res.playlistId,
-					songId: res.songId
-				});
-			});
-		});
-	}
-});
-
-CacheModule.runJob("SUB", {
-	channel: "playlist.moveSongToBottom",
-	cb: res => {
-		IOModule.runJob("SOCKETS_FROM_USER", { userId: res.userId }, this).then(response => {
-			response.sockets.forEach(socket => {
-				socket.emit("event:playlist.moveSongToBottom", {
-					playlistId: res.playlistId,
-					songId: res.songId
-				});
-			});
-		});
+					songsBeingChanged: res.songsBeingChanged
+				})
+			)
+		);
 	}
 });
 
@@ -474,11 +460,16 @@ export default {
 					);
 					return cb({ status: "failure", message: err });
 				}
+
+				const sortedSongs = playlist.songs.sort((a, b) => a.position - b.position);
+				playlist.songs = sortedSongs;
+
 				this.log(
 					"SUCCESS",
 					"PLAYLIST_GET",
 					`Successfully got private playlist "${playlistId}" for user "${session.userId}".`
 				);
+
 				return cb({
 					status: "success",
 					data: playlist
@@ -590,7 +581,7 @@ export default {
 	}),
 
 	/**
-	 * Updates a private playlist
+	 * Shuffles songs in a private playlist
 	 *
 	 * @param {object} session - the session object automatically added by socket.io
 	 * @param {string} playlistId - the id of the playlist we are updating
@@ -655,6 +646,178 @@ export default {
 		);
 	}),
 
+	/**
+	 * Changes the order of song(s) in a private playlist
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {string} playlistId - the id of the playlist we are targeting
+	 * @param {Array} songsBeingChanged - the songs to be repositioned, each element contains "songId" and "position" properties
+	 * @param {Function} cb - gets called with the result
+	 */
+	repositionSongs: isLoginRequired(async function repositionSongs(session, playlistId, songsBeingChanged, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				// get current playlist object (before changes)
+				next =>
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next),
+
+				(playlist, next) => {
+					if (!playlist.isUserModifiable) return next("Playlist cannot be modified.");
+					return next(null);
+				},
+
+				// update playlist object with each song's new position
+				next =>
+					async.each(
+						songsBeingChanged,
+						(song, nextSong) =>
+							playlistModel.updateOne(
+								{ _id: playlistId, "songs.songId": song.songId },
+								{
+									$set: {
+										"songs.$.position": song.position
+									}
+								},
+								err => {
+									if (err) return next(err);
+									return nextSong();
+								}
+							),
+						next
+					),
+
+				// update the cache with the new songs positioning
+				next => {
+					PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_REPOSITION_SONGS",
+						`Repositioning songs for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "failure", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_REPOSITION_SONGS",
+					`Successfully repositioned songs for private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "playlist.repositionSongs",
+					value: {
+						userId: session.userId,
+						playlistId,
+						songsBeingChanged
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Order of songs successfully updated"
+				});
+			}
+		);
+	}),
+
+	/**
+	 * Moves a song to the bottom of the list in a private playlist
+	 *
+	 * @param {object} session - the session object automatically added by socket.io
+	 * @param {string} playlistId - the id of the playlist we are moving the song to the bottom from
+	 * @param {string} songId - the id of the song we are moving to the bottom of the list
+	 * @param {Function} cb - gets called with the result
+	 */
+	moveSongToBottom: isLoginRequired(async function moveSongToBottom(session, playlistId, songId, cb) {
+		async.waterfall(
+			[
+				next => {
+					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
+						.then(playlist => next(null, playlist))
+						.catch(next);
+				},
+
+				(playlist, next) => {
+					if (!playlist || playlist.createdBy !== session.userId) return next("Playlist not found");
+					if (!playlist.isUserModifiable) return next("Playlist cannot be modified.");
+
+					// sort array by position
+					playlist.songs.sort((a, b) => a.position - b.position);
+
+					// find index of songId
+					playlist.songs.forEach((song, index) => {
+						// reorder array (simulates what would be done with a drag and drop interface)
+						if (song.songId === songId)
+							playlist.songs.splice(playlist.songs.length, 0, playlist.songs.splice(index, 1)[0]);
+					});
+
+					const songsBeingChanged = [];
+
+					playlist.songs.forEach((song, index) => {
+						// check if position needs updated based on index
+						if (song.position !== index + 1)
+							songsBeingChanged.push({
+								songId: song.songId,
+								position: index + 1
+							});
+					});
+
+					// update position property on songs that need to be changed
+					return IOModule.runJob(
+						"RUN_ACTION2",
+						{
+							session,
+							namespace: "playlists",
+							action: "repositionSongs",
+							args: [playlistId, songsBeingChanged]
+						},
+						this
+					)
+						.then(res => {
+							if (res.status === "success") return next();
+							return next("Unable to reposition song in playlist.");
+						})
+						.catch(next);
+				}
+			],
+			async err => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"PLAYLIST_MOVE_SONG_TO_BOTTOM",
+						`Moving song "${songId}" to the bottom for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+					return cb({ status: "failure", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_MOVE_SONG_TO_BOTTOM",
+					`Successfully moved song "${songId}" to the bottom for private playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				return cb({
+					status: "success",
+					message: "Order of songs successfully updated"
+				});
+			}
+		);
+	}),
+
 	/**
 	 * Adds a song to a private playlist
 	 *
@@ -665,13 +828,7 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 */
 	addSongToPlaylist: isLoginRequired(async function addSongToPlaylist(session, isSet, songId, playlistId, cb) {
-		const playlistModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "playlist"
-			},
-			this
-		);
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 
 		async.waterfall(
 			[
@@ -683,16 +840,16 @@ export default {
 
 							return async.each(
 								playlist.songs,
-								(song, next) => {
+								(song, nextSong) => {
 									if (song.songId === songId) return next("That song is already in the playlist");
-									return next();
+									return nextSong();
 								},
-								next
+								err => next(err, playlist.songs.length + 1)
 							);
 						})
 						.catch(next);
 				},
-				next => {
+				(position, next) => {
 					SongsModule.runJob("GET_SONG", { id: songId }, this)
 						.then(response => {
 							const { song } = response;
@@ -700,12 +857,13 @@ export default {
 								_id: song._id,
 								songId,
 								title: song.title,
-								duration: song.duration
+								duration: song.duration,
+								position
 							});
 						})
 						.catch(() => {
 							YouTubeModule.runJob("GET_SONG", { songId }, this)
-								.then(response => next(null, response.song))
+								.then(response => next(null, { ...response.song, position }))
 								.catch(next);
 						});
 				},
@@ -898,13 +1056,7 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 */
 	removeSongFromPlaylist: isLoginRequired(async function removeSongFromPlaylist(session, songId, playlistId, cb) {
-		const playlistModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "playlist"
-			},
-			this
-		);
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
 		async.waterfall(
 			[
 				next => {
@@ -915,22 +1067,56 @@ export default {
 
 				next => {
 					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
-						.then(playlist => {
-							next(null, playlist);
-						})
+						.then(playlist => next(null, playlist))
 						.catch(next);
 				},
 
 				(playlist, next) => {
 					if (!playlist || playlist.createdBy !== session.userId) return next("Playlist not found");
-					return playlistModel.updateOne({ _id: playlistId }, { $pull: { songs: { songId } } }, next);
+
+					// sort array by position
+					playlist.songs.sort((a, b) => a.position - b.position);
+
+					// find index of songId
+					playlist.songs.forEach((song, ind) => {
+						// remove song from array
+						if (song.songId === songId) playlist.songs.splice(ind, 1);
+					});
+
+					const songsBeingChanged = [];
+
+					playlist.songs.forEach((song, index) => {
+						// check if position needs updated based on index
+						if (song.position !== index + 1)
+							songsBeingChanged.push({
+								songId: song.songId,
+								position: index + 1
+							});
+					});
+
+					// update position property on songs that need to be changed
+					return IOModule.runJob(
+						"RUN_ACTION2",
+						{
+							session,
+							namespace: "playlists",
+							action: "repositionSongs",
+							args: [playlistId, songsBeingChanged]
+						},
+						this
+					)
+						.then(res => {
+							if (res.status === "success") return next();
+							return next("Unable to reposition song in playlist.");
+						})
+						.catch(next);
 				},
 
+				next => playlistModel.updateOne({ _id: playlistId }, { $pull: { songs: { songId } } }, next),
+
 				(res, next) => {
 					PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
-						.then(playlist => {
-							next(null, playlist);
-						})
+						.then(playlist => next(null, playlist))
 						.catch(next);
 				}
 			],
@@ -975,13 +1161,8 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 */
 	updateDisplayName: isLoginRequired(async function updateDisplayName(session, playlistId, displayName, cb) {
-		const playlistModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "playlist"
-			},
-			this
-		);
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
 		async.waterfall(
 			[
 				next => {
@@ -1044,217 +1225,6 @@ export default {
 		);
 	}),
 
-	/**
-	 * Moves a song to the top of the list in a private playlist
-	 *
-	 * @param {object} session - the session object automatically added by socket.io
-	 * @param {string} playlistId - the id of the playlist we are moving the song to the top from
-	 * @param {string} songId - the id of the song we are moving to the top of the list
-	 * @param {Function} cb - gets called with the result
-	 */
-	moveSongToTop: isLoginRequired(async function moveSongToTop(session, playlistId, songId, cb) {
-		const playlistModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "playlist"
-			},
-			this
-		);
-		async.waterfall(
-			[
-				next => {
-					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
-						.then(playlist => {
-							next(null, playlist);
-						})
-						.catch(next);
-				},
-
-				(playlist, next) => {
-					if (!playlist || playlist.createdBy !== session.userId) return next("Playlist not found");
-					if (!playlist.isUserModifiable) return next("Playlist cannot be modified.");
-
-					return async.each(
-						playlist.songs,
-						(song, next) => {
-							if (song.songId === songId) return next(song);
-							return next();
-						},
-						err => {
-							if (err && err.songId) return next(null, err);
-							return next("Song not found");
-						}
-					);
-				},
-
-				(song, next) => {
-					playlistModel.updateOne({ _id: playlistId }, { $pull: { songs: { songId } } }, err => {
-						if (err) return next(err);
-						return next(null, song);
-					});
-				},
-
-				(song, next) => {
-					playlistModel.updateOne(
-						{ _id: playlistId },
-						{
-							$push: {
-								songs: {
-									$each: [song],
-									$position: 0
-								}
-							}
-						},
-						next
-					);
-				},
-
-				(res, next) => {
-					PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
-						.then(playlist => {
-							next(null, playlist);
-						})
-						.catch(next);
-				}
-			],
-			async err => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"PLAYLIST_MOVE_SONG_TO_TOP",
-						`Moving song "${songId}" to the top for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
-					);
-					return cb({ status: "failure", message: err });
-				}
-
-				this.log(
-					"SUCCESS",
-					"PLAYLIST_MOVE_SONG_TO_TOP",
-					`Successfully moved song "${songId}" to the top for private playlist "${playlistId}" for user "${session.userId}".`
-				);
-
-				CacheModule.runJob("PUB", {
-					channel: "playlist.moveSongToTop",
-					value: {
-						playlistId,
-						songId,
-						userId: session.userId
-					}
-				});
-
-				return cb({
-					status: "success",
-					message: "Playlist has been successfully updated"
-				});
-			}
-		);
-	}),
-
-	/**
-	 * Moves a song to the bottom of the list in a private playlist
-	 *
-	 * @param {object} session - the session object automatically added by socket.io
-	 * @param {string} playlistId - the id of the playlist we are moving the song to the bottom from
-	 * @param {string} songId - the id of the song we are moving to the bottom of the list
-	 * @param {Function} cb - gets called with the result
-	 */
-	moveSongToBottom: isLoginRequired(async function moveSongToBottom(session, playlistId, songId, cb) {
-		const playlistModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "playlist"
-			},
-			this
-		);
-		async.waterfall(
-			[
-				next => {
-					PlaylistsModule.runJob("GET_PLAYLIST", { playlistId }, this)
-						.then(playlist => {
-							next(null, playlist);
-						})
-						.catch(next);
-				},
-
-				(playlist, next) => {
-					if (!playlist || playlist.createdBy !== session.userId) return next("Playlist not found");
-					if (!playlist.isUserModifiable) return next("Playlist cannot be modified.");
-
-					return async.each(
-						playlist.songs,
-						(song, next) => {
-							if (song.songId === songId) return next(song);
-							return next();
-						},
-						err => {
-							if (err && err.songId) return next(null, err);
-							return next("Song not found");
-						}
-					);
-				},
-
-				(song, next) => {
-					playlistModel.updateOne({ _id: playlistId }, { $pull: { songs: { songId } } }, err => {
-						if (err) return next(err);
-						return next(null, song);
-					});
-				},
-
-				(song, next) => {
-					playlistModel.updateOne(
-						{ _id: playlistId },
-						{
-							$push: {
-								songs: song
-							}
-						},
-						next
-					);
-				},
-
-				(res, next) => {
-					PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
-						.then(playlist => {
-							next(null, playlist);
-						})
-						.catch(next);
-				}
-			],
-			async err => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
-					this.log(
-						"ERROR",
-						"PLAYLIST_MOVE_SONG_TO_BOTTOM",
-						`Moving song "${songId}" to the bottom for private playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
-					);
-					return cb({ status: "failure", message: err });
-				}
-
-				this.log(
-					"SUCCESS",
-					"PLAYLIST_MOVE_SONG_TO_BOTTOM",
-					`Successfully moved song "${songId}" to the bottom for private playlist "${playlistId}" for user "${session.userId}".`
-				);
-
-				CacheModule.runJob("PUB", {
-					channel: "playlist.moveSongToBottom",
-					value: {
-						playlistId,
-						songId,
-						userId: session.userId
-					}
-				});
-
-				return cb({
-					status: "success",
-					message: "Playlist has been successfully updated"
-				});
-			}
-		);
-	}),
-
 	/**
 	 * Removes a private playlist
 	 *
@@ -1397,13 +1367,8 @@ export default {
 	 * @param {Function} cb - gets called with the result
 	 */
 	updatePrivacy: isLoginRequired(async function updatePrivacy(session, playlistId, privacy, cb) {
-		const playlistModel = await DBModule.runJob(
-			"GET_MODEL",
-			{
-				modelName: "playlist"
-			},
-			this
-		);
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
 		async.waterfall(
 			[
 				next => {

+ 8 - 1
backend/logic/db/schemas/playlist.js

@@ -1,7 +1,14 @@
 export default {
 	displayName: { type: String, min: 2, max: 32, required: true },
 	isUserModifiable: { type: Boolean, default: true, required: true },
-	songs: { type: Array },
+	songs: [
+		{
+			songId: { type: String },
+			title: { type: String },
+			duration: { type: Number },
+			position: { type: Number }
+		}
+	],
 	createdBy: { type: String, required: true },
 	createdAt: { type: Date, default: Date.now, required: true },
 	privacy: { type: String, enum: ["public", "private"], default: "private" }

+ 145 - 79
frontend/src/components/modals/EditPlaylist.vue

@@ -13,43 +13,67 @@
 			</nav>
 			<hr />
 			<aside class="menu">
-				<ul class="menu-list">
-					<li v-for="(song, index) in playlist.songs" :key="index">
-						<a href="#" target="_blank">{{ song.title }}</a>
-						<div class="controls" v-if="playlist.isUserModifiable">
-							<a href="#" @click="promoteSong(song.songId)">
-								<i class="material-icons" v-if="index > 0"
-									>keyboard_arrow_up</i
-								>
-								<i
-									v-else
-									class="material-icons"
-									style="opacity: 0"
-									>error</i
-								>
-							</a>
-							<a href="#" @click="demoteSong(song.songId)">
-								<i
-									v-if="playlist.songs.length - 1 !== index"
-									class="material-icons"
-									>keyboard_arrow_down</i
-								>
-								<i
-									v-else
-									class="material-icons"
-									style="opacity: 0"
-									>error</i
-								>
-							</a>
-							<a
-								href="#"
-								@click="removeSongFromPlaylist(song.songId)"
+				<draggable
+					class="menu-list scrollable-list"
+					tag="ul"
+					v-if="playlist.songs.length > 0"
+					v-model="playlist.songs"
+					v-bind="dragOptions"
+					@start="drag = true"
+					@end="drag = false"
+					@change="updateSongPositioning"
+				>
+					<transition-group
+						type="transition"
+						:name="!drag ? 'draggable-list-transition' : null"
+					>
+						<li
+							v-for="(song, index) in playlist.songs"
+							:key="'key-' + index"
+						>
+							<a href="#" target="_blank"
+								>({{ song.position }}) {{ song.title }}</a
 							>
-								<i class="material-icons">delete</i>
-							</a>
-						</div>
-					</li>
-				</ul>
+							<div
+								class="controls"
+								v-if="playlist.isUserModifiable"
+							>
+								<a href="#" @click="moveSongToTop(index)">
+									<i class="material-icons" v-if="index > 0"
+										>keyboard_arrow_up</i
+									>
+									<i
+										v-else
+										class="material-icons"
+										style="opacity: 0"
+										>error</i
+									>
+								</a>
+								<a href="#" @click="moveSongToBottom(index)">
+									<i
+										v-if="
+											playlist.songs.length - 1 !== index
+										"
+										class="material-icons"
+										>keyboard_arrow_down</i
+									>
+									<i
+										v-else
+										class="material-icons"
+										style="opacity: 0"
+										>error</i
+									>
+								</a>
+								<a
+									href="#"
+									@click="removeSongFromPlaylist(song.songId)"
+								>
+									<i class="material-icons">delete</i>
+								</a>
+							</div>
+						</li>
+					</transition-group>
+				</draggable>
 				<br />
 			</aside>
 			<div class="control is-grouped" v-if="playlist.isUserModifiable">
@@ -185,6 +209,7 @@
 
 <script>
 import { mapState, mapActions } from "vuex";
+import draggable from "vuedraggable";
 
 import Toast from "toasters";
 import Modal from "../Modal.vue";
@@ -193,10 +218,12 @@ import validation from "../../validation";
 import utils from "../../../js/utils";
 
 export default {
-	components: { Modal },
+	components: { Modal, draggable },
 	data() {
 		return {
 			utils,
+			drag: false,
+			interval: null,
 			playlist: { songs: [] },
 			songQueryResults: [],
 			searchSongQuery: "",
@@ -204,20 +231,33 @@ export default {
 			importQuery: ""
 		};
 	},
-	computed: mapState("user/playlists", {
-		editing: state => state.editing
-	}),
+	computed: {
+		...mapState("user/playlists", {
+			editing: state => state.editing
+		}),
+		dragOptions() {
+			return {
+				animation: 200,
+				group: "description",
+				disabled: false,
+				ghostClass: "draggable-list-ghost"
+			};
+		}
+	},
 	mounted() {
 		io.getSocket(socket => {
 			this.socket = socket;
+
 			this.socket.emit("playlists.getPlaylist", this.editing, res => {
 				if (res.status === "success") this.playlist = res.data;
 				this.playlist.oldId = res.data._id;
 			});
+
 			this.socket.on("event:playlist.addSong", data => {
 				if (this.playlist._id === data.playlistId)
 					this.playlist.songs.push(data.song);
 			});
+
 			this.socket.on("event:playlist.removeSong", data => {
 				if (this.playlist._id === data.playlistId) {
 					this.playlist.songs.forEach((song, index) => {
@@ -226,33 +266,60 @@ export default {
 					});
 				}
 			});
+
 			this.socket.on("event:playlist.updateDisplayName", data => {
 				if (this.playlist._id === data.playlistId)
 					this.playlist.displayName = data.displayName;
 			});
-			this.socket.on("event:playlist.moveSongToBottom", data => {
-				if (this.playlist._id === data.playlistId) {
-					let songIndex;
-					this.playlist.songs.forEach((song, index) => {
-						if (song.songId === data.songId) songIndex = index;
-					});
-					const song = this.playlist.songs.splice(songIndex, 1)[0];
-					this.playlist.songs.push(song);
-				}
-			});
-			this.socket.on("event:playlist.moveSongToTop", data => {
+
+			this.socket.on("event:playlist.repositionSongs", data => {
 				if (this.playlist._id === data.playlistId) {
-					let songIndex;
-					this.playlist.songs.forEach((song, index) => {
-						if (song.songId === data.songId) songIndex = index;
+					// for each song that has a new position
+					data.songsBeingChanged.forEach(changedSong => {
+						this.playlist.songs.forEach((song, index) => {
+							// find song locally
+							if (song.songId === changedSong.songId) {
+								// change song position attribute
+								this.playlist.songs[index].position =
+									changedSong.position;
+
+								// reposition in array if needed
+								if (index !== changedSong.position - 1)
+									this.playlist.songs.splice(
+										changedSong.position - 1,
+										0,
+										this.playlist.songs.splice(index, 1)[0]
+									);
+							}
+						});
 					});
-					const song = this.playlist.songs.splice(songIndex, 1)[0];
-					this.playlist.songs.unshift(song);
 				}
 			});
 		});
 	},
 	methods: {
+		updateSongPositioning({ moved }) {
+			if (!moved) return; // we only need to update when song is moved
+
+			const songsBeingChanged = [];
+
+			this.playlist.songs.forEach((song, index) => {
+				if (song.position !== index + 1)
+					songsBeingChanged.push({
+						songId: song.songId,
+						position: index + 1
+					});
+			});
+
+			this.socket.emit(
+				"playlists.repositionSongs",
+				this.playlist._id,
+				songsBeingChanged,
+				res => {
+					new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
+		},
 		totalLength() {
 			let length = 0;
 			this.playlist.songs.forEach(song => {
@@ -389,25 +456,23 @@ export default {
 				}
 			});
 		},
-		promoteSong(songId) {
-			this.socket.emit(
-				"playlists.moveSongToTop",
-				this.playlist._id,
-				songId,
-				res => {
-					new Toast({ content: res.message, timeout: 4000 });
-				}
+		moveSongToTop(index) {
+			this.playlist.songs.splice(
+				0,
+				0,
+				this.playlist.songs.splice(index, 1)[0]
 			);
+
+			this.updateSongPositioning({ moved: {} });
 		},
-		demoteSong(songId) {
-			this.socket.emit(
-				"playlists.moveSongToBottom",
-				this.playlist._id,
-				songId,
-				res => {
-					new Toast({ content: res.message, timeout: 4000 });
-				}
+		moveSongToBottom(index) {
+			this.playlist.songs.splice(
+				this.playlist.songs.length,
+				0,
+				this.playlist.songs.splice(index, 1)[0]
 			);
+
+			this.updateSongPositioning({ moved: {} });
 		},
 		updatePrivacy() {
 			const { privacy } = this.playlist;
@@ -437,15 +502,16 @@ export default {
 .menu-list li {
 	display: flex;
 	justify-content: space-between;
-}
 
-.menu-list a:hover {
-	color: $black !important;
-}
+	a {
+		display: flex;
+		align-items: center;
+		cursor: move;
 
-li a {
-	display: flex;
-	align-items: center;
+		&:hover {
+			color: $black !important;
+		}
+	}
 }
 
 .controls {

+ 10 - 24
frontend/src/pages/Station/index.vue

@@ -1236,26 +1236,13 @@ export default {
 											this.station._id,
 											data.song.songId,
 											data2 => {
-												if (
-													data2.status === "success"
-												) {
+												if (data2.status === "success")
 													this.socket.emit(
 														"playlists.moveSongToBottom",
 														this
 															.privatePlaylistQueueSelected,
-														data.song.songId,
-														data3 => {
-															if (
-																data3.status ===
-																"success"
-															) {
-																console.log(
-																	"This comment is just here because of eslint/prettier issues, ignore it"
-																);
-															}
-														}
+														data.song.songId
 													);
-												}
 											}
 										);
 									} else {
@@ -1263,27 +1250,26 @@ export default {
 											content: `Top song in playlist was too long to be added.`,
 											timeout: 3000
 										});
+
 										this.socket.emit(
 											"playlists.moveSongToBottom",
 											this.privatePlaylistQueueSelected,
 											data.song.songId,
 											data3 => {
-												if (
-													data3.status === "success"
-												) {
-													setTimeout(() => {
-														this.addFirstPrivatePlaylistSongToQueue();
-													}, 3000);
-												}
+												if (data3.status === "success")
+													setTimeout(
+														() =>
+															this.addFirstPrivatePlaylistSongToQueue(),
+														3000
+													);
 											}
 										);
 									}
-								} else {
+								} else
 									new Toast({
 										content: `Selected playlist has no songs.`,
 										timeout: 4000
 									});
-								}
 							}
 						}
 					);