소스 검색

Merge pull request #70 from Musare/owen-draggable

Draggable Queue
Jonathan Graham 3 년 전
부모
커밋
77755dbf07
4개의 변경된 파일235개의 추가작업 그리고 45개의 파일을 삭제
  1. 91 0
      backend/logic/actions/stations.js
  2. 110 43
      frontend/src/pages/Station/Sidebar/Queue.vue
  3. 14 2
      frontend/src/pages/Station/index.vue
  4. 20 0
      frontend/src/store/modules/station.js

+ 91 - 0
backend/logic/actions/stations.js

@@ -324,6 +324,16 @@ CacheModule.runJob("SUB", {
 	}
 });
 
+CacheModule.runJob("SUB", {
+	channel: "station.repositionSongInQueue",
+	cb: res => {
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${res.stationId}`,
+			args: ["event:queue.repositionSong", res.song]
+		});
+	}
+});
+
 CacheModule.runJob("SUB", {
 	channel: "station.voteSkipSong",
 	cb: stationId => {
@@ -3094,6 +3104,87 @@ export default {
 		);
 	},
 
+	/**
+	 * Reposition a song in station queue
+	 *
+	 * @param {object} session - user session
+	 * @param {object} song - contains details about the song that is to be repositioned
+	 * @param {string} song.songId - the id of the song
+	 * @param {number} song.newIndex - the new position for the song in the queue
+	 * @param {number} song.oldIndex - the old position of the song in the queue
+	 * @param {string} stationId - the station id
+	 * @param {Function} cb - callback
+	 */
+	repositionSongInQueue: isOwnerRequired(async function repositionQueue(session, song, stationId, cb) {
+		const stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					if (!song || !song.songId) return next("You must provide a song to reposition.");
+					return next();
+				},
+
+				// remove song from queue
+				next => {
+					stationModel.updateOne({ _id: stationId }, { $pull: { queue: { songId: song.songId } } }, next);
+				},
+
+				// add song back to queue (in new position)
+				(res, next) => {
+					stationModel.updateOne(
+						{ _id: stationId },
+						{ $push: { queue: { $each: [song], $position: song.newIndex } } },
+						err => next(err)
+					);
+				},
+
+				// update the cache representation of the station
+				next => {
+					StationsModule.runJob("UPDATE_STATION", { stationId }, this)
+						.then(station => next(null, station))
+						.catch(next);
+				}
+			],
+			async err => {
+				console.log(err);
+
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"STATIONS_REPOSITION_SONG_IN_QUEUE",
+						`Repositioning song ${song.songId} in queue of station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "failure", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"STATIONS_REPOSITION_SONG_IN_QUEUE",
+					`Repositioned song ${song.songId} in queue of station "${stationId}" successfully.`
+				);
+
+				CacheModule.runJob("PUB", {
+					channel: "station.repositionSongInQueue",
+					value: {
+						song: {
+							songId: song.songId,
+							oldIndex: song.oldIndex,
+							newIndex: song.newIndex
+						},
+						stationId
+					}
+				});
+
+				return cb({
+					status: "success",
+					message: "Successfully repositioned song in queue."
+				});
+			}
+		);
+	}),
+
 	/**
 	 * Selects a private playlist for a station
 	 *

+ 110 - 43
frontend/src/pages/Station/Sidebar/Queue.vue

@@ -1,54 +1,69 @@
 <template>
 	<div id="queue">
-		<div
+		<draggable
 			:class="{
 				'actionable-button-hidden': !actionableButtonVisible,
 				'scrollable-list': true
 			}"
+			v-if="queue.length > 0"
+			v-model="queue"
+			v-bind="dragOptions"
+			@start="drag = true"
+			@end="drag = false"
+			@change="repositionSongInQueue"
 		>
-			<song-item
-				v-for="(song, index) in songsList"
-				:key="index + song.songId"
-				:song="song"
-				:requested-by="
-					station.type === 'community' && station.partyMode === true
-				"
+			<transition-group
+				type="transition"
+				:name="!drag ? 'draggable-list-transition' : null"
 			>
-				<div
-					v-if="isAdminOnly() || isOwnerOnly()"
-					class="song-actions"
-					slot="actions"
+				<song-item
+					v-for="(song, index) in queue"
+					:key="index + song.songId"
+					:song="song"
+					:requested-by="
+						station.type === 'community' &&
+							station.partyMode === true
+					"
+					:class="{
+						'item-draggable': isAdminOnly() || isOwnerOnly()
+					}"
 				>
-					<i
-						v-if="isOwnerOnly() || isAdminOnly()"
-						class="material-icons delete-icon"
-						@click="removeFromQueue(song.songId)"
-						content="Remove Song from Queue"
-						v-tippy
-						>delete_forever</i
-					>
-					<i
-						class="material-icons"
-						v-if="index > 0"
-						@click="moveSongToTop(index)"
-						content="Move to top of Queue"
-						v-tippy
-						>vertical_align_top</i
-					>
-					<i
-						v-if="songsList.length - 1 !== index"
-						@click="moveSongToBottom(index)"
-						class="material-icons"
-						content="Move to bottom of Queue"
-						v-tippy
-						>vertical_align_bottom</i
+					<div
+						v-if="isAdminOnly() || isOwnerOnly()"
+						class="song-actions"
+						slot="actions"
 					>
-				</div>
-			</song-item>
-			<p class="nothing-here-text" v-if="songsList.length < 1">
-				There are no songs currently queued
-			</p>
-		</div>
+						<i
+							v-if="isOwnerOnly() || isAdminOnly()"
+							class="material-icons delete-icon"
+							@click="removeFromQueue(song.songId)"
+							content="Remove Song from Queue"
+							v-tippy
+							>delete_forever</i
+						>
+						<i
+							class="material-icons"
+							v-if="index > 0"
+							@click="moveSongToTop(song, index)"
+							content="Move to top of Queue"
+							v-tippy
+							>vertical_align_top</i
+						>
+						<i
+							v-if="queue.length - 1 !== index"
+							@click="moveSongToBottom(song, index)"
+							class="material-icons"
+							content="Move to bottom of Queue"
+							v-tippy
+							>vertical_align_bottom</i
+						>
+					</div>
+				</song-item>
+			</transition-group>
+		</draggable>
+		<p class="nothing-here-text" v-else>
+			There are no songs currently queued
+		</p>
 		<button
 			class="button is-primary tab-actionable-button"
 			v-if="
@@ -110,19 +125,37 @@
 
 <script>
 import { mapActions, mapState, mapGetters } from "vuex";
+import draggable from "vuedraggable";
 import Toast from "toasters";
 
 import SongItem from "@/components/SongItem.vue";
 
 export default {
-	components: { SongItem },
+	components: { draggable, SongItem },
 	data() {
 		return {
 			dismissedWarning: false,
-			actionableButtonVisible: false
+			actionableButtonVisible: false,
+			drag: false
 		};
 	},
 	computed: {
+		queue: {
+			get() {
+				return this.$store.state.station.songsList;
+			},
+			set(queue) {
+				this.$store.commit("station/updateSongsList", queue);
+			}
+		},
+		dragOptions() {
+			return {
+				animation: 200,
+				group: "queue",
+				disabled: !(this.isAdminOnly() || this.isOwnerOnly()),
+				ghostClass: "draggable-list-ghost"
+			};
+		},
 		...mapState({
 			loggedIn: state => state.user.auth.loggedIn,
 			userId: state => state.user.auth.userId,
@@ -164,6 +197,40 @@ export default {
 				}
 			);
 		},
+		repositionSongInQueue({ moved }) {
+			if (!moved) return; // we only need to update when song is moved
+
+			this.socket.dispatch(
+				"stations.repositionSongInQueue",
+				{
+					...moved.element,
+					oldIndex: moved.oldIndex,
+					newIndex: moved.newIndex
+				},
+				this.station._id,
+				res => {
+					new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
+		},
+		moveSongToTop(song, index) {
+			this.repositionSongInQueue({
+				moved: {
+					element: song,
+					oldIndex: index,
+					newIndex: 0
+				}
+			});
+		},
+		moveSongToBottom(song, index) {
+			this.repositionSongInQueue({
+				moved: {
+					element: song,
+					oldIndex: index,
+					newIndex: this.songsList.length
+				}
+			});
+		},
 		...mapActions("modalVisibility", ["openModal"])
 	}
 };

+ 14 - 2
frontend/src/pages/Station/index.vue

@@ -923,10 +923,21 @@ export default {
 
 		this.socket.on("event:queue.update", queue => {
 			this.updateSongsList(queue);
+
 			let nextSong = null;
-			if (this.songsList[0]) {
+			if (this.songsList[0])
 				nextSong = this.songsList[0].songId ? this.songsList[0] : null;
-			}
+
+			this.updateNextSong(nextSong);
+		});
+
+		this.socket.on("event:queue.repositionSong", song => {
+			this.repositionSongInList(song);
+
+			let nextSong = null;
+			if (this.songsList[0])
+				nextSong = this.songsList[0].songId ? this.songsList[0] : null;
+
 			this.updateNextSong(nextSong);
 		});
 
@@ -1861,6 +1872,7 @@ export default {
 			"updatePreviousSong",
 			"updateNextSong",
 			"updateSongsList",
+			"repositionSongInList",
 			"updateStationPaused",
 			"updateLocalPaused",
 			"updateNoSong",

+ 20 - 0
frontend/src/store/modules/station.js

@@ -45,6 +45,9 @@ const actions = {
 	updateSongsList: ({ commit }, songsList) => {
 		commit("updateSongsList", songsList);
 	},
+	repositionSongInList: ({ commit }, song) => {
+		commit("repositionSongInList", song);
+	},
 	updateStationPaused: ({ commit }, stationPaused) => {
 		commit("updateStationPaused", stationPaused);
 	},
@@ -87,6 +90,23 @@ const mutations = {
 	updateSongsList(state, songsList) {
 		state.songsList = songsList;
 	},
+	repositionSongInList(state, song) {
+		if (
+			state.songsList[song.newIndex] &&
+			state.songsList[song.newIndex].songId === song.songId
+		)
+			return;
+
+		const { songsList } = state;
+
+		songsList.splice(
+			song.newIndex,
+			0,
+			songsList.splice(song.oldIndex, 1)[0]
+		);
+
+		state.songsList = songsList;
+	},
 	updateStationPaused(state, stationPaused) {
 		state.stationPaused = stationPaused;
 	},