Browse Source

feat: added SoundCloud support (WIP)

Kristian Vos 2 years ago
parent
commit
7b1857d35f

+ 6 - 0
backend/config/template.json

@@ -32,6 +32,12 @@
 				}
 			]
 		},
+		"soundcloud": {
+			"key": "",
+			"rateLimit": 500,
+			"requestTimeout": 5000,
+			"retryAmount": 2
+		},
 		"recaptcha": {
 			"secret": "",
 			"enabled": false

+ 35 - 13
backend/index.js

@@ -41,16 +41,22 @@ const printVersion = () => {
 		const head_contents = fs.readFileSync(".parent_git/HEAD").toString().replaceAll("\n", "");
 		const branch = new RegExp("ref: refs/heads/([A-Za-z0-9_.-]+)").exec(head_contents)[1];
 		const config_contents = fs.readFileSync(".parent_git/config").toString().replaceAll("\t", "").split("\n");
-		const remote = new RegExp("remote = (.+)").exec(config_contents[config_contents.indexOf(`[branch "${branch}"]`) + 1])[1];
-		const remote_url = new RegExp("url = (.+)").exec(config_contents[config_contents.indexOf(`[remote "${remote}"]`) + 1])[1];
+		const remote = new RegExp("remote = (.+)").exec(
+			config_contents[config_contents.indexOf(`[branch "${branch}"]`) + 1]
+		)[1];
+		const remote_url = new RegExp("url = (.+)").exec(
+			config_contents[config_contents.indexOf(`[remote "${remote}"]`) + 1]
+		)[1];
 		const latest_commit = fs.readFileSync(`.parent_git/refs/heads/${branch}`).toString().replaceAll("\n", "");
 		const latest_commit_short = latest_commit.substr(0, 7);
 
-		console.log(`Git branch: ${remote}/${branch}. Remote url: ${remote_url}. Latest commit: ${latest_commit} (${latest_commit_short}).`);
-	} catch(e) {
+		console.log(
+			`Git branch: ${remote}/${branch}. Remote url: ${remote_url}. Latest commit: ${latest_commit} (${latest_commit_short}).`
+		);
+	} catch (e) {
 		console.log(`Could not get Git info: ${e.message}.`);
 	}
-}
+};
 
 printVersion();
 
@@ -262,6 +268,7 @@ if (!config.get("migration")) {
 	moduleManager.addModule("tasks");
 	moduleManager.addModule("utils");
 	moduleManager.addModule("youtube");
+	moduleManager.addModule("soundcloud");
 } else {
 	moduleManager.addModule("migration");
 }
@@ -298,22 +305,35 @@ function printTask(task, layer) {
 	});
 }
 
