浏览代码

feat: Add WIP station page

Owen Diffey 2 周之前
父节点
当前提交
8c9c04657f

二进制
frontend/dist/fonts/inter-v18-latin-300.woff2


二进制
frontend/dist/fonts/inter-v18-latin-500.woff2


二进制
frontend/dist/fonts/inter-v18-latin-600.woff2


二进制
frontend/dist/fonts/inter-v18-latin-regular.woff2


+ 2 - 1
frontend/src/main.ts

@@ -243,7 +243,8 @@ const router = createRouter({
 		{
 		{
 			name: "station",
 			name: "station",
 			path: "/:id",
 			path: "/:id",
-			component: () => import("@/pages//Station/index.vue")
+			props: true,
+			component: () => import("@/pages/NewStation/index.vue")
 		}
 		}
 	]
 	]
 });
 });

+ 315 - 0
frontend/src/pages/NewStation/LeftSidebar.vue

@@ -0,0 +1,315 @@
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, reactive, ref } from "vue";
+import Toast from "toasters";
+import { Station } from "@/types/station";
+import { useUserAuthStore } from "@/stores/userAuth";
+import { useWebsocketsStore } from "@/stores/websockets";
+
+const props = defineProps<{
+	station: Station;
+	canVoteToSkip: boolean;
+	votedToSkip: boolean;
+	votesToSkip: number;
+	votesRequiredToSkip: number;
+}>();
+
+const Button = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/Button.vue")
+);
+const Pill = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/Pill.vue")
+);
+const Sidebar = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/Sidebar.vue")
+);
+const Tabs = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/Tabs.vue")
+);
+const UserItem = defineAsyncComponent(
+	() => import("@/pages/NewStation/UserItem.vue")
+);
+
+const { loggedIn, hasPermissionForStation } = useUserAuthStore();
+
+const { socket } = useWebsocketsStore();
+
+const isOwner = userId => props.station.owner === userId;
+
+const djSearch = reactive({
+	query: "",
+	searchedQuery: "",
+	page: 0,
+	count: 0,
+	resultsLeft: 0,
+	pageSize: 0,
+	results: [],
+	nextPageResultsCount: 0
+});
+
+const userTabs = computed(() => {
+	const tabs = ["Audience"];
+
+	if (hasPermissionForStation(props.station._id, "stations.update"))
+		tabs.push("DJs");
+
+	return tabs;
+});
+
+const sortedUsers = computed(() =>
+	props.station.users && props.station.users.loggedIn
+		? props.station.users.loggedIn
+				.slice()
+				.sort(
+					(a, b) =>
+						Number(isOwner(b._id)) - Number(isOwner(a._id)) ||
+						Number(!isOwner(a._id)) - Number(!isOwner(b._id))
+				)
+		: []
+);
+
+const favorite = () => {
+	socket.dispatch("stations.favoriteStation", props.station._id, res => {
+		if (res.status === "success") {
+			new Toast("Successfully favorited station.");
+		} else new Toast(res.message);
+	});
+};
+
+const unfavorite = () => {
+	socket.dispatch("stations.unfavoriteStation", props.station._id, res => {
+		if (res.status === "success") {
+			new Toast("Successfully unfavorited station.");
+		} else new Toast(res.message);
+	});
+};
+
+const resetDjsSearch = () => {
+	djSearch.query = "";
+	djSearch.searchedQuery = "";
+	djSearch.page = 0;
+	djSearch.count = 0;
+	djSearch.resultsLeft = 0;
+	djSearch.pageSize = 0;
+	djSearch.results = [];
+	djSearch.nextPageResultsCount = 0;
+};
+
+const searchForDjs = (page: number) => {
+	if (djSearch.page >= page || djSearch.searchedQuery !== djSearch.query) {
+		djSearch.results = [];
+		djSearch.page = 0;
+		djSearch.count = 0;
+		djSearch.resultsLeft = 0;
+		djSearch.pageSize = 0;
+		djSearch.nextPageResultsCount = 0;
+	}
+
+	djSearch.searchedQuery = djSearch.query;
+	socket.dispatch("users.search", djSearch.query, page, res => {
+		const { data } = res;
+		if (res.status === "success") {
+			const { count, pageSize, users } = data;
+			djSearch.results = [...djSearch.results, ...users];
+			djSearch.page = page;
+			djSearch.count = count;
+			djSearch.resultsLeft = count - djSearch.results.length;
+			djSearch.pageSize = pageSize;
+			djSearch.nextPageResultsCount = Math.min(
+				djSearch.pageSize,
+				djSearch.resultsLeft
+			);
+		} else if (res.status === "error") {
+			djSearch.results = [];
+			djSearch.page = 0;
+			djSearch.count = 0;
+			djSearch.resultsLeft = 0;
+			djSearch.pageSize = 0;
+			djSearch.nextPageResultsCount = 0;
+			new Toast(res.message);
+		}
+	});
+};
+</script>
+
+<template>
+	<Sidebar>
+		<section class="information">
+			<div class="information__header">
+				<i
+					v-if="loggedIn && station.isFavorited"
+					class="material-icons"
+					@click.prevent="unfavorite"
+					title="Favorite station"
+					>star</i
+				>
+				<i
+					v-else-if="loggedIn"
+					class="material-icons"
+					@click.prevent="favorite"
+					title="Unfavorite station"
+					>star_border</i
+				>
+				<h1>{{ station.displayName }}</h1>
+			</div>
+			<div class="information__actions">
+				<Pill v-if="station.privacy === 'public'" icon="public">
+					Public
+				</Pill>
+				<Pill v-else-if="station.privacy === 'unlisted'" icon="link">
+					Unlisted
+				</Pill>
+				<Pill v-else-if="station.privacy === 'private'" icon="lock">
+					Private
+				</Pill>
+			</div>
+			<p style="min-height: 60px">{{ station.description }}</p>
+			<div class="information__actions">
+				<Button icon="share">Share</Button>
+			</div>
+		</section>
+		<hr class="sidebar__divider" />
+		<Tabs :tabs="userTabs">
+			<template #Audience>
+				<UserItem
+					v-for="user in sortedUsers"
+					:key="`audience-${user._id}`"
+					:station="station"
+					:user="user"
+				/>
+
+				<p
+					v-if="station.users && station.users.loggedOut?.length > 0"
+					class="guest-users"
+				>
+					{{ sortedUsers.length > 0 ? "..and" : "There are" }}
+					{{ station.users.loggedOut.length }} logged-out users.
+				</p>
+			</template>
+			<template #DJs>
+				<h3 style="margin: 0; font-size: 16px; font-weight: 600">
+					Add DJ
+				</h3>
+				<p style="font-size: 14px">
+					Search for a user to promote to DJ.
+				</p>
+				<div style="display: flex">
+					<input
+						type="text"
+						style="
+							line-height: 18px;
+							font-size: 12px;
+							padding: 5px 10px;
+							background-color: var(--light-grey-2);
+							border-radius: 5px 0 0 5px;
+							border: solid 1px var(--light-grey-1);
+							flex-grow: 1;
+							border-right-width: 0;
+						"
+						v-model="djSearch.query"
+						@keyup.enter="searchForDjs(1)"
+					/>
+					<Button
+						icon="restart_alt"
+						square
+						style="
+							border-right-width: 0;
+							background-color: var(--light-grey-2);
+							border-color: var(--light-grey-1);
+							color: var(--primary-color);
+							border-radius: 0;
+						"
+						@click.prevent="resetDjsSearch()"
+						title="Reset search"
+					/>
+					<Button
+						icon="search"
+						square
+						style="border-radius: 0 5px 5px 0; flex-shrink: 0"
+						@click.prevent="searchForDjs(1)"
+						title="Search"
+					/>
+				</div>
+
+				<UserItem
+					v-for="dj in djSearch.results"
+					:key="`dj-search-${dj._id}`"
+					:station="station"
+					:user="dj"
+				/>
+
+				<Button
+					v-if="djSearch.resultsLeft > 0"
+					icon="search"
+					@click.prevent="searchForDjs(djSearch.page + 1)"
+				>
+					Load {{ djSearch.nextPageResultsCount }}
+					more results
+				</Button>
+
+				<p
+					v-if="djSearch.page > 0 && djSearch.results.length === 0"
+					class="guest-users"
+				>
+					No users found with this search query.
+				</p>
+
+				<hr class="sidebar__divider" />
+
+				<h3 style="margin: 0; font-size: 16px; font-weight: 600">
+					Current DJs
+				</h3>
+
+				<UserItem
+					v-for="dj in station.djs"
+					:key="`djs-${dj._id}`"
+					:station="station"
+					:user="dj"
+				/>
+
+				<p v-if="station.djs.length === 0" class="guest-users">
+					There are currently no DJs.
+				</p>
+			</template>
+		</Tabs>
+	</Sidebar>
+</template>
+
+<style lang="less" scoped>
+.information {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+
+	&__header {
+		display: flex;
+		align-items: center;
+		gap: 10px;
+
+		h1 {
+			font-size: 26px;
+			font-weight: 600;
+			margin: 0;
+		}
+
+		i {
+			font-size: 26px;
+			color: var(--yellow);
+			// TODO: Wrap in button
+		}
+	}
+
+	&__actions {
+		display: flex;
+		flex-wrap: wrap;
+		gap: 10px;
+	}
+}
+
+.guest-users {
+	color: var(--dark-grey-1);
+	font-size: 14px !important;
+	font-weight: 500 !important;
+	text-align: center;
+	padding: 5px;
+}
+</style>

