浏览代码

Merge remote-tracking branch 'origin/polishing' into owen

Owen Diffey 3 年之前
父节点
当前提交
f480fd604e
共有 49 个文件被更改,包括 602 次插入860 次删除
  1. 96 22
      backend/logic/actions/playlists.js
  2. 3 1
      backend/logic/actions/stations.js
  3. 2 1
      backend/logic/stations.js
  4. 1 1
      backend/logic/ws.js
  5. 3 3
      frontend/package-lock.json
  6. 24 8
      frontend/src/App.vue
  7. 3 2
      frontend/src/components/AddToPlaylistDropdown.vue
  8. 5 0
      frontend/src/components/Queue.vue
  9. 33 8
      frontend/src/components/SearchQueryItem.vue
  10. 4 0
      frontend/src/components/SongItem.vue
  11. 138 67
      frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue
  12. 9 7
      frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue
  13. 3 1
      frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue
  14. 19 1
      frontend/src/components/modals/EditPlaylist/index.vue
  15. 6 6
      frontend/src/components/modals/EditSong/Tabs/Youtube.vue
  16. 1 1
      frontend/src/components/modals/Login.vue
  17. 11 12
      frontend/src/components/modals/ManageStation/Tabs/Playlists.vue
  18. 12 65
      frontend/src/components/modals/ManageStation/Tabs/Search.vue
  19. 9 5
      frontend/src/components/modals/ManageStation/Tabs/Settings.vue
  20. 0 490
      frontend/src/components/modals/ManageStation/Tabs/Songs.vue
  21. 18 19
      frontend/src/components/modals/ManageStation/index.vue
  22. 1 1
      frontend/src/components/modals/Register.vue
  23. 16 11
      frontend/src/components/modals/RequestSong.vue
  24. 1 1
      frontend/src/main.js
  25. 70 0
      frontend/src/mixins/SearchMusare.vue
  26. 13 10
      frontend/src/mixins/SearchYoutube.vue
  27. 1 1
      frontend/src/pages/404.vue
  28. 1 1
      frontend/src/pages/About.vue
  29. 1 1
      frontend/src/pages/Admin/tabs/HiddenSongs.vue
  30. 1 1
      frontend/src/pages/Admin/tabs/News.vue
  31. 1 1
      frontend/src/pages/Admin/tabs/Playlists.vue
  32. 1 1
      frontend/src/pages/Admin/tabs/Punishments.vue
  33. 1 1
      frontend/src/pages/Admin/tabs/Reports.vue
  34. 1 1
      frontend/src/pages/Admin/tabs/Stations.vue
  35. 1 1
      frontend/src/pages/Admin/tabs/Statistics.vue
  36. 1 1
      frontend/src/pages/Admin/tabs/UnverifiedSongs.vue
  37. 1 1
      frontend/src/pages/Admin/tabs/Users.vue
  38. 1 1
      frontend/src/pages/Admin/tabs/VerifiedSongs.vue
  39. 1 1
      frontend/src/pages/Banned.vue
  40. 1 1
      frontend/src/pages/Home.vue
  41. 1 1
      frontend/src/pages/News.vue
  42. 1 1
      frontend/src/pages/Privacy.vue
  43. 1 1
      frontend/src/pages/Profile/index.vue
  44. 1 1
      frontend/src/pages/ResetPassword.vue
  45. 1 1
      frontend/src/pages/Settings/index.vue
  46. 76 96
      frontend/src/pages/Station/index.vue
  47. 1 1
      frontend/src/pages/Team.vue
  48. 1 1
      frontend/src/pages/Terms.vue
  49. 4 0
      frontend/src/store/modules/modals/editPlaylist.js

+ 96 - 22
backend/logic/actions/playlists.js

@@ -948,16 +948,17 @@ export default {
 						? `${newSong.title} by ${newSong.artists.join(", ")}`
 						: newSong.title;
 
-					ActivitiesModule.runJob("ADD_ACTIVITY", {
-						userId: session.userId,
-						type: "playlist__add_song",
-						payload: {
-							message: `Added <youtubeId>${songName}</youtubeId> to playlist <playlistId>${playlist.displayName}</playlistId>`,
-							thumbnail: newSong.thumbnail,
-							playlistId,
-							youtubeId
-						}
-					});
+					if (playlist.privacy === "public")
+						ActivitiesModule.runJob("ADD_ACTIVITY", {
+							userId: session.userId,
+							type: "playlist__add_song",
+							payload: {
+								message: `Added <youtubeId>${songName}</youtubeId> to playlist <playlistId>${playlist.displayName}</playlistId>`,
+								thumbnail: newSong.thumbnail,
+								playlistId,
+								youtubeId
+							}
+						});
 				}
 
 				StationsModule.runJob("GET_STATIONS_THAT_INCLUDE_OR_EXCLUDE_PLAYLIST", { playlistId })
@@ -1084,14 +1085,15 @@ export default {
 					return cb({ status: "error", message: err });
 				}
 
