Browse Source

Merge tag 'v3.11.0' into release/backend-rewrite

Owen Diffey 2 months ago
parent
commit
02c0f4d645
51 changed files with 1683 additions and 1129 deletions
  1. 1 0
      .env.example
  2. 3 2
      .wiki/Configuration.md
  3. 37 0
      CHANGELOG.md
  4. 1 1
      backend/Dockerfile
  5. 1 0
      backend/config/custom-environment-variables.json
  6. 9 5
      backend/config/default.json
  7. 469 488
      backend/logic/actions/playlists.js
  8. 9 11
      backend/logic/actions/stations.js
  9. 43 45
      backend/logic/actions/users.js
  10. 10 10
      backend/logic/cache/index.js
  11. 1 0
      backend/logic/db/schemas/playlist.js
  12. 5 4
      backend/logic/hooks/hasPermission.js
  13. 2 2
      backend/logic/mail/schemas/dataRequest.js
  14. 11 1
      backend/logic/playlists.js
  15. 7 1
      backend/logic/ws.js
  16. 108 6
      backend/logic/youtube.js
  17. 1 1
      backend/package-lock.json
  18. 1 1
      backend/package.json
  19. 3 0
      docker-compose.yml
  20. 2 0
      frontend/Dockerfile
  21. 477 275
      frontend/package-lock.json
  22. 33 34
      frontend/package.json
  23. 22 7
      frontend/src/App.vue
  24. 4 4
      frontend/src/components/AdvancedTable.vue
  25. 1 1
      frontend/src/components/FloatingBox.vue
  26. 3 3
      frontend/src/components/LongJobs.spec.ts
  27. 1 1
      frontend/src/components/MainFooter.vue
  28. 9 5
      frontend/src/components/PlaylistTabBase.vue
  29. 7 11
      frontend/src/components/Queue.vue
  30. 4 3
      frontend/src/components/SoundcloudPlayer.vue
  31. 1 1
      frontend/src/components/SoundcloudTrackInfo.vue
  32. 6 5
      frontend/src/components/YoutubePlayer.vue
  33. 8 8
      frontend/src/components/__snapshots__/Modal.spec.ts.snap
  34. 2 2
      frontend/src/components/modals/ConvertSpotifySongs.vue
  35. 69 11
      frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue
  36. 12 23
      frontend/src/components/modals/EditPlaylist/index.vue
  37. 4 3
      frontend/src/components/modals/EditSong/index.vue
  38. 6 5
      frontend/src/components/modals/ManageStation/index.vue
  39. 26 11
      frontend/src/composables/useYoutubeDirect.ts
  40. 1 1
      frontend/src/index.html
  41. 18 0
      frontend/src/pages/Admin/Playlists.vue
  42. 16 33
      frontend/src/pages/Home.vue
  43. 2 2
      frontend/src/pages/Profile/Tabs/Playlists.vue
  44. 184 64
      frontend/src/pages/Station/index.vue
  45. 2 0
      frontend/src/stores/config.ts
  46. 12 13
      frontend/src/stores/editPlaylist.ts
  47. 12 13
      frontend/src/stores/manageStation.ts
  48. 12 12
      frontend/src/stores/station.ts
  49. 1 0
      frontend/src/types/playlist.ts
  50. 1 0
      frontend/src/types/vite-env.d.ts
  51. 3 0
      frontend/vite.config.js

+ 1 - 0
.env.example

@@ -33,6 +33,7 @@ BACKUP_LOCATION=
 BACKUP_NAME=
 
 MUSARE_SITENAME=Musare
+MUSARE_PRIMARY_COLOR="#03a9f4"
 
 MUSARE_DEBUG_VERSION=true
 MUSARE_DEBUG_GIT_REMOTE=false

+ 3 - 2
.wiki/Configuration.md

@@ -53,6 +53,7 @@ machine, even though the application within the container is listening on `21017
 | `BACKUP_LOCATION` | Directory to store musare.sh backups. Defaults to `/backups` in script location. |
 | `BACKUP_NAME` | Name of musare.sh backup files. Defaults to `musare-$(date +"%Y-%m-%d-%s").dump`. |
 | `MUSARE_SITENAME` | Should be the name of the site. [^1] |
+| `MUSARE_PRIMARY_COLOR` | Primary color of the application, in hex format. [^1] |
 | `MUSARE_DEBUG_VERSION` | Log/expose the current package.json version. [^1] |
 | `MUSARE_DEBUG_GIT_REMOTE` | Log/expose the current Git repository's remote. [^1] |
 | `MUSARE_DEBUG_GIT_REMOTE_URL` | Log/expose the current Git repository's remote URL. [^1] |
@@ -137,16 +138,16 @@ For more information on configuration files please refer to the
 | `mongo.port` | MongoDB port. |
 | `mongo.database` | MongoDB database name. |
 | `blacklistedCommunityStationNames` | Array of blacklisted community station names. |
-| `featuredPlaylists` | Array of featured playlist id's. Playlist privacy must be public. |
 | `messages.accountRemoval` | Message to display to users when they request their account to be removed. |
 | `siteSettings.christmas` | Whether to enable christmas theme. |
 | `footerLinks` | Add custom links to footer by specifying `"title": "url"`, e.g. `"GitHub": "https://github.com/Musare/Musare"`. You can disable about, team and news links (but not the pages themselves) by setting them to false, e.g. `"about": false`. |
 | `shortcutOverrides` | Overwrite keyboard shortcuts, for example `"editSong.useAllDiscogs": { "keyCode": 68, "ctrl": true, "alt": true, "shift": false, "preventDefault": true }`. |
+| `primaryColor` | Primary color of the application, in hex format. |
 | `registrationDisabled` | If set to `true`, users can't register accounts. |
 | `sendDataRequestEmails` | If `true` all admin users will be sent an email if a data request is received. Requires mail to be enabled and configured. |
 | `skipConfigVersionCheck` | Skips checking if the config version is outdated or not. Should almost always be set to false. |
 | `skipDbDocumentsVersionCheck` | Skips checking if there are any DB documents outdated or not. Should almost always be set to false. |
-| `debug.stationIssue` | If set to `true` it will enable the `/debug_station` API endpoint on the backend, which provides information useful to debugging stations not skipping, as well as capture all jobs specified in `debug.captureJobs`.
+| `debug.stationIssue` | If set to `true` it will enable the `/debug_station` API endpoint on the backend, which provides information useful to debugging stations not skipping, as well as capture all jobs specified in `debug.captureJobs`. |
 | `debug.traceUnhandledPromises` | Enables the trace-unhandled package, which provides detailed information when a promise is unhandled. |
 | `debug.captureJobs` | Array of jobs to capture for `debug.stationIssue`. |
 | `debug.git.remote` | Log/expose the current Git repository's remote. |

+ 37 - 0
CHANGELOG.md

@@ -1,5 +1,42 @@
 # Changelog
 
+## [v3.11.0] - 2024-03-02
+
+This release includes all changes from v3.11.0-rc1, in addition to the following.
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Fixed
+
+- fix: Regression where certain YouTube video URL's could no longer be parsed
+
+### Changed
+
+- refactor: Further improve station system time difference calculation
+
+## [v3.11.0-rc1] - 2024-02-24
+
+Upgrade instructions can be found at [.wiki/Upgrading](.wiki/Upgrading.md).
+
+### Added
+
+- feat: Added primary color configuration
+- feat: Added featured playlist toggle within Edit Playlist modal
+- feat: Added station paused overlay message to player
+
+### Changed
+
+- refactor: Improved support for importing YouTube mixes and non mixes
+- refactor: Improved queue and playlist client-side reordering
+- refactor: Improved station-system time difference handling
+
+### Fixed
+
+- fix: Station player plays 0.1s of video when paused on socket reconnection
+- fix: Station autoplay warning message not displayed
+- fix: Station autoplay warning increasing volume on click
+- fix: Unable to add YouTube video by URL with other query parameters
+- fix: Keyboard shortcut floating box styling
+
 ## [v3.10.0] - 2023-05-21
 
 This release includes all changes from v3.10.0-rc1, v3.10.0-rc2 and v3.10.0-rc3,

+ 1 - 1
backend/Dockerfile

@@ -5,7 +5,7 @@ WORKDIR /opt/app
 
 COPY backend/package.json backend/package-lock.json /opt/app/
 
-RUN npm install --silent
+RUN npm install
 
 FROM node:18 AS musare_backend
 

+ 1 - 0
backend/config/custom-environment-variables.json

@@ -1,5 +1,6 @@
 {
 	"sitename": "MUSARE_SITENAME",
+	"primaryColor": "MUSARE_PRIMARY_COLOR",
 	"redis": {
 		"password": "REDIS_PASSWORD"
 	},

+ 9 - 5
backend/config/default.json

@@ -32,7 +32,7 @@
 					"limit": 3000000
 				}
 			],
-			"maxPlaylistPages": 20
+			"maxPlaylistPages": 1000
 		},
 		"spotify": {
 			"enabled": false,
@@ -91,8 +91,9 @@
 		"port": 27017,
 		"database": "musare"
 	},
-	"blacklistedCommunityStationNames": ["musare"],
-	"featuredPlaylists": [],
+	"blacklistedCommunityStationNames": [
+		"musare"
+	],
 	"messages": {
 		"accountRemoval": "Your account will be deactivated instantly and your data will shortly be deleted by an admin."
 	},
@@ -103,6 +104,7 @@
 		"news": true,
 		"GitHub": "https://github.com/Musare/Musare"
 	},
+	"primaryColor": "#03a9f4",
 	"shortcutOverrides": {},
 	"registrationDisabled": false,
 	"sendDataRequestEmails": true,
@@ -122,7 +124,9 @@
 		"version": true
 	},
 	"defaultLogging": {
-		"hideType": ["INFO"],
+		"hideType": [
+			"INFO"
+		],
 		"blacklistedTerms": []
 	},
 	"customLoggingPerModule": {
@@ -151,4 +155,4 @@
 		"soundcloud": false,
 		"spotify": false
 	}
-}
+}

File diff suppressed because it is too large
+ 469 - 488
backend/logic/actions/playlists.js


+ 9 - 11
backend/logic/actions/stations.js

@@ -195,18 +195,18 @@ CacheModule.runJob("SUB", {
 });
 
 CacheModule.runJob("SUB", {
-	channel: "station.repositionSongInQueue",
+	channel: "station.changeQueueOrder",
 	cb: res => {
 		WSModule.runJob("EMIT_TO_ROOM", {
 			room: `station.${res.stationId}`,
-			args: ["event:station.queue.song.repositioned", { data: { song: res.song } }]
+			args: ["event:station.queue.order.changed", { data: { queueOrder: res.queueOrder } }]
 		});
 
 		WSModule.runJob("EMIT_TO_ROOM", {
 			room: `manage-station.${res.stationId}`,
 			args: [
-				"event:manageStation.queue.song.repositioned",
-				{ data: { stationId: res.stationId, song: res.song } }
+				"event:manageStation.queue.order.changed",
+				{ data: { stationId: res.stationId, queueOrder: res.queueOrder } }
 			]
 		});
 	}
@@ -2132,7 +2132,7 @@ export default {
 						.catch(next);
 				}
 			],
-			async err => {
+			async (err, station) => {
 				if (err) {
 					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
 					this.log(
@@ -2149,14 +2149,12 @@ export default {
 					`Repositioned song ${song.mediaSource} in queue of station "${stationId}" successfully.`
 				);
 
+				const queueOrder = station.queue.map(song => song.mediaSource);
+
 				CacheModule.runJob("PUB", {
-					channel: "station.repositionSongInQueue",
+					channel: "station.changeQueueOrder",
 					value: {
-						song: {
-							mediaSource: song.mediaSource,
-							oldIndex: song.oldIndex,
-							newIndex: song.newIndex
-						},
+						queueOrder,
 						stationId
 					}
 				});

+ 43 - 45
backend/logic/actions/users.js

@@ -1347,58 +1347,56 @@ export default {
 	 * @param {Array} favoriteStations - array of station ids (with a specific order)
 	 * @param {Function} cb - gets called with the result
 	 */
-	updateOrderOfFavoriteStations: isLoginRequired(async function updateOrderOfFavoriteStations(
-		session,
-		favoriteStations,
-		cb
-	) {
-		const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
+	updateOrderOfFavoriteStations: isLoginRequired(
+		async function updateOrderOfFavoriteStations(session, favoriteStations, cb) {
+			const userModel = await DBModule.runJob("GET_MODEL", { modelName: "user" }, this);
 
-		async.waterfall(
-			[
-				next => {
-					userModel.updateOne(
-						{ _id: session.userId },
-						{ $set: { favoriteStations } },
-						{ runValidators: true },
-						next
-					);
-				}
-			],
-			async err => {
-				if (err) {
-					err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+			async.waterfall(
+				[
+					next => {
+						userModel.updateOne(
+							{ _id: session.userId },
+							{ $set: { favoriteStations } },
+							{ runValidators: true },
+							next
+						);
+					}
+				],
+				async err => {
+					if (err) {
+						err = await UtilsModule.runJob("GET_ERROR", { error: err }, this);
+
+						this.log(
+							"ERROR",
+							"UPDATE_ORDER_OF_USER_FAVORITE_STATIONS",
+							`Couldn't update order of favorite stations for user "${session.userId}" to "${favoriteStations}". "${err}"`
+						);
+
+						return cb({ status: "error", message: err });
+					}
+
+					CacheModule.runJob("PUB", {
+						channel: "user.updateOrderOfFavoriteStations",
+						value: {
+							favoriteStations,
+							userId: session.userId
+						}
+					});
 
 					this.log(
-						"ERROR",
+						"SUCCESS",
 						"UPDATE_ORDER_OF_USER_FAVORITE_STATIONS",
-						`Couldn't update order of favorite stations for user "${session.userId}" to "${favoriteStations}". "${err}"`
+						`Updated order of favorite stations for user "${session.userId}" to "${favoriteStations}".`
 					);
 