-import * as readline from 'node:readline';
+import * as readline from "node:readline";
 
 var rl = readline.createInterface({
 	input: process.stdin,
 	output: process.stdout,
-	completer: function(command) {
+	completer: function (command) {
 		const parts = command.split(" ");
-		const commands = ["version", "lockdown", "status", "running ", "queued ", "paused ", "stats ", "jobinfo ", "runjob ", "eval "];
+		const commands = [
+			"version",
+			"lockdown",
+			"status",
+			"running ",
+			"queued ",
+			"paused ",
+			"stats ",
+			"jobinfo ",
+			"runjob ",
+			"eval "
+		];
 		if (parts.length === 1) {
 			const hits = commands.filter(c => c.startsWith(parts[0]));
 			return [hits.length ? hits : commands, command];
 		} else if (parts.length === 2) {
 			if (["queued", "running", "paused", "runjob", "stats"].indexOf(parts[0]) !== -1) {
 				const modules = Object.keys(moduleManager.modules);
-				const hits = modules.filter(module => module.startsWith(parts[1])).map(module => `${parts[0]} ${module}${parts[0] === "runjob" ? " " : ""}`);
-				return  [hits.length ? hits : modules, command];
+				const hits = modules
+					.filter(module => module.startsWith(parts[1]))
+					.map(module => `${parts[0]} ${module}${parts[0] === "runjob" ? " " : ""}`);
+				return [hits.length ? hits : modules, command];
 			} else {
 				return [];
 			}
@@ -322,8 +342,10 @@ var rl = readline.createInterface({
 				const modules = Object.keys(moduleManager.modules);
 				if (modules.indexOf(parts[1]) !== -1) {
 					const jobs = moduleManager.modules[parts[1]].jobNames;
-					const hits = jobs.filter(job => job.startsWith(parts[2])).map(job => `${parts[0]} ${parts[1]} ${job} `);
-					return  [hits.length ? hits : jobs, command];
+					const hits = jobs
+						.filter(job => job.startsWith(parts[2]))
+						.map(job => `${parts[0]} ${parts[1]} ${job} `);
+					return [hits.length ? hits : jobs, command];
 				}
 			} else {
 				return [];
@@ -334,7 +356,7 @@ var rl = readline.createInterface({
 	}
 });
 
-rl.on("line",function(command) {
+rl.on("line", function (command) {
 	if (command === "version") {
 		printVersion();
 	}

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

@@ -19,7 +19,8 @@ const REQUIRED_DOCUMENT_VERSIONS = {
 	youtubeVideo: 1,
 	ratings: 2,
 	importJob: 1,
-	stationHistory: 2
+	stationHistory: 2,
+	soundcloudTrack: 1
 };
 
 const regex = {
@@ -77,7 +78,8 @@ class _DBModule extends CoreClass {
 						youtubeApiRequest: {},
 						youtubeVideo: {},
 						ratings: {},
-						stationHistory: {}
+						stationHistory: {},
+						soundcloudTrack: {}
 					};
 
 					const importSchema = schemaName =>
@@ -103,6 +105,7 @@ class _DBModule extends CoreClass {
 					await importSchema("ratings");
 					await importSchema("importJob");
 					await importSchema("stationHistory");
+					await importSchema("soundcloudTrack");
 
 					this.models = {
 						song: mongoose.model("song", this.schemas.song),
@@ -119,7 +122,8 @@ class _DBModule extends CoreClass {
 						youtubeVideo: mongoose.model("youtubeVideo", this.schemas.youtubeVideo),
 						ratings: mongoose.model("ratings", this.schemas.ratings),
 						importJob: mongoose.model("importJob", this.schemas.importJob),
-						stationHistory: mongoose.model("stationHistory", this.schemas.stationHistory)
+						stationHistory: mongoose.model("stationHistory", this.schemas.stationHistory),
+						soundcloudTrack: mongoose.model("soundcloudTrack", this.schemas.soundcloudTrack)
 					};
 
 					mongoose.connection.on("error", err => {
@@ -271,6 +275,7 @@ class _DBModule extends CoreClass {
 					this.models.ratings.syncIndexes();
 					this.models.importJob.syncIndexes();
 					this.models.stationHistory.syncIndexes();
+					this.models.soundcloudTrack.syncIndexes();
 
 					if (config.get("skipDbDocumentsVersionCheck")) resolve();
 					else {

+ 18 - 0
backend/logic/db/schemas/soundcloudTrack.js

@@ -0,0 +1,18 @@
+export default {
+	trackId: { type: Number },
+	title: { type: String },
+	artworkUrl: { type: String },
+	soundcloudCreatedAt: { type: Date },
+	duration: { type: Number },
+	genre: { type: String },
+	kind: { type: String },
+	license: { type: String },
+	likesCount: { type: Number },
+	playbackCount: { type: Number },
+	public: { type: Boolean },
+	tagList: { type: String },
+	userId: { type: Number },
+	username: { type: String },
+	createdAt: { type: Date, default: Date.now, required: true },
+	documentVersion: { type: Number, default: 1, required: true }
+};

+ 23 - 0
backend/logic/media.js

@@ -6,6 +6,7 @@ let CacheModule;
 let DBModule;
 let UtilsModule;
 let YouTubeModule;
+let SoundCloudModule;
 let SongsModule;
 let WSModule;
 
@@ -29,6 +30,7 @@ class _MediaModule extends CoreClass {
 		DBModule = this.moduleManager.modules.db;
 		UtilsModule = this.moduleManager.modules.utils;
 		YouTubeModule = this.moduleManager.modules.youtube;
+		SoundCloudModule = this.moduleManager.modules.soundcloud;
 		SongsModule = this.moduleManager.modules.songs;
 		WSModule = this.moduleManager.modules.ws;
 
@@ -391,6 +393,27 @@ class _MediaModule extends CoreClass {
 								.catch(next);
 						}
 
+						if (payload.mediaSource.startsWith("soundcloud:")) {
+							const trackId = payload.mediaSource.split(":")[1];
+
+							return SoundCloudModule.runJob(
+								"GET_TRACK",
+								{ identifier: trackId, createMissing: true },
+								this
+							)
+								.then(response => {
+									const { trackId, title, username, artworkUrl, duration } = response.track;
+									next(null, song, {
+										mediaSource: `soundcloud:${trackId}`,
+										title,
+										artists: [username],
+										thumbnail: artworkUrl,
+										duration
+									});
+								})
+								.catch(next);
+						}
+
 						// TODO handle Spotify here
 
 						return next("Invalid media source provided.");

+ 289 - 0
backend/logic/soundcloud.js

@@ -0,0 +1,289 @@
+import mongoose from "mongoose";
+import async from "async";
+import config from "config";
+
+import * as rax from "retry-axios";
+import axios from "axios";
+
+import CoreClass from "../core";
+
+let SoundCloudModule;
+let CacheModule;
+let DBModule;
+let MediaModule;
+let SongsModule;
+let StationsModule;
+let PlaylistsModule;
+let WSModule;
+
+class RateLimitter {
+	/**
+	 * Constructor
+	 *
+	 * @param {number} timeBetween - The time between each allowed YouTube request
+	 */
+	constructor(timeBetween) {
+		this.dateStarted = Date.now();
+		this.timeBetween = timeBetween;
+	}
+
+	/**
+	 * Returns a promise that resolves whenever the ratelimit of a YouTube request is done
+	 *
+	 * @returns {Promise} - promise that gets resolved when the rate limit allows it
+	 */
+	continue() {
+		return new Promise(resolve => {
+			if (Date.now() - this.dateStarted >= this.timeBetween) resolve();
+			else setTimeout(resolve, this.dateStarted + this.timeBetween - Date.now());
+		});
+	}
+
+	/**
+	 * Restart the rate limit timer
+	 */
+	restart() {
+		this.dateStarted = Date.now();
+	}
+}
+
+class _SoundCloudModule extends CoreClass {
+	// eslint-disable-next-line require-jsdoc
+	constructor() {
+		super("soundcloud");
+
+		SoundCloudModule = this;
+	}
+
+	/**
+	 * Initialises the soundcloud module
+	 *
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async initialize() {
+		CacheModule = this.moduleManager.modules.cache;
+		DBModule = this.moduleManager.modules.db;
+		MediaModule = this.moduleManager.modules.media;
+		SongsModule = this.moduleManager.modules.songs;
+		StationsModule = this.moduleManager.modules.stations;
+		PlaylistsModule = this.moduleManager.modules.playlists;
+		WSModule = this.moduleManager.modules.ws;
+
+		// this.youtubeApiRequestModel = this.YoutubeApiRequestModel = await DBModule.runJob("GET_MODEL", {
+		// 	modelName: "youtubeApiRequest"
+		// });
+
+		this.soundcloudTrackModel = this.SoundCloudTrackModel = await DBModule.runJob("GET_MODEL", {
+			modelName: "soundcloudTrack"
+		});
+
+		return new Promise(resolve => {
+			this.rateLimiter = new RateLimitter(config.get("apis.soundcloud.rateLimit"));
+			this.requestTimeout = config.get("apis.soundcloud.requestTimeout");
+
+			this.axios = axios.create();
+			this.axios.defaults.raxConfig = {
+				instance: this.axios,
+				retry: config.get("apis.soundcloud.retryAmount"),
+				noResponseRetries: config.get("apis.soundcloud.retryAmount")
+			};
+			rax.attach(this.axios);
+
+			SoundCloudModule.runJob("GET_TRACK", { identifier: 469902882, createMissing: false })
+				.then(res => {
+					console.log(57567, res);
+				})
+				.catch(err => {
+					console.log(78768, err);
+				});
+
+			resolve();
+		});
+	}
+
+	/**
+	 * Perform SoundCloud API get track request
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.params - request parameters
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_GET_TRACK(payload) {
+		return new Promise((resolve, reject) => {
+			const { trackId } = payload;
+
+			SoundCloudModule.runJob(
+				"API_CALL",
+				{
+					url: `https://api-v2.soundcloud.com/tracks/${trackId}`
+				},
+				this
+			)
+				.then(response => {
+					resolve(response);
+				})
+				.catch(err => {
+					reject(err);
+				});
+		});
+	}
+
+	/**
+	 * Perform SoundCloud API call
+	 *
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.url - request url
+	 * @param {object} payload.params - request parameters
+	 * @param {object} payload.quotaCost - request quotaCost
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	API_CALL(payload) {
+		return new Promise((resolve, reject) => {
+			// const { url, params, quotaCost } = payload;
+			const { url } = payload;
+
+			const params = {
+				client_id: config.get("apis.soundcloud.key")
+			};
+
+			SoundCloudModule.axios
+				.get(url, {
+					params,
+					timeout: SoundCloudModule.requestTimeout
+				})
+				.then(response => {
+					if (response.data.error) {
+						reject(new Error(response.data.error));
+					} else {
+						resolve({ response });
+					}
+				})
+				.catch(err => {
+					reject(err);
+				});
+			// }
+		});
+	}
+
+	/**
+	 * Create SoundCloud track
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.soundcloudTrack - the soundcloudTrack object
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	CREATE_TRACK(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						const { soundcloudTrack } = payload;
+						if (typeof soundcloudTrack !== "object") next("Invalid soundcloudTrack type");
+						else {
+							SoundCloudModule.soundcloudTrackModel.insertMany(soundcloudTrack, next);
+						}
+					},
+
+					(soundcloudTrack, next) => {
+						const mediaSource = `soundcloud:${soundcloudTrack.trackId}`;
+
+						MediaModule.runJob("RECALCULATE_RATINGS", { mediaSource }, this)
+							.then(() => next(null, soundcloudTrack))
+							.catch(next);
+					}
+				],
+				(err, soundcloudTrack) => {
+					if (err) reject(new Error(err));
+					else resolve({ soundcloudTrack });
+				}
+			);
+		});
+	}
+
+	/**
+	 * Get SoundCloud track
+	 *
+	 * @param {object} payload - an object containing the payload
+	 * @param {string} payload.identifier - the soundcloud track ObjectId or track id
+	 * @param {string} payload.createMissing - attempt to fetch and create track if not in db
+	 * @returns {Promise} - returns a promise (resolve, reject)
+	 */
+	GET_TRACK(payload) {
+		return new Promise((resolve, reject) => {
+			async.waterfall(
+				[
+					next => {
+						const query = mongoose.isObjectIdOrHexString(payload.identifier)
+							? { _id: payload.identifier }
+							: { trackId: payload.identifier };
+						return SoundCloudModule.soundcloudTrackModel.findOne(query, next);
+					},
+
+					(track, next) => {
+						if (track) return next(null, track, false);
+						if (mongoose.isObjectIdOrHexString(payload.identifier) || !payload.createMissing)
+							return next("SoundCloud track not found.");
+						return SoundCloudModule.runJob("API_GET_TRACK", { trackId: payload.identifier }, this)
+							.then(({ response }) => {
+								const { data } = response;
+								if (!data || !data.id)
+									return next("The specified track does not exist or cannot be publicly accessed.");
+
+								const {
+									id,
+									title,
+									artwork_url: artworkUrl,
+									created_at: createdAt,
+									duration,
+									genre,
+									kind,
+									license,
+									likes_count: likesCount,
+									playback_count: playbackCount,
+									public: _public,
+									tag_list: tagList,
+									user_id: userId,
+									user
+								} = data;
+
+								const soundcloudTrack = {
+									trackId: id,
+									title,
+									artworkUrl,
+									soundcloudCreatedAt: new Date(createdAt),
+									duration: duration / 1000,
+									genre,
+									kind,
+									license,
+									likesCount,
+									playbackCount,
+									public: _public,
+									tagList,
+									userId,
+									username: user.username
+								};
+
+								return next(null, false, soundcloudTrack);
+							})
+							.catch(next);
+					},
+					(track, soundcloudTrack, next) => {
+						if (track) return next(null, track, true);
+						return SoundCloudModule.runJob("CREATE_TRACK", { soundcloudTrack }, this)
+							.then(res => {
+								if (res.soundcloudTrack.length === 1) next(null, res.soundcloudTrack, false);
+								else next("SoundCloud track not found.");
+							})
+							.catch(next);
+					}
+				],
+				(err, track, existing) => {
+					if (err) reject(new Error(err));
+					else resolve({ track, existing });
+				}
+			);
+		});
+	}
+}
+
+export default new _SoundCloudModule();

+ 11 - 0
backend/package-lock.json

@@ -26,6 +26,7 @@
         "retry-axios": "^3.0.0",
         "sha256": "^0.2.0",
         "socks": "^2.7.1",
+        "soundcloud-key-fetch": "^1.0.13",
         "underscore": "^1.13.6",
         "ws": "^8.11.0"
       },
@@ -4116,6 +4117,11 @@
         "npm": ">= 3.0.0"
       }
     },
+    "node_modules/soundcloud-key-fetch": {
+      "version": "1.0.13",
+      "resolved": "https://registry.npmjs.org/soundcloud-key-fetch/-/soundcloud-key-fetch-1.0.13.tgz",
+      "integrity": "sha512-X1ketDIELpXOj2zuNNlTEtEy8y9Zu5R9m2lzkocXkG0GmUMht2ZypS8mtEDAWtEeqnEqkkLxipOsly+qU1gb6Q=="
+    },
     "node_modules/sparse-bitfield": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
@@ -7641,6 +7647,11 @@
         "smart-buffer": "^4.2.0"
       }
     },
+    "soundcloud-key-fetch": {
+      "version": "1.0.13",
+      "resolved": "https://registry.npmjs.org/soundcloud-key-fetch/-/soundcloud-key-fetch-1.0.13.tgz",
+      "integrity": "sha512-X1ketDIELpXOj2zuNNlTEtEy8y9Zu5R9m2lzkocXkG0GmUMht2ZypS8mtEDAWtEeqnEqkkLxipOsly+qU1gb6Q=="
+    },
     "sparse-bitfield": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",

+ 2 - 1
backend/package.json

@@ -33,6 +33,7 @@
     "retry-axios": "^3.0.0",
     "sha256": "^0.2.0",
     "socks": "^2.7.1",
+    "soundcloud-key-fetch": "^1.0.13",
     "underscore": "^1.13.6",
     "ws": "^8.11.0"
   },
@@ -51,4 +52,4 @@
     "ts-node": "^10.9.1",
     "typescript": "^4.9.3"
   }
-}
+}

+ 14 - 0
backend/test.js

@@ -0,0 +1,14 @@
+import sckey from "soundcloud-key-fetch";
+
+// sckey.fetchKey().then(key => {
+// 	console.log(key);
+// });
+
+// sckey.testKey(KEY).then(result => {
+// 	// returns a boolean; true/false
+// 	if (result) {
+// 		console.log("The key works!");
+// 	} else {
+// 		console.log("The key didn't work.");
+// 	}
+// });

+ 204 - 0
frontend/src/composables/useSoundcloudPlayer.ts

@@ -0,0 +1,204 @@
+import { ref, watch } from "vue";
+
+export const useSoundcloudPlayer = () => {
+	const soundcloudIframeElement = ref();
+	const widgetId = ref();
+	const volume = ref();
+	const readyCallback = ref();
+	const attemptsToPlay = ref(0);
+
+	const paused = ref(true);
+
+	const methodCallbacks = {};
+	const eventListenerCallbacks = {};
+
+	const dispatchMessage = (method, value = null) => {
+		const payload = {
+			method,
+			value
+		};
+
+		if (!soundcloudIframeElement.value) return;
+
+		soundcloudIframeElement.value.contentWindow.postMessage(
+			JSON.stringify(payload),
+			"https://w.soundcloud.com/player"
+		);
+	};
+
+	const onLoadListener = () => {};
+
+	const onMessageListener = event => {
+		if (event.origin !== "https://w.soundcloud.com") return;
+
+		const data = JSON.parse(event.data);
+		if (data.method !== "getPosition") console.log("MESSAGE DATA", data);
+
+		if (data.method === "ready") {
+			widgetId.value = data.widgetId;
+
+			if (!readyCallback.value) return;
+
+			readyCallback.value();
+
+			return;
+		}
+
+		if (methodCallbacks[data.method]) {
+			methodCallbacks[data.method].forEach(callback => {
+				callback(data.value);
+			});
+			methodCallbacks[data.method] = [];
+		}
+
+		if (eventListenerCallbacks[data.method]) {
+			eventListenerCallbacks[data.method].forEach(callback => {
+				callback(data.value);
+			});
+		}
+	};
+
+	const addMethodCallback = (type, cb) => {
+		if (!methodCallbacks[type]) methodCallbacks[type] = [];
+		methodCallbacks[type].push(cb);
+	};
+
+	const attemptToPlay = () => {
+		attemptsToPlay.value += 1;
+
+		dispatchMessage("play");
+		dispatchMessage("isPaused", value => {
+			if (!value || paused.value || attemptsToPlay.value >= 10) return;
+
+			setTimeout(() => {
+				attemptToPlay();
+			}, 500);
+		});
+	};
+
+	watch(soundcloudIframeElement, (newElement, oldElement) => {
+		if (oldElement) {
+			oldElement.removeEventListener("load", onLoadListener);
+
+			window.removeEventListener("message", onMessageListener);
+		}
+
+		if (newElement) {
+			newElement.addEventListener("load", onLoadListener);
+
+			window.addEventListener("message", onMessageListener);
+		}
+	});
+
+	/* Exported functions */
+
+	const soundcloudPlay = () => {
+		paused.value = false;
+
+		console.log("SC PLAY");
+
+		dispatchMessage("play");
+	};
+
+	const soundcloudPause = () => {
+		paused.value = true;
+
+		console.log("SC PAUSE");
+
+		dispatchMessage("pause");
+	};
+
+	const soundcloudSetVolume = _volume => {
+		volume.value = _volume;
+
+		dispatchMessage("setVolume", _volume);
+	};
+
+	const soundcloudSeekTo = time => {
+		console.log("SC SEEK TO", time);
+
+		dispatchMessage("seekTo", time);
+	};
+
+	const soundcloudGetPosition = callback => {
+		let called = false;
+
+		const _callback = value => {
+			if (called) return;
+			called = true;
+
+			callback(value);
+		};
+		addMethodCallback("getPosition", _callback);
+
+		dispatchMessage("getPosition");
+	};
+
+	const soundcloudGetIsPaused = callback => {
+		let called = false;
+
+		const _callback = value => {
+			if (called) return;
+			called = true;
+
+			callback(value);
+		};
+		addMethodCallback("isPaused", _callback);
+
+		dispatchMessage("isPaused");
+	};
+
+	const soundcloudLoadTrack = (trackId, startTime, _paused) => {
+		if (!soundcloudIframeElement.value) return;
+
+		const url = `https://w.soundcloud.com/player?autoplay=false&buying=false&sharing=false&download=false&show_artwork=false&show_playcount=false&show_user=false&url=${`https://api.soundcloud.com/tracks/${trackId}`}`;
+
+		soundcloudIframeElement.value.setAttribute("src", url);
+
+		paused.value = _paused;
+
+		readyCallback.value = () => {
+			Object.keys(eventListenerCallbacks).forEach(event => {
+				dispatchMessage("addEventListener", event);
+			});
+
+			dispatchMessage("setVolume", volume.value ?? 20);
+			dispatchMessage("seekTo", (startTime ?? 0) * 1000);
+			if (!_paused) attemptToPlay();
+		};
+	};
+
+	const soundcloudBindListener = (name, callback) => {
+		if (!eventListenerCallbacks[name]) {
+			eventListenerCallbacks[name] = [];
+			dispatchMessage("addEventListener", name);
+		}
+
+		eventListenerCallbacks[name].push(callback);
+	};
+
+	const soundcloudDestroy = () => {
+		if (!soundcloudIframeElement.value) return;
+
+		const url = `https://w.soundcloud.com/player?autoplay=false&buying=false&sharing=false&download=false&show_artwork=false&show_playcount=false&show_user=false&url=${`https://api.soundcloud.com/tracks/${0}`}`;
+		soundcloudIframeElement.value.setAttribute("src", url);
+	};
+
+	const soundcloudUnload = () => {
+		window.removeEventListener("message", onMessageListener);
+	};
+
+	return {
+		soundcloudIframeElement,
+		soundcloudPlay,
+		soundcloudPause,
+		soundcloudSeekTo,
+		soundcloudSetVolume,
+		soundcloudLoadTrack,
+		soundcloudGetPosition,
+		soundcloudGetIsPaused,
+		soundcloudBindListener,
+		soundcloudDestroy,
+		soundcloudUnload
+	};
+};

+ 284 - 106
frontend/src/pages/Station/index.vue

@@ -12,6 +12,7 @@ import Toast from "toasters";
 import { storeToRefs } from "pinia";
 import { ContentLoader } from "vue-content-loader";
 import canAutoPlay from "can-autoplay";
+import { useSoundcloudPlayer } from "@/composables/useSoundcloudPlayer";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useStationStore } from "@/stores/station";
 import { useUserAuthStore } from "@/stores/userAuth";
@@ -53,6 +54,20 @@ const stationStore = useStationStore();
 const userAuthStore = useUserAuthStore();
 const userPreferencesStore = useUserPreferencesStore();
 
+const {
+	soundcloudIframeElement,
+	soundcloudLoadTrack,
+	soundcloudSeekTo,
+	soundcloudPlay,
+	soundcloudPause,
+	soundcloudSetVolume,
+	soundcloudGetPosition,
+	soundcloudGetIsPaused,
+	soundcloudBindListener,
+	soundcloudDestroy,
+	soundcloudUnload
+} = useSoundcloudPlayer();
+
 // TODO this might need a different place, like onMounted
 const isApple = ref(
 	navigator.platform.match(/iPhone|iPod|iPad/) ||
@@ -60,8 +75,8 @@ const isApple = ref(
 );
 const loading = ref(true);
 const exists = ref(true);
-const playerReady = ref(false);
-const player = ref(undefined);
+const youtubePlayerReady = ref(false);
+const youtubePlayer = ref(undefined);
 const timePaused = ref(0);
 const muted = ref(false);
 const timeElapsed = ref("0:00");
@@ -167,13 +182,32 @@ const stationState = computed(() => {
 		return "participate";
 	if (localPaused.value) return "local_paused";
 	if (volumeSliderValue.value === 0 || muted.value) return "muted";
-	if (playerReady.value && youtubePlayerState.value === "PLAYING")
+	if (youtubePlayerReady.value && youtubePlayerState.value === "PLAYING")
 		return "playing";
-	if (playerReady.value && youtubePlayerState.value === "BUFFERING")
+	if (youtubePlayerReady.value && youtubePlayerState.value === "BUFFERING")
 		return "buffering";
 	return "unknown";
 });
 
+const currentSongMediaType = computed(() => {
+	if (
+		!currentSong.value ||
+		!currentSong.value.mediaSource ||
+		currentSong.value.mediaSource.indexOf(":") === -1
+	)
+		return "none";
+	return currentSong.value.mediaSource.split(":")[0];
+});
+const currentSongMediaValue = computed(() => {
+	if (
+		!currentSong.value ||
+		!currentSong.value.mediaSource ||
+		currentSong.value.mediaSource.indexOf(":") === -1
+	)
+		return null;
+	return currentSong.value.mediaSource.split(":")[1];
+});
+
 const currentYoutubeId = computed(() => {
 	if (
 		!currentSong.value ||
@@ -335,6 +369,36 @@ const getTimeRemaining = () => {
 	}
 	return 0;
 };
+const getCurrentPlayerTime = () =>
+	new Promise<number>((resolve, reject) => {
+		if (
+			currentSongMediaType.value === "youtube" &&
+			youtubePlayerReady.value
+		) {
+			resolve(
+				Math.max(
+					youtubePlayer.value.getCurrentTime() -
+						currentSong.value.skipDuration,
+					0
+				) * 1000
+			);
+			return;
+		}
+
+		if (currentSongMediaType.value === "soundcloud") {
+			soundcloudGetPosition(position => {
+				resolve(
+					Math.max(
+						position / 1000 - currentSong.value.skipDuration,
+						0
+					) * 1000
+				);
+			});
+			return;
+		}
+
+		reject(new Error("No player."));
+	});
 const skipSong = () => {
 	if (nextCurrentSong.value && nextCurrentSong.value.currentSong) {
 		const _songsList = songsList.value.concat([]);
@@ -372,13 +436,14 @@ const resizeSeekerbar = () => {
 	seekerbarPercentage.value =
 		(getTimeElapsed() / 1000 / currentSong.value.duration) * 100;
 };
-const calculateTimeElapsed = () => {
+const calculateTimeElapsed = async () => {
 	if (experimentalChangableListenMode.value === "participate") return;
 	if (
-		playerReady.value &&
+		youtubePlayerReady.value &&
+		currentSongMediaType.value === "youtube" &&
 		!noSong.value &&
 		currentSong.value &&
-		player.value.getPlayerState() === -1
+		youtubePlayer.value.getPlayerState() === -1
 	) {
 		if (!canAutoplay.value) {
 			if (Date.now() - lastTimeRequestedIfCanAutoplay.value > 2000) {
@@ -393,23 +458,14 @@ const calculateTimeElapsed = () => {
 				});
 			}
 		} else {
-			player.value.playVideo();
+			youtubePlayer.value.playVideo();
 			attemptsToPlayVideo.value += 1;
 		}
 	}
 
-	if (
-		!stationPaused.value &&
-		!localPaused.value &&
-		playerReady.value &&
-		!isApple.value
-	) {
+	if (!stationPaused.value && !localPaused.value && !isApple.value) {
 		const timeElapsed = getTimeElapsed();
-		const currentPlayerTime =
-			Math.max(
-				player.value.getCurrentTime() - currentSong.value.skipDuration,
-				0
-			) * 1000;
+		const currentPlayerTime = await getCurrentPlayerTime();
 
 		const difference = timeElapsed - currentPlayerTime;
 
@@ -418,7 +474,7 @@ const calculateTimeElapsed = () => {
 		if (difference < -2000) {
 			if (!seeking.value) {
 				seeking.value = true;
-				player.value.seekTo(
+				playerSeekTo(
 					getTimeElapsed() / 1000 + currentSong.value.skipDuration
 				);
 			}
@@ -431,7 +487,7 @@ const calculateTimeElapsed = () => {
 		} else if (difference > 2000) {
 			if (!seeking.value) {
 				seeking.value = true;
-				player.value.seekTo(
+				playerSeekTo(
 					getTimeElapsed() / 1000 + currentSong.value.skipDuration
 				);
 			}
@@ -441,12 +497,20 @@ const calculateTimeElapsed = () => {
 			_playbackRate = 1.1;
 		} else if (difference > 25) {
 			_playbackRate = 1.05;
-		} else if (player.value.getPlaybackRate !== 1.0) {
-			player.value.setPlaybackRate(1.0);
+		} else if (
+			currentSongMediaType.value === "youtube" &&
+			youtubeReady.value &&
+			youtubePlayer.value.getPlaybackRate !== 1.0
+		) {
+			youtubePlayer.value.setPlaybackRate(1.0);
 		}
 
-		if (playbackRate.value !== _playbackRate) {
-			player.value.setPlaybackRate(_playbackRate);
+		if (
+			currentSongMediaType.value === "youtube" &&
+			youtubeReady.value &&
+			playbackRate.value !== _playbackRate
+		) {
+			youtubePlayer.value.setPlaybackRate(_playbackRate);
 			playbackRate.value = _playbackRate;
 		}
 	}
@@ -459,27 +523,82 @@ const calculateTimeElapsed = () => {
 		(dateCurrently() - startedAt.value - localTimePaused) / 1000;
 
 	const songDuration = currentSong.value.duration;
-	if (playerReady.value && songDuration <= duration)
-		player.value.pauseVideo();
+	if (youtubePlayerReady.value && songDuration <= duration) playerPause();
 	if (duration <= songDuration)
 		timeElapsed.value =
 			typeof duration === "number" ? utils.formatTime(duration) : "0";
 };
 const playVideo = () => {
-	if (playerReady.value && currentSongIsYoutube.value) {
-		videoLoading.value = true;
-		player.value.loadVideoById(
-			currentYoutubeId.value,
-			getTimeElapsed() / 1000 + currentSong.value.skipDuration
+	if (currentSongMediaType.value === "youtube") {
+		if (youtubePlayerReady.value) {
+			videoLoading.value = true;
+			youtubePlayer.value.loadVideoById(
+				currentYoutubeId.value,
+				getTimeElapsed() / 1000 + currentSong.value.skipDuration
+			);
+		}
+	} else if (currentSongMediaType.value === "soundcloud") {
+		const soundcloudId = currentSongMediaValue.value;
+
+		soundcloudLoadTrack(
+			soundcloudId,
+			getTimeElapsed() / 1000 + currentSong.value.skipDuration,
+			localPaused.value || stationPaused.value
 		);
+	}
 
-		if (window.stationInterval !== 0) clearInterval(window.stationInterval);
-		window.stationInterval = window.setInterval(() => {
-			if (!stationPaused.value) {
-				resizeSeekerbar();
-				calculateTimeElapsed();
-			}
-		}, 150);
+	if (window.stationInterval !== 0) clearInterval(window.stationInterval);
+	window.stationInterval = window.setInterval(() => {
+		if (!stationPaused.value) {
+			resizeSeekerbar();
+			calculateTimeElapsed();
+		}
+	}, 150);
+};
+const changeSoundcloudPlayerVolume = () => {
+	if (muted.value) soundcloudSetVolume(0);
+	else soundcloudSetVolume(volumeSliderValue.value);
+};
+const changePlayerVolume = () => {
+	if (youtubePlayerReady.value) {
+		youtubePlayer.value.setVolume(volumeSliderValue.value);
+		if (muted.value) youtubePlayer.value.mute();
+		else youtubePlayer.value.unMute();
+	}
+
+	changeSoundcloudPlayerVolume();
+};
+const playerPlay = () => {
+	if (currentSongMediaType.value === "youtube" && youtubePlayerReady.value) {
+		youtubePlayer.value.playVideo();
+	}
+
+	if (currentSongMediaType.value === "soundcloud") {
+		soundcloudPlay();
+	}
+};
+const playerStop = () => {
+	if (youtubePlayerReady.value) {
+		youtubePlayer.value.stopVideo();
+	}
+
+	soundcloudPause();
+};
+const playerPause = () => {
+	if (youtubePlayerReady.value) {
+		youtubePlayer.value.pauseVideo();
+	}
+
+	soundcloudPause();
+};
+const playerSeekTo = position => {
+	// Position is in seconds
+	if (youtubePlayerReady.value) {
+		youtubePlayer.value.seekTo(position * 1000);
+	}
+
+	if (currentSongMediaType.value === "soundcloud") {
+		soundcloudSeekTo(position * 1000);
 	}
 };
 const toggleSkipVote = (message?) => {
@@ -493,9 +612,9 @@ const toggleSkipVote = (message?) => {
 };
 const youtubeReady = () => {
 	if (experimentalChangableListenMode.value === "participate") return;
-	if (!player.value) {
+	if (!youtubePlayer.value) {
 		ms.setYTReady(false);
-		player.value = new window.YT.Player("stationPlayer", {
+		youtubePlayer.value = new window.YT.Player("youtubeStationPlayer", {
 			height: 270,
 			width: 480,
 			// TODO CHECK TYPE
@@ -513,17 +632,10 @@ const youtubeReady = () => {
 			},
 			events: {
 				onReady: () => {
-					playerReady.value = true;
+					youtubePlayerReady.value = true;
 					ms.setYTReady(true);
 
-					let volume = parseFloat(localStorage.getItem("volume"));
-
-					volume = typeof volume === "number" ? volume : 20;
-
-					player.value.setVolume(volume);
-
-					if (volume > 0) player.value.unMute();
-					if (muted.value) player.value.mute();
+					changePlayerVolume();
 
 					playVideo();
 
@@ -531,7 +643,8 @@ const youtubeReady = () => {
 						(dateCurrently() - startedAt.value - timePaused.value) /
 						1000;
 					const songDuration = currentSong.value.duration;
-					if (songDuration <= duration) player.value.pauseVideo();
+					if (songDuration <= duration)
+						youtubePlayer.value.pauseVideo();
 
 					// on ios, playback will be forcibly paused locally
 					if (isApple.value) {
@@ -613,20 +726,23 @@ const youtubeReady = () => {
 						videoLoading.value === true
 					) {
 						videoLoading.value = false;
-						player.value.seekTo(
+						youtubePlayer.value.seekTo(
 							getTimeElapsed() / 1000 +
 								currentSong.value.skipDuration,
 							true
 						);
 						canAutoplay.value = true;
 						if (localPaused.value || stationPaused.value)
-							player.value.pauseVideo();
+							youtubePlayer.value.pauseVideo();
 					} else if (
 						event.data === window.YT.PlayerState.PLAYING &&
 						(localPaused.value || stationPaused.value)
 					) {
-						player.value.seekTo(timeBeforePause.value / 1000, true);
-						player.value.pauseVideo();
+						youtubePlayer.value.seekTo(
+							timeBeforePause.value / 1000,
+							true
+						);
+						youtubePlayer.value.pauseVideo();
 					} else if (
 						event.data === window.YT.PlayerState.PLAYING &&
 						seeking.value === true
@@ -638,15 +754,15 @@ const youtubeReady = () => {
 						!localPaused.value &&
 						!stationPaused.value &&
 						!noSong.value &&
-						player.value.getDuration() / 1000 <
+						youtubePlayer.value.getDuration() / 1000 <
 							currentSong.value.duration
 					) {
-						player.value.seekTo(
+						youtubePlayer.value.seekTo(
 							getTimeElapsed() / 1000 +
 								currentSong.value.skipDuration,
 							true
 						);
-						player.value.playVideo();
+						youtubePlayer.value.playVideo();
 					}
 				}
 			}
@@ -699,8 +815,12 @@ const setCurrentSong = data => {
 	if (_currentSong) {
 		updateNoSong(false);
 
-		if (!playerReady.value) youtubeReady();
-		else playVideo();
+		if (currentSongMediaType.value === "youtube") {
+			if (!youtubePlayerReady.value) youtubeReady();
+			else playVideo();
+		} else if (currentSongMediaType.value === "soundcloud") {
+			playVideo();
+		}
 
 		// If the station is playing and the backend is not connected, set the next song to skip to after this song and set a timer to skip
 		if (!stationPaused.value && !socket.ready) {
@@ -791,7 +911,7 @@ const setCurrentSong = data => {
 			);
 		}
 	} else {
-		if (playerReady.value) player.value.stopVideo();
+		playerStop();
 		updateNoSong(true);
 	}
 
@@ -801,25 +921,17 @@ const setCurrentSong = data => {
 const changeVolume = () => {
 	const volume = volumeSliderValue.value;
 	localStorage.setItem("volume", `${volume}`);
-	if (playerReady.value) {
-		player.value.setVolume(volume);
-		if (volume > 0) {
-			player.value.unMute();
-			localStorage.setItem("muted", "false");
-			muted.value = false;
-		}
-	}
+	muted.value = volume <= 0;
+	localStorage.setItem("muted", `${muted.value}`);
+
+	changePlayerVolume();
 };
 const resumeLocalPlayer = () => {
 	if (experimentalMediaSession.value)
 		updateMediaSessionData(currentSong.value);
 	if (!noSong.value) {
-		if (playerReady.value) {
-			player.value.seekTo(
-				getTimeElapsed() / 1000 + currentSong.value.skipDuration
-			);
-			player.value.playVideo();
-		}
+		playerSeekTo(getTimeElapsed() / 1000 + currentSong.value.skipDuration);
+		playerPlay();
 	}
 };
 const pauseLocalPlayer = () => {
@@ -827,7 +939,7 @@ const pauseLocalPlayer = () => {
 		updateMediaSessionData(currentSong.value);
 	if (!noSong.value) {
 		timeBeforePause.value = getTimeElapsed();
-		if (playerReady.value) player.value.pauseVideo();
+		playerPause();
 	}
 };
 const resumeLocalStation = () => {
@@ -857,29 +969,22 @@ const pauseStation = () => {
 	});
 };
 const toggleMute = () => {
-	if (playerReady.value) {
-		const previousVolume = parseFloat(localStorage.getItem("volume"));
-		const volume = player.value.getVolume() <= 0 ? previousVolume : 0;
-		muted.value = !muted.value;
-		localStorage.setItem("muted", `${muted.value}`);
-		volumeSliderValue.value = volume;
-		player.value.setVolume(volume);
-		if (!muted.value) localStorage.setItem("volume", `${volume}`);
-	}
+	muted.value = !muted.value;
+
+	changePlayerVolume();
 };
 const increaseVolume = () => {
-	if (playerReady.value) {
-		const previousVolume = parseFloat(localStorage.getItem("volume"));
-		let volume = previousVolume + 5;
-		if (previousVolume === 0) {
-			muted.value = false;
-			localStorage.setItem("muted", "false");
-		}
-		if (volume > 100) volume = 100;
-		volumeSliderValue.value = volume;
-		player.value.setVolume(volume);
-		localStorage.setItem("volume", `${volume}`);
+	const previousVolume = parseFloat(localStorage.getItem("volume"));
+	let volume = previousVolume + 5;
+	if (previousVolume === 0) {
+		muted.value = false;
+		localStorage.setItem("muted", "false");
 	}
+	if (volume > 100) volume = 100;
+	volumeSliderValue.value = volume;
+	localStorage.setItem("volume", `${volume}`);
+
+	changePlayerVolume();
 };
 const toggleLike = () => {
 	if (currentSong.value.liked)
@@ -924,12 +1029,14 @@ const resetKeyboardShortcutsHelper = () => {
 };
 const sendActivityWatchVideoData = () => {
 	if (
+		currentSongMediaType.value === "youtube" &&
 		!stationPaused.value &&
 		(!localPaused.value ||
 			experimentalChangableListenMode.value === "participate") &&
 		!noSong.value &&
 		(experimentalChangableListenMode.value === "participate" ||
-			player.value.getPlayerState() === window.YT.PlayerState.PLAYING)
+			youtubePlayer.value.getPlayerState() ===
+				window.YT.PlayerState.PLAYING)
 	) {
 		if (activityWatchVideoLastStatus.value !== "playing") {
 			activityWatchVideoLastStatus.value = "playing";
@@ -970,7 +1077,7 @@ const sendActivityWatchVideoData = () => {
 					: Object.keys(window.YT.PlayerState).find(
 							key =>
 								window.YT.PlayerState[key] ===
-								player.value.getPlayerState()
+								youtubePlayer.value.getPlayerState()
 					  ),
 			playbackRate: playbackRate.value,
 			experimentalChangableListenMode:
@@ -992,12 +1099,14 @@ const experimentalChangableListenModeChange = newMode => {
 
 	if (newMode === "participate") {
 		// Destroy the YouTube player
-		if (player.value) {
-			player.value.destroy();
-			player.value = null;
-			playerReady.value = false;
+		if (youtubePlayer.value) {
+			youtubePlayer.value.destroy();
+			youtubePlayer.value = null;
+			youtubePlayerReady.value = false;
 			youtubePlayerState.value = null;
 		}
+
+		soundcloudDestroy();
 	} else {
 		// Recreate the YouTube player
 		youtubeReady();
@@ -1626,7 +1735,6 @@ onMounted(async () => {
 
 	if (JSON.parse(localStorage.getItem("muted"))) {
 		muted.value = true;
-		player.value.setVolume(0);
 		volumeSliderValue.value = 0;
 	} else {
 		let volume = parseFloat(localStorage.getItem("volume"));
@@ -1635,6 +1743,55 @@ onMounted(async () => {
 		localStorage.setItem("volume", `${volume}`);
 		volumeSliderValue.value = volume;
 	}
+
+	changePlayerVolume();
+
+	soundcloudBindListener("play", () => {
+		if (currentSongMediaType.value !== "soundcloud") {
+			soundcloudPause();
+			return;
+		}
+		console.log("PLAY BIND", localPaused.value, stationPaused.value);
+		if (localPaused.value || stationPaused.value) {
+			soundcloudSeekTo(
+				(getTimeElapsed() / 1000 + currentSong.value.skipDuration) *
+					1000
+			);
+			soundcloudPause();
+		}
+	});
+
+	soundcloudBindListener("pause", () => {
+		if (currentSongMediaType.value !== "soundcloud") return;
+		console.log("PAUSE BIND", localPaused.value, stationPaused.value);
+		if (!localPaused.value && !stationPaused.value) {
+			// soundcloudSeekTo(
+			// 	(getTimeElapsed() / 1000 + currentSong.value.skipDuration) *
+			// 		1000
+			// );
+			// soundcloudPlay();
+
+			// let called = false;
+
+			// soundcloudGetIsPaused(isPaused => {
+			// 	if (called) return;
+			// 	called = true;
+
+			// 	console.log(123, isPaused);
+			// 	if (isPaused) {
+			// 		setTimeout(soundcloudPlay, 500);
+			// 	}
+			// });
+		}
+	});
+
+	soundcloudBindListener("seek", () => {
+		if (seeking.value) seeking.value = false;
+	});
+
+	soundcloudBindListener("error", value => {
+		console.log("SOUNDCLOUD ERROR", value);
+	});
 });
 
 onBeforeUnmount(() => {
@@ -1685,6 +1842,8 @@ onBeforeUnmount(() => {
 
 	leaveStation();
 
+	soundcloudUnload();
+
 	// Delete the Pinia store that was created for this station, after all other cleanup tasks are performed
 	stationStore.$dispose();
 });
@@ -1953,13 +2112,29 @@ onBeforeUnmount(() => {
 						>
 							<div id="video-container">
 								<div
-									id="stationPlayer"
+									v-show="currentSongMediaType === 'youtube'"
+									id="youtubeStationPlayer"
 									style="
 										width: 100%;
 										height: 100%;
 										min-height: 200px;
 									"
 								/>
+								<iframe
+									v-show="
+										currentSongMediaType === 'soundcloud'
+									"
+									id="soundcloudStationPlayer"
+									ref="soundcloudIframeElement"
+									style="
+										width: 100%;
+										height: 100%;
+										min-height: 200px;
+									"
+									scrolling="no"
+									frameborder="no"
+									allow="autoplay"
+								></iframe>
 								<div
 									class="player-cannot-autoplay"
 									v-if="!canAutoplay"
@@ -2475,7 +2650,9 @@ onBeforeUnmount(() => {
 				>
 				<span><b>Loading</b>: {{ loading }}</span>
 				<span><b>Can autoplay</b>: {{ canAutoplay }}</span>
-				<span><b>Player ready</b>: {{ playerReady }}</span>
+				<span
+					><b>Youtube player ready</b>: {{ youtubePlayerReady }}</span
+				>
 				<span
 					><b>Attempts to play video</b>:
 					{{ attemptsToPlayVideo }}</span
@@ -2609,7 +2786,8 @@ onBeforeUnmount(() => {
 </template>
 
 <style lang="less">
-#stationPlayer {
+#youtubeStationPlayer,
+#soundcloudStationPlayer {
 	position: absolute;
 	top: 0;
 	left: 0;