+ 197 - 0
frontend/src/pages/NewStation/Queue.vue

@@ -0,0 +1,197 @@
+<script lang="ts" setup>
+import { defineAsyncComponent, onMounted, ref } from "vue";
+import { DraggableList } from "vue-draggable-list";
+import Toast from "toasters";
+import { Station } from "@/types/station";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useUserAuthStore } from "@/stores/userAuth";
+import DropdownListItem from "@/pages/NewStation/Components/DropdownListItem.vue";
+
+const MediaItem = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/MediaItem.vue")
+);
+
+const props = defineProps<{
+	station: Station;
+}>();
+
+const { hasPermissionForStation } = useUserAuthStore();
+const { socket } = useWebsocketsStore();
+
+const mediaItems = ref({});
+const queue = ref([]);
+
+const applyQueueOrder = queueOrder => {
+	queue.value.sort((previous, current) => {
+		const currentIndex = queueOrder.findIndex(
+			mediaSource => mediaSource === previous.mediaSource
+		);
+		const previousIndex = queueOrder.findIndex(
+			mediaSource => mediaSource === current.mediaSource
+		);
+		if (currentIndex > previousIndex) return 1;
+		if (currentIndex < previousIndex) return -1;
+		return 0;
+	});
+};
+
+const getQueue = () => {
+	socket.dispatch("stations.getQueue", props.station._id, res => {
+		if (res.status === "success") {
+			queue.value = res.data.queue;
+		}
+	});
+};
+
+const removeFromQueue = (media, index) => {
+	mediaItems.value[`media-item-${index}`].collapseActions();
+
+	socket.dispatch(
+		"stations.removeFromQueue",
+		props.station._id,
+		media.mediaSource,
+		res => {
+			if (res.status === "success")
+				new Toast("Successfully removed song from the queue.");
+			else new Toast(res.message);
+		}
+	);
+};
+
+const repositionSongInQueue = ({ moved, song }) => {
+	const { oldIndex, newIndex } = moved;
+
+	if (oldIndex === newIndex) return; // we only need to update when song is moved
+
+	const _song = song ?? queue.value[newIndex];
+
+	socket.dispatch(
+		"stations.repositionSongInQueue",
+		props.station._id,
+		{
+			..._song,
+			oldIndex,
+			newIndex
+		},
+		res => {
+			new Toast({ content: res.message, timeout: 4000 });
+
+			if (res.status === "success") return;
+
+			queue.value.splice(oldIndex, 0, queue.value.splice(newIndex, 1)[0]);
+		}
+	);
+};
+
+const moveToQueueTop = (media, oldIndex) => {
+	mediaItems.value[`media-item-${oldIndex}`].collapseActions();
+
+	repositionSongInQueue({
+		moved: {
+			oldIndex,
+			newIndex: 0
+		},
+		song: media
+	});
+};
+
+const moveToQueueBottom = (media, oldIndex) => {
+	mediaItems.value[`media-item-${oldIndex}`].collapseActions();
+
+	repositionSongInQueue({
+		moved: {
+			oldIndex,
+			newIndex: queue.value.length - 1
+		},
+		song: media
+	});
+};
+
+onMounted(() => {
+	socket.onConnect(() => {
+		getQueue();
+
+		socket.on("event:station.queue.updated", res => {
+			queue.value = res.data.queue;
+		});
+
+		socket.on("event:station.queue.order.changed", res => {
+			applyQueueOrder(res.data.queueOrder);
+		});
+	});
+});
+</script>
+
+<template>
+	<ul>
+		<DraggableList
+			v-model:list="queue"
+			tag="li"
+			item-key="mediaSource"
+			:disabled="
+				!hasPermissionForStation(
+					station._id,
+					'stations.queue.reposition'
+				)
+			"
+			@update="repositionSongInQueue"
+		>
+			<template #item="{ element: media, index }">
+				<MediaItem
+					:media="media"
+					:ref="el => (mediaItems[`media-item-${index}`] = el)"
+				>
+					<template
+						v-if="
+							hasPermissionForStation(
+								station._id,
+								'stations.queue.reposition'
+							)
+						"
+						#actions
+					>
+						<DropdownListItem
+							v-if="index > 0"
+							icon="vertical_align_top"
+							label="Move to top of queue"
+							@click="() => moveToQueueTop(media, index)"
+						/>
+						<DropdownListItem
+							v-if="queue.length - 1 !== index"
+							icon="vertical_align_bottom"
+							label="Move to bottom of queue"
+							@click="() => moveToQueueBottom(media, index)"
+						/>
+						<!-- TODO: Quick confirm -->
+						<DropdownListItem
+							v-if="
+								hasPermissionForStation(
+									station._id,
+									'stations.queue.remove'
+								)
+							"
+							icon="delete"
+							label="Remove from queue"
+							@click="() => removeFromQueue(media, index)"
+						/>
+					</template>
+				</MediaItem>
+			</template>
+		</DraggableList>
+	</ul>
+</template>
+
+<style lang="less" scoped>
+ul {
+	display: flex;
+	flex-direction: column;
+	gap: 10px;
+	list-style: none;
+	padding: 0;
+	overflow: auto;
+
+	:deep(li) {
+		margin: 0 !important;
+	}
+}
+</style>