-					return cb({ status: "error", message: err });
+					return cb({
+						status: "success",
+						message: "Order of favorite stations successfully updated"
+					});
 				}
-
-				CacheModule.runJob("PUB", {
-					channel: "user.updateOrderOfFavoriteStations",
-					value: {
-						favoriteStations,
-						userId: session.userId
-					}
-				});
-
-				this.log(
-					"SUCCESS",
-					"UPDATE_ORDER_OF_USER_FAVORITE_STATIONS",
-					`Updated order of favorite stations for user "${session.userId}" to "${favoriteStations}".`
-				);
-
-				return cb({
-					status: "success",
-					message: "Order of favorite stations successfully updated"
-				});
-			}
-		);
-	}),
+			);
+		}
+	),
 
 	/**
 	 * Updates the order of a user's playlists

+ 10 - 10
backend/logic/cache/index.js

@@ -113,7 +113,7 @@ class _CacheModule extends CoreClass {
 	 * @param {string} payload.key -  name of the key to set
 	 * @param {*} payload.value - the value we want to set
 	 * @param {number} payload.ttl -  ttl of the key in seconds
-	 * @param {boolean} [payload.stringifyJson=true] - stringify 'value' if it's an Object or Array
+	 * @param {boolean} [payload.stringifyJson] - stringify 'value' if it's an Object or Array
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	SET(payload) {
@@ -154,7 +154,7 @@ class _CacheModule extends CoreClass {
 	 * @param {string} payload.table - name of the table we want to set a key of (table === redis hash)
 	 * @param {string} payload.key -  name of the key to set
 	 * @param {*} payload.value - the value we want to set
-	 * @param {boolean} [payload.stringifyJson=true] - stringify 'value' if it's an Object or Array
+	 * @param {boolean} [payload.stringifyJson] - stringify 'value' if it's an Object or Array
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	HSET(payload) {
@@ -177,7 +177,7 @@ class _CacheModule extends CoreClass {
 	 * Gets a single value
 	 * @param {object} payload - object containing payload
 	 * @param {string} payload.key - name of the key to fetch
-	 * @param {boolean} [payload.parseJson=true] - attempt to parse returned data as JSON
+	 * @param {boolean} [payload.parseJson] - attempt to parse returned data as JSON
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	GET(payload) {
@@ -213,7 +213,7 @@ class _CacheModule extends CoreClass {
 	 * @param {object} payload - object containing payload
 	 * @param {string} payload.table - name of the table to get the value from (table === redis hash)
 	 * @param {string} payload.key - name of the key to fetch
-	 * @param {boolean} [payload.parseJson=true] - attempt to parse returned data as JSON
+	 * @param {boolean} [payload.parseJson] - attempt to parse returned data as JSON
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	HGET(payload) {
@@ -279,7 +279,7 @@ class _CacheModule extends CoreClass {
 	 * Returns all the keys for a table
 	 * @param {object} payload - object containing payload
 	 * @param {string} payload.table - name of the table to get the values from (table === redis hash)
-	 * @param {boolean} [payload.parseJson=true] - attempts to parse all values as JSON by default
+	 * @param {boolean} [payload.parseJson] - attempts to parse all values as JSON by default
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	HGETALL(payload) {
@@ -332,7 +332,7 @@ class _CacheModule extends CoreClass {
 	 * @param {object} payload - object containing payload
 	 * @param {string} payload.channel - the name of the channel we want to publish a message to
 	 * @param {*} payload.value - the value we want to send
-	 * @param {boolean} [payload.stringifyJson=true] - stringify 'value' if it's an Object or Array
+	 * @param {boolean} [payload.stringifyJson] - stringify 'value' if it's an Object or Array
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	PUB(payload) {
@@ -361,7 +361,7 @@ class _CacheModule extends CoreClass {
 	 * Subscribe to a channel, caches the redis client connection
 	 * @param {object} payload - object containing payload
 	 * @param {string} payload.channel - name of the channel to subscribe to
-	 * @param {boolean} [payload.parseJson=true] - parse the message as JSON
+	 * @param {boolean} [payload.parseJson] - parse the message as JSON
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	SUB(payload) {
@@ -426,7 +426,7 @@ class _CacheModule extends CoreClass {
 	 * @param {object} payload - object containing payload
 	 * @param {string} payload.key -  name of the list
 	 * @param {*} payload.value - the value we want to set
-	 * @param {boolean} [payload.stringifyJson=true] - stringify 'value' if it's an Object or Array
+	 * @param {boolean} [payload.stringifyJson] - stringify 'value' if it's an Object or Array
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	RPUSH(payload) {
@@ -449,7 +449,7 @@ class _CacheModule extends CoreClass {
 	 * @param {object} payload - object containing payload
 	 * @param {string} payload.key -  name of the list
 	 * @param {*} payload.value - the value we want to set
-	 * @param {boolean} [payload.stringifyJson=true] - stringify 'value' if it's an Object or Array
+	 * @param {boolean} [payload.stringifyJson] - stringify 'value' if it's an Object or Array
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	LPUSH(payload) {
@@ -506,7 +506,7 @@ class _CacheModule extends CoreClass {
 	 * @param {object} payload - object containing payload
 	 * @param {string} payload.key -  name of the list
 	 * @param {*} payload.value - the value we want to remove
-	 * @param {boolean} [payload.stringifyJson=true] - stringify 'value' if it's an Object or Array
+	 * @param {boolean} [payload.stringifyJson] - stringify 'value' if it's an Object or Array
 	 * @returns {Promise} - returns a promise (resolve, reject)
 	 */
 	LREM(payload) {

+ 1 - 0
backend/logic/db/schemas/playlist.js

@@ -26,5 +26,6 @@ export default {
 			replacedAt: { type: Date, required: true }
 		}
 	],
+	featured: { type: Boolean, default: false },
 	documentVersion: { type: Number, default: 7, required: true }
 };

+ 5 - 4
backend/logic/hooks/hasPermission.js

@@ -43,6 +43,7 @@ permissions.moderator = {
 	"playlists.create.admin": true,
 	"playlists.get": true,
 	"playlists.update.displayName": true,
+	"playlists.update.featured": true,
 	"playlists.update.privacy": true,
 	"playlists.songs.add": true,
 	"playlists.songs.remove": true,
@@ -71,7 +72,7 @@ permissions.moderator = {
 				"admin.view.soundcloudTracks": true,
 				"admin.view.soundcloud": true,
 				"soundcloud.getArtist": true
-		  }
+			}
 		: {}),
 	...(config.get("experimental.spotify")
 		? {
@@ -84,7 +85,7 @@ permissions.moderator = {
 				"spotify.getAlternativeMediaSourcesForTracks": true,
 				"admin.view.youtubeChannels": true,
 				"youtube.getChannel": true
-		  }
+			}
 		: {})
 };
 permissions.admin = {
@@ -122,12 +123,12 @@ permissions.admin = {
 		? {
 				"soundcloud.fetchNewApiKey": true,
 				"soundcloud.testApiKey": true
-		  }
+			}
 		: {}),
 	...(config.get("experimental.spotify")
 		? {
 				"youtube.getMissingChannels": true
-		  }
+			}
 		: {})
 };
 

+ 2 - 2
backend/logic/mail/schemas/dataRequest.js

