Browse Source

feat: added temporary station history, and some small tweaks

Kristian Vos 1 year ago
parent
commit
2cce4c3229

+ 5 - 1
backend/logic/actions/songs.js

@@ -656,7 +656,11 @@ export default {
 						1,
 						(stationId, next) => {
 							if (!youtubeVideo)
-								StationsModule.runJob("SKIP_STATION", { stationId, natural: false }, this)
+								StationsModule.runJob(
+									"SKIP_STATION",
+									{ stationId, natural: false, skipReason: "other" },
+									this
+								)
 									.then(() => {
 										next();
 									})

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

@@ -996,6 +996,70 @@ export default {
 		);
 	},
 
+	/**
+	 * Gets station history
+	 *
+	 * @param {object} session - user session
+	 * @param {string} stationId - the station id
+	 * @param {Function} cb - callback
+	 */
+	getHistory(session, stationId, cb) {
+		async.waterfall(
+			[
+				next => {
+					StationsModule.runJob("GET_STATION", { stationId }, this)
+						.then(station => {
+							next(null, station);
+						})
+						.catch(next);
+				},
+
+				(station, next) => {
+					if (!station) return next("Station not found.");
+					return StationsModule.runJob(
+						"CAN_USER_VIEW_STATION",
+						{
+							station,
+							userId: session.userId
+						},
+						this
+					)
+						.then(canView => {
+							if (!canView) next("Not allowed to access station history.");
+							else next();
+						})
+						.catch(err => next(err));
+				},
+
+				async () => {
+					const response = await StationsModule.stationHistoryModel
+						.find({ stationId }, { documentVersion: 0, __v: 0 })
+						.sort({ "payload.skippedAt": -1 })
+						.limit(50);
+
+					return response;
+				}
+			],
+			async (err, history) => {
+				if (err) {
+					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+					this.log(
+						"ERROR",
+						"GET_STATION_HISTORY",
+						`Getting station history for station "${stationId}" failed. "${err}"`
+					);
+					return cb({ status: "error", message: err });
+				}
+				this.log(
+					"SUCCESS",
+					"GET_STATION_HISTORY",
+					`Got station history for station "${stationId}" successfully.`
+				);
+				return cb({ status: "success", data: { history } });
+			}
+		);
+	},
+
 	getStationAutofillPlaylistsById(session, stationId, cb) {
 		async.waterfall(
 			[
@@ -1257,7 +1321,7 @@ export default {
 					this.log("ERROR", "STATIONS_FORCE_SKIP", `Force skipping station "${stationId}" failed. "${err}"`);
 					return cb({ status: "error", message: err });
 				}
-				StationsModule.runJob("SKIP_STATION", { stationId, natural: false });
+				StationsModule.runJob("SKIP_STATION", { stationId, natural: false, skipReason: "force_skip" });
 				this.log("SUCCESS", "STATIONS_FORCE_SKIP", `Force skipped station "${stationId}" successfully.`);
 				return cb({
 					status: "success",

+ 8 - 3
backend/logic/db/index.js

@@ -18,7 +18,8 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	youtubeApiRequest: 1,
 	youtubeVideo: 1,
 	ratings: 1,
-	importJob: 1
+	importJob: 1,
+	stationHistory: 1
 };
 
 const regex = {
@@ -75,7 +76,8 @@ class _DBModule extends CoreClass {
 						punishment: {},
 						youtubeApiRequest: {},
 						youtubeVideo: {},
-						ratings: {}
+						ratings: {},
+						stationHistory: {}
 					};
 
 					const importSchema = schemaName =>
@@ -100,6 +102,7 @@ class _DBModule extends CoreClass {
 					await importSchema("youtubeVideo");
 					await importSchema("ratings");
 					await importSchema("importJob");
+					await importSchema("stationHistory");
 
 					this.models = {
 						song: mongoose.model("song", this.schemas.song),
@@ -115,7 +118,8 @@ class _DBModule extends CoreClass {
 						youtubeApiRequest: mongoose.model("youtubeApiRequest", this.schemas.youtubeApiRequest),
 						youtubeVideo: mongoose.model("youtubeVideo", this.schemas.youtubeVideo),
 						ratings: mongoose.model("ratings", this.schemas.ratings),
-						importJob: mongoose.model("importJob", this.schemas.importJob)
+						importJob: mongoose.model("importJob", this.schemas.importJob),
+						stationHistory: mongoose.model("stationHistory", this.schemas.stationHistory)
 					};
 
 					mongoose.connection.on("error", err => {
@@ -261,6 +265,7 @@ class _DBModule extends CoreClass {
 					this.models.youtubeVideo.syncIndexes();
 					this.models.ratings.syncIndexes();
 					this.models.importJob.syncIndexes();
+					this.models.stationHistory.syncIndexes();
 
 					if (config.get("skipDbDocumentsVersionCheck")) resolve();
 					else {

+ 22 - 0
backend/logic/db/schemas/stationHistory.js

@@ -0,0 +1,22 @@
+import mongoose from "mongoose";
+
+export default {
+	stationId: { type: mongoose.Schema.Types.ObjectId, required: true },
+	type: { type: String, enum: ["song_played"], required: true },
+	payload: {
+		song: {
+			_id: { type: mongoose.Schema.Types.ObjectId },
+			youtubeId: { type: String, min: 11, max: 11, required: true },
+			title: { type: String, trim: true, required: true },
+			artists: [{ type: String, trim: true, default: [] }],
+			duration: { type: Number },
+			thumbnail: { type: String },
+			requestedBy: { type: String },
+			requestedAt: { type: Date },
+			verified: { type: Boolean }
+		},
+		skippedAt: { type: Date },
+		skipReason: { type: String, enum: ["natural", "force_skip", "vote_skip"] }
+	},
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 5 - 1
backend/logic/playlists.js

@@ -964,7 +964,11 @@ class _PlaylistsModule extends CoreClass {
 
 					(includedSongs, next) => {
 						if (originalPlaylist.songs.length === 0 && includedSongs.length > 0)
-							StationsModule.runJob("SKIP_STATION", { stationId: payload.stationId, natural: false });
+							StationsModule.runJob("SKIP_STATION", {
+								stationId: payload.stationId,
+								natural: false,
+								skipReason: "other"
+							});
 						next();
 					}
 				],

+ 71 - 4
backend/logic/stations.js

@@ -127,6 +127,9 @@ class _StationsModule extends CoreClass {
 		const stationModel = (this.stationModel = await DBModule.runJob("GET_MODEL", { modelName: "station" }));
 		const stationSchema = (this.stationSchema = await CacheModule.runJob("GET_SCHEMA", { schemaName: "station" }));
 
+		const stationHistoryModel = (this.stationHistoryModel = await DBModule.runJob("GET_MODEL", { modelName: "stationHistory" }));
+		const stationHistorySchema = (this.stationHistorySchema = await CacheModule.runJob("GET_SCHEMA", { schemaName: "stationHistory" }));
+
 		return new Promise((resolve, reject) => {
 			async.waterfall(
 				[
@@ -269,7 +272,8 @@ class _StationsModule extends CoreClass {
 									cb: () =>
 										StationsModule.runJob("SKIP_STATION", {
 											stationId: station._id,
-											natural: true
+											natural: true,
+											skipReason: "natural"
 										}),
 									unique: true,
 									station
@@ -288,7 +292,8 @@ class _StationsModule extends CoreClass {
 								"SKIP_STATION",
 								{
 									stationId: station._id,
-									natural: false
+									natural: false,
+									skipReason: "other"
 								},
 								this
 							)
@@ -309,7 +314,8 @@ class _StationsModule extends CoreClass {
 								"SKIP_STATION",
 								{
 									stationId: station._id,
-									natural: false
+									natural: false,
+									skipReason: "other"
 								},
 								this
 							)
@@ -909,7 +915,8 @@ class _StationsModule extends CoreClass {
 								"SKIP_STATION",
 								{
 									stationId: payload.stationId,
-									natural: false
+									natural: false,
+									skipReason: "vote_skip"
 								},
 								this
 							)
@@ -926,12 +933,50 @@ class _StationsModule extends CoreClass {
 		});
 	}
 
+	/**
+	 *
+	 *
+	 * @param {*} payload
+	 */
+	async ADD_STATION_HISTORY_ITEM(payload) {
+		const { stationId, currentSong, skipReason, skippedAt } = payload;
+
+		let document = await StationsModule.stationHistoryModel.create({
+			stationId,
+			type: "song_played",
+			payload: {
+				song: currentSong,
+				skippedAt,
+				skipReason
+			},
+			documentVersion: 1
+		});
+
+		if (!document) return;
+
+		document = document._doc;
+
+		delete document.__v;
+		delete document.documentVersion;
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `station.${stationId}`,
+			args: ["event:station.history.new", { data: { historyItem: document } }]
+		});
+
+		WSModule.runJob("EMIT_TO_ROOM", {
+			room: `manage-station.${stationId}`,
+			args: ["event:manageStation.history.new", { data: { stationId, historyItem: document } }]
+		});
+	}
+
 	/**
 	 * Skips a station
 	 *
 	 * @param {object} payload - object that contains the payload
 	 * @param {string} payload.stationId - the id of the station to skip
 	 * @param {string} payload.natural - whether to skip naturally or forcefully
+	 * @param {string} payload.skipReason - if it was skipped via force skip or via vote skipping or if it was natural
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	SKIP_STATION(payload) {
@@ -969,6 +1014,28 @@ class _StationsModule extends CoreClass {
 					(station, next) => {
 						if (!station) return next("Station not found.");
 
+						const { currentSong } = station;
+						if (!currentSong) return next(null, station);
+
+						const stationId = station._id;
+						const skippedAt = new Date();
+						const { skipReason } = payload;
+
+						return StationsModule.runJob(
+							"ADD_STATION_HISTORY_ITEM",
+							{
+								stationId,
+								currentSong,
+								skippedAt,
+								skipReason
+							},
+							this
+						).finally(() => {
+							next(null, station);
+						});
+					},
+
+					(station, next) => {
 						if (station.autofill.enabled)
 							return StationsModule.runJob("AUTOFILL_STATION", { stationId: station._id }, this)
 								.then(() => next(null, station))

+ 26 - 0
backend/logic/tasks.js

@@ -62,6 +62,12 @@ class _TasksModule extends CoreClass {
 				timeout: 1000 * 3
 			});
 
+			TasksModule.runJob("CREATE_TASK", {
+				name: "historyClearTask",
+				fn: TasksModule.historyClearTask,
+				timeout: 1000 * 60 * 60 * 6
+			});
+
 			resolve();
 		});
 	}
@@ -466,6 +472,26 @@ class _TasksModule extends CoreClass {
 			resolve();
 		});
 	}
+
+	/**
+	 * Periodically removes any old history documents
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async historyClearTask() {
+		TasksModule.log("INFO", "TASK_HISTORY_CLEAR", `Removing old history.`);
+
+		const stationHistoryModel = await DBModule.runJob("GET_MODEL", { modelName: "stationHistory" });
+
+		// Remove documents created more than 2 days ago
+		const mongoQuery = { "payload.skippedAt": { $lt: new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 2) } };
+
+		const count = await stationHistoryModel.count(mongoQuery);
+
+		await stationHistoryModel.remove(mongoQuery);
+
+		TasksModule.log("SUCCESS", "TASK_HISTORY_CLEAR", `Removed ${count} history items`);
+	}
 }
 
 export default new _TasksModule();

+ 5 - 1
backend/logic/youtube.js

@@ -1591,7 +1591,11 @@ class _YouTubeModule extends CoreClass {
 															(station, next) => {
 																StationsModule.runJob(
 																	"SKIP_STATION",
-																	{ stationId: station._id, natural: false },
+																	{
+																		stationId: station._id,
+																		natural: false,
+																		skipReason: "other"
+																	},
 																	this
 																)
 																	.then(() => {

+ 1 - 1
backend/package-lock.json

@@ -8062,4 +8062,4 @@
       "dev": true
     }
   }
-}
+}

+ 1 - 1
frontend/package-lock.json

@@ -10723,4 +10723,4 @@
       "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="
     }
   }
-}
+}

+ 6 - 2
frontend/src/App.vue

@@ -1318,15 +1318,19 @@ button.delete:focus {
 	font-family: monospace;
 	font-weight: 700;
 	color: white;
-	border-radius: 100%;
+	border-radius: 18px;
 	text-align: center;
-	padding: 0 !important;
+	padding: 0 4px !important;
 	font-size: 12px;
 	line-height: 18px;
 	min-width: 18px;
 	height: 18px;
 	margin-left: 4px;
 
+	&.has-icon {
+		padding: 0 !important;
+	}
+
 	.material-icons {
 		font-size: 18px;
 	}

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

@@ -419,6 +419,7 @@ onMounted(async () => {
 		.tab {
 			padding-bottom: 10px;
 			border-radius: 0;
+
 			.item.item-draggable:not(:last-of-type) {
 				margin-bottom: 10px;
 			}
@@ -430,6 +431,10 @@ onMounted(async () => {
 	}
 }
 
+.youtube-direct {
+	margin-top: 10px;
+}
+
 .youtube-search {
 	margin-top: 10px;
 

+ 15 - 3
frontend/src/components/SongItem.vue

@@ -131,7 +131,7 @@ onUnmounted(() => {
 <template>
 	<div
 		class="universal-item song-item"
-		:class="{ 'with-duration': duration }"
+		:class="{ 'with-duration': duration, 'with-header': header }"
 		v-if="song"
 	>
 		<div class="thumbnail-and-info">
@@ -311,7 +311,12 @@ onUnmounted(() => {
 }
 
 .song-item {
-	min-height: 70px;
+	height: 70px;
+
+	&.with-header {
+		height: initial;
+		min-height: 70px;
+	}
 
 	&:not(:last-of-type) {
 		margin-bottom: 10px;
@@ -333,14 +338,19 @@ onUnmounted(() => {
 
 	.thumbnail-and-info {
 		min-width: 0;
+
+		min-height: 70px;
+		position: relative;
 	}
 
 	.thumbnail {
 		min-width: 70px;
 		width: 70px;
-		height: 70px;
 		margin: -7.5px;
 		margin-right: calc(20px - 7.5px);
+
+		height: calc(100% + 15px);
+		position: absolute;
 	}
 
 	.song-info {
@@ -350,6 +360,8 @@ onUnmounted(() => {
 		// margin-left: 20px;
 		min-width: 0;
 
+		margin-left: 70px;
+
 		*:not(i) {
 			margin: 0;
 			font-family: Karla, Arial, sans-serif;

+ 27 - 20
frontend/src/components/SongThumbnail.vue

@@ -61,7 +61,7 @@ watch(
 		<slot name="icon" />
 		<div
 			v-if="-1 < loadError && loadError < 2 && isYoutubeThumbnail"
-			class="yt-thumbnail-bg"
+			class="thumbnail-bg"
 			:style="{
 				'background-image':
 					'url(' +
@@ -69,6 +69,13 @@ watch(
 					')'
 			}"
 		></div>
+		<div
+			v-if="-1 < loadError && loadError < 2 && !isYoutubeThumbnail"
+			class="thumbnail-bg"
+			:style="{
+				'background-image': `url(${song.thumbnail})`
+			}"
+		></div>
 		<img
 			v-if="-1 < loadError && loadError < 2 && isYoutubeThumbnail"
 			loading="lazy"
@@ -94,12 +101,12 @@ watch(
 	margin-bottom: -15px;
 	margin-left: -10px;
 
-	.yt-thumbnail-bg {
-		display: none;
-	}
+	// .yt-thumbnail-bg {
+	// 	display: none;
+	// }
 
 	img {
-		height: 100%;
+		// height: 100%;
 		width: 100%;
 		margin-top: auto;
 		margin-bottom: auto;
@@ -111,21 +118,21 @@ watch(
 		right: 0;
 	}
 
-	&.youtube-thumbnail {
-		.yt-thumbnail-bg {
-			height: 100%;
-			width: 100%;
-			display: block;
-			position: absolute;
-			top: 0;
-			filter: blur(1px);
-			background: url("/assets/notes-transparent.png") no-repeat center
-				center;
-		}
-
-		img {
-			height: auto;
-		}
+	.thumbnail-bg {
+		height: 100%;
+		width: 100%;
+		display: block;
+		position: absolute;
+		top: 0;
+		filter: blur(2px);
+		background: url("/assets/notes-transparent.png") no-repeat center center;
+		background-size: cover;
 	}
+
+	// &.youtube-thumbnail {
+	// 	img {
+	// 		height: auto;
+	// 	}
+	// }
 }
 </style>

+ 6 - 0
frontend/src/components/modals/ManageStation/index.vue

@@ -628,11 +628,17 @@ onBeforeUnmount(() => {
 		margin-bottom: 10px;
 	}
 	.currently-playing.song-item {
+		height: 130px;
+
 		.thumbnail {
 			min-width: 130px;
 			width: 130px;
 			height: 130px;
 		}
+
+		.song-info {
+			margin-left: 130px;
+		}
 	}
 }
 </style>

+ 12 - 4
frontend/src/components/modals/Report.vue

@@ -463,10 +463,18 @@ onMounted(() => {
 </template>
 
 <style lang="less">
-.report-modal .song-item .thumbnail {
-	min-width: 130px;
-	width: 130px;
-	height: 130px;
+.report-modal .song-item {
+	height: 130px !important;
+
+	.thumbnail {
+		min-width: 130px;
+		width: 130px;
+		height: 130px;
+	}
+
+	.song-info {
+		margin-left: 130px;
+	}
 }
 </style>
 

+ 177 - 0
frontend/src/pages/Station/Sidebar/History.vue

@@ -0,0 +1,177 @@
+<script setup lang="ts">
+import { defineAsyncComponent, computed, onMounted } from "vue";
+import Toast from "toasters";
+import { storeToRefs } from "pinia";
+import { useWebsocketsStore } from "@/stores/websockets";
+import { useStationStore } from "@/stores/station";
+import SongItem from "@/components/SongItem.vue";
+
+const stationStore = useStationStore();
+
+const { socket } = useWebsocketsStore();
+
+const { history } = storeToRefs(stationStore);
+
+const station = computed({
+	get() {
+		return stationStore.station;
+	},
+	set(value) {
+		stationStore.updateStation(value);
+	}
+});
+
+const songsList = computed({
+	get() {
+		return stationStore.songsList;
+	},
+	set(value) {
+		stationStore.updateSongsList(value);
+	}
+});
+
+const songsInQueue = computed(() => {
+	if (station.value.currentSong)
+		return songsList.value
+			.map(song => song.youtubeId)
+			.concat(station.value.currentSong.youtubeId);
+	return songsList.value.map(song => song.youtubeId);
+});
+
+const formatDate = dateString => {
+	const skippedAtDate = new Date(dateString);
+	const now = new Date();
+	const time = `${skippedAtDate
+		.getHours()
+		.toString()
+		.padStart(2, "0")}:${skippedAtDate
+		.getMinutes()
+		.toString()
+		.padStart(2, "0")}`;
+	const date = `${skippedAtDate.getFullYear()}-${(
+		skippedAtDate.getMonth() + 1
+	)
+		.toString()
+		.padStart(2, "0")}-${skippedAtDate
+		.getDate()
+		.toString()
+		.padStart(2, "0")}`;
+	if (skippedAtDate.toLocaleDateString() === now.toLocaleDateString()) {
+		return time;
+	}
+	return `${date} ${time}`;
+};
+const formatSkipReason = skipReason => {
+	if (skipReason === "natural") return "";
+	if (skipReason === "other") return " - automatically skipped";
+	if (skipReason === "vote_skip") return " - vote skipped";
+	if (skipReason === "force_skip") return " - force skipped";
+	return "";
+};
+
+const addSongToQueue = (youtubeId: string) => {
+	socket.dispatch(
+		"stations.addToQueue",
+		station.value._id,
+		youtubeId,
+		res => {
+			if (res.status !== "success") new Toast(`Error: ${res.message}`);
+			else {
+				new Toast(res.message);
+			}
+		}
+	);
+};
+
+onMounted(async () => {});
+</script>
+
+<template>
+	<div class="station-history">
+		<div v-for="historyItem in history" :key="historyItem._id">
+			<SongItem
+				:song="historyItem.payload.song"
+				:requested-by="true"
+				:header="`Finished playing at ${formatDate(
+					historyItem.payload.skippedAt
+				)}${formatSkipReason(historyItem.payload.skipReason)}`"
+			>
+				<template #actions>
+					<transition
+						name="musare-history-query-actions"
+						mode="out-in"
+					>
+						<i
+							v-if="
+								songsInQueue.indexOf(
+									historyItem.payload.song.youtubeId
+								) !== -1
+							"
+							class="material-icons disabled"
+							content="Song is already in queue"
+							v-tippy
+							>queue</i
+						>
+						<i
+							v-else
+							class="material-icons add-to-queue-icon"
+							@click="
+								addSongToQueue(
+									historyItem.payload.song.youtubeId
+								)
+							"
+							content="Add Song to Queue"
+							v-tippy
+							>queue</i
+						>
+					</transition>
+				</template>
+			</SongItem>
+		</div>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.night-mode {
+	.station-history {
+		background-color: var(--dark-grey-3) !important;
+		border: 0 !important;
+	}
+}
+
+.station-history {
+	background-color: var(--white);
+	margin-bottom: 20px;
+	border-radius: 0 0 @border-radius @border-radius;
+	max-height: 100%;
+	padding: 12px;
+
+	overflow: auto;
+
+	row-gap: 8px;
+	display: flex;
+	flex-direction: column;
+
+	h1 {
+		margin: 0;
+	}
+
+	.disabled {
+		cursor: not-allowed;
+	}
+
+	:deep(.song-item) {
+		height: 90px;
+
+		.thumbnail {
+			min-width: 90px;
+			width: 90px;
+			height: 90px;
+		}
+
+		.song-info {
+			margin-left: 90px;
+		}
+	}
+}
+</style>

+ 11 - 0
frontend/src/pages/Station/Sidebar/index.vue

@@ -11,6 +11,9 @@ const Users = defineAsyncComponent(
 	() => import("@/pages/Station/Sidebar/Users.vue")
 );
 const Request = defineAsyncComponent(() => import("@/components/Request.vue"));
+const History = defineAsyncComponent(
+	() => import("@/pages/Station/Sidebar/History.vue")
+);
 
 const route = useRoute();
 const userAuthStore = useUserAuthStore();
@@ -82,6 +85,13 @@ onMounted(() => {
 			>
 				Request
 			</button>
+			<button
+				class="button is-default"
+				:class="{ selected: tab === 'history' }"
+				@click="showTab('history')"
+			>
+				History
+			</button>
 		</div>
 		<Queue class="tab" v-show="tab === 'queue'" @on-change-tab="showTab" />
 		<Users class="tab" v-show="tab === 'users'" />
@@ -91,6 +101,7 @@ onMounted(() => {
 			class="tab requests-tab"
 			sector="station"
 		/>
+		<History class="tab" v-show="tab === 'history'" />
 	</div>
 </template>
 

+ 21 - 1
frontend/src/pages/Station/index.vue

@@ -175,7 +175,9 @@ const {
 	hasPermission,
 	addDj,
 	removeDj,
-	updatePermissions
+	updatePermissions,
+	addHistoryItem,
+	setHistory
 } = stationStore;
 
 // TODO fix this if it still has some use
@@ -1058,6 +1060,13 @@ onMounted(async () => {
 					}
 				});
 
+				socket.dispatch("stations.getHistory", _id, res => {
+					if (res.status === "success") {
+						const { history } = res.data;
+						setHistory(history);
+					}
+				});
+
 				if (hasPermission("stations.playback.toggle"))
 					keyboardShortcuts.registerShortcut("station.pauseResume", {
 						keyCode: 32, // Spacebar
@@ -1457,6 +1466,11 @@ onMounted(async () => {
 		removeDj(res.data.user);
 	});
 
+	socket.on("event:station.history.new", res => {
+		console.log(1111, res.data.historyItem);
+		addHistoryItem(res.data.historyItem);
+	});
+
 	socket.on("keep.event:user.role.updated", () => {
 		updatePermissions().then(() => {
 			if (
@@ -2490,11 +2504,17 @@ onBeforeUnmount(() => {
 #currently-playing-container,
 #next-up-container {
 	.song-item {
+		min-height: 130px;
+
 		.thumbnail {
 			min-width: 130px;
 			width: 130px;
 			height: 130px;
 		}
+
+		.song-info {
+			margin-left: 130px;
+		}
 	}
 }
 

+ 9 - 1
frontend/src/stores/station.ts

@@ -25,6 +25,7 @@ export const useStationStore = defineStore("station", {
 		blacklist: Playlist[];
 		mediaModalPlayingAudio: boolean;
 		permissions: Record<string, boolean>;
+		history: any[];
 	} => ({
 		station: {},
 		autoRequest: [],
@@ -43,7 +44,8 @@ export const useStationStore = defineStore("station", {
 		autofill: [],
 		blacklist: [],
 		mediaModalPlayingAudio: false,
-		permissions: {}
+		permissions: {},
+		history: []
 	}),
 	actions: {
 		joinStation(station) {
@@ -198,6 +200,12 @@ export const useStationStore = defineStore("station", {
 					this.station.djs.splice(index, 1);
 				}
 			});
+		},
+		setHistory(history) {
+			this.history = history;
+		},
+		addHistoryItem(historyItem) {
+			this.history.unshift(historyItem);
 		}
 	}
 });