+ 211 - 0
frontend/src/pages/NewStation/UserItem.vue

@@ -0,0 +1,211 @@
+<script lang="ts" setup>
+import { computed, defineAsyncComponent } from "vue";
+import { useRouter } from "vue-router";
+import Toast from "toasters";
+import { User } from "@/types/user";
+import { useUserAuthStore } from "@/stores/userAuth";
+import { Station } from "@/types/station";
+import { useWebsocketsStore } from "@/stores/websockets";
+import DropdownListItem from "@/pages/NewStation/Components/DropdownListItem.vue";
+
+const Button = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/Button.vue")
+);
+const DropdownList = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/DropdownList.vue")
+);
+const ProfilePicture = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/ProfilePicture.vue")
+);
+
+const props = defineProps<{
+	station: Station;
+	user: User;
+}>();
+
+const { hasPermissionForStation } = useUserAuthStore();
+const { socket } = useWebsocketsStore();
+
+const router = useRouter();
+
+const name = computed(() => props.user.name ?? props.user.username);
+const isOwner = computed(() => props.station.owner === props.user._id);
+const isDj = computed(
+	() => !!props.station.djs.find(dj => dj._id === props.user._id)
+);
+const status = computed(() => {
+	if (!props.user.state) return null;
+
+	switch (props.user.state) {
+		case "participate":
+			return "Participating";
+		case "local_paused":
+			return "Paused";
+		case "muted":
+			return "Muted";
+		case "playing":
+			return "Listening";
+		case "unavailable":
+			return "Unavailable";
+		case "buffering":
+			return "Loading";
+		case "station_paused":
+		case "no_song":
+			return "Waiting";
+		case "unknown":
+		default:
+			return "Unknown";
+	}
+});
+
+const promoteToDj = () => {
+	socket.dispatch(
+		"stations.addDj",
+		props.station._id,
+		props.user._id,
+		res => {
+			new Toast(res.message);
+		}
+	);
+};
+
+const demoteFromDj = () => {
+	socket.dispatch(
+		"stations.removeDj",
+		props.station._id,
+		props.user._id,
+		res => {
+			new Toast(res.message);
+		}
+	);
+};
+
+const viewProfile = () => {
+	router.push({
+		name: "profile",
+		params: { username: props.user.username }
+	});
+};
+</script>
+
+<template>
+	<div class="user-item">
+		<ProfilePicture :avatar="user.avatar" :name="name" />
+		<div class="user-item__content">
+			<p class="user-item__name">
+				<span
+					v-if="isOwner"
+					class="material-icons user-item__rank"
+					title="Station Owner"
+				>
+					local_police
+				</span>
+				<span
+					v-else-if="isDj"
+					class="material-icons user-item__rank"
+					title="Station DJ"
+				>
+					shield
+				</span>
+				<span :title="name">{{ name }}</span>
+			</p>
+			<p v-if="status" class="user-item__status" :title="status">
+				{{ status }}
+			</p>
+		</div>
+		<DropdownList>
+			<Button icon="more_horiz" square inverse title="Actions" />
+
+			<template #options>
+				<DropdownListItem
+					v-if="
+						hasPermissionForStation(
+							station._id,
+							'stations.djs.add'
+						) &&
+						!isOwner &&
+						!isDj
+					"
+					icon="add_moderator"
+					label="Promote to DJ"
+					@click="promoteToDj"
+				/>
+				<DropdownListItem
+					v-if="
+						hasPermissionForStation(
+							station._id,
+							'stations.djs.remove'
+						) &&
+						!isOwner &&
+						isDj
+					"
+					icon="remove_moderator"
+					label="Demote from DJ"
+					@click="demoteFromDj"
+				/>
+				<DropdownListItem
+					icon="account_circle"
+					label="View profile"
+					@click="viewProfile"
+				/>
+			</template>
+		</DropdownList>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.user-item {
+	display: flex;
+	background-color: var(--white);
+	border-radius: 5px;
+	border: solid 1px var(--light-grey-1);
+	padding: 5px;
+	gap: 5px;
+
+	:deep(.profile-picture) {
+		height: 30px;
+		width: 30px;
+		flex-shrink: 0;
+
+		&--initials span {
+			font-size: 14px;
+		}
+	}
+
+	&__name {
+		display: inline-flex;
+		align-items: center;
+		gap: 2px;
+		font-size: 12px !important;
+		line-height: 16px;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+	}
+
+	&__rank {
+		color: var(--primary-color);
+		font-size: 12px !important;
+	}
+
+	&__status {
+		display: inline-flex;
+		align-items: center;
+		font-size: 10px !important;
+		font-weight: 500 !important;
+		line-height: 12px;
+		color: var(--dark-grey-1);
+		overflow: hidden;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+	}
+
+	&__content {
+		display: flex;
+		flex-direction: column;
+		flex-grow: 1;
+		min-width: 0;
+		justify-content: center;
+	}
+}
+</style>

