Преглед изворни кода

feat: added standalone SoundcloudPlayer component, implemented in ViewMedia modal

Kristian Vos пре 1 година
родитељ
комит
b2dc048b70

+ 629 - 0
frontend/src/components/SoundcloudPlayer.vue

@@ -0,0 +1,629 @@
+<script setup lang="ts">
+import { computed, onBeforeUnmount, onMounted, ref } from "vue";
+import Toast from "toasters";
+import { useSoundcloudPlayer } from "@/composables/useSoundcloudPlayer";
+import { useStationStore } from "@/stores/station";
+
+import aw from "@/aw";
+
+const props = defineProps<{
+	song: {
+		mediaSource: string;
+		title: string;
+		artists: string[];
+		duration: number;
+	};
+}>();
+
+const TAG = "[SP]";
+
+const {
+	soundcloudIframeElement: playerElement,
+	soundcloudGetDuration,
+	soundcloudLoadTrack,
+	soundcloudSetVolume,
+	soundcloudPlay,
+	soundcloudPause,
+	soundcloudSeekTo,
+	soundcloudOnTrackStateChange,
+	soundcloudBindListener,
+	soundcloudGetPosition,
+	soundcloudGetCurrentSound,
+	soundcloudGetTrackState,
+	soundcloudUnload
+} = useSoundcloudPlayer();
+
+const stationStore = useStationStore();
+const { updateMediaModalPlayingAudio } = stationStore;
+
+const interval = ref(null);
+const durationCanvas = ref(null);
+const activityWatchMediaDataInterval = ref(null);
+const activityWatchMediaLastStatus = ref("");
+const activityWatchMediaLastStartDuration = ref(0);
+const canvasWidth = ref(760);
+const player = ref<{
+	error: boolean;
+	errorMessage: string;
+	paused: boolean;
+	currentTime: number;
+	duration: number;
+	muted: boolean;
+	volume: number;
+}>({
+	error: false,
+	errorMessage: "",
+	paused: true,
+	currentTime: 0,
+	duration: 0,
+	muted: false,
+	volume: 20
+});
+
+const playerVolumeControlIcon = computed(() => {
+	const { muted, volume } = player.value;
+	if (muted) return "volume_mute";
+	if (volume >= 50) return "volume_up";
+	return "volume_down";
+});
+
+const soundcloudTrackId = computed(() => props.song.mediaSource.split(":")[1]);
+
+const playerSetTrackPosition = event => {
+	console.debug(TAG, "PLAYER SET TRACK POSITION");
+
+	soundcloudGetDuration(duration => {
+		soundcloudSeekTo(
+			Number(
+				Number(duration / 1000) *
+					((event.pageX - event.target.getBoundingClientRect().left) /
+						canvasWidth.value)
+			) * 1000
+		);
+	});
+};
+
+const playerPlay = () => {
+	console.debug(TAG, "PLAYER PLAY");
+
+	soundcloudPlay();
+};
+
+const playerPause = () => {
+	console.debug(TAG, "PLAYER PAUSE");
+
+	soundcloudPause();
+};
+
+const playerStop = () => {
+	console.debug(TAG, "PLAYER STOP");
+
+	soundcloudPause();
+	soundcloudSeekTo(0);
+};
+
+const playerHardStop = () => {
+	console.debug(TAG, "PLAYER HARD STOP");
+
+	playerStop();
+};
+
+const playerToggleMute = () => {
+	console.debug(TAG, "PLAYER TOGGLE MUTE");
+
+	player.value.muted = !player.value.muted;
+
+	const { muted, volume } = player.value;
+	localStorage.setItem("muted", `${muted}`);
+
+	if (muted) {
+		soundcloudSetVolume(0);
+		player.value.volume = 0;
+	} else if (volume > 0) {
+		soundcloudSetVolume(volume);
+		player.value.volume = volume;
+		localStorage.setItem("volume", `${volume}`);
+	} else {
+		soundcloudSetVolume(20);
+		player.value.volume = 20;
+		localStorage.setItem("volume", `${20}`);
+	}
+};
+
+const playerChangeVolume = () => {
+	console.debug(TAG, "PLAYER CHANGE VOLUME");
+
+	const { muted, volume } = player.value;
+	localStorage.setItem("volume", `${volume}`);
+
+	soundcloudSetVolume(volume);
+
+	if (muted && volume > 0) {
+		player.value.muted = false;
+		localStorage.setItem("muted", `${false}`);
+	} else if (!muted && volume === 0) {
+		player.value.muted = true;
+		localStorage.setItem("muted", `${true}`);
+	}
+};
+
+const drawCanvas = () => {
+	const canvasElement = durationCanvas.value;
+	if (!canvasElement) return;
+
+	const ctx = canvasElement.getContext("2d");
+
+	const videoDuration = Number(player.value.duration);
+
+	const _duration = Number(player.value.duration);
+	const afterDuration = videoDuration - _duration;
+
+	canvasWidth.value = Math.min(document.body.clientWidth - 40, 760);
+	const width = canvasWidth.value;
+
+	const { currentTime } = player.value;
+
+	const widthDuration = (_duration / videoDuration) * width;
+	const widthAfterDuration = (afterDuration / videoDuration) * width;
+
+	const widthCurrentTime = (currentTime / videoDuration) * width;
+
+	const durationColor = "#03A9F4";
+	const afterDurationColor = "#41E841";
+	const currentDurationColor = "#3b25e8";
+
+	ctx.fillStyle = durationColor;
+	ctx.fillRect(0, 0, widthDuration, 20);
+	ctx.fillStyle = afterDurationColor;
+	ctx.fillRect(widthDuration, 0, widthAfterDuration, 20);
+
+	ctx.fillStyle = currentDurationColor;
+	ctx.fillRect(widthCurrentTime, 0, 1, 20);
+};
+
+const formatDuration = duration => duration.toFixed(3);
+
+const sendActivityWatchMediaData = () => {
+	if (!player.value.paused && soundcloudGetTrackState() === "playing") {
+		if (activityWatchMediaLastStatus.value !== "playing") {
+			activityWatchMediaLastStatus.value = "playing";
+			soundcloudGetPosition(position => {
+				activityWatchMediaLastStartDuration.value = Math.floor(
+					Number(position / 1000)
+				);
+			});
+		}
+
+		const videoData = {
+			title: props.song.title,
+			artists: props.song.artists?.join(", ") || "",
+			mediaSource: props.song.mediaSource,
+			muted: player.value.muted,
+			volume: player.value.volume,
+			startedDuration:
+				activityWatchMediaLastStartDuration.value <= 0
+					? 0
+					: activityWatchMediaLastStartDuration.value,
+			source: `viewMedia#${props.song.mediaSource}`,
+			hostname: window.location.hostname,
+			playerState: "",
+			playbackRate: 1
+		};
+
+		aw.sendMediaData(videoData);
+	} else {
+		activityWatchMediaLastStatus.value = "not_playing";
+	}
+};
+
+onMounted(() => {
+	console.debug(TAG, "ON MOUNTED");
+
+	// Generic
+	let volume = parseFloat(localStorage.getItem("volume"));
+	volume = typeof volume === "number" && !Number.isNaN(volume) ? volume : 20;
+	localStorage.setItem("volume", `${volume}`);
+	player.value.volume = volume;
+
+	let muted: boolean | string = localStorage.getItem("muted");
+	muted = muted === "true";
+	localStorage.setItem("muted", `${muted}`);
+	player.value.muted = muted;
+	if (muted) player.value.volume = 0;
+
+	soundcloudSetVolume(volume);
+
+	// SoundCloud specific
+	soundcloudBindListener("ready", value => {
+		console.debug(TAG, "Bind on ready", value);
+
+		soundcloudGetCurrentSound(sound => {
+			player.value.duration = sound.duration / 1000;
+		});
+
+		soundcloudOnTrackStateChange(newState => {
+			console.debug(TAG, `New state: ${newState}`);
+
+			const { paused } = player.value;
+
+			if (
+				newState === "attempting_to_play" ||
+				newState === "failed_to_play"
+			) {
+				if (!paused) {
+					if (newState === "failed_to_play")
+						new Toast(
+							"Failed to start SoundCloud player. Please try to manually start it."
+						);
+
+					player.value.paused = true;
+				}
+			} else if (newState === "paused") {
+				player.value.paused = true;
+			} else if (newState === "playing") {
+				player.value.paused = false;
+			} else if (newState === "finished") {
+				player.value.paused = true;
+			} else if (newState === "error") {
+				player.value.paused = true;
+			}
+
+			if (player.value.paused) updateMediaModalPlayingAudio(false);
+			else updateMediaModalPlayingAudio(true);
+		});
+
+		soundcloudBindListener("seek", () => {
+			console.debug(TAG, "Bind on seek");
+		});
+
+		soundcloudBindListener("error", value => {
+			console.debug(TAG, "Bind on error", value);
+		});
+	});
+
+	soundcloudLoadTrack(soundcloudTrackId.value, 0, true);
+
+	interval.value = setInterval(() => {
+		soundcloudGetPosition(position => {
+			player.value.currentTime = position / 1000;
+
+			drawCanvas();
+		});
+	}, 200);
+
+	activityWatchMediaDataInterval.value = setInterval(() => {
+		sendActivityWatchMediaData();
+	}, 1000);
+});
+
+onBeforeUnmount(() => {
+	clearInterval(interval.value);
+	clearInterval(activityWatchMediaDataInterval.value);
+
+	updateMediaModalPlayingAudio(false);
+
+	soundcloudUnload();
+});
+</script>
+
+<template>
+	<div class="player-section">
+		<div class="player-container">
+			<iframe
+				ref="playerElement"
+				style="width: 100%; height: 100%; min-height: 426px"
+				scrolling="no"
+				frameborder="no"
+				allow="autoplay"
+			></iframe>
+		</div>
+
+		<div v-show="player.error" class="player-error">
+			<h2>{{ player.errorMessage }}</h2>
+		</div>
+
+		<canvas
+			ref="durationCanvas"
+			class="duration-canvas"
+			v-show="!player.error"
+			height="20"
+			:width="canvasWidth"
+			@click="playerSetTrackPosition($event)"
+		></canvas>
+
+		<div class="player-footer">
+			<div class="player-footer-left">
+				<button
+					v-if="player.paused"
+					class="button is-primary"
+					@click="playerPlay()"
+					@keyup.enter="playerPlay()"
+					content="Resume Playback"
+					v-tippy
+				>
+					<i class="material-icons">play_arrow</i>
+				</button>
+				<button
+					v-else
+					class="button is-primary"
+					@click="playerPause()"
+					@keyup.enter="playerPause()"
+					content="Pause Playback"
+					v-tippy
+				>
+					<i class="material-icons">pause</i>
+				</button>
+				<button
+					class="button is-danger"
+					@click.exact="playerStop()"
+					@click.shift="playerHardStop()"
+					@keyup.enter.exact="playerStop()"
+					@keyup.shift.enter="playerHardStop()"
+					content="Stop Playback"
+					v-tippy
+				>
+					<i class="material-icons">stop</i>
+				</button>
+			</div>
+			<div class="player-footer-center">
+				<span>
+					<span>
+						{{ formatDuration(player.currentTime) }}
+					</span>
+					/
+					<span>
+						{{ formatDuration(player.duration) }}
+					</span>
+				</span>
+			</div>
+			<div class="player-footer-right">
+				<p id="volume-control">
+					<i
+						class="material-icons"
+						@click="playerToggleMute()"
+						:content="`${player.muted ? 'Unmute' : 'Mute'}`"
+						v-tippy
+						>{{ playerVolumeControlIcon }}</i
+					>
+					<input
+						v-model.number="player.volume"
+						type="range"
+						min="0"
+						max="100"
+						class="volume-slider active"
+						@change="playerChangeVolume()"
+						@input="playerChangeVolume()"
+					/>
+				</p>
+			</div>
+		</div>
+	</div>
+</template>
+
+<style lang="less" scoped>
+.night-mode {
+	.player-section {
+		background-color: var(--dark-grey-3) !important;
+		border: 0 !important;
+
+		.duration-canvas {
+			background-color: var(--dark-grey-2) !important;
+		}
+	}
+}
+
+.player-section {
+	display: flex;
+	flex-direction: column;
+	margin: 10px auto 0 auto;
+	border: 1px solid var(--light-grey-3);
+	border-radius: @border-radius;
+	overflow: hidden;
+
+	.player-container {
+		position: relative;
+		padding-bottom: 56.25%; /* proportion value to aspect ratio 16:9 (9 / 16 = 0.5625 or 56.25%) */
+		height: 0;
+		overflow: hidden;
+
+		:deep(iframe) {
+			position: absolute;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+			min-height: 426px;
+		}
+	}
+
+	.duration-canvas {
+		background-color: var(--light-grey-2);
+	}
+
+	.player-error {
+		display: flex;
+		height: 428px;
+		align-items: center;
+
+		* {
+			margin: 0;
+			flex: 1;
+			font-size: 30px;
+			text-align: center;
+		}
+	}
+
+	.player-footer {
+		display: flex;
+		justify-content: space-between;
+		height: 54px;
+		padding-left: 10px;
+		padding-right: 10px;
+
+		> * {
+			width: 33.3%;
+			display: flex;
+			align-items: center;
+		}
+
+		.player-footer-left {
+			flex: 1;
+
+			& > .button:not(:first-child) {
+				margin-left: 5px;
+			}
+
+			& > .playerRateDropdown {
+				margin-left: 5px;
+				margin-bottom: unset !important;
+
+				.control.has-addons {
+					margin-bottom: unset !important;
+
+					& > .button {
+						font-size: 24px;
+					}
+				}
+			}
+
+			:deep(.tippy-box[data-theme~="dropdown"]) {
+				max-width: 100px !important;
+
+				.nav-dropdown-items .nav-item {
+					justify-content: center !important;
+					border-radius: @border-radius !important;
+
+					&.active {
+						background-color: var(--primary-color);
+						color: var(--white);
+					}
+				}
+			}
+		}
+
+		.player-footer-center {
+			justify-content: center;
+			align-items: center;
+			flex: 2;
+			font-size: 18px;
+			font-weight: 400;
+			width: 200px;
+			margin: 0 5px;
+
+			img {
+				height: 21px;
+				margin-right: 12px;
+				filter: invert(26%) sepia(54%) saturate(6317%) hue-rotate(2deg)
+					brightness(92%) contrast(115%);
+			}
+		}
+
+		.player-footer-right {
+			justify-content: right;
+			flex: 1;
+
+			#volume-control {
+				margin: 3px;
+				margin-top: 0;
+				display: flex;
+				align-items: center;
+				cursor: pointer;
+
+				.volume-slider {
+					width: 100%;
+					padding: 0 15px;
+					background: transparent;
+					min-width: 100px;
+				}
+
+				input[type="range"] {
+					-webkit-appearance: none;
+					margin: 7.3px 0;
+				}
+
+				input[type="range"]:focus {
+					outline: none;
+				}
+
+				input[type="range"]::-webkit-slider-runnable-track {
+					width: 100%;
+					height: 5.2px;
+					cursor: pointer;
+					box-shadow: 0;
+					background: var(--light-grey-3);
+					border-radius: @border-radius;
+					border: 0;
+				}
+
+				input[type="range"]::-webkit-slider-thumb {
+					box-shadow: 0;
+					border: 0;
+					height: 19px;
+					width: 19px;
+					border-radius: 100%;
+					background: var(--primary-color);
+					cursor: pointer;
+					-webkit-appearance: none;
+					margin-top: -6.5px;
+				}
+
+				input[type="range"]::-moz-range-track {
+					width: 100%;
+					height: 5.2px;
+					cursor: pointer;
+					box-shadow: 0;
+					background: var(--light-grey-3);
+					border-radius: @border-radius;
+					border: 0;
+				}
+
+				input[type="range"]::-moz-range-thumb {
+					box-shadow: 0;
+					border: 0;
+					height: 19px;
+					width: 19px;
+					border-radius: 100%;
+					background: var(--primary-color);
+					cursor: pointer;
+					-webkit-appearance: none;
+					margin-top: -6.5px;
+				}
+				input[type="range"]::-ms-track {
+					width: 100%;
+					height: 5.2px;
+					cursor: pointer;
+					box-shadow: 0;
+					background: var(--light-grey-3);
+					border-radius: @border-radius;
+				}
+
+				input[type="range"]::-ms-fill-lower {
+					background: var(--light-grey-3);
+					border: 0;
+					border-radius: 0;
+					box-shadow: 0;
+				}
+
+				input[type="range"]::-ms-fill-upper {
+					background: var(--light-grey-3);
+					border: 0;
+					border-radius: 0;
+					box-shadow: 0;
+				}
+
+				input[type="range"]::-ms-thumb {
+					box-shadow: 0;
+					border: 0;
+					height: 15px;
+					width: 15px;
+					border-radius: 100%;
+					background: var(--primary-color);
+					cursor: pointer;
+					-webkit-appearance: none;
+					margin-top: 1.5px;
+				}
+			}
+		}
+	}
+}
+</style>