@@ -22,8 +22,8 @@ export default (to, userId, type, cb) => {
 				<br>
 				This request can be viewed and resolved in the
 				<a href="${config.get("url.secure") ? "https" : "http"}://${config.get(
-			"url.host"
-		)}/admin/users">Users tab of the admin page</a>. Note: All admins will be sent the same message.
+					"url.host"
+				)}/admin/users">Users tab of the admin page</a>. Note: All admins will be sent the same message.
 			`
 	};
 

+ 11 - 1
backend/logic/playlists.js

@@ -42,7 +42,17 @@ class _PlaylistsModule extends CoreClass {
 			cb: async data => {
 				PlaylistsModule.playlistModel.findOne(
 					{ _id: data.playlistId },
-					["_id", "displayName", "type", "privacy", "songs", "createdBy", "createdAt", "createdFor"],
+					[
+						"_id",
+						"displayName",
+						"type",
+						"privacy",
+						"songs",
+						"createdBy",
+						"createdAt",
+						"createdFor",
+						"featured"
+					],
 					(err, playlist) => {
 						const newPlaylist = {
 							...playlist._doc,

+ 7 - 1
backend/logic/ws.js

@@ -19,7 +19,7 @@ let PunishmentsModule;
 class _WSModule extends CoreClass {
 	// eslint-disable-next-line require-jsdoc
 	constructor() {
-		super("ws");
+		super("ws", { concurrency: 2 });
 
 		WSModule = this;
 	}
@@ -579,6 +579,7 @@ class _WSModule extends CoreClass {
 					messages: config.get("messages"),
 					christmas: config.get("christmas"),
 					footerLinks: config.get("footerLinks"),
+					primaryColor: config.get("primaryColor"),
 					shortcutOverrides: config.get("shortcutOverrides"),
 					registrationDisabled: config.get("registrationDisabled"),
 					mailEnabled: config.get("mail.enabled"),
@@ -628,6 +629,11 @@ class _WSModule extends CoreClass {
 				if (data.length === 0) return socket.dispatch("ERROR", "Not enough arguments specified.");
 				if (typeof data[0] !== "string") return socket.dispatch("ERROR", "First argument must be a string.");
 
+				if (data[0] === "ping" && data.length === 2) {
+					const [, CB_REF] = data;
+					return socket.dispatch("CB_REF", CB_REF.CB_REF, Date.now());
+				}
+
 				const namespaceAction = data[0];
 				if (
 					!namespaceAction ||

+ 108 - 6
backend/logic/youtube.js

@@ -7,6 +7,9 @@ import axios from "axios";
 
 import CoreClass from "../core";
 
+const YOUTUBE_OFFICIAL_CHANNEL_ID = "UCBR8-60-B28hp2BmDPdntcQ";
+const YOUTUBE_MIX_PLAYLIST_TITLE_PREFIX = "Mix - ";
+
 class RateLimitter {
 	/**
 	 * Constructor
@@ -615,28 +618,67 @@ class _YouTubeModule extends CoreClass {
 				return;
 			}
 			const playlistId = splitQuery[1];
-			const maxPages = Number.parseInt(config.get("apis.youtube.maxPlaylistPages"));
 
 			let currentPage = 0;
 
 			async.waterfall(
 				[
 					next => {
+						YouTubeModule.runJob(
+							"GET_PLAYLIST_INFO",
+							{
+								playlistId
+							},
+							this
+						)
+							.then(playlistInfo => {
+								next(null, playlistInfo);
+							})
+							.catch(err => {
+								next(err);
+							});
+					},
+
+					(playlistInfo, next) => {
+						if (playlistInfo.privacyStatus === "private") return next(new Error("Playlist is private."));
+
+						const maxPages = playlistInfo.isMix
+							? 4
+							: Number.parseInt(config.get("apis.youtube.maxPlaylistPages"));
+
+						return next(null, maxPages, playlistInfo.isMix);
+					},
+
+					(maxPages, isMix, next) => {
 						let songs = [];
 						let nextPageToken = "";
 
 						async.whilst(
 							next => {
+								if (nextPageToken === undefined) return next(null, false);
+
+								if (currentPage >= maxPages) {
+									YouTubeModule.log(
+										isMix ? "INFO" : "ERROR",
+										`Playlist ${playlistId}${
+											isMix ? " (mix)" : ""
+										} for job (${this.toString()}) has reached the max page limit.`
+									);
+									return next(null, false);
+								}
+
+								return next(null, true);
+							},
+							next => {
+								currentPage += 1;
+
 								YouTubeModule.log(
 									"INFO",
 									`Getting playlist progress for job (${this.toString()}): ${
 										songs.length
-									} songs gotten so far. Is there a next page: ${nextPageToken !== undefined}.`
+									} songs gotten so far. Current page: ${currentPage}`
 								);
