-
-
Notifications
You must be signed in to change notification settings - Fork 97
Expand file tree
/
Copy pathweb_player.ts
More file actions
383 lines (345 loc) · 12.6 KB
/
Copy pathweb_player.ts
File metadata and controls
383 lines (345 loc) · 12.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
import { reactive, watch } from "vue";
import authManager from "./auth";
import api from "./api";
import { EventType } from "./api/interfaces";
import { companionMode } from "./companion";
import router from "./router";
import { resetSendspinConnection } from "./sendspin-connection";
export enum WebPlayerMode {
DISABLED = "disabled",
CONTROLS_ONLY = "controls_only",
SENDSPIN_ONLY = "sendspin_only",
SENDSPIN_WITH_CONTROLS = "sendspin_with_controls",
}
// Helper to check if a mode is a playback mode (handles actual audio)
export const isPlaybackMode = (mode: WebPlayerMode) =>
mode === WebPlayerMode.SENDSPIN_ONLY ||
mode === WebPlayerMode.SENDSPIN_WITH_CONTROLS;
let unsubSubscriptions: (() => void)[] = [];
// We use a channel to communicate with all other tabs of MA open.
// This allows us to limit playback to only a single tab.
// Playback can still be controlled from any tab, this just avoids double playback
// of the music, and is independent from the player implementation used by the frontend.
// In case the "leading" tab is closed, another open tab will take control.
const bc = new BroadcastChannel("web-player");
const BC_MSG = {
IS_ACTIVE: "IS_WEBPLAYER_ACTIVE",
IS_ACTIVE_RESPONSE: "WEBPLAYER_IS_ACTIVE",
TAKING_CONTROL: "TAKING_CONTROL:", // Followed by the priority
CONTROL_AVAILABLE: "CONTROL_AVAILABLE",
CONTROL_TAKEN: "CONTROL_TAKEN",
};
// Assume we timed out if after this time we did not send any updates
// This is slightly smaller than on the server (90s) to avoid false positives with isAnotherTabActive
const TIMEOUT_DURATION_MS = 75_000;
// NOTE: using crypto.randomUUID() is not supported in insecure contexts (http)
// so we're using getRandomValues instead
const array = new Uint32Array(10);
self.crypto.getRandomValues(array);
const uniqueId = array.join("");
bc.onmessage = (event) => {
if (webPlayer.mode === WebPlayerMode.DISABLED) {
return;
}
if (
typeof event.data === "string" &&
event.data.startsWith(BC_MSG.TAKING_CONTROL)
) {
// Another tab is taking control, silently switch back to just the notification
// (maybe this tab was suspended by the browser and didn't respond in time?)
if (isPlaybackMode(webPlayer.tabMode)) {
// Silently fall back
webPlayer.setTabMode(WebPlayerMode.CONTROLS_ONLY, true);
}
const priority = event.data.substring(BC_MSG.TAKING_CONTROL.length);
if (highestPriority !== undefined)
highestPriority = highestPriority > priority ? highestPriority : priority;
else highestPriority = priority;
}
switch (event.data) {
case BC_MSG.IS_ACTIVE:
if (
isPlaybackMode(webPlayer.mode) &&
isPlaybackMode(webPlayer.tabMode) &&
webPlayer.player_id
) {
// Check if we timed out
if (webPlayer.timedOutDueToThrottling()) {
webPlayer.setTabMode(WebPlayerMode.CONTROLS_ONLY, true);
} else {
// Respond if this tab is active
bc.postMessage(BC_MSG.IS_ACTIVE_RESPONSE);
}
}
break;
case BC_MSG.IS_ACTIVE_RESPONSE:
// Resolve any pending active player checks
activePlayerChecks.forEach((check) => {
clearTimeout(check.timeout);
check.resolve(true);
});
activePlayerChecks = [];
break;
case BC_MSG.CONTROL_AVAILABLE:
// Another tab released control, take over if desired
if (
isPlaybackMode(webPlayer.mode) &&
!isPlaybackMode(webPlayer.tabMode)
) {
webPlayer.setTabMode(webPlayer.mode);
}
break;
case BC_MSG.CONTROL_TAKEN:
// Another tab took control, in case we still think we have control (if the tab was frozen by the browser),
// immediatly give it up
if (isPlaybackMode(webPlayer.tabMode)) {
webPlayer.setTabMode(WebPlayerMode.CONTROLS_ONLY, true);
}
break;
}
};
// Called on close — skip if page is entering bfcache (may be restored)
window.addEventListener("pagehide", function (event) {
if (event.persisted) return;
bc.onmessage = null;
if (isPlaybackMode(webPlayer.tabMode) && webPlayer.player_id) {
bc.postMessage(BC_MSG.CONTROL_AVAILABLE);
}
});
// Track active player checks
interface ActiveCheck {
resolve: (value: boolean) => void;
timeout: number;
}
let activePlayerChecks: ActiveCheck[] = [];
async function isAnotherTabActive(): Promise<boolean> {
return new Promise((resolve) => {
const timeout = window.setTimeout(() => {
activePlayerChecks = activePlayerChecks.filter(
(check) => check.timeout !== timeout,
);
resolve(false); // No response within 500ms, assume no active player
}, 500);
activePlayerChecks.push({ resolve, timeout });
// Send the message, onmessage will resolve to true in case another tab responds
bc.postMessage(BC_MSG.IS_ACTIVE);
});
}
let highestPriority: string | undefined;
let modeSyncInitialized = false;
let modeSyncInitializationPromise: Promise<void> | null = null;
let pendingModeApplication = Promise.resolve();
function resolvePreferredMode(): WebPlayerMode {
// This route explicitly disables the web player
const routeDisablesWebPlayer = router.currentRoute.value.matched.some(
(record) => record.meta.disableWebPlayer === true,
);
// Hard-disable conditions always win over user preferences.
if (
authManager.isPartyGuest() ||
companionMode.value ||
routeDisablesWebPlayer
) {
return WebPlayerMode.DISABLED;
}
const webPlayerEnabledPref =
window.localStorage.getItem("frontend.settings.web_player_enabled") ||
"true";
const browserControlsEnabledPref =
window.localStorage.getItem("frontend.settings.enable_browser_controls") ||
"true";
if (
webPlayerEnabledPref !== "false" &&
browserControlsEnabledPref !== "false"
) {
return WebPlayerMode.SENDSPIN_WITH_CONTROLS;
}
if (
webPlayerEnabledPref !== "false" &&
browserControlsEnabledPref === "false"
) {
return WebPlayerMode.SENDSPIN_ONLY;
}
if (
webPlayerEnabledPref === "false" &&
browserControlsEnabledPref !== "false"
) {
return WebPlayerMode.CONTROLS_ONLY;
}
return WebPlayerMode.DISABLED;
}
// Serializes mode updates so concurrent triggers (init/route/watchers) apply in order.
function queueModeApplication(): Promise<void> {
pendingModeApplication = pendingModeApplication.then(async () => {
const mode = resolvePreferredMode();
if (mode !== webPlayer.mode || mode !== webPlayer.tabMode) {
await webPlayer.setMode(mode);
}
});
return pendingModeApplication;
}
// Automatically sets web player mode from route policy, guest/companion state, and user preferences.
// Must be called once on page load to set up mode synchronization hooks.
// Safe to call multiple times.
export async function initializeWebPlayerModeSync(): Promise<void> {
if (modeSyncInitializationPromise) {
await modeSyncInitializationPromise;
return queueModeApplication();
}
if (modeSyncInitialized) {
return queueModeApplication();
}
modeSyncInitializationPromise = (async () => {
await router.isReady();
await queueModeApplication();
router.afterEach(() => {
void queueModeApplication();
});
watch(companionMode, () => {
void queueModeApplication();
});
modeSyncInitialized = true;
modeSyncInitializationPromise = null;
})();
return modeSyncInitializationPromise;
}
async function canTakeControl(): Promise<boolean> {
// Generate a unique priority string with interaction as a prefix
// (so interacted and visible with tabs are prioritized)
const priority =
(webPlayer.interacted ? "1" : "0") +
(document.hidden ? "0" : "1") +
uniqueId;
if (highestPriority !== undefined)
highestPriority = highestPriority > priority ? highestPriority : priority;
else highestPriority = priority;
bc.postMessage(BC_MSG.TAKING_CONTROL + priority);
return new Promise((resolve) => {
setTimeout(() => {
// Compare lexicographically - only one tab can win
const wonControl = highestPriority === priority;
highestPriority = undefined;
resolve(wonControl);
}, 2000);
});
}
export const webPlayer = reactive({
// This is target mode shared across all tabs
mode: WebPlayerMode.DISABLED,
// This is the true mode of this tab.
// In case of a playback mode (SENDSPIN), exactly one tab will have this equal to mode to avoid double playback
tabMode: WebPlayerMode.DISABLED,
// This dictates what component will play audio to have the notification show up on all browsers
audioSource: WebPlayerMode.DISABLED,
// URL of the webserver
baseUrl: "",
// id of the player that is provided by this frontend
player_id: null as string | null,
// If the user interacted with the frontend, required to avoid autoplay restrictions
interacted: false,
// Timestamp from when the last update was sent
lastUpdate: 0,
async setMode(mode: WebPlayerMode) {
this.mode = mode;
await this.setTabMode(mode);
},
async setTabMode(mode: WebPlayerMode, silent: boolean = false) {
for (const u of unsubSubscriptions) {
u();
}
unsubSubscriptions = [];
if (isPlaybackMode(this.tabMode)) {
if (this.player_id) {
// Notify other tabs, if another tab already has control, this will change nothing
bc.postMessage(BC_MSG.CONTROL_AVAILABLE);
}
}
this.audioSource = WebPlayerMode.DISABLED;
this.player_id = null;
// If trying to set to a playback mode, check if another tab already has it
if (isPlaybackMode(mode)) {
if (await isAnotherTabActive()) {
// Another tab already is already responsible for the playback, fall back to CONTROLS_ONLY
mode = WebPlayerMode.CONTROLS_ONLY;
} else {
// No other tab has control, but another tab tries to take control simultaneously?
if (!(await canTakeControl())) {
// No, another tab will take it from here
mode = WebPlayerMode.CONTROLS_ONLY;
}
}
}
if (isPlaybackMode(mode)) {
// Sendspin player is handled separately through the SendspinPlayer component
// audioSource determines whether browser media controls are shown
this.audioSource =
mode === WebPlayerMode.SENDSPIN_WITH_CONTROLS
? WebPlayerMode.CONTROLS_ONLY
: WebPlayerMode.DISABLED;
const saved_player_id = window.localStorage.getItem(
"sendspin_webplayer_id",
);
// Use saved player_id or generate a new one if none exists
let player_id = saved_player_id;
if (!player_id) {
player_id = `ma_${Math.random().toString(36).substring(2, 12)}`;
window.localStorage.setItem("sendspin_webplayer_id", player_id);
}
this.player_id = player_id;
this.lastUpdate = Date.now();
bc.postMessage(BC_MSG.CONTROL_TAKEN);
} else if (mode == WebPlayerMode.CONTROLS_ONLY) {
// This is guaranteed to not be a first tab (since that would have a playback tabMode)
// Therefore, this player_id should be already set - read based on target mode
const saved_player_id = window.localStorage.getItem(
"sendspin_webplayer_id",
);
this.player_id = saved_player_id;
this.audioSource = WebPlayerMode.CONTROLS_ONLY;
} else {
this.audioSource = WebPlayerMode.DISABLED;
}
this.tabMode = mode;
if (this.player_id) {
// The sendspin session follows the main API connection: tear the web player
// down when the server connection is lost, and let App.vue re-apply the mode
// (which remounts the player) once it is restored. A sendspin transport drop
// that leaves the main connection intact is recovered by sendspin-js's own
// reconnect, so it does not need to be handled here.
unsubSubscriptions.push(
api.subscribe(EventType.DISCONNECTED, () => {
resetSendspinConnection();
this.setTabMode(WebPlayerMode.CONTROLS_ONLY, true);
}),
);
unsubSubscriptions.push(
api.subscribe(
EventType.PLAYER_REMOVED,
() => {
// Player removed server-side: silently fall back to controls only.
this.setTabMode(WebPlayerMode.CONTROLS_ONLY, true);
},
this.player_id,
),
);
}
},
setBaseUrl(url: string) {
if (url.endsWith("/")) url = url.slice(0, -1);
if (url === this.baseUrl) {
return;
}
const prevMode = this.tabMode;
// First disable to avoid conflicts
this.setTabMode(WebPlayerMode.DISABLED);
this.baseUrl = url;
this.setTabMode(prevMode);
},
async setInteracted() {
if (this.interacted) return;
this.interacted = true;
},
timedOutDueToThrottling() {
return Date.now() - webPlayer.lastUpdate >= TIMEOUT_DURATION_MS;
},
});