Browse Source

Merge branch 'manage-station' into polishing

Kristian Vos 3 years ago
parent
commit
33a19eec64

+ 1 - 0
frontend/src/components/PlaylistItem.vue

@@ -1,5 +1,6 @@
 <template>
 	<div class="playlist-item universal-item">
+		<slot name="left-icon" />
 		<div class="left-part">
 			<p class="item-title">
 				{{ playlist.displayName }}

+ 995 - 0
frontend/src/components/modals/ManageStationKris/Tabs/Playlists.vue

@@ -0,0 +1,995 @@
+<template>
+	<div class="station-playlists">
+		<div class="tabs-container">
+			<div class="tab-selection">
+				<button
+					class="button is-default"
+					:class="{ selected: tab === 'search' }"
+					@click="showTab('search')"
+				>
+					Search
+				</button>
+				<button
+					v-if="station.type === 'community'"
+					class="button is-default"
+					ref="my-playlists-tab"
+					:class="{ selected: tab === 'my-playlists' }"
+					@click="showTab('my-playlists')"
+				>
+					My Playlists
+				</button>
+				<button
+					class="button is-default"
+					:class="{ selected: tab === 'party' }"
+					v-if="isPartyMode()"
+					@click="showTab('party')"
+				>
+					Party
+				</button>
+				<button
+					class="button is-default"
+					:class="{ selected: tab === 'included' }"
+					v-if="isPlaylistMode()"
+					@click="showTab('included')"
+				>
+					Included
+				</button>
+				<button
+					class="button is-default"
+					:class="{ selected: tab === 'excluded' }"
+					@click="showTab('excluded')"
+				>
+					Excluded
+				</button>
+			</div>
+			<div class="tab" v-show="tab === 'search'">
+				<label class="label"> Search for a public playlist </label>
+				<div class="control is-grouped input-with-button">
+					<p class="control is-expanded">
+						<input
+							class="input"
+							type="text"
+							placeholder="Enter your playlist query here..."
+							v-model="search.query"
+							@keyup.enter="searchForPlaylists(1)"
+						/>
+					</p>
+					<p class="control">
+						<a class="button is-info" @click="searchForPlaylists(1)"
+							><i class="material-icons icon-with-button"
+								>search</i
+							>Search</a
+						>
+					</p>
+				</div>
+				<div v-if="search.results.length > 0">
+					<playlist-item
+						v-for="playlist in search.results"
+						:key="`searchKey-${playlist._id}`"
+						:playlist="playlist"
+						:show-owner="true"
+					>
+						<i
+							class="material-icons"
+							slot="left-icon"
+							v-if="
+								isAllowedToParty() && isSelected(playlist._id)
+							"
+							content="This playlist is currently selected"
+							v-tippy
+						>
+							radio
+						</i>
+						<i
+							class="material-icons"
+							slot="left-icon"
+							v-else-if="
+								isOwnerOrAdmin() &&
+									isPlaylistMode() &&
+									isIncluded(playlist._id)
+							"
+							content="This playlist is currently included"
+							v-tippy
+						>
+							play_arrow
+						</i>
+						<i
+							class="material-icons excluded-icon"
+							slot="left-icon"
+							v-else-if="
+								isOwnerOrAdmin() && isExcluded(playlist._id)
+							"
+							content="This playlist is currently excluded"
+							v-tippy
+						>
+							block
+						</i>
+						<i
+							class="material-icons"
+							slot="left-icon"
+							v-else
+							:content="
+								isPartyMode()
+									? 'This playlist is currently not selected or excluded'
+									: 'This playlist is currently not included or excluded'
+							"
+							v-tippy
+						>
+							play_disabled
+						</i>
+						<div class="icons-group" slot="actions">
+							<i
+								v-if="isExcluded(playlist._id)"
+								class="material-icons stop-icon"
+								content="This playlist is blacklisted in this station"
+								v-tippy="{ theme: 'info' }"
+								>play_disabled</i
+							>
+							<confirm
+								v-if="isPartyMode() && isSelected(playlist._id)"
+								@confirm="deselectPartyPlaylist(playlist._id)"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Stop playing songs from this playlist"
+									v-tippy
+								>
+									stop
+								</i>
+							</confirm>
+							<confirm
+								v-if="
+									isOwnerOrAdmin() &&
+										isPlaylistMode() &&
+										isIncluded(playlist._id)
+								"
+								@confirm="removeIncludedPlaylist(playlist._id)"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Stop playing songs from this playlist"
+									v-tippy
+								>
+									stop
+								</i>
+							</confirm>
+							<i
+								v-if="
+									isPartyMode() &&
+										!isSelected(playlist._id) &&
+										!isExcluded(playlist._id)
+								"
+								@click="selectPartyPlaylist(playlist)"
+								class="material-icons play-icon"
+								content="Request songs from this playlist"
+								v-tippy
+								>play_arrow</i
+							>
+							<i
+								v-if="
+									isOwnerOrAdmin() &&
+										isPlaylistMode() &&
+										!isIncluded(playlist._id) &&
+										!isExcluded(playlist._id)
+								"
+								@click="includePlaylist(playlist)"
+								class="material-icons play-icon"
+								:content="'Play songs from this playlist'"
+								v-tippy
+								>play_arrow</i
+							>
+							<confirm
+								v-if="
+									isOwnerOrAdmin() &&
+										!isExcluded(playlist._id)
+								"
+								@confirm="blacklistPlaylist(playlist._id)"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Blacklist Playlist"
+									v-tippy
+									>block</i
+								>
+							</confirm>
+							<confirm
+								v-if="
+									isOwnerOrAdmin() && isExcluded(playlist._id)
+								"
+								@confirm="removeExcludedPlaylist(playlist._id)"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Stop blacklisting songs from this playlist"
+									v-tippy
+								>
+									stop
+								</i>
+							</confirm>
+							<i
+								v-if="playlist.createdBy === myUserId"
+								@click="showPlaylist(playlist._id)"
+								class="material-icons edit-icon"
+								content="Edit Playlist"
+								v-tippy
+								>edit</i
+							>
+							<i
+								v-if="
+									playlist.createdBy !== myUserId &&
+										(playlist.privacy === 'public' ||
+											isAdmin())
+								"
+								@click="showPlaylist(playlist._id)"
+								class="material-icons edit-icon"
+								content="View Playlist"
+								v-tippy
+								>visibility</i
+							>
+						</div>
+					</playlist-item>
+					<button
+						v-if="resultsLeftCount > 0"
+						class="button is-primary load-more-button"
+						@click="searchForPlaylists(search.page + 1)"
+					>
+						Load {{ nextPageResultsCount }} more results
+					</button>
+				</div>
+			</div>
+			<div
+				v-if="station.type === 'community'"
+				class="tab"
+				v-show="tab === 'my-playlists'"
+			>
+				<button
+					class="button is-primary"
+					id="create-new-playlist-button"
+					@click="openModal('createPlaylist')"
+				>
+					Create new playlist
+				</button>
+				<draggable
+					class="menu-list scrollable-list"
+					v-if="playlists.length > 0"
+					v-model="playlists"
+					v-bind="dragOptions"
+					@start="drag = true"
+					@end="drag = false"
+					@change="savePlaylistOrder"
+				>
+					<transition-group
+						type="transition"
+						:name="!drag ? 'draggable-list-transition' : null"
+					>
+						<playlist-item
+							class="item-draggable"
+							v-for="playlist in playlists"
+							:key="playlist._id"
+							:playlist="playlist"
+						>
+							<i
+								class="material-icons"
+								slot="left-icon"
+								v-if="
+									isAllowedToParty() &&
+										isSelected(playlist._id)
+								"
+								content="This playlist is currently selected"
+								v-tippy
+							>
+								radio
+							</i>
+							<i
+								class="material-icons"
+								slot="left-icon"
+								v-else-if="
+									isOwnerOrAdmin() &&
+										isPlaylistMode() &&
+										isIncluded(playlist._id)
+								"
+								content="This playlist is currently included"
+								v-tippy
+							>
+								play_arrow
+							</i>
+							<i
+								class="material-icons excluded-icon"
+								slot="left-icon"
+								v-else-if="
+									isOwnerOrAdmin() && isExcluded(playlist._id)
+								"
+								content="This playlist is currently excluded"
+								v-tippy
+							>
+								block
+							</i>
+							<i
+								class="material-icons"
+								slot="left-icon"
+								v-else
+								:content="
+									isPartyMode()
+										? 'This playlist is currently not selected or excluded'
+										: 'This playlist is currently not included or excluded'
+								"
+								v-tippy
+							>
+								play_disabled
+							</i>
+							<div slot="actions">
+								<!-- <i
+									v-if="isExcluded(playlist._id)"
+									class="material-icons stop-icon"
+									content="This playlist is blacklisted in this station"
+									v-tippy="{ theme: 'info' }"
+									>play_disabled</i
+								> -->
+								<i
+									v-if="
+										isPartyMode() &&
+											!isSelected(playlist._id)
+									"
+									@click="selectPartyPlaylist(playlist)"
+									class="material-icons play-icon"
+									content="Request songs from this playlist"
+									v-tippy
+									>play_arrow</i
+								>
+								<i
+									v-if="
+										isPlaylistMode() &&
+											isOwnerOrAdmin() &&
+											!isSelected(playlist._id)
+									"
+									@click="includePlaylist(playlist)"
+									class="material-icons play-icon"
+									content="Play songs from this playlist"
+									v-tippy
+									>play_arrow</i
+								>
+								<confirm
+									v-if="
+										isPartyMode() &&
+											isSelected(playlist._id)
+									"
+									@confirm="
+										deselectPartyPlaylist(playlist._id)
+									"
+								>
+									<i
+										class="material-icons stop-icon"
+										content="Stop requesting songs from this playlist"
+										v-tippy
+										>stop</i
+									>
+								</confirm>
+								<confirm
+									v-if="
+										isPlaylistMode() &&
+											isOwnerOrAdmin() &&
+											isIncluded(playlist._id)
+									"
+									@confirm="
+										removeIncludedPlaylist(playlist._id)
+									"
+								>
+									<i
+										class="material-icons stop-icon"
+										content="Stop playing songs from this playlist"
+										v-tippy
+										>stop</i
+									>
+								</confirm>
+								<confirm
+									v-if="
+										isOwnerOrAdmin() &&
+											!isExcluded(playlist._id)
+									"
+									@confirm="blacklistPlaylist(playlist._id)"
+								>
+									<i
+										class="material-icons stop-icon"
+										content="Blacklist Playlist"
+										v-tippy
+										>block</i
+									>
+								</confirm>
+								<confirm
+									v-if="
+										isOwnerOrAdmin() &&
+											isExcluded(playlist._id)
+									"
+									@confirm="
+										removeExcludedPlaylist(playlist._id)
+									"
+								>
+									<i
+										class="material-icons stop-icon"
+										content="Stop blacklisting songs from this playlist"
+										v-tippy
+									>
+										stop
+									</i>
+								</confirm>
+								<i
+									@click="showPlaylist(playlist._id)"
+									class="material-icons edit-icon"
+									content="Edit Playlist"
+									v-tippy
+									>edit</i
+								>
+							</div>
+						</playlist-item>
+					</transition-group>
+				</draggable>
+				<p v-else class="has-text-centered scrollable-list">
+					You don't have any playlists!
+				</p>
+			</div>
+			<div class="tab" v-show="tab === 'party'" v-if="isPartyMode()">
+				<div v-if="partyPlaylists.length > 0">
+					<playlist-item
+						v-for="playlist in partyPlaylists"
+						:key="`key-${playlist._id}`"
+						:playlist="playlist"
+						:show-owner="true"
+					>
+						<i
+							class="material-icons"
+							slot="left-icon"
+							content="This playlist is currently selected"
+							v-tippy
+						>
+							radio
+						</i>
+						<div class="icons-group" slot="actions">
+							<confirm
+								v-if="isOwnerOrAdmin()"
+								@confirm="deselectPartyPlaylist(playlist._id)"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Stop playing songs from this playlist"
+									v-tippy
+								>
+									stop
+								</i>
+							</confirm>
+							<confirm
+								v-if="isOwnerOrAdmin()"
+								@confirm="blacklistPlaylist(playlist._id)"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Blacklist Playlist"
+									v-tippy
+									>block</i
+								>
+							</confirm>
+							<i
+								v-if="playlist.createdBy === myUserId"
+								@click="showPlaylist(playlist._id)"
+								class="material-icons edit-icon"
+								content="Edit Playlist"
+								v-tippy
+								>edit</i
+							>
+							<i
+								v-if="
+									playlist.createdBy !== myUserId &&
+										(playlist.privacy === 'public' ||
+											isAdmin())
+								"
+								@click="showPlaylist(playlist._id)"
+								class="material-icons edit-icon"
+								content="View Playlist"
+								v-tippy
+								>visibility</i
+							>
+						</div>
+					</playlist-item>
+				</div>
+				<p v-else class="has-text-centered scrollable-list">
+					No playlists currently being played.
+				</p>
+			</div>
+			<div
+				class="tab"
+				v-show="tab === 'included'"
+				v-if="isPlaylistMode()"
+			>
+				<div v-if="includedPlaylists.length > 0">
+					<playlist-item
+						v-for="playlist in includedPlaylists"
+						:key="`key-${playlist._id}`"
+						:playlist="playlist"
+						:show-owner="true"
+					>
+						<i
+							class="material-icons"
+							slot="left-icon"
+							content="This playlist is currently included"
+							v-tippy
+						>
+							play_arrow
+						</i>
+						<div class="icons-group" slot="actions">
+							<confirm
+								v-if="isOwnerOrAdmin()"
+								@confirm="removeIncludedPlaylist(playlist._id)"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Stop playing songs from this playlist"
+									v-tippy
+								>
+									stop
+								</i>
+							</confirm>
+							<confirm
+								v-if="isOwnerOrAdmin()"
+								@confirm="blacklistPlaylist(playlist._id)"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Blacklist Playlist"
+									v-tippy
+									>block</i
+								>
+							</confirm>
+							<i
+								v-if="playlist.createdBy === myUserId"
+								@click="showPlaylist(playlist._id)"
+								class="material-icons edit-icon"
+								content="Edit Playlist"
+								v-tippy
+								>edit</i
+							>
+							<i
+								v-if="
+									playlist.createdBy !== myUserId &&
+										(playlist.privacy === 'public' ||
+											isAdmin())
+								"
+								@click="showPlaylist(playlist._id)"
+								class="material-icons edit-icon"
+								content="View Playlist"
+								v-tippy
+								>visibility</i
+							>
+						</div>
+					</playlist-item>
+				</div>
+				<p v-else class="has-text-centered scrollable-list">
+					No playlists currently included.
+				</p>
+			</div>
+			<div
+				class="tab"
+				v-show="tab === 'excluded'"
+				v-if="isOwnerOrAdmin()"
+			>
+				<div v-if="excludedPlaylists.length > 0">
+					<playlist-item
+						:playlist="playlist"
+						v-for="playlist in excludedPlaylists"
+						:key="`key-${playlist._id}`"
+					>
+						<i
+							class="material-icons excluded-icon"
+							slot="left-icon"
+							content="This playlist is currently excluded"
+							v-tippy
+						>
+							block
+						</i>
+						<div class="icons-group" slot="actions">
+							<confirm
+								@confirm="removeExcludedPlaylist(playlist._id)"
+							>
+								<i
+									class="material-icons stop-icon"
+									content="Stop blacklisting songs from this playlist
+							"
+									v-tippy
+									>stop</i
+								>
+							</confirm>
+							<i
+								v-if="playlist.createdBy === userId"
+								@click="showPlaylist(playlist._id)"
+								class="material-icons edit-icon"
+								content="Edit Playlist"
+								v-tippy
+								>edit</i
+							>
+							<i
+								v-else
+								@click="showPlaylist(playlist._id)"
+								class="material-icons edit-icon"
+								content="View Playlist"
+								v-tippy
+								>visibility</i
+							>
+						</div>
+					</playlist-item>
+				</div>
+				<p v-else class="has-text-centered scrollable-list">
+					No playlists currently excluded.
+				</p>
+			</div>
+		</div>
+	</div>
+</template>
+<script>
+import { mapActions, mapState, mapGetters } from "vuex";
+
+import Toast from "toasters";
+import PlaylistItem from "@/components/PlaylistItem.vue";
+import Confirm from "@/components/Confirm.vue";
+
+import SortablePlaylists from "@/mixins/SortablePlaylists.vue";
+
+export default {
+	components: {
+		PlaylistItem,
+		Confirm
+	},
+	mixins: [SortablePlaylists],
+	data() {
+		return {
+			tab: "included",
+			search: {
+				query: "",
+				searchedQuery: "",
+				page: 0,
+				count: 0,
+				resultsLeft: 0,
+				results: []
+			}
+		};
+	},
+	computed: {
+		resultsLeftCount() {
+			return this.search.count - this.search.results.length;
+		},
+		nextPageResultsCount() {
+			return Math.min(this.search.pageSize, this.resultsLeftCount);
+		},
+		...mapState({
+			loggedIn: state => state.user.auth.loggedIn,
+			role: state => state.user.auth.role,
+			userId: state => state.user.auth.userId,
+			partyPlaylists: state => state.station.partyPlaylists
+		}),
+		...mapState("modals/manageStation", {
+			originalStation: state => state.originalStation,
+			station: state => state.station,
+			includedPlaylists: state => state.includedPlaylists,
+			excludedPlaylists: state => state.excludedPlaylists,
+			songsList: state => state.songsList
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	mounted() {
+		if (this.station.type === "community" && this.station.partyMode)
+			this.showTab("search");
+
+		this.socket.dispatch("playlists.indexMyPlaylists", true, res => {
+			if (res.status === "success") this.setPlaylists(res.data.playlists);
+			this.orderOfPlaylists = this.calculatePlaylistOrder(); // order in regards to the database
+		});
+
+		this.socket.dispatch(
+			`stations.getStationIncludedPlaylistsById`,
+			this.station._id,
+			res => {
+				if (res.status === "success") {
+					this.station.includedPlaylists = res.data.playlists;
+					this.originalStation.includedPlaylists = res.data.playlists;
+				}
+			}
+		);
+
+		this.socket.dispatch(
+			`stations.getStationExcludedPlaylistsById`,
+			this.station._id,
+			res => {
+				if (res.status === "success") {
+					this.station.excludedPlaylists = res.data.playlists;
+					this.originalStation.excludedPlaylists = res.data.playlists;
+				}
+			}
+		);
+	},
+	methods: {
+		showTab(tab) {
+			this.$refs[`${tab}-tab`].scrollIntoView();
+			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();
+		},
+		showPlaylist(playlistId) {
+			this.editPlaylist(playlistId);
+			this.openModal("editPlaylist");
+		},
+		selectPartyPlaylist(playlist) {
+			if (!this.isSelected(playlist.id)) {
+				this.partyPlaylists.push(playlist);
+				this.addPartyPlaylistSongToQueue();
+				new Toast(
+					"Successfully selected playlist to auto request songs."
+				);
+			} else {
+				new Toast("Error: Playlist already selected.");
+			}
+		},
+		includePlaylist(playlist) {
+			this.socket.dispatch(
+				"stations.includePlaylist",
+				this.station._id,
+				playlist._id,
+				res => {
+					new Toast(res.message);
+				}
+			);
+		},
+		deselectPartyPlaylist(id) {
+			return new Promise(resolve => {
+				let selected = false;
+				this.partyPlaylists.forEach((playlist, index) => {
+					if (playlist._id === id) {
+						selected = true;
+						this.partyPlaylists.splice(index, 1);
+					}
+				});
+				if (selected) {
+					new Toast("Successfully deselected playlist.");
+					resolve();
+				} else {
+					new Toast("Playlist not selected.");
+					resolve();
+				}
+			});
+		},
+		removeIncludedPlaylist(id) {
+			return new Promise(resolve => {
+				this.socket.dispatch(
+					"stations.removeIncludedPlaylist",
+					this.station._id,
+					id,
+					res => {
+						new Toast(res.message);
+						resolve();
+					}
+				);
+			});
+		},
+		removeExcludedPlaylist(id) {
+			return new Promise(resolve => {
+				this.socket.dispatch(
+					"stations.removeExcludedPlaylist",
+					this.station._id,
+					id,
+					res => {
+						new Toast(res.message);
+						resolve();
+					}
+				);
+			});
+		},
+		isSelected(id) {
+			let selected = false;
+			this.partyPlaylists.forEach(playlist => {
+				if (playlist._id === id) selected = true;
+			});
+			return selected;
+		},
+		isIncluded(id) {
+			let included = false;
+			this.includedPlaylists.forEach(playlist => {
+				if (playlist._id === id) included = true;
+			});
+			return included;
+		},
+		isExcluded(id) {
+			let selected = false;
+			this.excludedPlaylists.forEach(playlist => {
+				if (playlist._id === id) selected = true;
+			});
+			return selected;
+		},
+		searchForPlaylists(page) {
+			if (
+				this.search.page >= page ||
+				this.search.searchedQuery !== this.search.query
+			) {
+				this.search.results = [];
+				this.search.page = 0;
+				this.search.count = 0;
+				this.search.resultsLeft = 0;
+				this.search.pageSize = 0;
+			}
+
+			const { query } = this.search;
+			const action =
+				this.station.type === "official"
+					? "playlists.searchOfficial"
+					: "playlists.searchCommunity";
+
+			this.search.searchedQuery = this.search.query;
+			this.socket.dispatch(action, query, page, res => {
+				const { data } = res;
+				const { count, pageSize, playlists } = data;
+				if (res.status === "success") {
+					this.search.results = [
+						...this.search.results,
+						...playlists
+					];
+					this.search.page = page;
+					this.search.count = count;
+					this.search.resultsLeft =
+						count - this.search.results.length;
+					this.search.pageSize = pageSize;
+				} else if (res.status === "error") {
+					this.search.results = [];
+					this.search.page = 0;
+					this.search.count = 0;
+					this.search.resultsLeft = 0;
+					this.search.pageSize = 0;
+					new Toast(res.message);
+				}
+			});
+		},
+		async blacklistPlaylist(id) {
+			if (this.isIncluded(id)) await this.removeIncludedPlaylist(id);
+
+			this.socket.dispatch(
+				"stations.excludePlaylist",
+				this.station._id,
+				id,
+				res => {
+					new Toast(res.message);
+				}
+			);
+		},
+		addPartyPlaylistSongToQueue() {
+			let isInQueue = false;
+			if (
+				this.station.type === "community" &&
+				this.station.partyMode === true
+			) {
+				this.songsList.forEach(queueSong => {
+					if (queueSong.requestedBy === this.userId) isInQueue = true;
+				});
+				if (!isInQueue && this.partyPlaylists) {
+					const selectedPlaylist = this.partyPlaylists[
+						Math.floor(Math.random() * this.partyPlaylists.length)
+					];
+					if (
+						selectedPlaylist._id &&
+						selectedPlaylist.songs.length > 0
+					) {
+						const selectedSong =
+							selectedPlaylist.songs[
+								Math.floor(
+									Math.random() *
+										selectedPlaylist.songs.length
+								)
+							];
+						if (selectedSong.youtubeId) {
+							this.socket.dispatch(
+								"stations.addToQueue",
+								this.station._id,
+								selectedSong.youtubeId,
+								data => {
+									if (data.status !== "success")
+										new Toast("Error auto queueing song");
+								}
+							);
+						}
+					}
+				}
+			}
+		},
+		...mapActions("station", ["updatePartyPlaylists"]),
+		...mapActions("modalVisibility", ["openModal"]),
+		...mapActions("user/playlists", ["editPlaylist", "setPlaylists"])
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.excluded-icon {
+	color: var(--red);
+}
+
+.included-icon {
+	color: var(--green);
+}
+
+.selected-icon {
+	color: var(--purple);
+}
+
+.station-playlists {
+	.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;
+			}
+		}
+	}
+}
+.draggable-list-transition-move {
+	transition: transform 0.5s;
+}
+
+.draggable-list-ghost {
+	opacity: 0.5;
+	filter: brightness(95%);
+}
+</style>

+ 595 - 0
frontend/src/components/modals/ManageStationKris/Tabs/Settings.vue

@@ -0,0 +1,595 @@
+<template>
+	<div class="station-settings">
+		<label class="label">Name</label>
+		<div class="control is-grouped input-with-button">
+			<p class="control is-expanded">
+				<input class="input" type="text" v-model="station.name" />
+			</p>
+			<p class="control">
+				<a class="button is-info" @click.prevent="updateName()">Save</a>
+			</p>
+		</div>
+		<label class="label">Display Name</label>
+		<div class="control is-grouped input-with-button">
+			<p class="control is-expanded">
+				<input
+					class="input"
+					type="text"
+					v-model="station.displayName"
+				/>
+			</p>
+			<p class="control">
+				<a class="button is-info" @click.prevent="updateDisplayName()"
+					>Save</a
+				>
+			</p>
+		</div>
+		<label class="label">Description</label>
+		<div class="control is-grouped input-with-button">
+			<p class="control is-expanded">
+				<input
+					class="input"
+					type="text"
+					v-model="station.description"
+				/>
+			</p>
+			<p class="control">
+				<a class="button is-info" @click.prevent="updateDescription()"
+					>Save</a
+				>
+			</p>
+		</div>
+		<div class="settings-buttons">
+			<div class="small-section">
+				<label class="label">Theme</label>
+				<tippy
+					class="button-wrapper"
+					theme="addToPlaylist"
+					interactive="true"
+					touch="true"
+					placement="bottom"
+					trigger="click"
+					append-to="parent"
+				>
+					<template #trigger>
+						<button :class="station.theme">
+							<i class="material-icons">palette</i>
+							{{ station.theme }}
+						</button>
+					</template>
+					<button
+						class="blue"
+						v-if="station.theme !== 'blue'"
+						@click="updateTheme('blue')"
+					>
+						<i class="material-icons">palette</i>
+						Blue
+					</button>
+					<button
+						class="purple"
+						v-if="station.theme !== 'purple'"
+						@click="updateTheme('purple')"
+					>
+						<i class="material-icons">palette</i>
+						Purple
+					</button>
+					<button
+						class="teal"
+						v-if="station.theme !== 'teal'"
+						@click="updateTheme('teal')"
+					>
+						<i class="material-icons">palette</i>
+						Teal
+					</button>
+					<button
+						class="orange"
+						v-if="station.theme !== 'orange'"
+						@click="updateTheme('orange')"
+					>
+						<i class="material-icons">palette</i>
+						Orange
+					</button>
+				</tippy>
+			</div>
+			<div class="small-section">
+				<label class="label">Privacy</label>
+				<tippy
+					class="button-wrapper"
+					theme="addToPlaylist"
+					interactive="true"
+					touch="true"
+					placement="bottom"
+					trigger="click"
+					append-to="parent"
+				>
+					<template #trigger>
+						<button :class="privacyButtons[station.privacy].style">
+							<i class="material-icons">{{
+								privacyButtons[station.privacy].iconName
+							}}</i>
+							{{ station.privacy }}
+						</button>
+					</template>
+					<button
+						class="green"
+						v-if="station.privacy !== 'public'"
+						@click="updatePrivacy('public')"
+					>
+						<i class="material-icons">{{
+							privacyButtons["public"].iconName
+						}}</i>
+						Public
+					</button>
+					<button
+						class="orange"
+						v-if="station.privacy !== 'unlisted'"
+						@click="updatePrivacy('unlisted')"
+					>
+						<i class="material-icons">{{
+							privacyButtons["unlisted"].iconName
+						}}</i>
+						Unlisted
+					</button>
+					<button
+						class="red"
+						v-if="station.privacy !== 'private'"
+						@click="updatePrivacy('private')"
+					>
+						<i class="material-icons">{{
+							privacyButtons["private"].iconName
+						}}</i>
+						Private
+					</button>
+				</tippy>
+			</div>
+			<div class="small-section">
+				<label class="label">Station Mode</label>
+				<tippy
+					v-if="station.type === 'community'"
+					class="button-wrapper"
+					theme="addToPlaylist"
+					touch="true"
+					interactive="true"
+					placement="bottom"
+					trigger="click"
+					append-to="parent"
+				>
+					<template #trigger>
+						<button
+							:class="{
+								blue: !station.partyMode,
+								yellow: station.partyMode
+							}"
+						>
+							<i class="material-icons">{{
+								station.partyMode
+									? "emoji_people"
+									: "playlist_play"
+							}}</i>
+							{{ station.partyMode ? "Party" : "Playlist" }}
+						</button>
+					</template>
+					<button
+						class="blue"
+						v-if="station.partyMode"
+						@click="updatePartyMode(false)"
+					>
+						<i class="material-icons">playlist_play</i>
+						Playlist
+					</button>
+					<button
+						class="yellow"
+						v-if="!station.partyMode"
+						@click="updatePartyMode(true)"
+					>
+						<i class="material-icons">emoji_people</i>
+						Party
+					</button>
+				</tippy>
+				<div v-else class="button-wrapper">
+					<button
+						class="blue"
+						content="Can not be changed on official stations."
+						v-tippy="{ theme: 'info' }"
+					>
+						<i class="material-icons">playlist_play</i>
+						Playlist
+					</button>
+				</div>
+			</div>
+			<div v-if="!station.partyMode" class="small-section">
+				<label class="label">Play Mode</label>
+				<tippy
+					v-if="station.type === 'community'"
+					class="button-wrapper"
+					theme="addToPlaylist"
+					touch="true"
+					interactive="true"
+					placement="bottom"
+					trigger="click"
+					append-to="parent"
+				>
+					<template #trigger>
+						<button class="blue">
+							<i class="material-icons">{{
+								station.playMode === "random"
+									? "shuffle"
+									: "format_list_numbered"
+							}}</i>
+							{{
+								station.playMode === "random"
+									? "Random"
+									: "Sequential"
+							}}
+						</button>
+					</template>
+					<button
+						class="blue"
+						v-if="station.playMode === 'sequential'"
+						@click="updatePlayMode('random')"
+					>
+						<i class="material-icons">shuffle</i>
+						Random
+					</button>
+					<button
+						class="blue"
+						v-if="station.playMode === 'random'"
+						@click="updatePlayMode('sequential')"
+					>
+						<i class="material-icons">format_list_numbered</i>
+						Sequential
+					</button>
+				</tippy>
+				<div v-else class="button-wrapper">
+					<button
+						class="blue"
+						content="Can not be changed on official stations."
+						v-tippy="{ theme: 'info' }"
+					>
+						<i class="material-icons">shuffle</i>
+						Random
+					</button>
+				</div>
+			</div>
+			<div
+				v-if="
+					station.type === 'community' && station.partyMode === true
+				"
+				class="small-section"
+			>
+				<label class="label">Queue lock</label>
+				<tippy
+					class="button-wrapper"
+					theme="addToPlaylist"
+					interactive="true"
+					touch="true"
+					placement="bottom"
+					trigger="click"
+					append-to="parent"
+				>
+					<template #trigger>
+						<button
+							:class="{
+								green: station.locked,
+								red: !station.locked
+							}"
+						>
+							<i class="material-icons">{{
+								station.locked ? "lock" : "lock_open"
+							}}</i>
+							{{ station.locked ? "Locked" : "Unlocked" }}
+						</button>
+					</template>
+					<button
+						class="green"
+						v-if="!station.locked"
+						@click="updateQueueLock(true)"
+					>
+						<i class="material-icons">lock</i>
+						Locked
+					</button>
+					<button
+						class="red"
+						v-if="station.locked"
+						@click="updateQueueLock(false)"
+					>
+						<i class="material-icons">lock_open</i>
+						Unlocked
+					</button>
+				</tippy>
+			</div>
+		</div>
+	</div>
+</template>
+
+<script>
+import { mapState, mapGetters } from "vuex";
+
+import Toast from "toasters";
+
+import validation from "@/validation";
+
+export default {
+	data() {
+		return {
+			privacyButtons: {
+				public: {
+					style: "green",
+					iconName: "public"
+				},
+				private: {
+					style: "red",
+					iconName: "lock"
+				},
+				unlisted: {
+					style: "orange",
+					iconName: "link"
+				}
+			}
+		};
+	},
+	computed: {
+		...mapState("modals/manageStation", {
+			station: state => state.station,
+			originalStation: state => state.originalStation
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	methods: {
+		updateName() {
+			if (this.originalStation.name !== this.station.name) {
+				const { name } = this.station;
+				if (!validation.isLength(name, 2, 16)) {
+					new Toast("Name must have between 2 and 16 characters.");
+				} else if (!validation.regex.az09_.test(name)) {
+					new Toast(
+						"Invalid name format. Allowed characters: a-z, 0-9 and _."
+					);
+				} else {
+					this.socket.dispatch(
+						"stations.updateName",
+						this.station._id,
+						name,
+						res => {
+							new Toast(res.message);
+
+							if (res.status === "success") {
+								this.station.name = name;
+								this.originalStation.name = name;
+							}
+						}
+					);
+				}
+			} else {
+				new Toast("Please make a change before saving.");
+			}
+		},
+		updateDisplayName() {
+			if (this.originalStation.displayName !== this.station.displayName) {
+				const { displayName } = this.station;
+				if (!validation.isLength(displayName, 2, 32)) {
+					new Toast(
+						"Display name must have between 2 and 32 characters."
+					);
+				} else if (!validation.regex.ascii.test(displayName)) {
+					new Toast(
+						"Invalid display name format. Only ASCII characters are allowed."
+					);
+				} else {
+					this.socket.dispatch(
+						"stations.updateDisplayName",
+						this.station._id,
+						displayName,
+						res => {
+							new Toast(res.message);
+
+							if (res.status === "success") {
+								this.station.displayName = displayName;
+								this.originalStation.displayName = displayName;
+							}
+						}
+					);
+				}
+			} else {
+				new Toast("Please make a change before saving.");
+			}
+		},
+		updateDescription() {
+			if (this.originalStation.description !== this.station.description) {
+				const { description } = this.station;
+				const characters = description.split("").filter(character => {
+					return character.charCodeAt(0) === 21328;
+				});
+				if (!validation.isLength(description, 2, 200)) {
+					new Toast(
+						"Description must have between 2 and 200 characters."
+					);
+				} else if (characters.length !== 0) {
+					new Toast("Invalid description format.");
+				} else {
+					this.socket.dispatch(
+						"stations.updateDescription",
+						this.station._id,
+						description,
+						res => {
+							new Toast(res.message);
+
+							if (res.status === "success") {
+								this.station.description = description;
+								this.originalStation.description = description;
+							}
+						}
+					);
+				}
+			} else {
+				new Toast("Please make a change before saving.");
+			}
+		},
+		updateTheme(theme) {
+			if (this.station.theme !== theme) {
+				this.socket.dispatch(
+					"stations.updateTheme",
+					this.station._id,
+					theme,
+					res => {
+						new Toast(res.message);
+
+						if (res.status === "success") {
+							this.station.theme = theme;
+							this.originalStation.theme = theme;
+						}
+					}
+				);
+			}
+		},
+		updatePrivacy(privacy) {
+			if (this.station.privacy !== privacy) {
+				this.socket.dispatch(
+					"stations.updatePrivacy",
+					this.station._id,
+					privacy,
+					res => {
+						new Toast(res.message);
+
+						if (res.status === "success") {
+							this.station.privacy = privacy;
+							this.originalStation.privacy = privacy;
+						}
+					}
+				);
+			}
+		},
+		updatePartyMode(partyMode) {
+			if (this.station.partyMode !== partyMode) {
+				this.socket.dispatch(
+					"stations.updatePartyMode",
+					this.station._id,
+					partyMode,
+					res => {
+						new Toast(res.message);
+
+						if (res.status === "success") {
+							this.station.partyMode = partyMode;
+							this.originalStation.partyMode = partyMode;
+						}
+					}
+				);
+			}
+		},
+		updatePlayMode(playMode) {
+			if (this.station.playMode !== playMode) {
+				this.socket.dispatch(
+					"stations.updatePlayMode",
+					this.station._id,
+					playMode,
+					res => {
+						new Toast(res.message);
+
+						if (res.status === "success") {
+							this.station.playMode = playMode;
+							this.originalStation.playMode = playMode;
+						}
+					}
+				);
+			}
+		},
+		updateQueueLock(locked) {
+			if (this.station.locked !== locked) {
+				this.socket.dispatch(
+					"stations.toggleLock",
+					this.station._id,
+					res => {
+						if (res.status === "success") {
+							if (this.originalStation) {
+								this.station.locked = res.data.locked;
+								this.originalStation.locked = res.data.locked;
+							}
+
+							new Toast(
+								`Toggled queue lock successfully to ${res.data.locked}`
+							);
+						} else {
+							new Toast("Failed to toggle queue lock.");
+						}
+					}
+				);
+			}
+		}
+	}
+};
+</script>
+
+<style lang="scss" scoped>
+.station-settings {
+	.settings-buttons {
+		display: flex;
+		justify-content: center;
+		flex-wrap: wrap;
+		.small-section {
+			width: calc(50% - 10px);
+			min-width: 150px;
+			margin: 5px auto;
+		}
+	}
+	.button-wrapper {
+		display: flex;
+		flex-direction: column;
+
+		button {
+			width: 100%;
+			height: 36px;
+			border: 0;
+			border-radius: 3px;
+			font-size: 18px;
+			color: var(--white);
+			box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25);
+			display: block;
+			text-align: center;
+			justify-content: center;
+			display: inline-flex;
+			-ms-flex-align: center;
+			align-items: center;
+			-moz-user-select: none;
+			user-select: none;
+			cursor: pointer;
+			padding: 0;
+			text-transform: capitalize;
+
+			&.red {
+				background-color: var(--red);
+			}
+
+			&.green {
+				background-color: var(--green);
+			}
+
+			&.blue {
+				background-color: var(--blue);
+			}
+
+			&.orange {
+				background-color: var(--orange);
+			}
+
+			&.yellow {
+				background-color: var(--yellow);
+			}
+
+			&.purple {
+				background-color: var(--purple);
+			}
+
+			&.teal {
+				background-color: var(--teal);
+			}
+
+			i {
+				font-size: 20px;
+				margin-right: 4px;
+			}
+		}
+	}
+}
+</style>

+ 456 - 0
frontend/src/components/modals/ManageStationKris/Tabs/Songs.vue

@@ -0,0 +1,456 @@
+<template>
+	<div class="songs">
+		<div class="tabs-container">
+			<div class="tab-selection">
+				<button
+					class="button is-default"
+					:class="{ selected: tab === 'search' }"
+					v-if="
+						station.type === 'community' &&
+							station.partyMode &&
+							(isOwnerOrAdmin() || !station.locked)
+					"
+					@click="showTab('search')"
+				>
+					Search
+				</button>
+				<button
+					class="button is-default"
+					:class="{ selected: tab === 'stationPlaylist' }"
+					v-if="
+						isOwnerOrAdmin() &&
+							!(station.type === 'community' && station.partyMode)
+					"
+					@click="showTab('stationPlaylist')"
+				>
+					Station playlist
+				</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"
+						>
+							<div class="song-actions" slot="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
+								>
+							</div>
+						</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"
+						>
+							<div slot="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>
+							</div>
+						</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 === 'stationPlaylist'"
+				v-if="
+					isOwnerOrAdmin() &&
+						!(station.type === 'community' && station.partyMode)
+				"
+			>
+				<div v-if="stationPlaylist.songs.length > 0">
+					<div id="playlist-info-section" class="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", {
+			station: state => state.station,
+			originalStation: state => state.originalStation,
+			excludedPlaylists: state => state.excludedPlaylists,
+			stationPlaylist: state => state.stationPlaylist
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	mounted() {
+		if (
+			this.isOwnerOrAdmin() &&
+			!(this.station.type === "community" && this.station.partyMode)
+		)
+			this.showTab("stationPlaylist");
+	},
+	methods: {
+		showTab(tab) {
+			this.tab = tab;
+		},
+		isOwner() {
+			return this.loggedIn && this.userId === this.station.owner;
+		},
+		isAdmin() {
+			return this.loggedIn && this.role === "admin";
+		},
+		isOwnerOrAdmin() {
+			return this.isOwner() || this.isAdmin();
+		},
+		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>
+.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>

+ 835 - 0
frontend/src/components/modals/ManageStationKris/index.vue

@@ -0,0 +1,835 @@
+<template>
+	<modal
+		v-if="station"
+		:title="
+			!isOwnerOrAdmin() && station.partyMode
+				? 'Add Song to Queue'
+				: 'Manage Station'
+		"
+		:style="`--primary-color: var(--${station.theme})`"
+		class="manage-station-modal"
+	>
+		<template #body>
+			<div class="custom-modal-body" v-if="station && station._id">
+				<div class="left-section">
+					<div class="section">
+						<div id="about-station-container">
+							<div id="station-info">
+								<div id="station-name">
+									<h1>{{ station.displayName }}</h1>
+									<i
+										v-if="station.type === 'official'"
+										class="material-icons verified-station"
+										content="Verified Station"
+										v-tippy
+									>
+										check_circle
+									</i>
+									<i
+										class="material-icons stationMode"
+										:content="
+											station.partyMode
+												? 'Station in Party mode'
+												: 'Station in Playlist mode'
+										"
+										v-tippy
+										>{{
+											station.partyMode
+												? "emoji_people"
+												: "playlist_play"
+										}}</i
+									>
+								</div>
+								<p>{{ station.description }}</p>
+							</div>
+
+							<div id="admin-buttons" v-if="isOwnerOrAdmin()">
+								<!-- (Admin) Pause/Resume Button -->
+								<button
+									class="button is-danger"
+									v-if="stationPaused"
+									@click="resumeStation()"
+								>
+									<i class="material-icons icon-with-button"
+										>play_arrow</i
+									>
+									<span class="optional-desktop-only-text">
+										Resume Station
+									</span>
+								</button>
+								<button
+									class="button is-danger"
+									@click="pauseStation()"
+									v-else
+								>
+									<i class="material-icons icon-with-button"
+										>pause</i
+									>
+									<span class="optional-desktop-only-text">
+										Pause Station
+									</span>
+								</button>
+
+								<!-- (Admin) Skip Button -->
+								<button
+									class="button is-danger"
+									@click="skipStation()"
+								>
+									<i class="material-icons icon-with-button"
+										>skip_next</i
+									>
+									<span class="optional-desktop-only-text">
+										Force Skip
+									</span>
+								</button>
+
+								<!-- Station Settings Button -->
+								<!-- <button
+									class="button is-primary"
+									@click="openModal('manageStation')"
+								>
+									<i class="material-icons icon-with-button"
+										>settings</i
+									>
+									<span class="optional-desktop-only-text">
+										Manage Station
+									</span>
+								</button> -->
+								<router-link
+									v-if="sector !== 'station' && station.name"
+									:to="{
+										name: 'station',
+										params: { id: station.name }
+									}"
+									class="button is-primary"
+								>
+									Go To Station
+								</router-link>
+							</div>
+						</div>
+						<div class="tab-selection">
+							<button
+								v-if="isOwnerOrAdmin()"
+								class="button is-default"
+								:class="{ selected: tab === 'settings' }"
+								ref="settings-tab"
+								@click="showTab('settings')"
+							>
+								Settings
+							</button>
+							<button
+								v-if="isAllowedToParty() || isOwnerOrAdmin()"
+								class="button is-default"
+								:class="{ selected: tab === 'playlists' }"
+								ref="playlists-tab"
+								@click="showTab('playlists')"
+							>
+								Playlists
+							</button>
+							<button
+								v-if="isAllowedToParty() || isOwnerOrAdmin()"
+								class="button is-default"
+								:class="{ selected: tab === 'songs' }"
+								ref="songs-tab"
+								@click="showTab('songs')"
+							>
+								Songs
+							</button>
+						</div>
+						<settings
+							v-if="isOwnerOrAdmin()"
+							class="tab"
+							v-show="tab === 'settings'"
+						/>
+						<playlists
+							v-if="isAllowedToParty() || isOwnerOrAdmin()"
+							class="tab"
+							v-show="tab === 'playlists'"
+						/>
+						<songs
+							v-if="isAllowedToParty() || isOwnerOrAdmin()"
+							class="tab"
+							v-show="tab === 'songs'"
+						/>
+					</div>
+				</div>
+				<div class="right-section">
+					<div class="section">
+						<div class="queue-title">
+							<h4 class="section-title">Queue</h4>
+						</div>
+						<hr class="section-horizontal-rule" />
+						<song-item
+							v-if="currentSong._id"
+							:song="currentSong"
+							:requested-by="
+								station.type === 'community' &&
+									station.partyMode === true
+							"
+							header="Currently Playing.."
+							class="currently-playing"
+						/>
+						<queue sector="manageStation" />
+					</div>
+				</div>
+			</div>
+		</template>
+		<template #footer>
+			<!-- <router-link
+				v-if="sector !== 'station' && station.name"
+				:to="{
+					name: 'station',
+					params: { id: station.name }
+				}"
+				class="button is-primary"
+			>
+				Go To Station
+			</router-link> -->
+			<button
+				class="button is-primary tab-actionable-button"
+				v-if="loggedIn && station.type === 'official'"
+				@click="openModal('requestSong')"
+			>
+				<i class="material-icons icon-with-button">queue</i>
+				<span class="optional-desktop-only-text"> Request Song </span>
+			</button>
+			<div v-if="isOwnerOrAdmin()" class="right">
+				<confirm @confirm="clearAndRefillStationQueue()">
+					<a class="button is-danger">
+						Clear and refill station queue
+					</a>
+				</confirm>
+				<confirm
+					v-if="station && station.type === 'community'"
+					@confirm="removeStation()"
+				>
+					<button class="button is-danger">Delete station</button>
+				</confirm>
+			</div>
+		</template>
+	</modal>
+</template>
+
+<script>
+import { mapState, mapGetters, mapActions } from "vuex";
+
+import Toast from "toasters";
+
+import Confirm from "@/components/Confirm.vue";
+import Queue from "@/components/Queue.vue";
+import SongItem from "@/components/SongItem.vue";
+import Modal from "../../Modal.vue";
+
+import Settings from "./Tabs/Settings.vue";
+import Playlists from "./Tabs/Playlists.vue";
+import Songs from "./Tabs/Songs.vue";
+
+export default {
+	components: {
+		Modal,
+		Confirm,
+		Queue,
+		SongItem,
+		Settings,
+		Playlists,
+		Songs
+	},
+	props: {
+		stationId: { type: String, default: "" },
+		sector: { type: String, default: "admin" }
+	},
+	computed: {
+		...mapState({
+			loggedIn: state => state.user.auth.loggedIn,
+			userId: state => state.user.auth.userId,
+			role: state => state.user.auth.role
+		}),
+		...mapState("modals/manageStation", {
+			tab: state => state.tab,
+			station: state => state.station,
+			originalStation: state => state.originalStation,
+			songsList: state => state.songsList,
+			stationPlaylist: state => state.stationPlaylist,
+			includedPlaylists: state => state.includedPlaylists,
+			excludedPlaylists: state => state.excludedPlaylists,
+			stationPaused: state => state.stationPaused,
+			currentSong: state => state.currentSong
+		}),
+		...mapGetters({
+			socket: "websockets/getSocket"
+		})
+	},
+	mounted() {
+		this.socket.dispatch(`stations.getStationById`, this.stationId, res => {
+			if (res.status === "success") {
+				const { station } = res.data;
+				this.editStation(station);
+
+				if (!this.isOwnerOrAdmin() && this.station.partyMode)
+					this.showTab("songs");
+
+				const currentSong = res.data.station.currentSong
+					? res.data.station.currentSong
+					: {};
+
+				this.updateCurrentSong(currentSong);
+
+				this.updateStationPaused(res.data.station.paused);
+
+				this.socket.dispatch(
+					"stations.getStationIncludedPlaylistsById",
+					this.stationId,
+					res => {
+						if (res.status === "success")
+							this.setIncludedPlaylists(res.data.playlists);
+					}
+				);
+
+				this.socket.dispatch(
+					"stations.getStationExcludedPlaylistsById",
+					this.stationId,
+					res => {
+						if (res.status === "success")
+							this.setExcludedPlaylists(res.data.playlists);
+					}
+				);
+
+				if (this.isOwnerOrAdmin()) {
+					this.socket.dispatch(
+						"playlists.getPlaylistForStation",
+						this.station._id,
+						true,
+						res => {
+							if (res.status === "success") {
+								this.updateStationPlaylist(res.data.playlist);
+							}
+						}
+					);
+				}
+
+				this.socket.dispatch(
+					"stations.getQueue",
+					this.stationId,
+					res => {
+						if (res.status === "success")
+							this.updateSongsList(res.data.queue);
+					}
+				);
+
+				this.socket.dispatch(
+					"apis.joinRoom",
+					`manage-station.${this.stationId}`
+				);
+
+				this.socket.on(
+					"event:station.name.updated",
+					res => {
+						this.station.name = res.data.name;
+					},
+					{ modal: "manageStation" }
+				);
+
+				this.socket.on(
+					"event:station.displayName.updated",
+					res => {
+						this.station.displayName = res.data.displayName;
+					},
+					{ modal: "manageStation" }
+				);
+
+				this.socket.on(
+					"event:station.description.updated",
+					res => {
+						this.station.description = res.data.description;
+					},
+					{ modal: "manageStation" }
+				);
+
+				this.socket.on(
+					"event:station.partyMode.updated",
+					res => {
+						if (this.station.type === "community")
+							this.station.partyMode = res.data.partyMode;
+					},
+					{ modal: "manageStation" }
+				);
+
+				this.socket.on(
+					"event:station.playMode.updated",
+					res => {
+						this.station.playMode = res.data.playMode;
+					},
+					{ modal: "manageStation" }
+				);
+
+				this.socket.on(
+					"event:station.theme.updated",
+					res => {
+						const { theme } = res.data;
+						this.station.theme = theme;
+					},
+					{ modal: "manageStation" }
+				);
+
+				this.socket.on(
+					"event:station.privacy.updated",
+					res => {
+						this.station.privacy = res.data.privacy;
+					},
+					{ modal: "manageStation" }
+				);
+
+				this.socket.on(
+					"event:station.queue.lock.toggled",
+					res => {
+						this.station.locked = res.data.locked;
+					},
+					{ modal: "manageStation" }
+				);
+
+				this.socket.on(
+					"event:station.includedPlaylist",
+					res => {
+						const { playlist } = res.data;
+						const playlistIndex = this.includedPlaylists
+							.map(includedPlaylist => includedPlaylist._id)
+							.indexOf(playlist._id);
+						if (playlistIndex === -1)
+							this.includedPlaylists.push(playlist);
+					},
+					{ modal: "manageStation" }
+				);
+
+				this.socket.on(
+					"event:station.excludedPlaylist",
+					res => {
+						const { playlist } = res.data;
+						const playlistIndex = this.excludedPlaylists
+							.map(excludedPlaylist => excludedPlaylist._id)
+							.indexOf(playlist._id);
+						if (playlistIndex === -1)
+							this.excludedPlaylists.push(playlist);
+					},
+					{ modal: "manageStation" }
+				);
+
+				this.socket.on(
+					"event:station.removedIncludedPlaylist",
+					res => {
+						const { playlistId } = res.data;
+						const playlistIndex = this.includedPlaylists
+							.map(playlist => playlist._id)
+							.indexOf(playlistId);
+						if (playlistIndex >= 0)
+							this.includedPlaylists.splice(playlistIndex, 1);
+					},
+					{ modal: "manageStation" }
+				);
+
+				this.socket.on(
+					"event:station.removedExcludedPlaylist",
+					res => {
+						const { playlistId } = res.data;
+						const playlistIndex = this.excludedPlaylists
+							.map(playlist => playlist._id)
+							.indexOf(playlistId);
+						if (playlistIndex >= 0)
+							this.excludedPlaylists.splice(playlistIndex, 1);
+					},
+					{ modal: "manageStation" }
+				);
+			} else {
+				new Toast(`Station with that ID not found`);
+				this.closeModal("manageStation");
+			}
+		});
+
+		this.socket.on(
+			"event:station.queue.updated",
+			res => this.updateSongsList(res.data.queue),
+			{ modal: "manageStation" }
+		);
+
+		this.socket.on(
+			"event:station.queue.song.repositioned",
+			res => this.repositionSongInList(res.data.song),
+			{ modal: "manageStation" }
+		);
+
+		this.socket.on(
+			"event:station.pause",
+			() => this.updateStationPaused(true),
+			{ modal: "manageStation" }
+		);
+
+		this.socket.on(
+			"event:station.resume",
+			() => this.updateStationPaused(false),
+			{ modal: "manageStation" }
+		);
+
+		this.socket.on(
+			"event:station.nextSong",
+			res => {
+				const { currentSong } = res.data;
+				this.updateCurrentSong(currentSong || {});
+			},
+			{ modal: "manageStation" }
+		);
+
+		if (this.isOwnerOrAdmin()) {
+			this.socket.on(
+				"event:playlist.song.added",
+				res => {
+					if (this.stationPlaylist._id === res.data.playlistId)
+						this.stationPlaylist.songs.push(res.data.song);
+				},
+				{
+					modal: "manageStation"
+				}
+			);
+
+			this.socket.on(
+				"event:playlist.song.removed",
+				res => {
+					if (this.stationPlaylist._id === res.data.playlistId) {
+						// remove song from array of playlists
+						this.stationPlaylist.songs.forEach((song, index) => {
+							if (song.youtubeId === res.data.youtubeId)
+								this.stationPlaylist.songs.splice(index, 1);
+						});
+					}
+				},
+				{
+					modal: "manageStation"
+				}
+			);
+
+			this.socket.on(
+				"event:playlist.songs.repositioned",
+				res => {
+					if (this.stationPlaylist._id === res.data.playlistId) {
+						// for each song that has a new position
+						res.data.songsBeingChanged.forEach(changedSong => {
+							this.stationPlaylist.songs.forEach(
+								(song, index) => {
+									// find song locally
+									if (
+										song.youtubeId === changedSong.youtubeId
+									) {
+										// change song position attribute
+										this.stationPlaylist.songs[
+											index
+										].position = changedSong.position;
+
+										// reposition in array if needed
+										if (index !== changedSong.position - 1)
+											this.stationPlaylist.songs.splice(
+												changedSong.position - 1,
+												0,
+												this.stationPlaylist.songs.splice(
+													index,
+													1
+												)[0]
+											);
+									}
+								}
+							);
+						});
+					}
+				},
+				{
+					modal: "manageStation"
+				}
+			);
+		}
+	},
+	beforeDestroy() {
+		this.socket.dispatch(
+			"apis.leaveRoom",
+			`manage-station.${this.stationId}`,
+			() => {}
+		);
+
+		this.repositionSongInList([]);
+		this.clearStation();
+		this.showTab("settings");
+	},
+	methods: {
+		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();
+		},
+		removeStation() {
+			this.socket.dispatch("stations.remove", this.station._id, res => {
+				new Toast(res.message);
+				if (res.status === "success") {
+					this.closeModal("manageStation");
+				}
+			});
+		},
+		resumeStation() {
+			this.socket.dispatch("stations.resume", this.station._id, res => {
+				if (res.status !== "success")
+					new Toast(`Error: ${res.message}`);
+				else new Toast("Successfully resumed the station.");
+			});
+		},
+		pauseStation() {
+			this.socket.dispatch("stations.pause", this.station._id, res => {
+				if (res.status !== "success")
+					new Toast(`Error: ${res.message}`);
+				else new Toast("Successfully paused the station.");
+			});
+		},
+		skipStation() {
+			this.socket.dispatch(
+				"stations.forceSkip",
+				this.station._id,
+				res => {
+					if (res.status !== "success")
+						new Toast(`Error: ${res.message}`);
+					else
+						new Toast(
+							"Successfully skipped the station's current song."
+						);
+				}
+			);
+		},
+		clearAndRefillStationQueue() {
+			this.socket.dispatch(
+				"stations.clearAndRefillStationQueue",
+				this.station._id,
+				res => {
+					if (res.status !== "success")
+						new Toast({
+							content: `Error: ${res.message}`,
+							timeout: 8000
+						});
+					else new Toast({ content: res.message, timeout: 4000 });
+				}
+			);
+		},
+		...mapActions("modals/manageStation", [
+			"editStation",
+			"setIncludedPlaylists",
+			"setExcludedPlaylists",
+			"clearStation",
+			"updateSongsList",
+			"updateStationPlaylist",
+			"repositionSongInList",
+			"updateStationPaused",
+			"updateCurrentSong"
+		]),
+		...mapActions({
+			showTab(dispatch, payload) {
+				this.$refs[`${payload}-tab`].scrollIntoView();
+				return dispatch("modals/manageStation/showTab", payload);
+			}
+		}),
+		...mapActions("modalVisibility", ["openModal", "closeModal"]),
+		...mapActions("user/playlists", ["editPlaylist"])
+	}
+};
+</script>
+
+<style lang="scss">
+.manage-station-modal.modal {
+	z-index: 1800;
+	.modal-card {
+		width: 1300px;
+		height: 100%;
+		overflow: auto;
+		.tab > button {
+			width: 100%;
+			margin-bottom: 10px;
+		}
+		.currently-playing.song-item {
+			.song-info {
+				width: calc(100% - 150px);
+			}
+			.thumbnail {
+				min-width: 130px;
+				width: 130px;
+				height: 130px;
+			}
+		}
+	}
+}
+</style>
+
+<style lang="scss" scoped>
+.manage-station-modal.modal .modal-card-body .custom-modal-body {
+	display: flex;
+	flex-wrap: wrap;
+
+	.section {
+		display: flex;
+		flex-direction: column;
+		flex-grow: 1;
+		width: auto;
+		padding: 15px !important;
+		margin: 0 10px;
+	}
+
+	.left-section {
+		flex-basis: 50%;
+		height: 100%;
+		overflow-y: auto;
+		flex-grow: 1;
+
+		#about-station-container {
+			padding: 20px;
+			display: flex;
+			flex-direction: column;
+			flex-grow: unset;
+			border-radius: 5px;
+			margin: 0 0 20px 0;
+			background-color: var(--white);
+			border: 1px solid var(--light-grey-3);
+
+			#station-info {
+				#station-name {
+					flex-direction: row !important;
+					display: flex;
+					flex-direction: row;
+					max-width: 100%;
+
+					h1 {
+						margin: 0;
+						font-size: 36px;
+						line-height: 0.8;
+					}
+
+					i {
+						margin-left: 10px;
+						font-size: 30px;
+						color: var(--yellow);
+						&.stationMode {
+							padding-left: 10px;
+							margin-left: auto;
+							color: var(--primary-color);
+						}
+					}
+
+					.verified-station {
+						color: var(--primary-color);
+					}
+				}
+
+				p {
+					max-width: 700px;
+					margin-bottom: 10px;
+				}
+			}
+
+			#admin-buttons {
+				display: flex;
+
+				.button {
+					margin: 3px;
+				}
+			}
+		}
+
+		.tab-selection {
+			display: flex;
+			overflow-x: auto;
+
+			.button {
+				border-radius: 5px 5px 0 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 {
+			border: 1px solid var(--light-grey-3);
+			padding: 15px;
+			border-radius: 0 0 5px 5px;
+		}
+	}
+	.right-section {
+		flex-basis: 50%;
+		height: 100%;
+		overflow-y: auto;
+		flex-grow: 1;
+		.section {
+			.queue-title {
+				display: flex;
+				line-height: 30px;
+				.material-icons {
+					margin-left: 5px;
+					margin-bottom: 5px;
+					font-size: 28px;
+					cursor: pointer;
+					&:first-of-type {
+						margin-left: auto;
+					}
+					&.skip-station {
+						color: var(--red);
+					}
+					&.resume-station,
+					&.pause-station {
+						color: var(--primary-color);
+					}
+				}
+			}
+			.currently-playing {
+				margin-bottom: 10px;
+			}
+		}
+	}
+}
+
+@media screen and (max-width: 1100px) {
+	.manage-station-modal.modal .modal-card-body .custom-modal-body {
+		.left-section,
+		.right-section {
+			flex-basis: unset;
+			height: auto;
+		}
+	}
+}
+</style>

+ 6 - 0
frontend/src/store/modules/modals/manageStation.js

@@ -6,6 +6,7 @@ export default {
 		tab: "settings",
 		originalStation: {},
 		station: {},
+		stationPlaylist: { songs: [] },
 		includedPlaylists: [],
 		excludedPlaylists: [],
 		songsList: [],
@@ -23,6 +24,8 @@ export default {
 		clearStation: ({ commit }) => commit("clearStation"),
 		updateSongsList: ({ commit }, songsList) =>
 			commit("updateSongsList", songsList),
+		updateStationPlaylist: ({ commit }, stationPlaylist) =>
+			commit("updateStationPlaylist", stationPlaylist),
 		repositionSongInList: ({ commit }, song) =>
 			commit("repositionSongInList", song),
 		updateStationPaused: ({ commit }, stationPaused) =>
@@ -55,6 +58,9 @@ export default {
 		updateSongsList(state, songsList) {
 			state.songsList = songsList;
 		},
+		updateStationPlaylist(state, stationPlaylist) {
+			state.stationPlaylist = stationPlaylist;
+		},
 		repositionSongInList(state, song) {
 			if (
 				state.songsList[song.newIndex] &&