-								next(null, nextPageToken !== undefined && currentPage < maxPages);
-							},
-							next => {
-								currentPage += 1;
+
 								// Add 250ms delay between each job request
 								setTimeout(() => {
 									YouTubeModule.runJob("GET_PLAYLIST_PAGE", { playlistId, nextPageToken }, this)
@@ -677,6 +719,43 @@ class _YouTubeModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Returns playlist info
+	 * @param {object} payload - object that contains the payload
+	 * @param {string} payload.playlistId - the playlist id
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async GET_PLAYLIST_INFO(payload) {
+		const { playlistId } = payload;
+		const part = ["id", "snippet", "status", "localizations"].join(",");
+		const params = {
+			part,
+			id: playlistId
+		};
+
+		const { response } = await YouTubeModule.runJob("API_GET_PLAYLIST", { params }, this);
+		const [playlistInfo] = response.data.items;
+
+		const channelId = playlistInfo?.snippet?.channelId;
+		const title = playlistInfo?.snippet?.title;
+		const enTitle = playlistInfo?.localizations?.en?.title;
+		const privacyStatus = playlistInfo?.status?.privacyStatus;
+
+		// Another way to possibly check for mix is if the first two letters of the playlist ID starts with RD
+		const isMix =
+			channelId === YOUTUBE_OFFICIAL_CHANNEL_ID &&
+			(title?.startsWith(YOUTUBE_MIX_PLAYLIST_TITLE_PREFIX) ||
+				enTitle?.startsWith(YOUTUBE_MIX_PLAYLIST_TITLE_PREFIX));
+
+		return {
+			channelId,
+			title,
+			enTitle,
+			privacyStatus,
+			isMix
+		};
+	}
+
 	/**
 	 * Returns a a page from a YouTube playlist. Is used internally by GET_PLAYLIST and GET_CHANNEL_VIDEOS.
 	 * @param {object} payload - object that contains the payload
@@ -929,6 +1008,29 @@ class _YouTubeModule extends CoreClass {
 		});
 	}
 
+	/**
+	 * Perform YouTube API get playlist items request
+	 * @param {object} payload - object that contains the payload
+	 * @param {object} payload.params - request parameters
+	 * @returns {Promise} - returns promise (reject, resolve)
+	 */
+	async API_GET_PLAYLIST(payload) {
+		const { params } = payload;
+
+		return YouTubeModule.runJob(
+			"API_CALL",
+			{
+				url: "https://www.googleapis.com/youtube/v3/playlists",
+				params: {
+					key: config.get("apis.youtube.key"),
+					...params
+				},
+				quotaCost: 1
+			},
+			this
+		);
+	}
+
 	/**
 	 * Perform YouTube API get playlist items request
 	 * @param {object} payload - object that contains the payload

+ 1 - 1
backend/package-lock.json

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

+ 1 - 1
backend/package.json

@@ -65,4 +65,4 @@
 		"tsconfig-paths": "^4.2.0",
 		"typescript": "^5.0.4"
 	}
-}
+}

+ 3 - 0
docker-compose.yml

@@ -15,6 +15,7 @@ services:
       - BACKEND_MODE=${BACKEND_MODE:-production}
       - CONTAINER_MODE=${CONTAINER_MODE:-production}
       - MUSARE_SITENAME=${MUSARE_SITENAME:-Musare}
+      - MUSARE_PRIMARY_COLOR=${MUSARE_PRIMARY_COLOR:-#03a9f4}
       - MUSARE_DEBUG_VERSION=${MUSARE_DEBUG_VERSION:-true}
       - MUSARE_DEBUG_GIT_REMOTE=${MUSARE_DEBUG_GIT_REMOTE:-false}
       - MUSARE_DEBUG_GIT_REMOTE_URL=${MUSARE_DEBUG_GIT_REMOTE_URL:-false}
@@ -44,6 +45,7 @@ services:
         FRONTEND_MODE: "${FRONTEND_MODE:-production}"
         FRONTEND_PROD_DEVTOOLS: "${FRONTEND_PROD_DEVTOOLS:-false}"
         MUSARE_SITENAME: "${MUSARE_SITENAME:-Musare}"
+        MUSARE_PRIMARY_COLOR: "${MUSARE_PRIMARY_COLOR:-#03a9f4}"
         MUSARE_DEBUG_VERSION: "${MUSARE_DEBUG_VERSION:-true}"
         MUSARE_DEBUG_GIT_REMOTE: "${MUSARE_DEBUG_GIT_REMOTE:-false}"
         MUSARE_DEBUG_GIT_REMOTE_URL: "${MUSARE_DEBUG_GIT_REMOTE_URL:-false}"
@@ -62,6 +64,7 @@ services:
       - FRONTEND_DEV_PORT=${FRONTEND_DEV_PORT:-81}
       - FRONTEND_PROD_DEVTOOLS=${FRONTEND_PROD_DEVTOOLS:-false}
       - MUSARE_SITENAME=${MUSARE_SITENAME:-Musare}
+      - MUSARE_PRIMARY_COLOR=${MUSARE_PRIMARY_COLOR:-#03a9f4}
       - MUSARE_DEBUG_VERSION=${MUSARE_DEBUG_VERSION:-true}
       - MUSARE_DEBUG_GIT_REMOTE=${MUSARE_DEBUG_GIT_REMOTE:-false}
       - MUSARE_DEBUG_GIT_REMOTE_URL=${MUSARE_DEBUG_GIT_REMOTE_URL:-false}

+ 2 - 0
frontend/Dockerfile

@@ -12,6 +12,7 @@ FROM node:18 AS musare_frontend
 ARG FRONTEND_MODE=production
 ARG FRONTEND_PROD_DEVTOOLS=false
 ARG MUSARE_SITENAME=Musare
+ARG MUSARE_PRIMARY_COLOR="#03a9f4"
 ARG MUSARE_DEBUG_VERSION=true
 ARG MUSARE_DEBUG_GIT_REMOTE=false
 ARG MUSARE_DEBUG_GIT_REMOTE_URL=false
@@ -22,6 +23,7 @@ ARG MUSARE_DEBUG_GIT_LATEST_COMMIT_SHORT=true
 ENV FRONTEND_MODE=${FRONTEND_MODE} \
     FRONTEND_PROD_DEVTOOLS=${FRONTEND_PROD_DEVTOOLS} \
     MUSARE_SITENAME=${MUSARE_SITENAME} \
+    MUSARE_PRIMARY_COLOR=${MUSARE_PRIMARY_COLOR} \
     MUSARE_DEBUG_VERSION=${MUSARE_DEBUG_VERSION} \
     MUSARE_DEBUG_GIT_REMOTE=${MUSARE_DEBUG_GIT_REMOTE} \
     MUSARE_DEBUG_GIT_REMOTE_URL=${MUSARE_DEBUG_GIT_REMOTE_URL} \

File diff suppressed because it is too large
+ 477 - 275
frontend/package-lock.json


+ 33 - 34
frontend/package.json

@@ -20,48 +20,47 @@
     "coverage": "vitest run --coverage"
   },
   "devDependencies": {
-    "@pinia/testing": "^0.1.0",
-    "@types/can-autoplay": "^3.0.1",
-    "@types/dompurify": "^3.0.2",
-    "@types/marked": "^4.3.0",
-    "@typescript-eslint/eslint-plugin": "^5.59.5",
-    "@typescript-eslint/parser": "^5.59.5",
-    "@vitest/coverage-c8": "^0.31.0",
-    "@vue/test-utils": "^2.3.2",
-    "eslint": "^8.40.0",
-    "eslint-config-prettier": "^8.8.0",
-    "eslint-plugin-import": "^2.27.5",
-    "eslint-plugin-prettier": "^4.2.1",
-    "eslint-plugin-vue": "^9.12.0",
-    "jsdom": "^22.0.0",
-    "less": "^4.1.3",
-    "prettier": "^2.8.8",
-    "vite-plugin-dynamic-import": "^1.3.4",
-    "vitest": "^0.31.0",
-    "vue-eslint-parser": "^9.2.1",
-    "vue-tsc": "^1.6.5"
+    "@pinia/testing": "^0.1.3",
+    "@types/can-autoplay": "^3.0.4",
+    "@types/dompurify": "^3.0.5",
+    "@typescript-eslint/eslint-plugin": "^7.0.2",
+    "@typescript-eslint/parser": "^7.0.2",
+    "@vitest/coverage-v8": "^1.3.1",
+    "@vue/test-utils": "^2.4.4",
+    "eslint": "^8.56.0",
+    "eslint-config-prettier": "^9.1.0",
+    "eslint-plugin-import": "^2.29.1",
+    "eslint-plugin-prettier": "^5.1.3",
+    "eslint-plugin-vue": "^9.22.0",
+    "jsdom": "^24.0.0",
+    "less": "^4.2.0",
+    "prettier": "^3.2.5",
+    "vite-plugin-dynamic-import": "^1.5.0",
+    "vitest": "^1.3.1",
+    "vue-eslint-parser": "^9.4.2",
+    "vue-tsc": "^1.8.27"
   },
   "dependencies": {
-    "@intlify/unplugin-vue-i18n": "^0.10.0",
-    "@vitejs/plugin-vue": "^4.2.3",
+    "@intlify/unplugin-vue-i18n": "^2.0.0",
+    "@vitejs/plugin-vue": "^5.0.4",
     "can-autoplay": "^3.0.2",
-    "chart.js": "^4.3.0",
-    "date-fns": "^2.30.0",
-    "dompurify": "^3.0.3",
+    "chart.js": "^4.4.1",
+    "date-fns": "^3.3.1",
+    "dompurify": "^3.0.9",
     "eslint-config-airbnb-base": "^15.0.0",
-    "marked": "^5.0.2",
+    "marked": "^12.0.0",
     "normalize.css": "^8.0.1",
-    "pinia": "^2.0.36",
+    "pinia": "^2.1.7",
     "toasters": "^2.3.1",
-    "typescript": "^5.0.4",
-    "vite": "^4.3.5",
+    "typescript": "^5.3.3",
+    "vite": "^5.1.4",
     "vue": "^3.3.2",
-    "vue-chartjs": "^5.2.0",
+    "vue-chartjs": "^5.3.0",
     "vue-content-loader": "^2.0.1",
     "vue-draggable-list": "^0.1.3",
-    "vue-i18n": "^9.2.2",
-    "vue-json-pretty": "^2.2.4",
-    "vue-router": "^4.2.0",
-    "vue-tippy": "^6.1.2"
+    "vue-i18n": "^9.9.1",
+    "vue-json-pretty": "^2.3.0",
+    "vue-router": "^4.3.0",
+    "vue-tippy": "^6.4.1"
   }
 }

+ 22 - 7
frontend/src/App.vue

@@ -104,6 +104,9 @@ watch(christmas, enabled => {
 });
 
 onMounted(async () => {
+	document.getElementsByTagName("html")[0].style.cssText =
+		`--primary-color: ${configStore.primaryColor}`;
+
 	window
 		.matchMedia("(prefers-color-scheme: dark)")
 		.addEventListener("change", e => {
@@ -121,6 +124,9 @@ onMounted(async () => {
 	socket.onConnect(() => {
 		socketConnected.value = true;
 
+		document.getElementsByTagName("html")[0].style.cssText =
+			`--primary-color: ${configStore.primaryColor}`;
+
 		if (!loggedIn.value) {
 			broadcastChannel.value.user_login = new BroadcastChannel(
 				`${configStore.cookie}.user_login`
@@ -366,7 +372,8 @@ onMounted(async () => {
 	font-style: normal;
 	font-weight: 200;
 	src: url("/fonts/nunito-v16-latin-200.eot"); /* IE9 Compat Modes */
-	src: local(""),
+	src:
+		local(""),
 		url("/fonts/nunito-v16-latin-200.eot?#iefix")
 			format("embedded-opentype"),
 		/* IE6-IE8 */ url("/fonts/nunito-v16-latin-200.woff2") format("woff2"),
@@ -384,7 +391,8 @@ onMounted(async () => {
 	font-style: normal;
 	font-weight: 400;
 	src: url("/fonts/nunito-v16-latin-regular.eot"); /* IE9 Compat Modes */
-	src: local(""),
+	src:
+		local(""),
 		url("/fonts/nunito-v16-latin-regular.eot?#iefix")
 			format("embedded-opentype"),
 		/* IE6-IE8 */ url("/fonts/nunito-v16-latin-regular.woff2")
@@ -403,7 +411,8 @@ onMounted(async () => {
 	font-style: normal;
 	font-weight: 600;
 	src: url("/fonts/nunito-v16-latin-600.eot"); /* IE9 Compat Modes */
-	src: local(""),
+	src:
+		local(""),
 		url("/fonts/nunito-v16-latin-600.eot?#iefix")
 			format("embedded-opentype"),
 		/* IE6-IE8 */ url("/fonts/nunito-v16-latin-600.woff2") format("woff2"),
@@ -421,7 +430,8 @@ onMounted(async () => {
 	font-style: normal;
 	font-weight: 700;
 	src: url("/fonts/nunito-v16-latin-700.eot"); /* IE9 Compat Modes */
-	src: local(""),
+	src:
+		local(""),
 		url("/fonts/nunito-v16-latin-700.eot?#iefix")
 			format("embedded-opentype"),
 		/* IE6-IE8 */ url("/fonts/nunito-v16-latin-700.woff2") format("woff2"),
@@ -439,7 +449,8 @@ onMounted(async () => {
 	font-style: normal;
 	font-weight: 800;
 	src: url("/fonts/nunito-v16-latin-800.eot"); /* IE9 Compat Modes */
-	src: local(""),
+	src:
+		local(""),
 		url("/fonts/nunito-v16-latin-800.eot?#iefix")
 			format("embedded-opentype"),
 		/* IE6-IE8 */ url("/fonts/nunito-v16-latin-800.woff2") format("woff2"),
@@ -457,7 +468,8 @@ onMounted(async () => {
 	font-style: normal;
 	font-weight: 400;
 	src: url("/fonts/pacifico-v17-latin-regular.eot"); /* IE9 Compat Modes */
-	src: local(""),
+	src:
+		local(""),
 		url("/fonts/pacifico-v17-latin-regular.eot?#iefix")
 			format("embedded-opentype"),
 		/* IE6-IE8 */ url("/fonts/pacifico-v17-latin-regular.woff2")
@@ -475,7 +487,9 @@ onMounted(async () => {
 	font-style: normal;
 	font-weight: 400;
 	src: url(/fonts/MaterialIcons-Regular.ttf); /* For IE6-8 */
-	src: local("Material Icons"), local("MaterialIcons-Regular"),
+	src:
+		local("Material Icons"),
+		local("MaterialIcons-Regular"),
 		url(/fonts/MaterialIcons-Regular.ttf) format("truetype");
 }
 
@@ -2026,6 +2040,7 @@ h4.section-title {
 		border-radius: 34px;
 
 		&.disabled {
+			filter: grayscale(1);
 			cursor: not-allowed;
 		}
 	}

+ 4 - 4
frontend/src/components/AdvancedTable.vue

@@ -64,7 +64,7 @@ const props = defineProps({
 	model: { type: String, required: true },
 	maxWidth: { type: Number, default: 1880 },
 	query: { type: Boolean, default: true },
-	keyboardShortcuts: { type: Boolean, default: true },
+	hasKeyboardShortcuts: { type: Boolean, default: true },
 	bulkActions: {
 		type: Object as PropType<TableBulkActions>,
 		default: () => ({})
@@ -984,7 +984,7 @@ onMounted(async () => {
 		);
 	});
 
-	if (props.keyboardShortcuts) {
+	if (props.hasKeyboardShortcuts) {
 		// Navigation section
 
 		// Page navigation section
@@ -1142,7 +1142,7 @@ onUnmounted(() => {
 	if (columnOrderChangedDebounceTimeout.value)
 		clearTimeout(columnOrderChangedDebounceTimeout.value);
 
-	if (props.keyboardShortcuts) {
+	if (props.hasKeyboardShortcuts) {
 		const shortcutNames = [
 			// Navigation
 			"advancedTable.previousPage",
@@ -1762,7 +1762,7 @@ watch(selectedRows, (newSelectedRows, oldSelectedRows) => {
 															] !== undefined
 																? previous[
 																		current
-																  ]
+																	]
 																: null,
 														item
 													) !== null

+ 1 - 1
frontend/src/components/FloatingBox.vue

@@ -43,7 +43,7 @@ const saveBox = () => {
 				? Math.max(
 						document.body.clientHeight - 10 - dragBox.value.height,
 						0
-				  )
+					)
 				: 10,
 		left: 10
 	});

+ 3 - 3
frontend/src/components/LongJobs.spec.ts

@@ -36,11 +36,11 @@ describe("LongJobs component", async () => {
 											message: "Successfully edited tags."
 										}
 									}
-							  }
+								}
 							: {
 									status: "error",
 									message: "Long job not found."
-							  },
+								},
 					"users.removeLongJob": () => ({
 						status: "success"
 					})
@@ -61,7 +61,7 @@ describe("LongJobs component", async () => {
 										status: "update",
 										message: "Updating tags in MongoDB."
 									}
-							  ]
+								]
 							: []
 				},
 				on: {

+ 1 - 1
frontend/src/components/MainFooter.vue

@@ -25,7 +25,7 @@ const getLink = title =>
 		<div class="container">
 			<div class="footer-content">
 				<div id="footer-copyright">
-					<p>© Copyright Musare 2015 - 2023</p>
+					<p>© Copyright Musare 2015 - 2024</p>
 				</div>
 				<router-link id="footer-logo" to="/">
 					<img

+ 9 - 5
frontend/src/components/PlaylistTabBase.vue

@@ -317,10 +317,14 @@ onMounted(() => {
 			orderOfPlaylists.value = calculatePlaylistOrder(); // order in regards to the database
 		});
 
-		socket.dispatch("playlists.indexFeaturedPlaylists", res => {
-			if (res.status === "success")
-				featuredPlaylists.value = res.data.playlists;
-		});
+		socket.dispatch(
+			"playlists.indexFeaturedPlaylists",
+			station.value.type === "community",
+			res => {
+				if (res.status === "success")
+					featuredPlaylists.value = res.data.playlists;
+			}
+		);
 
 		if (props.type === "autofill")
 			socket.dispatch(
@@ -1038,7 +1042,7 @@ onMounted(() => {
 														'future',
 														null,
 														true
-												  )} songs from this playlist`
+													)} songs from this playlist`
 												: 'Your preferences are set to skip disliked songs'
 										"
 										v-tippy

+ 7 - 11
frontend/src/components/Queue.vue

@@ -85,15 +85,15 @@ const removeFromQueue = mediaSource => {
 	);
 };
 
-const repositionSongInQueue = ({ moved }) => {
+const repositionSongInQueue = ({ moved, song }) => {
 	const { oldIndex, newIndex } = moved;
 	if (oldIndex === newIndex) return; // we only need to update when song is moved
-	const song = queue.value[newIndex];
+	const _song = song ?? queue.value[newIndex];
 	socket.dispatch(
 		"stations.repositionSongInQueue",
 		station.value._id,
 		{
-			...song,
+			..._song,
 			oldIndex,
 			newIndex
 		},
@@ -111,27 +111,23 @@ const repositionSongInQueue = ({ moved }) => {
 
 const moveSongToTop = index => {
 	songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
-	queue.value.splice(0, 0, queue.value.splice(index, 1)[0]);
 	repositionSongInQueue({
 		moved: {
 			oldIndex: index,
 			newIndex: 0
-		}
+		},
+		song: queue.value[index]
 	});
 };
 
 const moveSongToBottom = index => {
 	songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
-	queue.value.splice(
-		queue.value.length - 1,
-		0,
-		queue.value.splice(index, 1)[0]
-	);
 	repositionSongInQueue({
 		moved: {
 			oldIndex: index,
 			newIndex: queue.value.length - 1
-		}
+		},
+		song: queue.value[index]
 	});
 };
 

+ 4 - 3
frontend/src/components/SoundcloudPlayer.vue

@@ -2,6 +2,7 @@
 import { computed, onBeforeUnmount, onMounted, ref } from "vue";
 import Toast from "toasters";
 import { useSoundcloudPlayer } from "@/composables/useSoundcloudPlayer";
+import { useConfigStore } from "@/stores/config";
 import { useStationStore } from "@/stores/station";
 
 import aw from "@/aw";
@@ -33,6 +34,7 @@ const {
 	soundcloudUnload
 } = useSoundcloudPlayer();
 
+const configStore = useConfigStore();
 const stationStore = useStationStore();
 const { updateMediaModalPlayingAudio } = stationStore;
 
@@ -169,7 +171,7 @@ const drawCanvas = () => {
 
 	const widthCurrentTime = (currentTime / videoDuration) * width;
 
-	const durationColor = "#03A9F4";
+	const durationColor = configStore.primaryColor;
 	const afterDurationColor = "#41E841";
 	const currentDurationColor = "#3b25e8";
 
@@ -425,8 +427,7 @@ onBeforeUnmount(() => {
 
 	.player-container {
 		position: relative;
-		padding-bottom: 56.25%; /* proportion value to aspect ratio 16:9 (9 / 16 = 0.5625 or 56.25%) */
-		height: 0;
+		aspect-ratio: 16/9;
 		overflow: hidden;
 
 		:deep(iframe) {

+ 1 - 1
frontend/src/components/SoundcloudTrackInfo.vue

@@ -64,7 +64,7 @@ defineProps<{
 						track.soundcloudCreatedAt
 							? utils.getDateFormatted(
 									new Date(track.soundcloudCreatedAt)
-							  )
+								)
 							: "Unknown"
 					}}</span
 				>

+ 6 - 5
frontend/src/components/YoutubePlayer.vue

@@ -2,6 +2,7 @@
 import { computed, onBeforeUnmount, onMounted, ref } from "vue";
 import Toast from "toasters";
 import { useYoutubePlayer } from "@/composables/useYoutubePlayer";
+import { useConfigStore } from "@/stores/config";
 import { useStationStore } from "@/stores/station";
 
 import aw from "@/aw";
@@ -25,6 +26,7 @@ const {
 	setPlaybackRate: youtubeSetPlaybackRate
 } = useYoutubePlayer();
 
+const configStore = useConfigStore();
 const { updateMediaModalPlayingAudio } = useStationStore();
 
 const interval = ref(null);
@@ -132,7 +134,7 @@ const drawCanvas = () => {
 
 	const widthCurrentTime = (currentTime / videoDuration) * width;
 
-	const durationColor = "#03A9F4";
+	const durationColor = configStore.primaryColor;
 	const afterDurationColor = "#41E841";
 	const currentDurationColor = "#3b25e8";
 
@@ -534,8 +536,8 @@ onBeforeUnmount(() => {
 							youtubePlayer.muted
 								? "volume_mute"
 								: youtubePlayer.volume >= 50
-								? "volume_up"
-								: "volume_down"
+									? "volume_up"
+									: "volume_down"
 						}}</i
 					>
 					<input
@@ -575,8 +577,7 @@ onBeforeUnmount(() => {
 
 	.player-container {
 		position: relative;
-		padding-bottom: 56.25%; /* proportion value to aspect ratio 16:9 (9 / 16 = 0.5625 or 56.25%) */
-		height: 0;
+		aspect-ratio: 16/9;
 		overflow: hidden;
 
 		:deep(iframe) {

+ 8 - 8
frontend/src/components/__snapshots__/Modal.spec.ts.snap

@@ -1,21 +1,21 @@
 // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
 
 exports[`Modal component > renders slots 1`] = `
-"<div class=\\"modal is-active\\">
-  <div class=\\"modal-background\\"></div>
+"<div class="modal is-active">
+  <div class="modal-background"></div>
   <div>Sidebar Slot</div>
-  <div class=\\"modal-card\\">
-    <header class=\\"modal-card-head\\">
+  <div class="modal-card">
+    <header class="modal-card-head">
       <div>Toggle Mobile Sidebar Slot</div>
-      <h2 class=\\"modal-card-title is-marginless\\">Modal</h2><span class=\\"delete material-icons\\">highlight_off</span>
-      <transition-stub appear=\\"false\\" persisted=\\"false\\" css=\\"true\\">
+      <h2 class="modal-card-title is-marginless">Modal</h2><span class="delete material-icons">highlight_off</span>
+      <transition-stub appear="false" persisted="false" css="true">
         <!--v-if-->
       </transition-stub>
     </header>
-    <section class=\\"modal-card-body\\">
+    <section class="modal-card-body">
       <div>Body Slot</div>
     </section>
-    <footer class=\\"modal-card-foot\\">
+    <footer class="modal-card-foot">
       <div>Footer Slot</div>
     </footer>
   </div>

+ 2 - 2
frontend/src/components/modals/ConvertSpotifySongs.vue

@@ -99,7 +99,7 @@ const replaceSongUrlMap = reactive({});
 const showReplacementInputs = ref(false);
 
 const youtubeVideoUrlRegex =
-	/^(https?:\/\/)?(www\.)?(m\.)?(music\.)?(youtube\.com|youtu\.be)\/(watch\?v=)?(?<youtubeId>[\w-]{11})((&([A-Za-z0-9]+)?)*)?$/;
+	/^(?:https?:\/\/)?(?:www\.)?(m\.)?(?:music\.)?(?:youtube\.com|youtu\.be)\/(?:watch\/?\?v=)?(?:.*&v=)?(?<youtubeId>[\w-]{11}).*$/;
 const youtubeVideoIdRegex = /^([\w-]{11})$/;
 
 const youtubePlaylistUrlRegex = /[\\?&]list=([^&#]*)/;
@@ -113,7 +113,7 @@ const filteredSpotifySongs = computed(() =>
 					(alternativeMediaPerTrack[spotifySong.mediaSource] &&
 						alternativeMediaPerTrack[spotifySong.mediaSource]
 							.mediaSources.length > 0)
-		  )
+			)
 		: spotifySongs.value
 );
 

+ 69 - 11
frontend/src/components/modals/EditPlaylist/Tabs/Settings.vue

@@ -1,7 +1,7 @@
 <script setup lang="ts">
 import Toast from "toasters";
 import { storeToRefs } from "pinia";
-import { onBeforeUnmount, onMounted, watch } from "vue";
+import { ref, onBeforeUnmount, onMounted, watch } from "vue";
 import validation from "@/validation";
 import { useWebsocketsStore } from "@/stores/websockets";
 import { useUserAuthStore } from "@/stores/userAuth";
@@ -24,18 +24,27 @@ const { playlist } = storeToRefs(editPlaylistStore);
 
 const { preventCloseUnsaved } = useModalsStore();
 
+const featured = ref(playlist.value.featured);
+
 const isOwner = () =>
 	loggedIn.value && currentUser.value._id === playlist.value.createdBy;
 
-const isEditable = permission =>
-	((playlist.value.type === "user" ||
-		playlist.value.type === "user-liked" ||
-		playlist.value.type === "user-disliked" ||
-		playlist.value.type === "admin") &&
-		(isOwner() || hasPermission(permission))) ||
-	(playlist.value.type === "genre" &&
-		permission === "playlists.update.privacy" &&
-		hasPermission(permission));
+const isEditable = permission => {
+	if (permission === "playlists.update.featured")
+		return playlist.value.type !== "station" && hasPermission(permission);
+	if (
+		["user", "user-liked", "user-disliked", "admin"].includes(
+			playlist.value.type
+		)
+	)
+		return isOwner() || hasPermission(permission);
+	if (
+		playlist.value.type === "genre" &&
+		permission === "playlists.update.privacy"
+	)
+		return hasPermission(permission);
+	return false;
+};
 
 const {
 	inputs: displayNameInputs,
@@ -100,6 +109,7 @@ const {
 				values.privacy,
 				res => {
 					playlist.value.privacy = values.privacy;
+					if (values.privacy !== "public") featured.value = false;
 					if (res.status === "success") {
 						resolve();
 						new Toast(res.message);
@@ -117,11 +127,28 @@ const {
 	}
 );
 
+const toggleFeatured = () => {
+	if (playlist.value.privacy !== "public") return;
+	featured.value = !featured.value;
+	socket.dispatch(
+		"playlists.updateFeatured",
+		playlist.value._id,
+		featured.value,
+		res => {
+			playlist.value.featured = featured.value;
+			new Toast(res.message);
+		}
+	);
+};
+
 watch(playlist, (value, oldValue) => {
 	if (value.displayName !== oldValue.displayName)
 		setDisplayName({ displayName: value.displayName });
-	if (value.privacy !== oldValue.privacy)
+	if (value.privacy !== oldValue.privacy) {
 		setPrivacy({ privacy: value.privacy });
+		if (value.privacy !== "public") featured.value = false;
+	}
+	if (value.featured !== oldValue.featured) featured.value = value.featured;
 });
 
 onMounted(() => {
@@ -187,10 +214,41 @@ onBeforeUnmount(() => {
 				</p>
 			</div>
 		</div>
+
+		<div
+			v-if="isEditable('playlists.update.featured')"
+			class="control is-expanded checkbox-control"
+		>
+			<label class="switch">
+				<input
+					type="checkbox"
+					id="featured"
+					:checked="featured"
+					@click="toggleFeatured"
+					:disabled="playlist.privacy !== 'public'"
+				/>
+				<span
+					v-if="playlist.privacy === 'public'"
+					class="slider round"
+				></span>
+				<span
+					v-else
+					class="slider round disabled"
+					content="Only public playlists can be featured"
+					v-tippy
+				></span>
+			</label>
+
+			<label class="label" for="featured">Featured Playlist</label>
+		</div>
 	</div>
 </template>
 
 <style lang="less" scoped>
+.checkbox-control label.label {
+	margin-left: 10px;
+}
+
 @media screen and (max-width: 1300px) {
 	.section {
 		max-width: 100% !important;

+ 12 - 23
frontend/src/components/modals/EditPlaylist/index.vue

@@ -71,7 +71,7 @@ const {
 	addSong,
 	removeSong,
 	replaceSong,
-	repositionedSong
+	reorderSongsList
 } = editPlaylistStore;
 
 const { closeCurrentModal, openModal } = useModalsStore();
@@ -97,52 +97,41 @@ const isEditable = permission =>
 		permission === "playlists.update.privacy" &&
 		hasPermission(permission));
 
-const repositionSong = ({ moved }) => {
+const repositionSong = ({ moved, song }) => {
 	const { oldIndex, newIndex } = moved;
 	if (oldIndex === newIndex) return; // we only need to update when song is moved
-	const song = playlistSongs.value[newIndex];
+	const _song = song ?? playlistSongs.value[newIndex];
 	socket.dispatch(
 		"playlists.repositionSong",
 		playlist.value._id,
 		{
-			...song,
+			..._song,
 			oldIndex,
 			newIndex
 		},
-		res => {
-			if (res.status !== "success")
-				repositionedSong({
-					...song,
-					newIndex: oldIndex,
-					oldIndex: newIndex
-				});
-		}
+		() => {}
 	);
 };
 
 const moveSongToTop = index => {
 	songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
-	playlistSongs.value.splice(0, 0, playlistSongs.value.splice(index, 1)[0]);
 	repositionSong({
 		moved: {
 			oldIndex: index,
 			newIndex: 0
-		}
+		},
+		song: playlistSongs.value[index]
 	});
 };
 
 const moveSongToBottom = index => {
 	songItems.value[`song-item-${index}`].$refs.songActions.tippy.hide();
-	playlistSongs.value.splice(
-		playlistSongs.value.length - 1,
-		0,
-		playlistSongs.value.splice(index, 1)[0]
-	);
 	repositionSong({
 		moved: {
 			oldIndex: index,
 			newIndex: playlistSongs.value.length - 1
-		}
+		},
+		song: playlistSongs.value[index]
 	});
 };
 
@@ -320,13 +309,13 @@ onMounted(() => {
 	);
 
 	socket.on(
-		"event:playlist.song.repositioned",
+		"event:playlist.changeOrder",
 		res => {
 			if (playlist.value._id === res.data.playlistId) {
-				const { song, playlistId } = res.data;
+				const { playlistId, playlistOrder } = res.data;
 
 				if (playlist.value._id === playlistId) {
-					repositionedSong(song);
+					reorderSongsList(playlistOrder);
 				}
 			}
 		},

+ 4 - 3
frontend/src/components/modals/EditSong/index.vue

@@ -82,6 +82,7 @@ const {
 const { openModal, closeCurrentModal, preventCloseCbs } = useModalsStore();
 const { hasPermission } = userAuthStore;
 
+// eslint-disable-next-line vue/no-dupe-keys
 const {
 	tab,
 	video,
@@ -732,7 +733,7 @@ const drawCanvas = async () => {
 	const widthCurrentTime = (currentTime / videoDuration) * width;
 
 	const skipDurationColor = "#F42003";
-	const durationColor = "#03A9F4";
+	const durationColor = configStore.primaryColor;
 	const afterDurationColor = "#41E841";
 	const currentDurationColor = "#3b25e8";
 
@@ -2250,8 +2251,8 @@ onBeforeUnmount(() => {
 												muted
 													? "volume_mute"
 													: volumeSliderValue >= 50
-													? "volume_up"
-													: "volume_down"
+														? "volume_up"
+														: "volume_down"
 											}}</i
 										>
 										<input

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

@@ -46,6 +46,7 @@ const { socket } = useWebsocketsStore();
 const manageStationStore = useManageStationStore({
 	modalUuid: props.modalUuid
 });
+// eslint-disable-next-line vue/no-dupe-keys
 const {
 	stationId,
 	sector,
@@ -64,7 +65,7 @@ const {
 	clearStation,
 	updateSongsList,
 	updateStationPlaylist,
-	repositionSongInList,
+	reorderSongsList,
 	updateStationPaused,
 	updateCurrentSong,
 	updateStation,
@@ -321,10 +322,10 @@ onMounted(() => {
 		);
 
 		socket.on(
-			"event:manageStation.queue.song.repositioned",
+			"event:manageStation.queue.order.changed",
 			res => {
 				if (res.data.stationId === stationId.value)
-					repositionSongInList(res.data.song);
+					reorderSongsList(res.data.queueOrder);
 			},
 			{ modalUuid: props.modalUuid }
 		);
@@ -484,8 +485,8 @@ onBeforeUnmount(() => {
 			sector === 'home' && !hasPermission('stations.view.manage')
 				? 'View Queue'
 				: !hasPermission('stations.view.manage')
-				? 'Add Song to Queue'
-				: 'Manage Station'
+					? 'Add Song to Queue'
+					: 'Manage Station'
 		"
 		:style="`--primary-color: var(--${station.theme})`"
 		class="manage-station-modal"

+ 26 - 11
frontend/src/composables/useYoutubeDirect.ts

@@ -4,8 +4,8 @@ import { AddSongToPlaylistResponse } from "@musare_types/actions/PlaylistsAction
 import { useWebsocketsStore } from "@/stores/websockets";
 
 const youtubeVideoUrlRegex =
-	/^(https?:\/\/)?(www\.)?(m\.)?(music\.)?(youtube\.com|youtu\.be)\/(watch\?v=)?(?<youtubeId>[\w-]{11})((&([A-Za-z0-9]+)?)*)?$/;
-const youtubeVideoIdRegex = /^([\w-]{11})$/;
+	/^(?:https?:\/\/)?(?:www\.)?(m\.)?(?:music\.)?(?:youtube\.com|youtu\.be)\/(?:watch\/?\?v=)?(?:.*&v=)?(?<youtubeId>[\w-]{11}).*$/;
+const youtubeVideoIdRegex = /^(?<youtubeId>[\w-]{11})$/;
 
 export const useYoutubeDirect = () => {
 	const youtubeDirect = ref("");
@@ -13,20 +13,35 @@ export const useYoutubeDirect = () => {
 	const { socket } = useWebsocketsStore();
 
 	const getYoutubeVideoId = () => {
-		const youtubeVideoUrlMatch = youtubeVideoUrlRegex.exec(
-			youtubeDirect.value.trim()
-		);
+		const inputValue = youtubeDirect.value.trim();
+
+		// Check if the user simply used a YouTube ID in the input directly
+		const youtubeVideoIdMatch = youtubeVideoIdRegex.exec(inputValue);
+		if (youtubeVideoIdMatch && youtubeVideoIdMatch.groups.youtubeId) {
+			// eslint-disable-next-line prefer-destructuring
+			return youtubeVideoIdMatch.groups.youtubeId;
+		}
+
+		// Check if we can get the video ID from passing in the input value into the URL regex
+		const youtubeVideoUrlMatch = youtubeVideoUrlRegex.exec(inputValue);
 		if (youtubeVideoUrlMatch && youtubeVideoUrlMatch.groups.youtubeId) {
 			// eslint-disable-next-line prefer-destructuring
 			return youtubeVideoUrlMatch.groups.youtubeId;
 		}
 
-		const youtubeVideoIdParts = youtubeVideoIdRegex.exec(
-			youtubeDirect.value.trim()
-		);
-		if (youtubeVideoIdParts) {
-			// eslint-disable-next-line prefer-destructuring
-			return youtubeVideoIdParts[1];
+		// Check if the user provided a URL of some kind that has the query parameter v, which also passes the YouTube video ID regex
+		try {
+			const { searchParams } = new URL(inputValue);
+			if (searchParams.has("v")) {
+				const vValue = searchParams.get("v");
+				const vValueMatch = youtubeVideoIdRegex.exec(vValue);
+				if (vValueMatch && vValueMatch.groups.youtubeId) {
+					// eslint-disable-next-line prefer-destructuring
+					return youtubeVideoIdMatch.groups.youtubeId;
+				}
+			}
+		} catch (error) {
+			return null;
 		}
 
 		return null;

+ 1 - 1
frontend/src/index.html

@@ -19,7 +19,7 @@
 		/>
 		<meta
 			name="copyright"
-			content="© Copyright Musare 2015-2023 All Right Reserved"
+			content="© Copyright Musare 2015-2024 All Right Reserved"
 		/>
 
 		<link

+ 18 - 0
frontend/src/pages/Admin/Playlists.vue

@@ -55,6 +55,12 @@ const columns = ref<TableColumn[]>([
 		properties: ["privacy"],
 		sortProperty: "privacy"
 	},
+	{
+		name: "featured",
+		displayName: "Featured",
+		properties: ["featured"],
+		sortProperty: "featured"
+	},
 	{
 		name: "songsCount",
 		displayName: "Songs #",
@@ -143,6 +149,13 @@ const filters = ref<TableFilter[]>([
 			["private", "Private"]
 		]
 	},
+	{
+		name: "featured",
+		displayName: "Featured",
+		property: "featured",
+		filterTypes: ["boolean"],
+		defaultFilterType: "boolean"
+	},
 	{
 		name: "songsCount",
 		displayName: "Songs Count",
@@ -305,6 +318,11 @@ const create = () => {
 					slotProps.item.privacy
 				}}</span>
 			</template>
+			<template #column-featured="slotProps">
+				<span :title="slotProps.item.featured">{{
+					slotProps.item.featured
+				}}</span>
+			</template>
 			<template #column-songsCount="slotProps">
 				<span :title="slotProps.item.songsCount">{{
 					slotProps.item.songsCount

+ 16 - 33
frontend/src/pages/Home.vue

@@ -619,21 +619,21 @@ onBeforeUnmount(() => {
 									:title="
 										element.currentSong.artists.length > 0
 											? 'Now Playing: ' +
-											  element.currentSong.title +
-											  ' by ' +
-											  element.currentSong.artists.join(
+												element.currentSong.title +
+												' by ' +
+												element.currentSong.artists.join(
 													', '
-											  )
+												)
 											: 'Now Playing: ' +
-											  element.currentSong.title
+												element.currentSong.title
 									"
 									>{{ element.currentSong.title }}
 									{{
 										element.currentSong.artists.length > 0
 											? " by " +
-											  element.currentSong.artists.join(
+												element.currentSong.artists.join(
 													", "
-											  )
+												)
 											: ""
 									}}</span
 								>
@@ -887,17 +887,17 @@ onBeforeUnmount(() => {
 							:title="
 								station.currentSong.artists.length > 0
 									? 'Now Playing: ' +
-									  station.currentSong.title +
-									  ' by ' +
-									  station.currentSong.artists.join(', ')
+										station.currentSong.title +
+										' by ' +
+										station.currentSong.artists.join(', ')
 									: 'Now Playing: ' +
-									  station.currentSong.title
+										station.currentSong.title
 							"
 							>{{ station.currentSong.title }}
 							{{
 								station.currentSong.artists.length > 0
 									? " by " +
-									  station.currentSong.artists.join(", ")
+										station.currentSong.artists.join(", ")
 									: ""
 							}}</span
 						>
@@ -934,13 +934,7 @@ onBeforeUnmount(() => {
 <style lang="less">
 .christmas-mode .home-page {
 	.header .overlay {
-		background: linear-gradient(
-			180deg,
-			rgba(231, 77, 60, 0.8) 0%,
-			rgba(231, 77, 60, 0.95) 31.25%,
-			rgba(231, 77, 60, 0.9) 54.17%,
-			rgba(231, 77, 60, 0.8) 100%
-		);
+		background-color: var(--red);
 	}
 	.christmas-lights {
 		top: 300px !important;
@@ -990,13 +984,7 @@ html {
 
 .night-mode {
 	.header .overlay {
-		background: linear-gradient(
-			180deg,
-			rgba(34, 34, 34, 0.8) 0%,
-			rgba(34, 34, 34, 0.95) 31.25%,
-			rgba(34, 34, 34, 0.9) 54.17%,
-			rgba(34, 34, 34, 0.8) 100%
-		);
+		background-color: var(--dark-grey-3);
 	}
 	.station-card {
 		background-color: var(--dark-grey-3);
@@ -1040,13 +1028,8 @@ html {
 		user-select: none;
 	}
 	.overlay {
-		background: linear-gradient(
-			180deg,
-			rgba(3, 169, 244, 0.8) 0%,
-			rgba(3, 169, 244, 0.95) 31.25%,
-			rgba(3, 169, 244, 0.9) 54.17%,
-			rgba(3, 169, 244, 0.8) 100%
-		);
+		background-color: var(--primary-color);
+		opacity: 0.85;
 		position: absolute;
 		height: 300px;
 		width: 100%;

+ 2 - 2
frontend/src/pages/Profile/Tabs/Playlists.vue

@@ -15,7 +15,7 @@ const props = defineProps({
 const {
 	DraggableList,
 	drag,
-	userId,
+	userId: playlistUserId,
 	isCurrentUser,
 	playlists,
 	savePlaylistOrder
@@ -24,7 +24,7 @@ const {
 const { openModal } = useModalsStore();
 
 onMounted(() => {
-	userId.value = props.userId;
+	playlistUserId.value = props.userId;
 });
 </script>
 

+ 184 - 64
frontend/src/pages/Station/index.vue

@@ -54,7 +54,8 @@ const router = useRouter();
 
 const { socket } = useWebsocketsStore();
 const configStore = useConfigStore();
-const { experimental, sitename, christmas } = storeToRefs(configStore);
+const { experimental, primaryColor, sitename, christmas } =
+	storeToRefs(configStore);
 const stationStore = useStationStore();
 const userAuthStore = useUserAuthStore();
 const userPreferencesStore = useUserPreferencesStore();
@@ -112,12 +113,13 @@ const persistentToasts = ref([]);
 const experimentalChangableListenModeEnabled = ref(false);
 const experimentalChangableListenMode = ref("listen_and_participate"); // Can be either listen_and_participate or participate
 // End experimental options
-// NEW
 const videoLoading = ref();
 const startedAt = ref();
 const pausedAt = ref();
 const stationIdentifier = ref();
-// ENDNEW
+const calculateTimeDifferenceTimeout = ref();
+const systemDifferenceHighDifferenceInARowCount = ref(0);
+const lastSystemDifference = ref(0);
 
 const playerDebugBox = ref();
 const keyboardShortcutsHelper = ref();
@@ -237,7 +239,7 @@ const {
 	updateCurrentSong,
 	updateNextSong,
 	updateSongsList,
-	repositionSongInList,
+	reorderSongsList,
 	updateStationPaused,
 	updateLocalPaused,
 	updateNoSong,
@@ -261,6 +263,105 @@ const {
 // const stopVideo = payload =>
 // 	store.dispatch("modals/editSong/stopVideo", payload);
 
+const calculateTimeDifference = () => {
+	if (localStorage.getItem("stationNoSystemTimeDifference") === "true") {
+		console.log(
+			"Not calculating time different because 'stationNoSystemTimeDifference' is 'true' in localStorage"
+		);
+		return;
+	}
+	if (!station.value._id) return;
+	if (calculateTimeDifferenceTimeout.value)
+		clearTimeout(calculateTimeDifferenceTimeout.value);
+
+	// 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;
+		console.log(
+			`Latency between client and server: ${connectionLatency}ms`,
+			beforePing,
+			afterPing
+		);
+
+		// If we have a station latency in localStorage, use that. Can be used for debugging.
+		if (localStorage.getItem("stationLatency")) {
+			connectionLatency = parseInt(
+				localStorage.getItem("stationLatency")
+			);
+			console.log(
+				`Using latency from local storage: ${connectionLatency}ms`
+			);
+		}
+
+		// Store the server time in ms that the server had before sending the pong
+		// const serverDate = res.data.date;
+
+		// 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;
+
+		console.log(
+			`Difference in system time compared to server: ${difference}ms`
+		);
+		if (Math.abs(difference) > 3000) {
+			console.log("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(
+			lastSystemDifference.value - difference
+		);
+		const differenceBetweenCurrent = Math.abs(
+			systemDifference.value - 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
+				systemDifferenceHighDifferenceInARowCount.value = 1;
+			} else if (systemDifferenceHighDifferenceInARowCount.value < 3) {
+				systemDifferenceHighDifferenceInARowCount.value += 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
+				systemDifferenceHighDifferenceInARowCount.value = 0;
+				systemDifference.value = difference;
+			}
+			timeoutTime = 1000 * 10;
+		} else {
+			// Calculated difference is less than 250ms, so we just accept that it's correct
+			systemDifferenceHighDifferenceInARowCount.value = 0;
+			systemDifference.value = difference;
+		}
+		if (systemDifferenceHighDifferenceInARowCount.value > 0) {
+			console.log(
+				`System difference high difference in a row count: ${systemDifferenceHighDifferenceInARowCount.value}`
+			);
+		}
+
+		lastSystemDifference.value = difference;
+
+		console.log(
+			`Will attempt to get system time difference again in ${
+				timeoutTime / 1000
+			} seconds.`
+		);
+		if (calculateTimeDifferenceTimeout.value)
+			clearTimeout(calculateTimeDifferenceTimeout.value);
+		calculateTimeDifferenceTimeout.value = setTimeout(() => {
+			calculateTimeDifference();
+		}, timeoutTime);
+	});
+};
 const updateMediaSessionData = song => {
 	if (song) {
 		ms.setMediaSessionData(
@@ -456,7 +557,7 @@ const calculateTimeElapsed = async () => {
 					}
 				});
 			}
-		} else {
+		} else if (!stationPaused.value && !localPaused.value) {
 			youtubePlayer.value.playVideo();
 			attemptsToPlayVideo.value += 1;
 		}
@@ -543,10 +644,17 @@ const playVideo = () => {
 	if (currentSongMediaType.value === "youtube") {
 		if (youtubePlayerReady.value) {
 			videoLoading.value = true;
-			youtubePlayer.value.loadVideoById(
-				currentYoutubeId.value,
-				getTimeElapsed() / 1000 + currentSong.value.skipDuration
-			);
+			if (stationPaused.value || localPaused.value) {
+				youtubePlayer.value.cueVideoById(
+					currentYoutubeId.value,
+					getTimeElapsed() / 1000 + currentSong.value.skipDuration
+				);
+			} else {
+				youtubePlayer.value.loadVideoById(
+					currentYoutubeId.value,
+					getTimeElapsed() / 1000 + currentSong.value.skipDuration
+				);
+			}
 		}
 	} else if (currentSongMediaType.value === "soundcloud") {
 		const soundcloudId = currentSongMediaValue.value;
@@ -581,6 +689,7 @@ const changePlayerVolume = () => {
 	changeSoundcloudPlayerVolume();
 };
 const playerPlay = () => {
+	if (stationPaused.value || localPaused.value) return;
 	console.debug(
 		TAG,
 		"PLAYER PLAY",
@@ -1000,19 +1109,6 @@ const toggleMute = () => {
 
 	changePlayerVolume();
 };
-const increaseVolume = () => {
-	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)
 		socket.dispatch("media.unlike", currentSong.value.mediaSource, res => {
@@ -1098,7 +1194,7 @@ const sendActivityWatchMediaData = () => {
 					? 0
 					: Math.floor(
 							activityWatchMediaLastStartDuration.value / 1000
-					  ),
+						),
 			source: `station#${station.value.name}`,
 			hostname: window.location.hostname,
 			experimentalChangableListenMode:
@@ -1115,7 +1211,7 @@ const sendActivityWatchMediaData = () => {
 							key =>
 								window.YT.PlayerState[key] ===
 								youtubePlayer.value?.getPlayerState()
-					  );
+						);
 
 			videoData.playbackRate = playbackRate.value;
 		} else {
@@ -1277,9 +1373,8 @@ onMounted(async () => {
 					djs
 				});
 
-				document.getElementsByTagName(
-					"html"
-				)[0].style.cssText = `--primary-color: var(--${res.data.theme})`;
+				document.getElementsByTagName("html")[0].style.cssText =
+					`--primary-color: var(--${res.data.theme})`;
 
 				setCurrentSong({
 					currentSong: res.data.currentSong,
@@ -1449,29 +1544,21 @@ onMounted(async () => {
 					}
 				);
 
-				// UNIX client time before ping
-				const beforePing = Date.now();
-				socket.dispatch("apis.ping", res => {
-					if (res.status === "success") {
-						// UNIX client time after ping
-						const afterPing = Date.now();
-						// Average time in MS it took between the server responding and the client receiving
-						const connectionLatency = (afterPing - beforePing) / 2;
-						console.log(connectionLatency, beforePing - afterPing);
-						// UNIX server time
-						const serverDate = res.data.date;
-						// Difference between the server UNIX time and the client UNIX time after ping, with the connectionLatency added to the server UNIX time
-						const difference =
-							serverDate + connectionLatency - afterPing;
-						console.log("Difference: ", difference);
-						if (difference > 3000 || difference < -3000) {
-							console.log(
-								"System time difference is bigger than 3 seconds."
-							);
+				keyboardShortcuts.registerShortcut(
+					"station.recalculateSystemTimeDifference",
+					{
+						keyCode: 82, // R key
+						shift: true,
+						alt: true,
+						preventDefault: true,
+						handler: () => {
+							calculateTimeDifference();
 						}
-						systemDifference.value = difference;
 					}
-				});
+				);
+
+				calculateTimeDifference();
+
 				console.debug(TAG, "Station join end");
 			} else {
 				loading.value = false;
@@ -1529,6 +1616,7 @@ onMounted(async () => {
 			if (!noSong.value && currentSong.value._id === _currentSong._id)
 				skipSong();
 		}, getTimeRemaining());
+		clearTimeout(calculateTimeDifferenceTimeout.value);
 	}, true);
 
 	socket.on("event:station.nextSong", res => {
@@ -1622,8 +1710,8 @@ onMounted(async () => {
 		autoRequestSong();
 	});
 
-	socket.on("event:station.queue.song.repositioned", res => {
-		repositionSongInList(res.data.song);
+	socket.on("event:station.queue.order.changed", res => {
+		reorderSongsList(res.data.queueOrder);
 
 		let nextSong = null;
 		if (songsList.value[0])
@@ -1681,9 +1769,8 @@ onMounted(async () => {
 			}
 
 			if (station.value.theme !== theme)
-				document.getElementsByTagName(
-					"html"
-				)[0].style.cssText = `--primary-color: var(--${theme})`;
+				document.getElementsByTagName("html")[0].style.cssText =
+					`--primary-color: var(--${theme})`;
 
 			updateStation(res.data.station);
 		}
@@ -1855,7 +1942,8 @@ onMounted(async () => {
 });
 
 onBeforeUnmount(() => {
-	document.getElementsByTagName("html")[0].style.cssText = "";
+	document.getElementsByTagName("html")[0].style.cssText =
+		`--primary-color: ${primaryColor.value}`;
 
 	if (experimental.value.media_session) {
 		ms.removeListeners(0);
@@ -1872,7 +1960,8 @@ onBeforeUnmount(() => {
 		"station.lowerVolumeSmall",
 		"station.increaseVolumeLarge",
 		"station.increaseVolumeSmall",
-		"station.toggleDebug"
+		"station.toggleDebug",
+		"station.recalculateSystemTimeDifference"
 	];
 
 	shortcutNames.forEach(shortcutName => {
@@ -1885,6 +1974,7 @@ onBeforeUnmount(() => {
 	clearTimeout(window.stationNextSongTimeout);
 	clearTimeout(persistentToastCheckerInterval.value);
 	clearInterval(reportStationStateInterval.value);
+	clearTimeout(calculateTimeDifferenceTimeout.value);
 	persistentToasts.value.forEach(persistentToast => {
 		persistentToast.toast.destroy();
 	});
@@ -2200,9 +2290,19 @@ onBeforeUnmount(() => {
 									allow="autoplay"
 								></iframe>
 								<div
-									class="player-cannot-autoplay"
+									class="player-fullscreen-message"
+									v-if="stationPaused"
+								>
+									<p>
+										This station is currently paused. <br />
+										It can only be resumed by a station
+										owner, station DJ or a site
+										admin/moderator.
+									</p>
+								</div>
+								<div
+									class="player-fullscreen-message"
 									v-if="!canAutoplay"
-									@click="increaseVolume()"
 								>
 									<p>
 										Please click anywhere on the screen for
@@ -2456,8 +2556,8 @@ onBeforeUnmount(() => {
 											muted
 												? "volume_mute"
 												: volumeSliderValue >= 50
-												? "volume_up"
-												: "volume_down"
+													? "volume_up"
+													: "volume_down"
 										}}</i
 									>
 									<input
@@ -2814,7 +2914,7 @@ onBeforeUnmount(() => {
 							hasPermission('stations.skip')
 						"
 					>
-						<span class="biggest"><b>Admin/owner</b></span>
+						<span class="biggest"><b>Owner/DJ</b></span>
 						<span><b>Ctrl + Space</b> - Pause/resume station</span>
 						<span><b>Ctrl + Numpad right</b> - Skip station</span>
 					</div>
@@ -2838,6 +2938,10 @@ onBeforeUnmount(() => {
 					<hr />
 					<div>
 						<span class="biggest"><b>Misc</b></span>
+						<span
+							><b>Shift + Alt + R</b> - Recalculates the system
+							time difference</span
+						>
 						<span><b>Ctrl + D</b> - Toggles debug box</span>
 						<span><b>Ctrl + Shift + D</b> - Resets debug box</span>
 						<span
@@ -2928,6 +3032,24 @@ onBeforeUnmount(() => {
 	}
 }
 
+#keyboardShortcutsHelper {
+	.box-body {
+		.biggest {
+			font-size: 1.4rem;
+		}
+
+		> div,
+		> div > div {
+			display: flex;
+			flex-direction: column;
+		}
+
+		> div {
+			row-gap: 8px;
+		}
+	}
+}
+
 .nav,
 .button.is-primary {
 	background-color: var(--primary-color) !important;
@@ -3048,15 +3170,13 @@ onBeforeUnmount(() => {
 
 			#video-container {
 				position: relative;
-				padding-bottom: 56.25%; /* proportion value to aspect ratio 16:9 (9 / 16 = 0.5625 or 56.25%) */
-				height: 0;
+				aspect-ratio: 16/9;
 				overflow: hidden;
 
-				.player-cannot-autoplay {
+				.player-fullscreen-message {
 					position: relative;
 					width: 100%;
 					height: 100%;
-					bottom: calc(100% + 5px);
 					background: var(--primary-color);
 					display: flex;
 					align-items: center;

+ 2 - 0
frontend/src/stores/config.ts

@@ -12,6 +12,7 @@ export const useConfigStore = defineStore("config", {
 		messages: Record<string, string>;
 		christmas: boolean;
 		footerLinks: Record<string, string | boolean>;
+		primaryColor: string;
 		shortcutOverrides: Record<string, any>;
 		registrationDisabled: boolean;
 		mailEnabled: boolean;
@@ -38,6 +39,7 @@ export const useConfigStore = defineStore("config", {
 		},
 		christmas: false,
 		footerLinks: {},
+		primaryColor: MUSARE_PRIMARY_COLOR,
 		shortcutOverrides: {},
 		registrationDisabled: false,
 		mailEnabled: true,

+ 12 - 13
frontend/src/stores/editPlaylist.ts

@@ -37,19 +37,18 @@ export const useEditPlaylistStore = ({ modalUuid }: { modalUuid: string }) =>
 			updatePlaylistSongs(playlistSongs) {
 				this.playlist.songs = playlistSongs;
 			},
-			repositionedSong(song) {
-				if (
-					this.playlist.songs[song.newIndex] &&
-					this.playlist.songs[song.newIndex].mediaSource ===
-						song.mediaSource
-				)
-					return;
-
-				this.playlist.songs.splice(
-					song.newIndex,
-					0,
-					this.playlist.songs.splice(song.oldIndex, 1)[0]
-				);
+			reorderSongsList(songsOrder) {
+				this.playlist.songs.sort((songA, songB) => {
+					const indexA = songsOrder.findIndex(
+						mediaSource => mediaSource === songA.mediaSource
+					);
+					const indexB = songsOrder.findIndex(
+						mediaSource => mediaSource === songB.mediaSource
+					);
+					if (indexA > indexB) return 1;
+					if (indexA < indexB) return -1;
+					return 0;
+				});
 			}
 		}
 	})();

+ 12 - 13
frontend/src/stores/manageStation.ts

@@ -64,19 +64,18 @@ export const useManageStationStore = ({ modalUuid }: { modalUuid: string }) =>
 			updateStationPlaylist(stationPlaylist) {
 				this.stationPlaylist = stationPlaylist;
 			},
-			repositionSongInList(song) {
-				if (
-					this.songsList[song.newIndex] &&
-					this.songsList[song.newIndex].mediaSource ===
-						song.mediaSource
-				)
-					return;
-
-				this.songsList.splice(
-					song.newIndex,
-					0,
-					this.songsList.splice(song.oldIndex, 1)[0]
-				);
+			reorderSongsList(songsOrder) {
+				this.songsList.sort((songA, songB) => {
+					const indexA = songsOrder.findIndex(
+						mediaSource => mediaSource === songA.mediaSource
+					);
+					const indexB = songsOrder.findIndex(
+						mediaSource => mediaSource === songB.mediaSource
+					);
+					if (indexA > indexB) return 1;
+					if (indexA < indexB) return -1;
+					return 0;
+				});
 			},
 			updateStationPaused(stationPaused) {
 				this.stationPaused = stationPaused;

+ 12 - 12
frontend/src/stores/station.ts

@@ -154,18 +154,18 @@ export const useStationStore = defineStore("station", {
 		updateSongsList(songsList) {
 			this.songsList = songsList;
 		},
-		repositionSongInList(song) {
-			if (
-				this.songsList[song.newIndex] &&
-				this.songsList[song.newIndex].mediaSource === song.mediaSource
-			)
-				return;
-
-			this.songsList.splice(
-				song.newIndex,
-				0,
-				this.songsList.splice(song.oldIndex, 1)[0]
-			);
+		reorderSongsList(songsOrder) {
+			this.songsList.sort((songA, songB) => {
+				const indexA = songsOrder.findIndex(
+					mediaSource => mediaSource === songA.mediaSource
+				);
+				const indexB = songsOrder.findIndex(
+					mediaSource => mediaSource === songB.mediaSource
+				);
+				if (indexA > indexB) return 1;
+				if (indexA < indexB) return -1;
+				return 0;
+			});
 		},
 		updateStationPaused(stationPaused) {
 			this.stationPaused = stationPaused;

+ 1 - 0
frontend/src/types/playlist.ts

@@ -9,4 +9,5 @@ export interface Playlist {
 	createdFor: string;
 	privacy: string;
 	type: string;
+	featured: boolean;
 }

+ 1 - 0
frontend/src/types/vite-env.d.ts

@@ -1 +1,2 @@
 declare const MUSARE_SITENAME: string;
+declare const MUSARE_PRIMARY_COLOR: string;

+ 3 - 0
frontend/vite.config.js

@@ -168,6 +168,9 @@ export default {
 		MUSARE_SITENAME: JSON.stringify(
 			process.env.MUSARE_SITENAME ?? "Musare"
 		),
+		MUSARE_PRIMARY_COLOR: JSON.stringify(
+			process.env.MUSARE_PRIMARY_COLOR ?? "#03a9f4"
+		),
 		MUSARE_VERSION: JSON.stringify(debug.version),
 		MUSARE_GIT: debug.git,
 		__VUE_I18N_LEGACY_API__: false

Some files were not shown because too many files changed in this diff