+ 13 - 1
frontend/src/components/modals/ViewMedia.vue

@@ -25,6 +25,9 @@ const YoutubePlayer = defineAsyncComponent(
 const SoundcloudTrackInfo = defineAsyncComponent(
 	() => import("@/components/SoundcloudTrackInfo.vue")
 );
+const SoundcloudPlayer = defineAsyncComponent(
+	() => import("@/components/SoundcloudPlayer.vue")
+);
 
 const props = defineProps({
 	modalUuid: { type: String, required: true },
@@ -61,9 +64,18 @@ const youtubeSongNormalized = computed(() => ({
 	duration: youtubeVideo.value.duration
 }));
 
+const soundcloudSongNormalized = computed(() => ({
+	mediaSource: `soundcloud:${soundcloudTrack.value.trackId}`,
+	title: soundcloudTrack.value.title,
+	artists: [soundcloudTrack.value.username],
+	duration: soundcloudTrack.value.duration
+}));
+
 const songNormalized = computed(() => {
 	if (currentSongMediaType.value === "youtube")
 		return youtubeSongNormalized.value;
+	if (currentSongMediaType.value === "soundcloud")
+		return soundcloudSongNormalized.value;
 	return {};
 });
 
@@ -170,7 +182,7 @@ onBeforeUnmount(() => {
 				</template>
 				<template v-else-if="currentSongMediaType === 'soundcloud'">
 					<soundcloud-track-info :track="soundcloudTrack" />
-					<!-- <youtube-player :song="youtubeSongNormalized" /> -->
+					<soundcloud-player :song="soundcloudSongNormalized" />
 				</template>
 			</template>
 			<div v-else class="vertical-padding">

+ 7 - 2
frontend/src/composables/useSoundcloudPlayer.ts

@@ -95,9 +95,11 @@ export const useSoundcloudPlayer = () => {
 		if (data.method === "ready") {
 			widgetId.value = data.widgetId;
 
-			if (!readyCallback.value) return;
+			if (readyCallback.value) readyCallback.value();
 
-			readyCallback.value();
+			eventListenerCallbacks[data.method].forEach(callback => {
+				callback(data.value);
+			});
 
 			return;
 		}
@@ -283,6 +285,8 @@ export const useSoundcloudPlayer = () => {
 
 	const soundcloudGetTrackId = () => currentTrackId.value;
 
+	const soundcloudGetTrackState = () => trackState.value;
+
 	const soundcloudLoadTrack = (trackId, startTime, _paused) => {
 		if (!soundcloudIframeElement.value) return;
 
@@ -386,6 +390,7 @@ export const useSoundcloudPlayer = () => {
 		soundcloudGetState,
 		soundcloudGetTrackId,
 		soundcloudGetCurrentSound,
+		soundcloudGetTrackState,
 		soundcloudBindListener,
 		soundcloudOnTrackStateChange,
 		soundcloudDestroy,