-				ActivitiesModule.runJob("ADD_ACTIVITY", {
-					userId: session.userId,
-					type: "playlist__import_playlist",
-					payload: {
-						message: `Imported ${addSongsStats.successful} songs to playlist <playlistId>${playlist.displayName}</playlistId>`,
-						playlistId
-					}
-				});
+				if (playlist.privacy === "public")
+					ActivitiesModule.runJob("ADD_ACTIVITY", {
+						userId: session.userId,
+						type: "playlist__import_playlist",
+						payload: {
+							message: `Imported ${addSongsStats.successful} songs to playlist <playlistId>${playlist.displayName}</playlistId>`,
+							playlistId
+						}
+					});
 
 				this.log(
 					"SUCCESS",
@@ -1172,7 +1174,11 @@ export default {
 						? `${youtubeSong.title} by ${youtubeSong.artists.join(", ")}`
 						: youtubeSong.title;
 
-					if (playlist.displayName !== "Liked Songs" && playlist.displayName !== "Disliked Songs") {
+					if (
+						playlist.displayName !== "Liked Songs" &&
+						playlist.displayName !== "Disliked Songs" &&
+						playlist.privacy === "public"
+					) {
 						ActivitiesModule.runJob("ADD_ACTIVITY", {
 							userId: session.userId,
 							type: "playlist__remove_song",
@@ -1455,9 +1461,13 @@ export default {
 				},
 
 				(res, next) => {
-					PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
-						.then(playlist => next(null, playlist))
-						.catch(next);
+					if (res.n === 0) next("No user playlist found with that id and owned by you.");
+					else if (res.nModified === 0) next(`Nothing changed, the playlist was already ${privacy}.`);
+					else {
+						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+							.then(playlist => next(null, playlist))
+							.catch(next);
+					}
 				}
 			],
 			async (err, playlist) => {
@@ -1504,6 +1514,70 @@ export default {
 		);
 	}),
 
+	/**
+	 * Updates the privacy of a playlist
+	 *
+	 * @param {object} session - the session object automatically added by the websocket
+	 * @param {string} playlistId - the id of the playlist we are updating the privacy for
+	 * @param {string} privacy - what the new privacy of the playlist should be e.g. public
+	 * @param {Function} cb - gets called with the result
+	 */
+	updatePrivacyAdmin: isAdminRequired(async function updatePrivacyAdmin(session, playlistId, privacy, cb) {
+		const playlistModel = await DBModule.runJob("GET_MODEL", { modelName: "playlist" }, this);
+
+		async.waterfall(
+			[
+				next => {
+					playlistModel.updateOne({ _id: playlistId }, { $set: { privacy } }, { runValidators: true }, next);
+				},
+
+				(res, next) => {
+					if (res.n === 0) next("No playlist found with that id.");
+					else if (res.nModified === 0) next(`Nothing changed, the playlist was already ${privacy}.`);
+					else {
+						PlaylistsModule.runJob("UPDATE_PLAYLIST", { playlistId }, this)
+							.then(playlist => next(null, playlist))
+							.catch(next);
+					}
+				}
+			],
+			async (err, playlist) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+					this.log(
+						"ERROR",
+						"PLAYLIST_UPDATE_PRIVACY_ADMIN",
+						`Updating privacy to "${privacy}" for playlist "${playlistId}" failed for user "${session.userId}". "${err}"`
+					);
+
+					return cb({ status: "error", message: err });
+				}
+
+				this.log(
+					"SUCCESS",
+					"PLAYLIST_UPDATE_PRIVACY_ADMIn",
+					`Successfully updated privacy to "${privacy}" for playlist "${playlistId}" for user "${session.userId}".`
+				);
+
+				if (playlist.type === "user") {
+					CacheModule.runJob("PUB", {
+						channel: "playlist.updatePrivacy",
+						value: {
+							userId: playlist.createdBy,
+							playlist
+						}
+					});
+				}
+
+				return cb({
+					status: "success",
+					message: "Playlist has been successfully updated"
+				});
+			}
+		);
+	}),
+
 	/**
 	 * Deletes all orphaned station playlists
 	 *

+ 3 - 1
backend/logic/actions/stations.js

@@ -48,6 +48,7 @@ CacheModule.runJob("SUB", {
 
 				sockets.forEach(async socketId => {
 					const socket = await WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this);
+					if (!socket) return;
 					const { session } = socket;
 
 					if (session.sessionId) {
@@ -513,6 +514,7 @@ CacheModule.runJob("SUB", {
 
 				sockets.forEach(async socketId => {
 					const socket = await WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this);
+					if (!socket) return;
 					const { session } = socket;
 
 					if (session.sessionId) {
@@ -1326,7 +1328,7 @@ export default {
 						(socketId, next) => {
 							WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this)
 								.then(socket => {
-									if (socket.session && socket.session.userId) {
+									if (socket && socket.session && socket.session.userId) {
 										if (!users.includes(socket.session.userId)) users.push(socket.session.userId);
 									} else users.push(socketId);
 									return next();

+ 2 - 1
backend/logic/stations.js

@@ -1006,6 +1006,7 @@ class _StationsModule extends CoreClass {
 
 						sockets.forEach(async socketId => {
 							const socket = await WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId });
+							if (!socket) return;
 							const { session } = socket;
 
 							if (session.sessionId) {
@@ -1175,7 +1176,7 @@ class _StationsModule extends CoreClass {
 						(socketId, next) => {
 							WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this)
 								.then(socket => {
-									sockets.push(socket);
+									if (socket) sockets.push(socket);
 									next();
 								})
 								.catch(err => {

+ 1 - 1
backend/logic/ws.js

@@ -317,7 +317,7 @@ class _WSModule extends CoreClass {
 				return WSModule.rooms[payload.room].forEach(async socketId => {
 					// get every socketId (and thus every socket) in the room, and dispatch to each
 					const socket = await WSModule.runJob("SOCKET_FROM_SOCKET_ID", { socketId }, this);
-					socket.dispatch(...payload.args);
+					if (socket) socket.dispatch(...payload.args);
 					return resolve();
 				});
 

+ 3 - 3
frontend/package-lock.json

@@ -8233,9 +8233,9 @@
       "integrity": "sha512-FBk4IesMV1rBxX2tfiK8RAmogtWn53puLOQlvO8XuwlgxcYbP4mVPS9Ph4aeamSyyVjOl24aYWAuc8U5kCVwMw=="
     },
     "tar": {
-      "version": "6.1.0",
-      "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.0.tgz",
-      "integrity": "sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==",
+      "version": "6.1.5",
+      "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.5.tgz",
+      "integrity": "sha512-FiK6MQyyaqd5vHuUjbg/NpO8BuEGeSXcmlH7Pt/JkugWS8s0w8nKybWjHDJiwzCAIKZ66uof4ghm4tBADjcqRA==",
       "dev": true,
       "requires": {
         "chownr": "^2.0.0",

+ 24 - 8
frontend/src/App.vue

@@ -448,6 +448,13 @@ a {
 			color: var(--black);
 		}
 
+		&[data-theme~="songActions"],
+		&[data-theme~="addToPlaylist"],
+		&[data-theme~="stationSettings"] {
+			background-color: var(--dark-grey-2);
+			border: 0 !important;
+		}
+
 		&[data-theme~="songActions"] {
 			background-color: var(--dark-grey-2);
 			border: 0 !important;
@@ -461,6 +468,7 @@ a {
 				background-color: var(--white);
 			}
 		}
+
 		&[data-theme~="addToPlaylist"] {
 			background-color: var(--dark-grey-2);
 			border: 0 !important;
@@ -492,7 +500,8 @@ a {
 
 	.tippy-box[data-placement^="bottom"] {
 		&[data-theme~="songActions"],
-		&[data-theme~="addToPlaylist"] {
+		&[data-theme~="addToPlaylist"],
+		&[data-theme~="stationSettings"] {
 			> .tippy-arrow::before {
 				border-bottom-color: var(--dark-grey-2);
 			}
@@ -612,7 +621,8 @@ a {
 
 .tippy-box[data-placement^="bottom"] {
 	&[data-theme~="songActions"],
-	&[data-theme~="addToPlaylist"] {
+	&[data-theme~="addToPlaylist"],
+	&[data-theme~="stationSettings"] {
 		> .tippy-arrow::before {
 			border-bottom-color: var(--white);
 		}
@@ -646,6 +656,16 @@ a {
 	}
 }
 
+.tippy-box[data-theme~="stationSettings"] {
+	border: 1px solid var(--light-grey-3);
+	box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22);
+	background-color: var(--white);
+
+	button:not(:last-of-type) {
+		margin-bottom: 5px;
+	}
+}
+
 .tippy-box[data-theme~="addToPlaylist"] {
 	font-size: 15px;
 	padding: 5px;
@@ -653,7 +673,7 @@ a {
 	box-shadow: 0 14px 28px rgba(0, 0, 0, 0.25), 0 10px 10px rgba(0, 0, 0, 0.22);
 	background-color: var(--white);
 	color: var(--dark-grey);
-	width: max-content;
+	width: 100%;
 
 	.nav-dropdown-items {
 		.nav-item {
@@ -692,6 +712,7 @@ a {
 				}
 
 				.slider {
+					width: 100%;
 					position: absolute;
 					cursor: pointer;
 					top: 0;
@@ -979,11 +1000,6 @@ h4.section-title {
 			cursor: pointer;
 			// color: var(--dark-grey);
 
-			&:hover,
-			&:focus {
-				filter: brightness(90%);
-			}
-
 			&:not(:first-child) {
 				margin-left: 5px;
 			}

+ 3 - 2
frontend/src/components/AddToPlaylistDropdown.vue

@@ -145,8 +145,9 @@ export default {
 		},
 		hasSong(playlist) {
 			return (
-				playlist.songs.map(song => song._id).indexOf(this.song._id) !==
-				-1
+				playlist.songs
+					.map(song => song.youtubeId)
+					.indexOf(this.song.youtubeId) !== -1
 			);
 		},
 		createPlaylist() {

+ 5 - 0
frontend/src/components/Queue.vue

@@ -30,6 +30,7 @@
 							'item-draggable': isAdminOnly() || isOwnerOnly()
 						}"
 						:disabled-actions="[]"
+						:ref="`song-item-${index}`"
 					>
 						<template
 							v-if="isAdminOnly() || isOwnerOnly()"
@@ -253,6 +254,8 @@ export default {
 			);
 		},
 		moveSongToTop(song, index) {
+			this.$refs[`song-item-${index}`].$refs.songActions.tippy.hide();
+
 			this.repositionSongInQueue({
 				moved: {
 					element: song,
@@ -262,6 +265,8 @@ export default {
 			});
 		},
 		moveSongToBottom(song, index) {
+			this.$refs[`song-item-${index}`].$refs.songActions.tippy.hide();
+
 			this.repositionSongInQueue({
 				moved: {
 					element: song,

+ 33 - 8
frontend/src/components/SearchQueryItem.vue

@@ -17,8 +17,37 @@
 				</a>
 			</div>
 		</div>
+
 		<div class="universal-item-actions">
-			<slot name="actions" />
+			<tippy
+				:touch="true"
+				:interactive="true"
+				placement="left"
+				theme="songActions"
+				ref="songActions"
+				trigger="click"
+			>
+				<i
+					class="material-icons action-dropdown-icon"
+					content="Song Options"
+					v-tippy
+					>more_horiz</i
+				>
+
+				<template #content>
+					<div class="icons-group">
+						<a
+							target="_blank"
+							:href="`https://www.youtube.com/watch?v=${result.id}`"
+							content="View on Youtube"
+							v-tippy
+						>
+							<div class="youtube-icon"></div>
+						</a>
+						<slot name="actions" />
+					</div>
+				</template>
+			</tippy>
 		</div>
 	</div>
 </template>
@@ -35,10 +64,6 @@ export default {
 </script>
 
 <style lang="scss">
-.search-query-item .universal-item-actions i {
-	color: var(--white) !important;
-}
-
 .search-query-actions-enter-active {
 	transition: all 0.2s ease;
 }
@@ -61,7 +86,7 @@ export default {
 <style lang="scss" scoped>
 .night-mode {
 	.search-query-item {
-		background-color: var(--dark-grey-3) !important;
+		background-color: var(--dark-grey-2) !important;
 		border: 0 !important;
 	}
 }
@@ -94,12 +119,12 @@ export default {
 		width: calc(100% - 65px);
 
 		.item-title {
-			font-size: 16px;
+			font-size: 20px;
 		}
 
 		.item-description {
 			margin: 0;
-			font-size: 12px;
+			font-size: 14px;
 		}
 
 		*:not(i) {

+ 4 - 0
frontend/src/components/SongItem.vue

@@ -270,6 +270,10 @@ export default {
 }
 
 .song-item {
+	&:not(:last-of-type) {
+		margin-bottom: 10px;
+	}
+
 	.thumbnail-and-info,
 	.duration-and-actions {
 		display: flex;

+ 138 - 67
frontend/src/components/modals/EditPlaylist/Tabs/AddSongs.vue

@@ -1,66 +1,131 @@
 <template>
 	<div class="youtube-tab section">
-		<label class="label"> Search for a song from YouTube </label>
-		<div class="control is-grouped input-with-button">
-			<p class="control is-expanded">
-				<input
-					class="input"
-					type="text"
-					placeholder="Enter your YouTube query here..."
-					v-model="search.songs.query"
-					autofocus
-					@keyup.enter="searchForSongs()"
+		<div>
+			<label class="label"> Search for a song on Musare </label>
+			<div class="control is-grouped input-with-button">
+				<p class="control is-expanded">
+					<input
+						class="input"
+						type="text"
+						placeholder="Enter your song query here..."
+						v-model="musareSearch.query"
+						@keyup.enter="searchForMusareSongs(1)"
+					/>
+				</p>
+				<p class="control">
+					<a class="button is-info" @click="searchForMusareSongs(1)"
+						><i class="material-icons icon-with-button">search</i
+						>Search</a
+					>
+				</p>
+			</div>
+			<div
+				v-if="musareSearch.results.length > 0"
+				class="song-query-results"
+			>
+				<song-item
+					v-for="song in musareSearch.results"
+					:key="song._id"
+					:song="song"
 				/>
-			</p>
-			<p class="control">
-				<a
-					class="button is-info"
-					@click.prevent="searchForSongs()"
-					href="#"
-					><i class="material-icons icon-with-button">search</i
-					>Search</a
+
+				<button
+					v-if="resultsLeftCount > 0"
+					class="button is-primary load-more-button"
+					@click="searchForMusareSongs(musareSearch.page + 1)"
 				>
-			</p>
+					Load {{ nextPageResultsCount }} more results
+				</button>
+			</div>
 		</div>
 
-		<div v-if="search.songs.results.length > 0" id="song-query-results">
-			<search-query-item
-				v-for="(result, index) in search.songs.results"
-				:key="result.id"
-				:result="result"
+		<br v-if="musareSearch.results.length > 0" />
+
+		<div>
+			<label class="label"> Search for a song from YouTube </label>
+			<div class="control is-grouped input-with-button">
+				<p class="control is-expanded">
+					<input
+						class="input"
+						type="text"
+						placeholder="Enter your YouTube query here..."
+						v-model="youtubeSearch.songs.query"
+						autofocus
+						@keyup.enter="searchForSongs()"
+					/>
+				</p>
+				<p class="control">
+					<a
+						class="button is-info"
+						@click.prevent="searchForSongs()"
+						href="#"
+						><i class="material-icons icon-with-button">search</i
+						>Search</a
+					>
+				</p>
+			</div>
+
+			<div
+				v-if="youtubeSearch.songs.results.length > 0"
+				class="song-query-results"
 			>
-				<template #actions>
-					<transition name="search-query-actions" mode="out-in">
-						<a
-							class="button is-success"
-							v-if="result.isAddedToQueue"
-							href="#"
-							key="added-to-playlist"
-						>
-							<i class="material-icons icon-with-button">done</i>
-							Added to playlist
-						</a>
-						<a
-							class="button is-dark"
-							v-else
-							@click.prevent="addSongToPlaylist(result.id, index)"
-							href="#"
-							key="add-to-playlist"
+				<search-query-item
+					v-for="result in youtubeSearch.songs.results"
+					:key="result.id"
+					:result="result"
+				>
+					<template #actions>
+						<add-to-playlist-dropdown
+							:song="{ youtubeId: result.id }"
+							placement="top-end"
 						>
-							<i class="material-icons icon-with-button">add</i>
-							Add to playlist
-						</a>
-					</transition>
-				</template>
-			</search-query-item>
+							<template #button>
+								<i
+									class="material-icons add-to-playlist-icon"
+									content="Add Song to Playlist"
+									v-tippy
+									>playlist_add</i
+								>
+							</template>
+						</add-to-playlist-dropdown>
+						<!-- <transition name="search-query-actions" mode="out-in">
+							<a
+								class="button is-success"
+								v-if="result.isAddedToQueue"
+								href="#"
+								key="added-to-playlist"
+							>
+								<i class="material-icons icon-with-button"
+									>done</i
+								>
+								Added to playlist
+							</a>
+							<a
+								class="button is-dark"
+								v-else
+								@click.prevent="
+									addSongToPlaylist(result.id, index)
+								"
+								href="#"
+								key="add-to-playlist"
+							>
+								<i class="material-icons icon-with-button"
+									>add</i
+								>
+								Add to playlist
+							</a>
+						</transition> -->
+					</template>
+				</search-query-item>
 
-			<a
-				class="button is-primary load-more-button"
-				@click.prevent="loadMoreSongs()"
-				href="#"
-			>
-				Load more...
-			</a>
+				<a
+					class="button is-primary load-more-button"
+					@click.prevent="loadMoreSongs()"
+					href="#"
+				>
+					Load more...
+				</a>
+			</div>
 		</div>
 	</div>
 </template>
@@ -69,15 +134,15 @@
 import { mapState, mapGetters } from "vuex";
 
 import SearchYoutube from "@/mixins/SearchYoutube.vue";
+import SearchMusare from "@/mixins/SearchMusare.vue";
 
-import SearchQueryItem from "../../../SearchQueryItem.vue";
+import SongItem from "@/components/SongItem.vue";
+import AddToPlaylistDropdown from "@/components/AddToPlaylistDropdown.vue";
+import SearchQueryItem from "@/components/SearchQueryItem.vue";
 
 export default {
-	components: { SearchQueryItem },
-	mixins: [SearchYoutube],
-	data() {
-		return {};
-	},
+	components: { SearchQueryItem, SongItem, AddToPlaylistDropdown },
+	mixins: [SearchYoutube, SearchMusare],
 	computed: {
 		...mapState("modals/editPlaylist", {
 			playlist: state => state.playlist
@@ -87,22 +152,28 @@ export default {
 		})
 	},
 	watch: {
-		"search.songs.results": function checkIfSongInPlaylist(songs) {
+		"youtubeSearch.songs.results": function checkIfSongInPlaylist(songs) {
 			songs.forEach((searchItem, index) =>
 				this.playlist.songs.find(song => {
 					if (song.youtubeId === searchItem.id)
-						this.search.songs.results[index].isAddedToQueue = true;
+						this.youtubeSearch.songs.results[
+							index
+						].isAddedToQueue = true;
 
 					return song.youtubeId === searchItem.id;
 				})
 			);
 		},
 		"playlist.songs": function checkIfSongInPlaylist() {
-			this.search.songs.results.forEach((searchItem, index) =>
+			this.youtubeSearch.songs.results.forEach((searchItem, index) =>
 				this.playlist.songs.find(song => {
-					this.search.songs.results[index].isAddedToQueue = false;
+					this.youtubeSearch.songs.results[
+						index
+					].isAddedToQueue = false;
 					if (song.youtubeId === searchItem.id)
-						this.search.songs.results[index].isAddedToQueue = true;
+						this.youtubeSearch.songs.results[
+							index
+						].isAddedToQueue = true;
 
 					return song.youtubeId === searchItem.id;
 				})
@@ -114,7 +185,7 @@ export default {
 
 <style lang="scss" scoped>
 .youtube-tab {
-	#song-query-results {
+	.song-query-results {
 		padding: 10px;
 		margin-top: 10px;
 		border: 1px solid var(--light-grey-3);
@@ -135,7 +206,7 @@ export default {
 }
 
 @media screen and (max-width: 1300px) {
-	.youtube-tab #song-query-results,
+	.youtube-tab .song-query-results,
 	.section {
 		max-width: 100% !important;
 	}

+ 9 - 7
frontend/src/components/modals/EditPlaylist/Tabs/ImportPlaylists.vue

@@ -7,13 +7,15 @@
 					class="input"
 					type="text"
 					placeholder="Enter YouTube Playlist URL here..."
-					v-model="search.playlist.query"
+					v-model="youtubeSearch.playlist.query"
 					@keyup.enter="importPlaylist()"
 				/>
 			</p>
 			<p class="control has-addons">
 				<span class="select" id="playlist-import-type">
-					<select v-model="search.playlist.isImportingOnlyMusic">
+					<select
+						v-model="youtubeSearch.playlist.isImportingOnlyMusic"
+					>
 						<option :value="false">Import all</option>
 						<option :value="true">Import only music</option>
 					</select>
@@ -54,11 +56,11 @@ export default {
 			let isImportingPlaylist = true;
 
 			// import query is blank
-			if (!this.search.playlist.query)
+			if (!this.youtubeSearch.playlist.query)
 				return new Toast("Please enter a YouTube playlist URL.");
 
 			const regex = new RegExp(`[\\?&]list=([^&#]*)`);
-			const splitQuery = regex.exec(this.search.playlist.query);
+			const splitQuery = regex.exec(this.youtubeSearch.playlist.query);
 
 			if (!splitQuery) {
 				return new Toast({
@@ -78,14 +80,14 @@ export default {
 
 			return this.socket.dispatch(
 				"playlists.addSetToPlaylist",
-				this.search.playlist.query,
+				this.youtubeSearch.playlist.query,
 				this.playlist._id,
-				this.search.playlist.isImportingOnlyMusic,
+				this.youtubeSearch.playlist.isImportingOnlyMusic,
 				res => {
 					new Toast({ content: res.message, timeout: 20000 });
 					if (res.status === "success") {
 						isImportingPlaylist = false;
-						if (this.search.playlist.isImportingOnlyMusic) {
+						if (this.youtubeSearch.playlist.isImportingOnlyMusic) {
 							new Toast({
 								content: `${res.data.stats.songsInPlaylistTotal} of the ${res.data.stats.videosInPlaylistTotal} videos in the playlist were songs.`,
 								timeout: 20000

+ 3 - 1
frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue

@@ -108,7 +108,9 @@ export default {
 			const { privacy } = this.playlist;
 			if (privacy === "public" || privacy === "private") {
 				this.socket.dispatch(
-					"playlists.updatePrivacy",
+					this.playlist.type === "genre"
+						? "playlists.updatePrivacyAdmin"
+						: "playlists.updatePrivacy",
 					this.playlist._id,
 					privacy,
 					res => {

+ 19 - 1
frontend/src/components/modals/EditPlaylist/index.vue

@@ -112,6 +112,7 @@
 											:class="{
 												'item-draggable': isEditable()
 											}"
+											:ref="`song-item-${index}`"
 										>
 											<template #actions>
 												<i
@@ -194,6 +195,12 @@
 									</div>
 								</template>
 							</draggable>
+							<p
+								v-else-if="gettingSongs"
+								class="nothing-here-text"
+							>
+								Loading songs...
+							</p>
 							<p v-else class="nothing-here-text">
 								This playlist doesn't have any songs.
 							</p>
@@ -269,7 +276,8 @@ export default {
 		return {
 			utils,
 			drag: false,
-			apiDomain: ""
+			apiDomain: "",
+			gettingSongs: false
 		};
 	},
 	computed: {
@@ -311,12 +319,14 @@ export default {
 		})
 	},
 	mounted() {
+		this.gettingSongs = true;
 		this.socket.dispatch("playlists.getPlaylist", this.editing, res => {
 			if (res.status === "success") {
 				// this.playlist = res.data.playlist;
 				// this.playlist.songs.sort((a, b) => a.position - b.position);
 				this.setPlaylist(res.data.playlist);
 			} else new Toast(res.message);
+			this.gettingSongs = false;
 		});
 
 		this.socket.on(
@@ -376,6 +386,9 @@ export default {
 			{ modal: "editPlaylist" }
 		);
 	},
+	beforeUnmount() {
+		this.clearPlaylist();
+	},
 	methods: {
 		isEditable() {
 			return (
@@ -409,6 +422,8 @@ export default {
 			);
 		},
 		moveSongToTop(song, index) {
+			this.$refs[`song-item-${index}`].$refs.songActions.tippy.hide();
+
 			this.repositionSong({
 				moved: {
 					element: song,
@@ -418,6 +433,8 @@ export default {
 			});
 		},
 		moveSongToBottom(song, index) {
+			this.$refs[`song-item-${index}`].$refs.songActions.tippy.hide();
+
 			this.repositionSong({
 				moved: {
 					element: song,
@@ -557,6 +574,7 @@ export default {
 		}),
 		...mapActions("modals/editPlaylist", [
 			"setPlaylist",
+			"clearPlaylist",
 			"addSong",
 			"removeSong",
 			"repositionedSong"

+ 6 - 6
frontend/src/components/modals/EditSong/Tabs/Youtube.vue

@@ -7,7 +7,7 @@
 					class="input"
 					type="text"
 					placeholder="Enter your YouTube query here..."
-					v-model="search.songs.query"
+					v-model="youtubeSearch.songs.query"
 					autofocus
 					@keyup.enter="searchForSongs()"
 				/>
@@ -23,9 +23,12 @@
 			</p>
 		</div>
 
-		<div v-if="search.songs.results.length > 0" id="song-query-results">
+		<div
+			v-if="youtubeSearch.songs.results.length > 0"
+			id="song-query-results"
+		>
 			<search-query-item
-				v-for="result in search.songs.results"
+				v-for="result in youtubeSearch.songs.results"
 				:key="result.id"
 				:result="result"
 			>
@@ -86,9 +89,6 @@ export default {
 </script>
 
 <style lang="scss" scoped>
-.night-mode {
-}
-
 .youtube-tab {
 	height: calc(100% - 32px);
 

+ 1 - 1
frontend/src/components/modals/Login.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<metadata title="Login" v-if="isPage" />
+		<page-metadata title="Login" v-if="isPage" />
 		<div class="modal is-active">
 			<div class="modal-background" @click="closeLoginModal()" />
 			<div class="modal-card">

+ 11 - 12
frontend/src/components/modals/ManageStation/Tabs/Playlists.vue

@@ -71,8 +71,7 @@
 							<i
 								v-if="
 									playlist.createdBy !== myUserId &&
-										(playlist.privacy === 'public' ||
-											isAdmin())
+									(playlist.privacy === 'public' || isAdmin())
 								"
 								@click="showPlaylist(playlist._id)"
 								class="material-icons edit-icon"
@@ -127,7 +126,7 @@
 									(isOwnerOrAdmin() ||
 										(station.type === 'community' &&
 											station.partyMode)) &&
-										isSelected(playlist._id)
+									isSelected(playlist._id)
 								"
 								@confirm="deselectPlaylist(playlist._id)"
 							>
@@ -144,8 +143,8 @@
 									(isOwnerOrAdmin() ||
 										(station.type === 'community' &&
 											station.partyMode)) &&
-										!isSelected(playlist._id) &&
-										!isExcluded(playlist._id)
+									!isSelected(playlist._id) &&
+									!isExcluded(playlist._id)
 								"
 								@click="selectPlaylist(playlist)"
 								class="material-icons play-icon"
@@ -245,10 +244,10 @@
 									<i
 										v-if="
 											station.type === 'community' &&
-												(isOwnerOrAdmin() ||
-													station.partyMode) &&
-												!isSelected(element._id) &&
-												!isExcluded(element._id)
+											(isOwnerOrAdmin() ||
+												station.partyMode) &&
+											!isSelected(element._id) &&
+											!isExcluded(element._id)
 										"
 										@click="selectPlaylist(element)"
 										class="material-icons play-icon"
@@ -263,9 +262,9 @@
 									<confirm
 										v-if="
 											station.type === 'community' &&
-												(isOwnerOrAdmin() ||
-													station.partyMode) &&
-												isSelected(element._id)
+											(isOwnerOrAdmin() ||
+												station.partyMode) &&
+											isSelected(element._id)
 										"
 										@confirm="deselectPlaylist(element._id)"
 									>

+ 12 - 65
frontend/src/components/modals/ManageStation/Tabs/Search.vue

@@ -53,7 +53,7 @@
 						class="input"
 						type="text"
 						placeholder="Enter your YouTube query here..."
-						v-model="search.songs.query"
+						v-model="youtubeSearch.songs.query"
 						autofocus
 						@keyup.enter="searchForSongs()"
 					/>
@@ -66,9 +66,12 @@
 				</p>
 			</div>
 
-			<div v-if="search.songs.results.length > 0" id="song-query-results">
+			<div
+				v-if="youtubeSearch.songs.results.length > 0"
+				id="song-query-results"
+			>
 				<search-query-item
-					v-for="(result, index) in search.songs.results"
+					v-for="(result, index) in youtubeSearch.songs.results"
 					:key="result.id"
 					:result="result"
 				>
@@ -117,6 +120,7 @@ import { mapState, mapGetters } from "vuex";
 
 import Toast from "toasters";
 import SearchYoutube from "@/mixins/SearchYoutube.vue";
+import SearchMusare from "@/mixins/SearchMusare.vue";
 
 import SongItem from "@/components/SongItem.vue";
 import SearchQueryItem from "../../../SearchQueryItem.vue";
@@ -126,26 +130,8 @@ export default {
 		SongItem,
 		SearchQueryItem
 	},
-	mixins: [SearchYoutube],
-	data() {
-		return {
-			musareSearch: {
-				query: "",
-				searchedQuery: "",
-				page: 0,
-				count: 0,
-				resultsLeft: 0,
-				results: []
-			}
-		};
-	},
+	mixins: [SearchYoutube, SearchMusare],
 	computed: {
-		resultsLeftCount() {
-			return this.musareSearch.count - this.musareSearch.results.length;
-		},
-		nextPageResultsCount() {
-			return Math.min(this.musareSearch.pageSize, this.resultsLeftCount);
-		},
 		...mapState("modals/manageStation", {
 			station: state => state.station,
 			originalStation: state => state.originalStation
@@ -166,7 +152,7 @@ export default {
 							new Toast(`Error: ${res.message}`);
 						else {
 							if (index)
-								this.search.songs.results[
+								this.youtubeSearch.songs.results[
 									index
 								].isAddedToQueue = true;
 
@@ -179,53 +165,14 @@ export default {
 					if (res.status !== "success")
 						new Toast(`Error: ${res.message}`);
 					else {
-						this.search.songs.results[index].isAddedToQueue = true;
+						this.youtubeSearch.songs.results[
+							index
+						].isAddedToQueue = true;
 
 						new Toast(res.message);
 					}
 				});
 			}
-		},
-		searchForMusareSongs(page) {
-			if (
-				this.musareSearch.page >= page ||
-				this.musareSearch.searchedQuery !== this.musareSearch.query
-			) {
-				this.musareSearch.results = [];
-				this.musareSearch.page = 0;
-				this.musareSearch.count = 0;
-				this.musareSearch.resultsLeft = 0;
-				this.musareSearch.pageSize = 0;
-			}
-
-			this.musareSearch.searchedQuery = this.musareSearch.query;
-			this.socket.dispatch(
-				"songs.searchOfficial",
-				this.musareSearch.query,
-				page,
-				res => {
-					const { data } = res;
-					const { count, pageSize, songs } = data;
-					if (res.status === "success") {
-						this.musareSearch.results = [
-							...this.musareSearch.results,
-							...songs
-						];
-						this.musareSearch.page = page;
-						this.musareSearch.count = count;
-						this.musareSearch.resultsLeft =
-							count - this.musareSearch.results.length;
-						this.musareSearch.pageSize = pageSize;
-					} else if (res.status === "error") {
-						this.musareSearch.results = [];
-						this.musareSearch.page = 0;
-						this.musareSearch.count = 0;
-						this.musareSearch.resultsLeft = 0;
-						this.musareSearch.pageSize = 0;
-						new Toast(res.message);
-					}
-				}
-			);
 		}
 	}
 };

+ 9 - 5
frontend/src/components/modals/ManageStation/Tabs/Settings.vue

@@ -44,7 +44,7 @@
 				<label class="label">Theme</label>
 				<div class="button-wrapper">
 					<tippy
-						theme="addToPlaylist"
+						theme="stationSettings"
 						:interactive="true"
 						:touch="true"
 						placement="bottom"
@@ -97,7 +97,7 @@
 				<label class="label">Privacy</label>
 				<div class="button-wrapper">
 					<tippy
-						theme="addToPlaylist"
+						theme="stationSettings"
 						:interactive="true"
 						:touch="true"
 						placement="bottom"
@@ -150,7 +150,7 @@
 				<label class="label">Station Mode</label>
 				<div class="button-wrapper" v-if="station.type === 'community'">
 					<tippy
-						theme="addToPlaylist"
+						theme="stationSettings"
 						:interactive="true"
 						:touch="true"
 						placement="bottom"
@@ -206,7 +206,7 @@
 				<label class="label">Play Mode</label>
 				<div class="button-wrapper" v-if="station.type === 'community'">
 					<tippy
-						theme="addToPlaylist"
+						theme="stationSettings"
 						:interactive="true"
 						:touch="true"
 						placement="bottom"
@@ -268,7 +268,7 @@
 				<label class="label">Queue lock</label>
 				<div class="button-wrapper">
 					<tippy
-						theme="addToPlaylist"
+						theme="stationSettings"
 						:interactive="true"
 						:touch="true"
 						placement="bottom"
@@ -547,6 +547,10 @@ export default {
 		display: flex;
 		flex-direction: column;
 
+		* >>> .tippy-box[data-theme~="addToPlaylist"] .tippy-content > span {
+			max-width: 150px !important;
+		}
+
 		button {
 			width: 100%;
 			height: 36px;

+ 0 - 490
frontend/src/components/modals/ManageStation/Tabs/Songs.vue

@@ -1,490 +0,0 @@
-<template>
-	<div class="songs">
-		<div class="tabs-container">
-			<div class="tab-selection">
-				<button
-					class="button is-default"
-					:class="{ selected: tab === 'search' }"
-					v-if="isAllowedToParty()"
-					@click="showTab('search')"
-				>
-					Search
-				</button>
-				<button
-					class="button is-default"
-					:class="{ selected: tab === 'included' }"
-					v-if="isOwnerOrAdmin() && isPlaylistMode()"
-					@click="showTab('included')"
-				>
-					Included
-				</button>
-				<button
-					class="button is-default"
-					:class="{ selected: tab === 'excluded' }"
-					v-if="isOwnerOrAdmin()"
-					@click="showTab('excluded')"
-				>
-					Excluded
-				</button>
-			</div>
-			<div
-				class="tab"
-				v-show="tab === 'search'"
-				v-if="
-					station.type === 'community' &&
-					station.partyMode &&
-					(isOwnerOrAdmin() || !station.locked)
-				"
-			>
-				<div class="musare-songs">
-					<label class="label"> Search for a song on Musare </label>
-					<div class="control is-grouped input-with-button">
-						<p class="control is-expanded">
-							<input
-								class="input"
-								type="text"
-								placeholder="Enter your song query here..."
-								v-model="musareSearch.query"
-								@keyup.enter="searchForMusareSongs(1)"
-							/>
-						</p>
-						<p class="control">
-							<a
-								class="button is-info"
-								@click="searchForMusareSongs(1)"
-								><i class="material-icons icon-with-button"
-									>search</i
-								>Search</a
-							>
-						</p>
-					</div>
-					<div v-if="musareSearch.results.length > 0">
-						<song-item
-							v-for="song in musareSearch.results"
-							:key="song._id"
-							:song="song"
-						>
-							<template #actions>
-								<i
-									class="material-icons add-to-queue-icon"
-									v-if="station.partyMode && !station.locked"
-									@click="addSongToQueue(song.youtubeId)"
-									content="Add Song to Queue"
-									v-tippy
-									>queue</i
-								>
-							</template>
-						</song-item>
-						<button
-							v-if="resultsLeftCount > 0"
-							class="button is-primary load-more-button"
-							@click="searchForMusareSongs(musareSearch.page + 1)"
-						>
-							Load {{ nextPageResultsCount }} more results
-						</button>
-					</div>
-				</div>
-				<div class="youtube-search">
-					<label class="label"> Search for a song on YouTube </label>
-					<div class="control is-grouped input-with-button">
-						<p class="control is-expanded">
-							<input
-								class="input"
-								type="text"
-								placeholder="Enter your YouTube query here..."
-								v-model="search.songs.query"
-								autofocus
-								@keyup.enter="searchForSongs()"
-							/>
-						</p>
-						<p class="control">
-							<a
-								class="button is-info"
-								@click.prevent="searchForSongs()"
-								><i class="material-icons icon-with-button"
-									>search</i
-								>Search</a
-							>
-						</p>
-					</div>
-
-					<div
-						v-if="search.songs.results.length > 0"
-						id="song-query-results"
-					>
-						<search-query-item
-							v-for="(result, index) in search.songs.results"
-							:key="result.id"
-							:result="result"
-						>
-							<template #actions>
-								<transition
-									name="search-query-actions"
-									mode="out-in"
-								>
-									<a
-										class="button is-success"
-										v-if="result.isAddedToQueue"
-										key="added-to-queue"
-									>
-										<i
-											class="
-												material-icons
-												icon-with-button
-											"
-											>done</i
-										>
-										Added to queue
-									</a>
-									<a
-										class="button is-dark"
-										v-else
-										@click.prevent="
-											addSongToQueue(result.id, index)
-										"
-										key="add-to-queue"
-									>
-										<i
-											class="
-												material-icons
-												icon-with-button
-											"
-											>add</i
-										>
-										Add to queue
-									</a>
-								</transition>
-							</template>
-						</search-query-item>
-
-						<a
-							class="button is-primary load-more-button"
-							@click.prevent="loadMoreSongs()"
-						>
-							Load more...
-						</a>
-					</div>
-				</div>
-			</div>
-			<div
-				class="tab"
-				v-show="tab === 'included'"
-				v-if="
-					isOwnerOrAdmin() &&
-					!(station.type === 'community' && station.partyMode)
-				"
-			>
-				<div v-if="stationPlaylist.songs.length > 0">
-					<div id="playlist-info-section">
-						<h5>Song Count: {{ stationPlaylist.songs.length }}</h5>
-						<h5>Duration: {{ totalLength(stationPlaylist) }}</h5>
-					</div>
-					<song-item
-						v-for="song in stationPlaylist.songs"
-						:key="song._id"
-						:song="song"
-					>
-					</song-item>
-				</div>
-				<p v-else class="has-text-centered scrollable-list">
-					No songs currently included. To include songs, include a
-					playlist.
-				</p>
-			</div>
-			<div
-				class="tab"
-				v-show="tab === 'excluded'"
-				v-if="isOwnerOrAdmin()"
-			>
-				<div v-if="excludedSongs.length > 0">
-					<div id="playlist-info-section" class="section">
-						<h5>Song Count: {{ excludedSongs.length }}</h5>
-					</div>
-					<song-item
-						v-for="song in excludedSongs"
-						:key="song._id"
-						:song="song"
-					>
-					</song-item>
-				</div>
-				<p v-else class="has-text-centered scrollable-list">
-					No songs currently excluded. To excluded songs, exclude a
-					playlist.
-				</p>
-			</div>
-		</div>
-	</div>
-</template>
-
-<script>
-import { mapState, mapGetters } from "vuex";
-
-import Toast from "toasters";
-import SearchYoutube from "@/mixins/SearchYoutube.vue";
-
-import SongItem from "@/components/SongItem.vue";
-import SearchQueryItem from "../../../SearchQueryItem.vue";
-
-import utils from "../../../../../js/utils";
-
-export default {
-	components: {
-		SongItem,
-		SearchQueryItem
-	},
-	mixins: [SearchYoutube],
-	data() {
-		return {
-			utils,
-			tab: "search",
-			musareSearch: {
-				query: "",
-				searchedQuery: "",
-				page: 0,
-				count: 0,
-				resultsLeft: 0,
-				results: []
-			}
-		};
-	},
-	computed: {
-		resultsLeftCount() {
-			return this.musareSearch.count - this.musareSearch.results.length;
-		},
-		nextPageResultsCount() {
-			return Math.min(this.musareSearch.pageSize, this.resultsLeftCount);
-		},
-		excludedSongs() {
-			return this.excludedPlaylists
-				.map(playlist => playlist.songs)
-				.flat()
-				.filter((song, index, self) => self.indexOf(song) === index);
-		},
-		excludedSongIds() {
-			return this.excludedSongs.map(excludedSong => excludedSong._id);
-		},
-		...mapState({
-			loggedIn: state => state.user.auth.loggedIn,
-			userId: state => state.user.auth.userId,
-			role: state => state.user.auth.role
-		}),
-		...mapState("modals/manageStation", {
-			parentTab: state => state.tab,
-			station: state => state.station,
-			originalStation: state => state.originalStation,
-			excludedPlaylists: state => state.excludedPlaylists,
-			stationPlaylist: state => state.stationPlaylist
-		}),
-		...mapGetters({
-			socket: "websockets/getSocket"
-		})
-	},
-	watch: {
-		// eslint-disable-next-line func-names
-		parentTab(value) {
-			if (value === "songs") {
-				if (this.tab === "search" && this.isPlaylistMode()) {
-					this.showTab("included");
-				} else if (this.tab === "included" && this.isPartyMode()) {
-					this.showTab("search");
-				}
-			}
-		}
-	},
-	methods: {
-		showTab(tab) {
-			this.tab = tab;
-		},
-		isOwner() {
-			return (
-				this.loggedIn &&
-				this.station &&
-				this.userId === this.station.owner
-			);
-		},
-		isAdmin() {
-			return this.loggedIn && this.role === "admin";
-		},
-		isOwnerOrAdmin() {
-			return this.isOwner() || this.isAdmin();
-		},
-		isPartyMode() {
-			return (
-				this.station &&
-				this.station.type === "community" &&
-				this.station.partyMode
-			);
-		},
-		isAllowedToParty() {
-			return (
-				this.station &&
-				this.isPartyMode() &&
-				(!this.station.locked || this.isOwnerOrAdmin()) &&
-				this.loggedIn
-			);
-		},
-		isPlaylistMode() {
-			return this.station && !this.isPartyMode();
-		},
-		totalLength(playlist) {
-			let length = 0;
-			playlist.songs.forEach(song => {
-				length += song.duration;
-			});
-			return this.utils.formatTimeLong(length);
-		},
-		addSongToQueue(youtubeId, index) {
-			if (this.station.type === "community") {
-				this.socket.dispatch(
-					"stations.addToQueue",
-					this.station._id,
-					youtubeId,
-					res => {
-						if (res.status !== "success")
-							new Toast(`Error: ${res.message}`);
-						else {
-							if (index)
-								this.search.songs.results[
-									index
-								].isAddedToQueue = true;
-
-							new Toast(res.message);
-						}
-					}
-				);
-			} else {
-				this.socket.dispatch("songs.request", youtubeId, res => {
-					if (res.status !== "success")
-						new Toast(`Error: ${res.message}`);
-					else {
-						this.search.songs.results[index].isAddedToQueue = true;
-
-						new Toast(res.message);
-					}
-				});
-			}
-		},
-		searchForMusareSongs(page) {
-			if (
-				this.musareSearch.page >= page ||
-				this.musareSearch.searchedQuery !== this.musareSearch.query
-			) {
-				this.musareSearch.results = [];
-				this.musareSearch.page = 0;
-				this.musareSearch.count = 0;
-				this.musareSearch.resultsLeft = 0;
-				this.musareSearch.pageSize = 0;
-			}
-
-			this.musareSearch.searchedQuery = this.musareSearch.query;
-			this.socket.dispatch(
-				"songs.searchOfficial",
-				this.musareSearch.query,
-				page,
-				res => {
-					const { data } = res;
-					const { count, pageSize, songs } = data;
-					if (res.status === "success") {
-						this.musareSearch.results = [
-							...this.musareSearch.results,
-							...songs
-						];
-						this.musareSearch.page = page;
-						this.musareSearch.count = count;
-						this.musareSearch.resultsLeft =
-							count - this.musareSearch.results.length;
-						this.musareSearch.pageSize = pageSize;
-					} else if (res.status === "error") {
-						this.musareSearch.results = [];
-						this.musareSearch.page = 0;
-						this.musareSearch.count = 0;
-						this.musareSearch.resultsLeft = 0;
-						this.musareSearch.pageSize = 0;
-						new Toast(res.message);
-					}
-				}
-			);
-		}
-	}
-};
-</script>
-
-<style lang="scss" scoped>
-.night-mode {
-	.tabs-container .tab-selection .button {
-		background: var(--dark-grey) !important;
-		color: var(--white) !important;
-	}
-}
-
-.songs {
-	.tabs-container {
-		.tab-selection {
-			display: flex;
-			overflow-x: auto;
-			.button {
-				border-radius: 0;
-				border: 0;
-				text-transform: uppercase;
-				font-size: 14px;
-				color: var(--dark-grey-3);
-				background-color: var(--light-grey-2);
-				flex-grow: 1;
-				height: 32px;
-
-				&:not(:first-of-type) {
-					margin-left: 5px;
-				}
-			}
-
-			.selected {
-				background-color: var(--primary-color) !important;
-				color: var(--white) !important;
-				font-weight: 600;
-			}
-		}
-		.tab {
-			padding: 15px 0;
-			border-radius: 0;
-			.playlist-item:not(:last-of-type),
-			.item.item-draggable:not(:last-of-type) {
-				margin-bottom: 10px;
-			}
-			.load-more-button {
-				width: 100%;
-				margin-top: 10px;
-			}
-		}
-	}
-
-	.musare-songs,
-	.universal-item:not(:last-of-type) {
-		margin-bottom: 10px;
-	}
-	.load-more-button {
-		width: 100%;
-		margin-top: 10px;
-	}
-
-	#playlist-info-section {
-		border: 1px solid var(--light-grey-3);
-		border-radius: 3px;
-		padding: 15px !important;
-		margin-bottom: 16px;
-
-		h3 {
-			font-weight: 600;
-			font-size: 30px;
-		}
-
-		h5 {
-			font-size: 18px;
-		}
-
-		h3,
-		h5 {
-			margin: 0;
-		}
-	}
-}
-</style>

+ 18 - 19
frontend/src/components/modals/ManageStation/index.vue

@@ -26,12 +26,11 @@
 							<button
 								v-if="
 									isOwnerOrAdmin() ||
-										(loggedIn &&
-											station.type === 'community' &&
-											station.partyMode &&
-											((station.locked &&
-												isOwnerOrAdmin()) ||
-												!station.locked))
+									(loggedIn &&
+										station.type === 'community' &&
+										station.partyMode &&
+										((station.locked && isOwnerOrAdmin()) ||
+											!station.locked))
 								"
 								class="button is-default"
 								:class="{ selected: tab === 'playlists' }"
@@ -43,10 +42,10 @@
 							<button
 								v-if="
 									loggedIn &&
-										station.type === 'community' &&
-										station.partyMode &&
-										((station.locked && isOwnerOrAdmin()) ||
-											!station.locked)
+									station.type === 'community' &&
+									station.partyMode &&
+									((station.locked && isOwnerOrAdmin()) ||
+										!station.locked)
 								"
 								class="button is-default"
 								:class="{ selected: tab === 'search' }"
@@ -73,11 +72,11 @@
 						<playlists
 							v-if="
 								isOwnerOrAdmin() ||
-									(loggedIn &&
-										station.type === 'community' &&
-										station.partyMode &&
-										((station.locked && isOwnerOrAdmin()) ||
-											!station.locked))
+								(loggedIn &&
+									station.type === 'community' &&
+									station.partyMode &&
+									((station.locked && isOwnerOrAdmin()) ||
+										!station.locked))
 							"
 							class="tab"
 							v-show="tab === 'playlists'"
@@ -85,10 +84,10 @@
 						<search
 							v-if="
 								loggedIn &&
-									station.type === 'community' &&
-									station.partyMode &&
-									((station.locked && isOwnerOrAdmin()) ||
-										!station.locked)
+								station.type === 'community' &&
+								station.partyMode &&
+								((station.locked && isOwnerOrAdmin()) ||
+									!station.locked)
 							"
 							class="tab"
 							v-show="tab === 'search'"

+ 1 - 1
frontend/src/components/modals/Register.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<metadata title="Register" v-if="isPage" />
+		<page-metadata title="Register" v-if="isPage" />
 		<div class="modal is-active">
 			<div class="modal-background" @click="closeRegisterModal()" />
 			<div class="modal-card">

+ 16 - 11
frontend/src/components/modals/RequestSong.vue

@@ -17,7 +17,7 @@
 							class="input"
 							type="text"
 							placeholder="Enter your YouTube query here..."
-							v-model="search.songs.query"
+							v-model="youtubeSearch.songs.query"
 							autofocus
 							@keyup.enter="searchForSongs()"
 						/>
@@ -38,10 +38,10 @@
 
 				<div
 					id="song-query-results"
-					v-if="search.songs.results.length > 0"
+					v-if="youtubeSearch.songs.results.length > 0"
 				>
 					<search-query-item
-						v-for="(result, index) in search.songs.results"
+						v-for="(result, index) in youtubeSearch.songs.results"
 						:key="result.id"
 						:result="result"
 					>
@@ -106,7 +106,7 @@
 								class="input"
 								type="text"
 								placeholder="YouTube Playlist URL"
-								v-model="search.playlist.query"
+								v-model="youtubeSearch.playlist.query"
 								@keyup.enter="importPlaylist()"
 							/>
 						</p>
@@ -114,7 +114,8 @@
 							<span class="select" id="playlist-import-type">
 								<select
 									v-model="
-										search.playlist.isImportingOnlyMusic
+										youtubeSearch.playlist
+											.isImportingOnlyMusic
 									"
 								>
 									<option :value="false">Import all</option>
@@ -172,7 +173,9 @@ export default {
 						if (res.status !== "success")
 							new Toast(`Error: ${res.message}`);
 						else {
-							this.search.songs.results[index].isRequested = true;
+							this.youtubeSearch.songs.results[
+								index
+							].isRequested = true;
 
 							new Toast(res.message);
 						}
@@ -183,7 +186,9 @@ export default {
 					if (res.status !== "success")
 						new Toast(`Error: ${res.message}`);
 					else {
-						this.search.songs.results[index].isRequested = true;
+						this.youtubeSearch.songs.results[
+							index
+						].isRequested = true;
 
 						new Toast(res.message);
 					}
@@ -194,11 +199,11 @@ export default {
 			let isImportingPlaylist = true;
 
 			// import query is blank
-			if (!this.search.playlist.query)
+			if (!this.youtubeSearch.playlist.query)
 				return new Toast("Please enter a YouTube playlist URL.");
 
 			const regex = new RegExp(`[\\?&]list=([^&#]*)`);
-			const splitQuery = regex.exec(this.search.playlist.query);
+			const splitQuery = regex.exec(this.youtubeSearch.playlist.query);
 
 			if (!splitQuery) {
 				return new Toast({
@@ -218,8 +223,8 @@ export default {
 
 			return this.socket.dispatch(
 				"songs.requestSet",
-				this.search.playlist.query,
-				this.search.playlist.isImportingOnlyMusic,
+				this.youtubeSearch.playlist.query,
+				this.youtubeSearch.playlist.isImportingOnlyMusic,
 				false,
 				res => {
 					isImportingPlaylist = false;

+ 1 - 1
frontend/src/main.js

@@ -31,7 +31,7 @@ app.use(VueTippy, {
 
 app.component("Tippy", Tippy);
 
-app.component("Metadata", {
+app.component("PageMetadata", {
 	watch: {
 		$attrs: {
 			// eslint-disable-next-line vue/no-arrow-functions-in-watch

+ 70 - 0
frontend/src/mixins/SearchMusare.vue

@@ -0,0 +1,70 @@
+<script>
+import Toast from "toasters";
+
+export default {
+	data() {
+		return {
+			musareSearch: {
+				query: "",
+				searchedQuery: "",
+				page: 0,
+				count: 0,
+				resultsLeft: 0,
+				results: []
+			}
+		};
+	},
+	computed: {
+		resultsLeftCount() {
+			return this.musareSearch.count - this.musareSearch.results.length;
+		},
+		nextPageResultsCount() {
+			return Math.min(this.musareSearch.pageSize, this.resultsLeftCount);
+		}
+	},
+	methods: {
+		searchForMusareSongs(page) {
+			if (
+				this.musareSearch.page >= page ||
+				this.musareSearch.searchedQuery !== this.musareSearch.query
+			) {
+				this.musareSearch.results = [];
+				this.musareSearch.page = 0;
+				this.musareSearch.count = 0;
+				this.musareSearch.resultsLeft = 0;
+				this.musareSearch.pageSize = 0;
+			}
+
+			this.musareSearch.searchedQuery = this.musareSearch.query;
+			this.socket.dispatch(
+				"songs.searchOfficial",
+				this.musareSearch.query,
+				page,
+				res => {
+					const { data } = res;
+					const { count, pageSize, songs } = data;
+
+					if (res.status === "success") {
+						this.musareSearch.results = [
+							...this.musareSearch.results,
+							...songs
+						];
+						this.musareSearch.page = page;
+						this.musareSearch.count = count;
+						this.musareSearch.resultsLeft =
+							count - this.musareSearch.results.length;
+						this.musareSearch.pageSize = pageSize;
+					} else if (res.status === "error") {
+						this.musareSearch.results = [];
+						this.musareSearch.page = 0;
+						this.musareSearch.count = 0;
+						this.musareSearch.resultsLeft = 0;
+						this.musareSearch.pageSize = 0;
+						new Toast(res.message);
+					}
+				}
+			);
+		}
+	}
+};
+</script>

+ 13 - 10
frontend/src/mixins/SearchYoutube.vue

@@ -4,7 +4,7 @@ import Toast from "toasters";
 export default {
 	data() {
 		return {
-			search: {
+			youtubeSearch: {
 				songs: {
 					results: [],
 					query: "",
@@ -19,7 +19,7 @@ export default {
 	},
 	methods: {
 		searchForSongs() {
-			let { query } = this.search.songs;
+			let { query } = this.youtubeSearch.songs;
 
 			if (query.indexOf("&index=") !== -1) {
 				query = query.split("&index=");
@@ -35,11 +35,12 @@ export default {
 
 			this.socket.dispatch("apis.searchYoutube", query, res => {
 				if (res.status === "success") {
-					this.search.songs.nextPageToken = res.data.nextPageToken;
-					this.search.songs.results = [];
+					this.youtubeSearch.songs.nextPageToken =
+						res.data.nextPageToken;
+					this.youtubeSearch.songs.results = [];
 
 					res.data.items.forEach(result => {
-						this.search.songs.results.push({
+						this.youtubeSearch.songs.results.push({
 							id: result.id.videoId,
 							url: `https://www.youtube.com/watch?v=${this.id}`,
 							title: result.snippet.title,
@@ -55,15 +56,15 @@ export default {
 		loadMoreSongs() {
 			this.socket.dispatch(
 				"apis.searchYoutubeForPage",
-				this.search.songs.query,
-				this.search.songs.nextPageToken,
+				this.youtubeSearch.songs.query,
+				this.youtubeSearch.songs.nextPageToken,
 				res => {
 					if (res.status === "success") {
-						this.search.songs.nextPageToken =
+						this.youtubeSearch.songs.nextPageToken =
 							res.data.nextPageToken;
 
 						res.data.items.forEach(result => {
-							this.search.songs.results.push({
+							this.youtubeSearch.songs.results.push({
 								id: result.id.videoId,
 								url: `https://www.youtube.com/watch?v=${this.id}`,
 								title: result.snippet.title,
@@ -87,7 +88,9 @@ export default {
 				res => {
 					new Toast(res.message);
 					if (res.status === "success")
-						this.search.songs.results[index].isAddedToQueue = true;
+						this.youtubeSearch.songs.results[
+							index
+						].isAddedToQueue = true;
 				}
 			);
 		}

+ 1 - 1
frontend/src/pages/404.vue

@@ -1,7 +1,7 @@
 <template>
 	<div>
 		<div class="wrapper">
-			<metadata title="404" />
+			<page-metadata title="404" />
 
 			<h3><strong>404</strong>&nbsp;Not Found</h3>
 			<router-link class="button is-dark" to="/">

+ 1 - 1
frontend/src/pages/About.vue

@@ -1,6 +1,6 @@
 <template>
 	<div class="app">
-		<metadata title="About" />
+		<page-metadata title="About" />
 		<main-header />
 		<div class="container">
 			<div class="content-wrapper">

+ 1 - 1
frontend/src/pages/Admin/tabs/HiddenSongs.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<metadata title="Admin | Hidden songs" />
+		<page-metadata title="Admin | Hidden songs" />
 		<div class="container">
 			<p>
 				<span>Sets loaded: {{ setsLoaded }} / {{ maxSets }}</span>

+ 1 - 1
frontend/src/pages/Admin/tabs/News.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<metadata title="Admin | News" />
+		<page-metadata title="Admin | News" />
 		<div class="container">
 			<table class="table is-striped">
 				<thead>

+ 1 - 1
frontend/src/pages/Admin/tabs/Playlists.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<metadata title="Admin | Playlists" />
+		<page-metadata title="Admin | Playlists" />
 		<div class="container">
 			<button
 				class="button is-primary"

+ 1 - 1
frontend/src/pages/Admin/tabs/Punishments.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<metadata title="Admin | Punishments" />
+		<page-metadata title="Admin | Punishments" />
 		<div class="container">
 			<table class="table is-striped">
 				<thead>

+ 1 - 1
frontend/src/pages/Admin/tabs/Reports.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<metadata title="Admin | Reports" />
+		<page-metadata title="Admin | Reports" />
 		<div class="container">
 			<table class="table is-striped">
 				<thead>

+ 1 - 1
frontend/src/pages/Admin/tabs/Stations.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<metadata title="Admin | Stations" />
+		<page-metadata title="Admin | Stations" />
 		<div class="container">
 			<button class="button is-primary" @click="clearEveryStationQueue()">
 				Clear every station queue

+ 1 - 1
frontend/src/pages/Admin/tabs/Statistics.vue

@@ -1,6 +1,6 @@
 <template>
 	<div class="container">
-		<metadata title="Admin | Statistics" />
+		<page-metadata title="Admin | Statistics" />
 		<div class="columns">
 			<div
 				class="

+ 1 - 1
frontend/src/pages/Admin/tabs/UnverifiedSongs.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<metadata title="Admin | Unverified songs" />
+		<page-metadata title="Admin | Unverified songs" />
 		<div class="container">
 			<p>
 				<span>Sets loaded: {{ setsLoaded }} / {{ maxSets }}</span>

+ 1 - 1
frontend/src/pages/Admin/tabs/Users.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<metadata title="Admin | Users" />
+		<page-metadata title="Admin | Users" />
 		<div class="container">
 			<h2 v-if="dataRequests.length > 0">Data Requests</h2>
 

+ 1 - 1
frontend/src/pages/Admin/tabs/VerifiedSongs.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<metadata title="Admin | Songs" />
+		<page-metadata title="Admin | Songs" />
 		<div class="container">
 			<p>
 				<span>Sets loaded: {{ setsLoaded }} / {{ maxSets }}</span>

+ 1 - 1
frontend/src/pages/Banned.vue

@@ -1,6 +1,6 @@
 <template>
 	<div class="container">
-		<metadata title="Banned" />
+		<page-metadata title="Banned" />
 		<i class="material-icons">not_interested</i>
 		<h4>
 			You are banned for

+ 1 - 1
frontend/src/pages/Home.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<metadata title="Home" />
+		<page-metadata title="Home" />
 		<div class="app">
 			<main-header
 				:hide-logo="true"

+ 1 - 1
frontend/src/pages/News.vue

@@ -1,6 +1,6 @@
 <template>
 	<div class="app">
-		<metadata title="News" />
+		<page-metadata title="News" />
 		<main-header />
 		<div class="container">
 			<div class="content-wrapper">

+ 1 - 1
frontend/src/pages/Privacy.vue

@@ -1,6 +1,6 @@
 <template>
 	<div class="app">
-		<metadata title="Privacy policy" />
+		<page-metadata title="Privacy policy" />
 		<main-header />
 		<div class="container">
 			<h1>MUSARE PRIVACY POLICY</h1>

+ 1 - 1
frontend/src/pages/Profile/index.vue

@@ -5,7 +5,7 @@
 		<view-report v-if="modals.viewReport" />
 		<edit-song v-if="modals.editSong" song-type="songs" />
 
-		<metadata :title="`Profile | ${user.username}`" />
+		<page-metadata :title="`Profile | ${user.username}`" />
 		<main-header />
 		<div class="container">
 			<div class="info-section">

+ 1 - 1
frontend/src/pages/ResetPassword.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<metadata
+		<page-metadata
 			:title="mode === 'reset' ? 'Reset password' : 'Set password'"
 		/>
 		<main-header />

+ 1 - 1
frontend/src/pages/Settings/index.vue

@@ -1,6 +1,6 @@
 <template>
 	<div>
-		<metadata title="Settings" />
+		<page-metadata title="Settings" />
 		<main-header />
 		<div class="container">
 			<h1 id="page-title">Settings</h1>

+ 76 - 96
frontend/src/pages/Station/index.vue

@@ -1,7 +1,10 @@
 <template>
 	<div>
-		<metadata v-if="exists && !loading" :title="`${station.displayName}`" />
-		<metadata v-else-if="!exists && !loading" :title="`Not found`" />
+		<page-metadata
+			v-if="exists && !loading"
+			:title="`${station.displayName}`"
+		/>
+		<page-metadata v-else-if="!exists && !loading" :title="`Not found`" />
 
 		<div id="page-loader-container" v-if="loading">
 			<content-loader
@@ -39,7 +42,8 @@
 				currentSong &&
 				(currentSong.youtubeId === 'l9PxOanFjxQ' ||
 					currentSong.youtubeId === 'xKVcVSYmesU' ||
-					currentSong.youtubeId === '60ItHLz5WEA')
+					currentSong.youtubeId === '60ItHLz5WEA' ||
+					currentSong.youtubeId === 'e6vkFbtSGm0')
 			"
 			class="bg-bubbles"
 		>
@@ -253,7 +257,9 @@
 											currentSong.youtubeId ===
 												'xKVcVSYmesU' ||
 											currentSong.youtubeId ===
-												'60ItHLz5WEA')
+												'60ItHLz5WEA' ||
+											currentSong.youtubeId ===
+												'e6vkFbtSGm0')
 									"
 									src="/assets/old_logo.png"
 									:style="{
@@ -850,7 +856,10 @@ export default {
 			activityWatchVideoLastStartDuration: "",
 			nextCurrentSong: null,
 			editSongModalWatcher: null,
-			beforeEditSongModalLocalPaused: null
+			beforeEditSongModalLocalPaused: null,
+			socketConnected: null,
+			persistentToastCheckerInterval: null,
+			persistentToasts: []
 		};
 	},
 	computed: {
@@ -929,9 +938,48 @@ export default {
 		this.activityWatchVideoDataInterval = setInterval(() => {
 			this.sendActivityWatchVideoData();
 		}, 1000);
+		this.persistentToastCheckerInterval = setInterval(() => {
+			this.persistentToasts.filter(
+				persistentToast => !persistentToast.checkIfCanRemove()
+			);
+		}, 1000);
 
 		if (this.socket.readyState === 1) this.join();
-		ws.onConnect(() => this.join());
+		ws.onConnect(() => {
+			this.socketConnected = true;
+			clearTimeout(window.stationNextSongTimeout);
+			this.join();
+		});
+
+		ws.onDisconnect(true, () => {
+			this.socketConnected = false;
+			const { currentSong } = this.currentSong;
+			if (this.nextSong)
+				this.setNextCurrentSong(
+					{
+						currentSong: this.nextSong,
+						startedAt: Date.now() + this.getTimeRemaining(),
+						paused: false,
+						timePaused: 0
+					},
+					true
+				);
+			else
+				this.setNextCurrentSong(
+					{
+						currentSong: null,
+						startedAt: 0,
+						paused: false,
+						timePaused: 0,
+						pausedAt: 0
+					},
+					true
+				);
+			window.stationNextSongTimeout = setTimeout(() => {
+				if (!this.noSong && this.currentSong._id === currentSong._id)
+					this.skipSong("window.stationNextSongTimeout 2");
+			}, this.getTimeRemaining());
+		});
 
 		this.frontendDevMode = await lofig.get("mode");
 
@@ -956,32 +1004,15 @@ export default {
 		);
 
 		this.socket.on("event:station.nextSong", res => {
-			const { currentSong, startedAt, paused, timePaused, natural } =
-				res.data;
-
-			if (this.noSong || !natural) {
-				this.setCurrentSong({
-					currentSong,
-					startedAt,
-					paused,
-					timePaused,
-					pausedAt: 0
-				});
-			} else if (this.currentSong._id === currentSong._id) {
-				if (this.currentSong.skipVotesLoaded !== true) {
-					this.updateCurrentSongSkipVotes({
-						skipVotes: currentSong.skipVotes,
-						skipVotesCurrent: true
-					});
-				}
-			} else {
-				this.setNextCurrentSong({
-					currentSong,
-					startedAt,
-					paused,
-					timePaused
-				});
-			}
+			const { currentSong, startedAt, paused, timePaused } = res.data;
+
+			this.setCurrentSong({
+				currentSong,
+				startedAt,
+				paused,
+				timePaused,
+				pausedAt: 0
+			});
 		});
 
 		this.socket.on("event:station.pause", res => {
@@ -996,29 +1027,6 @@ export default {
 			this.timePaused = res.data.timePaused;
 			this.updateStationPaused(false);
 			if (!this.localPaused) this.resumeLocalPlayer();
-
-			if (this.currentSong) {
-				const currentSongId = this.currentSong._id;
-				if (this.nextSong)
-					this.setNextCurrentSong({
-						currentSong: this.nextSong,
-						startedAt: Date.now() + this.getTimeRemaining(),
-						paused: false,
-						timePaused: 0
-					});
-				else
-					this.setNextCurrentSong({
-						currentSong: null,
-						startedAt: 0,
-						paused: false,
-						timePaused: 0,
-						pausedAt: 0
-					});
-				window.stationNextSongTimeout = setTimeout(() => {
-					if (!this.noSong && this.currentSong._id === currentSongId)
-						this.skipSong("window.stationNextSongTimeout 2");
-				}, this.getTimeRemaining());
-			}
 		});
 
 		this.socket.on("event:station.deleted", () => {
@@ -1077,22 +1085,6 @@ export default {
 
 			this.updateNextSong(nextSong);
 
-			if (nextSong)
-				this.setNextCurrentSong({
-					currentSong: nextSong,
-					startedAt: Date.now() + this.getTimeRemaining(),
-					paused: false,
-					timePaused: 0
-				});
-			else
-				this.setNextCurrentSong({
-					currentSong: null,
-					startedAt: 0,
-					paused: false,
-					timePaused: 0,
-					pausedAt: 0
-				});
-
 			this.addPartyPlaylistSongToQueue();
 		});
 
@@ -1106,13 +1098,6 @@ export default {
 					: null;
 
 			this.updateNextSong(nextSong);
-
-			this.setNextCurrentSong({
-				currentSong: nextSong,
-				startedAt: Date.now() + this.getTimeRemaining(),
-				paused: false,
-				timePaused: 0
-			});
 		});
 
 		this.socket.on("event:station.voteSkipSong", () => {
@@ -1235,6 +1220,10 @@ export default {
 
 		clearInterval(this.activityWatchVideoDataInterval);
 		clearTimeout(window.stationNextSongTimeout);
+		clearTimeout(this.persistentToastCheckerInterval);
+		this.persistentToasts.forEach(persistentToast => {
+			persistentToast.toast.destroy();
+		});
 
 		this.socket.dispatch("stations.leave", this.station._id, () => {});
 
@@ -1335,7 +1324,8 @@ export default {
 				if (!this.playerReady) this.youtubeReady();
 				else this.playVideo();
 
-				if (!this.stationPaused) {
+				// If the station is playing and the backend is not connected, set the next song to skip to after this song and set a timer to skip
+				if (!this.stationPaused && !this.socketConnected) {
 					if (this.nextSong)
 						this.setNextCurrentSong(
 							{
@@ -1498,22 +1488,19 @@ export default {
 								const erroredYoutubeId =
 									this.currentSong.youtubeId;
 
-								// remove persistent toast if video has finished
-								window.isSongErroredInterval = setInterval(
-									() => {
+								this.persistentToasts.push({
+									toast: persistentToast,
+									checkIfCanRemove: () => {
 										if (
 											this.currentSong.youtubeId !==
 											erroredYoutubeId
 										) {
 											persistentToast.destroy();
-
-											clearInterval(
-												window.isSongErroredInterval
-											);
+											return true;
 										}
-									},
-									150
-								);
+										return false;
+									}
+								});
 							} else {
 								new Toast(
 									"There has been an error with the YouTube Embed"
@@ -2018,13 +2005,6 @@ export default {
 								const [nextSong] = queue;
 
 								this.updateNextSong(nextSong);
-								this.setNextCurrentSong({
-									currentSong: nextSong,
-									startedAt:
-										Date.now() + this.getTimeRemaining(),
-									paused: false,
-									timePaused: 0
-								});
 							}
 						});
 

+ 1 - 1
frontend/src/pages/Team.vue

@@ -1,6 +1,6 @@
 <template>
 	<div class="app">
-		<metadata title="Team" />
+		<page-metadata title="Team" />
 		<main-header />
 		<div class="container">
 			<div class="content-wrapper">

+ 1 - 1
frontend/src/pages/Terms.vue

@@ -1,6 +1,6 @@
 <template>
 	<div class="app">
-		<metadata title="Terms of Service" />
+		<page-metadata title="Terms of Service" />
 		<main-header />
 		<div class="container">
 			<h1>MUSARE TERMS OF SERVICE</h1>

+ 4 - 0
frontend/src/store/modules/modals/editPlaylist.js

@@ -10,6 +10,7 @@ export default {
 	actions: {
 		showTab: ({ commit }, tab) => commit("showTab", tab),
 		setPlaylist: ({ commit }, playlist) => commit("setPlaylist", playlist),
+		clearPlaylist: ({ commit }) => commit("clearPlaylist"),
 		addSong: ({ commit }, song) => commit("addSong", song),
 		removeSong: ({ commit }, youtubeId) => commit("removeSong", youtubeId),
 		updatePlaylistSongs: ({ commit }, playlistSongs) =>
@@ -24,6 +25,9 @@ export default {
 			state.playlist = { ...playlist };
 			state.playlist.songs.sort((a, b) => a.position - b.position);
 		},
+		clearPlaylist(state) {
+			state.playlist = { songs: [] };
+		},
 		addSong(state, song) {
 			state.playlist.songs.push(song);
 		},