+ 712 - 0
frontend/src/pages/NewStation/index.vue

@@ -0,0 +1,712 @@
+<script lang="ts" setup>
+import {
+	computed,
+	defineAsyncComponent,
+	onBeforeUnmount,
+	onMounted,
+	ref,
+	watch
+} from "vue";
+import { useRoute, useRouter } from "vue-router";
+import Toast from "toasters";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useUserAuthStore } from "@/stores/userAuth";
+import { useConfigStore } from "@/stores/config";
+import { Station } from "@/types/station";
+import dayjs from "@/dayjs";
+
+const MainHeader = defineAsyncComponent(
+	() => import("@/components/MainHeader.vue")
+);
+const MainFooter = defineAsyncComponent(
+	() => import("@/components/MainFooter.vue")
+);
+const MediaItem = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/MediaItem.vue")
+);
+const MediaPlayer = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/MediaPlayer.vue")
+);
+const LeftSidebar = defineAsyncComponent(
+	() => import("@/pages/NewStation/LeftSidebar.vue")
+);
+const RightSidebar = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/Sidebar.vue")
+);
+const Queue = defineAsyncComponent(
+	() => import("@/pages/NewStation/Queue.vue")
+);
+const Button = defineAsyncComponent(
+	() => import("@/pages/NewStation/Components/Button.vue")
+);
+
+const props = defineProps<{
+	id: string;
+}>();
+
+const { primaryColor } = useConfigStore();
+const { userId, hasPermissionForStation, updatePermissionsForStation } =
+	useUserAuthStore();
+const { socket } = useWebsocketsStore();
+
+const router = useRouter();
+const route = useRoute();
+
+const station = ref<Station>();
+const canVoteToSkip = ref(false);
+const votedToSkip = ref(false);
+const votesToSkip = ref(0);
+const reportStationStateInterval = ref();
+const mediaPlayer = ref<typeof MediaPlayer>();
+const systemTimeDifference = ref<{
+	timeout?: number;
+	current: number;
+	last: number;
+	consecutiveHighDifferenceCount: number;
+}>({
+	timeout: null,
+	current: 0,
+	last: 0,
+	consecutiveHighDifferenceCount: 0
+});
+
+const startedAt = computed(() => dayjs(station.value.startedAt));
+
+const pausedAt = computed(() => {
+	if (station.value.paused) {
+		return dayjs(station.value.pausedAt || station.value.startedAt);
+	}
+
+	return null;
+});
+
+const timePaused = computed(() => dayjs.duration(station.value.timePaused));
+
+const timeOffset = computed(() =>
+	dayjs.duration(systemTimeDifference.value.current)
+);
+
+const stationState = computed(() => {
+	if (!station.value?.currentSong) return "no_song";
+	if (station.value?.paused) return "station_paused";
+	if (mediaPlayer.value) return mediaPlayer.value.playerState;
+	return "buffering";
+});
+
+const votesRequiredToSkip = computed(() => {
+	if (!station.value || !station.value.users.loggedIn) return 0;
+
+	return Math.max(
+		1,
+		Math.round(
+			station.value.users.loggedIn.length *
+				(station.value.skipVoteThreshold / 100)
+		)
+	);
+});
+
+const refreshSkipVotes = () => {
+	const { currentSong } = station.value;
+
+	if (!currentSong) {
+		canVoteToSkip.value = false;
+		votedToSkip.value = false;
+		votesToSkip.value = 0;
+
+		return;
+	}
+
+	socket.dispatch(
+		"stations.getSkipVotes",
+		station.value._id,
+		currentSong._id,
+		res => {
+			if (res.status === "success") {
+				if (
+					station.value.currentSong &&
+					station.value.currentSong._id === currentSong._id
+				) {
+					const { skipVotes, skipVotesCurrent, voted } = res.data;
+
+					canVoteToSkip.value = skipVotesCurrent;
+					votedToSkip.value = voted;
+					votesToSkip.value = skipVotes;
+				}
+			}
+		}
+	);
+};
+
+const redirectAwayUnauthorizedUser = () => {
+	if (
+		!hasPermissionForStation(station.value._id, "stations.view") &&
+		station.value.privacy === "private"
+	)
+		router.push({
+			path: "/",
+			query: {
+				toast: "You no longer have access to the station you were in."
+			}
+		});
+};
+
+const updateStationState = () => {
+	socket.dispatch("stations.setStationState", stationState.value, () => {});
+};
+
+const setDocumentPrimaryColor = (theme: string) => {
+	document.getElementsByTagName("html")[0].style.cssText =
+		`--primary-color: var(--${theme})`;
+};
+
+const resume = () => {
+	socket.dispatch("stations.resume", station.value._id, data => {
+		if (data.status !== "success") new Toast(`Error: ${data.message}`);
+		else new Toast("Successfully resumed the station.");
+	});
+};
+
+const pause = () => {
+	socket.dispatch("stations.pause", station.value._id, data => {
+		if (data.status !== "success") new Toast(`Error: ${data.message}`);
+		else new Toast("Successfully paused the station.");
+	});
+};
+
+const forceSkip = () => {
+	socket.dispatch("stations.forceSkip", station.value._id, data => {
+		if (data.status !== "success") new Toast(`Error: ${data.message}`);
+		else new Toast("Successfully skipped the station's current song.");
+	});
+};
+
+const toggleSkipVote = (toastMessage?: string) => {
+	if (!station.value.currentSong?._id) return;
+
+	socket.dispatch(
+		"stations.toggleSkipVote",
+		station.value._id,
+		station.value.currentSong._id,
+		data => {
+			if (data.status !== "success") new Toast(`Error: ${data.message}`);
+			else
+				new Toast(
+					toastMessage ??
+						"Successfully toggled vote to skip the current song."
+				);
+		}
+	);
+};
+
+const automaticallySkipVote = () => {
+	if (mediaPlayer.value?.isMediaPaused || station.value.currentSong.voted) {
+		return;
+	}
+
+	toggleSkipVote(
+		"Automatically voted to skip as this song isn't available for you."
+	);
+
+	// TODO: Persistent toast
+};
+
+const calculateTimeDifference = () => {
+	if (localStorage.getItem("stationNoSystemTimeDifference") === "true") {
+		console.log(
+			"Not calculating time different because 'stationNoSystemTimeDifference' is 'true' in localStorage"
+		);
+		return;
+	}
+
+	clearTimeout(systemTimeDifference.value.timeout);
+
+	// Store the current time in ms before we send a ping to the backend
+	const beforePing = Date.now();
+	socket.dispatch("ping", serverDate => {
+		// Store the current time in ms after we receive a pong from the backend
+		const afterPing = Date.now();
+
+		// Calculate the approximate latency between the client and the backend, by taking the time the request took and dividing it in 2
+		// This is not perfect, as the request could take longer to get to the server than be sent back, or the other way around
+		let connectionLatency = (afterPing - beforePing) / 2;
+
+		// If we have a station latency in localStorage, use that. Can be used for debugging.
+		if (localStorage.getItem("stationLatency")) {
+			connectionLatency = parseInt(
+				localStorage.getItem("stationLatency")
+			);
+		}
+
+		// Calculates the approximate different in system time that the current client has, compared to the system time of the backend
+		// Takes into account the approximate latency, so if it took approximately 500ms between the backend sending the pong, and the client receiving the pong,
+		// the system time from the backend has to have 500ms added for it to be correct
+		const difference = serverDate + connectionLatency - afterPing;
+
+		if (Math.abs(difference) > 3000) {
+			console.warn("System time difference is bigger than 3 seconds.");
+		}
+
+		// Gets how many ms. difference there is between the last time this function was called and now
+		const differenceBetweenLastTime = Math.abs(
+			systemTimeDifference.value.last - difference
+		);
+		const differenceBetweenCurrent = Math.abs(
+			systemTimeDifference.value.current - difference
+		);
+
+		// By default, we want to re-run this function every 5 minutes
+		let timeoutTime = 1000 * 300;
+
+		if (differenceBetweenCurrent > 250) {
+			// If the calculated difference is more than 250ms, there might be something wrong
+			if (differenceBetweenLastTime > 250) {
+				// If there's more than 250ms difference between the last calculated difference, reset the difference in a row count to 1
+				systemTimeDifference.value.consecutiveHighDifferenceCount = 1;
+			} else if (
+				systemTimeDifference.value.consecutiveHighDifferenceCount < 3
+			) {
+				systemTimeDifference.value.consecutiveHighDifferenceCount += 1;
+			} else {
+				// If we're on the third attempt in a row where the difference between last time is less than 250ms, accept it as the difference
+				systemTimeDifference.value.consecutiveHighDifferenceCount = 0;
+				systemTimeDifference.value.current = difference;
+			}
+
+			timeoutTime = 1000 * 10;
+		} else {
+			// Calculated difference is less than 250ms, so we just accept that it's correct
+			systemTimeDifference.value.consecutiveHighDifferenceCount = 0;
+			systemTimeDifference.value.current = difference;
+		}
+
+		if (systemTimeDifference.value.consecutiveHighDifferenceCount > 0) {
+			console.warn(
+				`System difference high difference in a row count: ${systemTimeDifference.value.consecutiveHighDifferenceCount}`
+			);
+		}
+
+		systemTimeDifference.value.last = difference;
+
+		systemTimeDifference.value.timeout = setTimeout(() => {
+			calculateTimeDifference();
+		}, timeoutTime);
+	});
+};
+
+watch(
+	() => station.value?.djs,
+	(djs, oldDjs) => {
+		if (!djs || !oldDjs) return;
+
+		const wasDj = oldDjs.find(dj => dj._id === userId);
+		const isDj = djs.find(dj => dj._id === userId);
+
+		if (wasDj !== isDj)
+			updatePermissionsForStation(station.value._id).then(
+				redirectAwayUnauthorizedUser
+			);
+	}
+);
+
+watch(
+	() => station.value?.name,
+	async (name, oldName) => {
+		if (!name || !oldName) return;
+
+		await router.push(
+			`${name}?${Object.keys(route.query)
+				.map(
+					key =>
+						`${encodeURIComponent(key)}=${encodeURIComponent(
+							JSON.stringify(route.query[key])
+						)}`
+				)
+				.join("&")}`
+		);
+
+		// eslint-disable-next-line no-restricted-globals
+		window.history.replaceState({ ...window.history.state, ...{} }, null);
+	}
+);
+
+watch(
+	() => station.value?.privacy,
+	(privacy, oldPrivacy) => {
+		if (!privacy || !oldPrivacy) return;
+
+		if (privacy === "private") redirectAwayUnauthorizedUser();
+	}
+);
+
+watch(
+	() => station.value?.theme,
+	theme => {
+		if (!theme) return;
+
+		setDocumentPrimaryColor(theme);
+	}
+);
+
+watch(
+	() => station.value?.currentSong?._id,
+	() => {
+		votedToSkip.value = false;
+		votesToSkip.value = 0;
+	}
+);
+
+onMounted(() => {
+	socket.onConnect(() => {
+		socket.dispatch("stations.join", props.id, ({ status, data }) => {
+			if (status !== "success") {
+				station.value = null;
+
+				router.push("/404");
+				return;
+			}
+
+			station.value = data;
+
+			refreshSkipVotes();
+
+			updatePermissionsForStation(station.value._id);
+
+			updateStationState();
+
+			reportStationStateInterval.value = setInterval(
+				updateStationState,
+				5000
+			);
+
+			calculateTimeDifference();
+		});
+	});
+
+	socket.on("event:station.updated", res => {
+		redirectAwayUnauthorizedUser();
+
+		station.value = {
+			...station.value,
+			...res.data.station
+		};
+	});
+
+	socket.on("event:station.pause", res => {
+		station.value.pausedAt = res.data.pausedAt;
+		station.value.paused = true;
+	});
+
+	socket.on("event:station.resume", res => {
+		station.value.timePaused = res.data.timePaused;
+		station.value.paused = false;
+	});
+
+	socket.on("event:station.toggleSkipVote", res => {
+		console.log("toggleSkipVote", res);
+		if (res.data.currentSongId !== station.value.currentSong?._id) return;
+
+		if (res.data.voted) votesToSkip.value += 1;
+		else votesToSkip.value -= 1;
+
+		if (res.data.userId === userId) votedToSkip.value = res.data.voted;
+	});
+
+	socket.on("event:station.nextSong", res => {
+		console.log("nextSong", res);
+		station.value.currentSong = res.data.currentSong;
+		station.value.startedAt = res.data.startedAt;
+		station.value.paused = res.data.paused;
+		station.value.timePaused = res.data.timePaused;
+		station.value.pausedAt = 0;
+	});
+
+	socket.on("event:station.users.updated", res => {
+		station.value.users = res.data.users;
+	});
+
+	socket.on("event:station.userCount.updated", res => {
+		station.value.userCount = res.data.userCount;
+	});
+
+	socket.on("event:station.djs.added", res => {
+		station.value.djs.push(res.data.user);
+	});
+
+	socket.on("event:station.djs.removed", res => {
+		station.value.djs.forEach((dj, index) => {
+			if (dj._id === res.data.user._id) {
+				station.value.djs.splice(index, 1);
+			}
+		});
+	});
+
+	socket.on("keep.event:user.role.updated", redirectAwayUnauthorizedUser);
+
+	socket.on("event:user.station.favorited", res => {
+		if (res.data.stationId === station.value._id)
+			station.value.isFavorited = true;
+	});
+
+	socket.on("event:user.station.unfavorited", res => {
+		if (res.data.stationId === station.value._id)
+			station.value.isFavorited = false;
+	});
+
+	socket.on("event:station.deleted", () => {
+		router.push({
+			path: "/",
+			query: {
+				toast: "The station you were in was deleted."
+			}
+		});
+	});
+});
+
+onBeforeUnmount(() => {
+	document.getElementsByTagName("html")[0].style.cssText =
+		`--primary-color: ${primaryColor}`;
+
+	clearInterval(reportStationStateInterval.value);
+
+	clearTimeout(systemTimeDifference.value.timeout);
+
+	if (station.value) {
+		socket.dispatch("stations.leave", station.value._id, () => {});
+	}
+});
+</script>
+
+<template>
+	<div v-if="station" class="app">
+		<page-metadata :title="station.displayName" />
+
+		<MainHeader />
+		<div class="station-container">
+			<LeftSidebar
+				:station="station"
+				:can-vote-to-skip="canVoteToSkip"
+				:voted-to-skip="votedToSkip"
+				:votes-to-skip="votesToSkip"
+				:votes-required-to-skip="votesRequiredToSkip"
+			/>
+			<section
+				style="
+					display: flex;
+					flex-grow: 1;
+					gap: 40px;
+					padding: 40px;
+					max-height: calc(100vh - 64px);
+					overflow: auto;
+				"
+			>
+				<section
+					style="
+						display: flex;
+						flex-direction: column;
+						flex: 0 0 450px;
+						gap: 10px;
+						max-width: 450px;
+						padding: 20px;
+						background-color: var(--white);
+						border-radius: 5px;
+						border: solid 1px var(--light-grey-1);
+					"
+				>
+					<MediaPlayer
+						v-if="station.currentSong"
+						ref="mediaPlayer"
+						:source="station.currentSong"
+						:source-started-at="startedAt"
+						:source-paused-at="pausedAt"
+						:source-time-paused="timePaused"
+						:source-time-offset="timeOffset"
+						sync-player-time-enabled
+						@not-allowed="automaticallySkipVote"
+						@not-found="automaticallySkipVote"
+					/>
+					<h3
+						style="
+							margin: 0px;
+							font-size: 16px;
+							font-weight: 600 !important;
+							line-height: 30px;
+							display: inline-flex;
+							align-items: center;
+							gap: 5px;
+						"
+					>
+						Currently Playing
+						<span style="margin-right: auto"></span>
+
+						<Button
+							v-if="
+								hasPermissionForStation(
+									station._id,
+									'stations.playback.toggle'
+								) && !station.paused
+							"
+							icon="pause"
+							square
+							danger
+							@click.prevent="pause"
+							title="Pause station"
+						/>
+						<Button
+							v-if="
+								hasPermissionForStation(
+									station._id,
+									'stations.playback.toggle'
+								) && station.paused
+							"
+							icon="play_arrow"
+							square
+							danger
+							@click.prevent="resume"
+							title="Resume station"
+						/>
+						<Button
+							v-if="
+								hasPermissionForStation(
+									station._id,
+									'stations.skip'
+								)
+							"
+							icon="skip_next"
+							square
+							danger
+							@click.prevent="forceSkip"
+							title="Force skip station"
+						/>
+
+						<Button
+							v-if="canVoteToSkip"
+							icon="skip_next"
+							:inverse="!votedToSkip"
+							@click.prevent="toggleSkipVote"
+							:title="
+								votedToSkip
+									? 'Remove vote to skip'
+									: 'Vote to skip'
+							"
+						>
+							{{ votesToSkip }}
+							<small>/ {{ votesRequiredToSkip }}</small>
+						</Button>
+					</h3>
+					<MediaItem
+						v-if="station.currentSong"
+						:media="station.currentSong"
+					/>
+					<h3
+						style="
+							margin: 0px;
+							font-size: 16px;
+							font-weight: 600 !important;
+							line-height: 30px;
+						"
+					>
+						Upcoming Queue
+					</h3>
+					<Queue :station="station" />
+				</section>
+				<section
+					style="
+						display: flex;
+						flex-direction: column;
+						flex-grow: 1;
+						padding: 20px;
+						background-color: var(--white);
+						border-radius: 5px;
+						border: solid 1px var(--light-grey-1);
+					"
+				></section>
+			</section>
+			<RightSidebar />
+		</div>
+		<MainFooter />
+	</div>
+</template>
+
+<style lang="less" scoped>
+/* inter-300 - latin */
+@font-face {
+	font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
+	font-family: "Inter";
+	font-style: normal;
+	font-weight: 300;
+	src: url("/fonts/inter-v18-latin-300.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
+}
+
+/* inter-regular - latin */
+@font-face {
+	font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
+	font-family: "Inter";
+	font-style: normal;
+	font-weight: 400;
+	src: url("/fonts/inter-v18-latin-regular.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
+}
+
+/* inter-500 - latin */
+@font-face {
+	font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
+	font-family: "Inter";
+	font-style: normal;
+	font-weight: 500;
+	src: url("/fonts/inter-v18-latin-500.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
+}
+
+/* inter-600 - latin */
+@font-face {
+	font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
+	font-family: "Inter";
+	font-style: normal;
+	font-weight: 600;
+	src: url("/fonts/inter-v18-latin-600.woff2") format("woff2"); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
+}
+
+.station-container {
+	position: relative;
+	display: flex;
+	flex: 1 0 auto;
+	min-height: calc(100vh - 64px);
+	background-color: var(--light-grey-2);
+	color: var(--black);
+}
+
+:deep(.station-container) {
+	--dark-grey-1: #515151;
+	--light-grey-1: #d4d4d4;
+	--light-grey-2: #ececec;
+	--red: rgb(249, 49, 0);
+
+	h1,
+	h2,
+	h3,
+	h4,
+	h5,
+	h6,
+	p,
+	button,
+	input,
+	select,
+	textarea {
+		font-family: "Inter";
+		font-style: normal;
+		font-weight: normal;
+	}
+
+	p,
+	button,
+	input,
+	select,
+	textarea {
+		font-size: 16px;
+	}
+
+	*,
+	*:before,
+	*:after {
+		box-sizing: border-box;
+	}
+}
+</style>