@microsoft/live-share-media 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.nycrc.json +10 -10
  2. package/LICENSE +90 -90
  3. package/README.md +135 -135
  4. package/bin/LiveMediaSession.d.ts.map +1 -1
  5. package/bin/LiveMediaSession.js +1 -1
  6. package/bin/LiveMediaSession.js.map +1 -1
  7. package/bin/LiveMediaSessionCoordinator.d.ts +3 -2
  8. package/bin/LiveMediaSessionCoordinator.d.ts.map +1 -1
  9. package/bin/LiveMediaSessionCoordinator.js +27 -13
  10. package/bin/LiveMediaSessionCoordinator.js.map +1 -1
  11. package/bin/MediaSessionExtensions.d.ts +0 -4
  12. package/bin/MediaSessionExtensions.d.ts.map +1 -1
  13. package/bin/MediaSessionExtensions.js.map +1 -1
  14. package/bin/internals/consts.d.ts +2 -2
  15. package/bin/internals/consts.d.ts.map +1 -1
  16. package/bin/internals/consts.js +2 -2
  17. package/bin/internals/consts.js.map +1 -1
  18. package/package.json +45 -45
  19. package/src/IMediaPlayer.ts +29 -29
  20. package/src/LiveMediaSession.ts +337 -336
  21. package/src/LiveMediaSessionCoordinator.ts +711 -678
  22. package/src/LiveMediaSessionCoordinatorSuspension.ts +39 -39
  23. package/src/MediaPlayerSynchronizer.ts +714 -714
  24. package/src/MediaSessionActionThrottler.ts +19 -19
  25. package/src/MediaSessionExtensions.ts +182 -187
  26. package/src/RepeatedActionThrottler.ts +76 -76
  27. package/src/VolumeManager.ts +200 -200
  28. package/src/index.ts +14 -14
  29. package/src/internals/GroupCoordinatorState.ts +685 -685
  30. package/src/internals/GroupPlaybackPosition.ts +228 -228
  31. package/src/internals/GroupPlaybackTrack.ts +182 -182
  32. package/src/internals/GroupPlaybackTrackData.ts +104 -104
  33. package/src/internals/GroupTransportState.ts +170 -170
  34. package/src/internals/consts.ts +103 -102
  35. package/src/internals/errors.ts +42 -42
  36. package/src/internals/index.ts +12 -12
  37. package/tsconfig.build.json +8 -8
  38. package/tsconfig.json +76 -76
  39. package/typedoc.json +10 -10
@@ -1,714 +1,714 @@
1
- /**
2
- * Copyright (c) Microsoft Corporation. All rights reserved.
3
- * Licensed under the Microsoft Live Share SDK License.
4
- */
5
- import {
6
- LiveTelemetryLogger,
7
- ILiveEvent,
8
- IRuntimeSignaler,
9
- } from "@microsoft/live-share";
10
- import EventEmitter from "events";
11
- import {
12
- ExtendedMediaSessionAction,
13
- ExtendedMediaSessionPlaybackState,
14
- ExtendedMediaMetadata,
15
- CoordinationWaitPoint,
16
- ExtendedMediaSessionActionDetails,
17
- MediaSessionCoordinatorEvents,
18
- MediaSessionCoordinatorSuspension,
19
- } from "./MediaSessionExtensions";
20
- import { VolumeManager } from "./VolumeManager";
21
- import { LiveMediaSession } from "./LiveMediaSession";
22
- import { IMediaPlayer } from "./IMediaPlayer";
23
- import { TelemetryEvents } from "./internals";
24
- import { waitUntilConnected } from "@microsoft/live-share/bin/internals";
25
-
26
- /**
27
- * Event data returned by `MediaPlayerSynchronizer` object.
28
- */
29
- export interface IMediaPlayerSynchronizerEvent extends ILiveEvent {
30
- /**
31
- * Event details.
32
- */
33
- details: ExtendedMediaSessionActionDetails;
34
- /**
35
- * Action errors, including DOMException errors thrown by HTMLMediaElement
36
- */
37
- error?: Error;
38
- }
39
-
40
- /**
41
- * Events supported by `MediaPlayerSynchronizer` object.
42
- */
43
- export enum MediaPlayerSynchronizerEvents {
44
- /**
45
- *
46
- */
47
- coordinatorstatechange = "coordinatorstatechange",
48
- groupaction = "groupaction",
49
- useraction = "useraction",
50
- }
51
-
52
- /**
53
- * Synchronizes a local HTML Media Element with a group of remote HTML Media Elements.
54
- *
55
- * @remarks
56
- * All of an apps transport control commands should be routed through the synchronizer. If the
57
- * app is not currently joined to the group media session, the commands will be applied directly
58
- * to the local player. When the group session is joined the commands will be broadcast to the
59
- * group in addition to being applied to the local player.
60
- */
61
- export class MediaPlayerSynchronizer extends EventEmitter {
62
- private static SESSION_ACTIONS: ExtendedMediaSessionAction[] = [
63
- "play",
64
- "pause",
65
- "seekto",
66
- "settrack",
67
- "datachange",
68
- "catchup",
69
- "wait",
70
- ];
71
- private static PLAYER_EVENTS: string[] = [
72
- "playing",
73
- "pause",
74
- "ratechange",
75
- "timeupdate",
76
- "ended",
77
- "loadedmetadata",
78
- "blocked",
79
- ];
80
-
81
- private _logger: LiveTelemetryLogger;
82
- private _player: IMediaPlayer;
83
- private _mediaSession: LiveMediaSession;
84
- private _runtime: IRuntimeSignaler;
85
- private _volumeManager: VolumeManager;
86
- private _onEnd?: () => void;
87
- private _onPlayerEvent: EventListener;
88
- private _seekSuspension?: MediaSessionCoordinatorSuspension;
89
- private _viewOnly = false;
90
- private _blockUnexpectedPlayerEvents = true;
91
- private _expectedPlaybackState: ExtendedMediaSessionPlaybackState = "none";
92
- private _metadata: ExtendedMediaMetadata | null = null;
93
- private _trackData: object | null = null;
94
-
95
- /**
96
- * Creates a new `MediaElementSynchronizer` instance.
97
- * @param player Media player element. This can be an HTML Media Element or any player that looks like an HTML Media Element.
98
- * @param mediaSession Group MediaSession object being used.
99
- * @param onEnd Optional. Function to call when synchronizers `end()` method is called.
100
- */
101
- constructor(
102
- player: IMediaPlayer,
103
- mediaSession: LiveMediaSession,
104
- runtime: IRuntimeSignaler,
105
- onEnd: () => void
106
- ) {
107
- super();
108
- this._player = player;
109
- this._mediaSession = mediaSession;
110
- this._runtime = runtime;
111
- this._logger = mediaSession.logger;
112
- this._volumeManager = new VolumeManager(player);
113
- this._onEnd = onEnd;
114
-
115
- // Listen for player state requests
116
- mediaSession.setRequestPlayerStateHandler(() => {
117
- // Find playback state
118
- let playbackState: ExtendedMediaSessionPlaybackState;
119
- const src = this._player.currentSrc || this._player.src;
120
- if (this._player.ended) {
121
- playbackState = "ended";
122
- } else if (
123
- !src ||
124
- (this._player.paused && this._player.currentTime < 1.0)
125
- ) {
126
- playbackState = "none";
127
- } else if (this._player.paused) {
128
- playbackState = "paused";
129
- } else {
130
- playbackState = "playing";
131
- }
132
-
133
- // Metadata equality check in `GroupPlaybackTrack` would return false if only providing src.
134
- // Use full metadata if this._metadata has same src
135
- const metadata =
136
- this._metadata && this._metadata.trackIdentifier === src
137
- ? this._metadata
138
- : ({ trackIdentifier: src } as ExtendedMediaMetadata);
139
-
140
- const state = {
141
- metadata: metadata,
142
- playbackState: playbackState,
143
- positionState: {
144
- position: this._player.currentTime,
145
- playbackRate: this._player.playbackRate,
146
- duration: this._player.duration,
147
- },
148
- trackData: this._trackData,
149
- };
150
-
151
- return state;
152
- });
153
-
154
- // Listen for player events
155
- this._onPlayerEvent = (evt: Event) => {
156
- if (evt.type != "timeupdate") {
157
- this._logger.sendTelemetryEvent(
158
- TelemetryEvents.MediaPlayerSynchronizer.PlayerEvent,
159
- null,
160
- {
161
- type: evt.type,
162
- }
163
- );
164
- }
165
-
166
- switch (evt.type) {
167
- case "loadedmetadata":
168
- // eslint-disable-next-line no-case-declarations
169
- const src = this._player.currentSrc || this._player.src;
170
- this._trackData = null;
171
- break;
172
- case "playing":
173
- // Handle case for YouTube player where user can pause/play video by clicking on it.
174
- // - Videos don't always start at 0.0 seconds.
175
- if (
176
- this._blockUnexpectedPlayerEvents &&
177
- this._expectedPlaybackState != "playing"
178
- ) {
179
- if (
180
- this._mediaSession.coordinator.canPlayPause &&
181
- this._player.currentTime < 1.0
182
- ) {
183
- this._logger.sendTelemetryEvent(
184
- TelemetryEvents.MediaPlayerSynchronizer
185
- .UserTappedVideoToPlay
186
- );
187
- this.play().catch((err) => {
188
- this._logger.sendErrorEvent(
189
- TelemetryEvents.MediaPlayerSynchronizer
190
- .UserTappedVideoToPlayError,
191
- err
192
- );
193
- });
194
- }
195
- }
196
- // block play if player state is playing when expected synced state is paused and coordinator is not suspended.
197
- // needed because cannot tell if its a user initiated event, so disallow play
198
- if (
199
- this._blockUnexpectedPlayerEvents &&
200
- this._expectedPlaybackState === "paused" &&
201
- !this._mediaSession.coordinator.isSuspended
202
- ) {
203
- this._player.pause();
204
- }
205
-
206
- // block play if player state is playing when expected synced state is none and coordinator is not suspended.
207
- // needed because user who is not in control should not be able to start, so disallow play
208
- if (
209
- this._blockUnexpectedPlayerEvents &&
210
- this._expectedPlaybackState === "none" &&
211
- !this._mediaSession.coordinator.isSuspended
212
- ) {
213
- this._player.pause();
214
- }
215
- break;
216
- case "pause":
217
- // block pause if player state is paused when expected synced state is playing and coordinator is not suspended.
218
- // needed because cannot tell if its a user initiated event, so disallow pause
219
- if (
220
- this._blockUnexpectedPlayerEvents &&
221
- this._expectedPlaybackState === "playing" &&
222
- !this._mediaSession.coordinator.isSuspended &&
223
- // some players have ended, but emit a pause event immediately before ended event. Do not play if ended
224
- !this._player.ended
225
- ) {
226
- this._player.play();
227
- }
228
- break;
229
- case "ratechange":
230
- // Block rate changes unless suspended.
231
- if (
232
- this._player.playbackRate != 1.0 &&
233
- !this._mediaSession.coordinator.isSuspended
234
- ) {
235
- this._logger.sendTelemetryEvent(
236
- TelemetryEvents.MediaPlayerSynchronizer
237
- .PlaybackRateChangeBlocked
238
- );
239
- this._player.playbackRate = 1.0;
240
- }
241
- break;
242
- case "blocked":
243
- this._logger.sendTelemetryEvent(
244
- TelemetryEvents.MediaPlayerSynchronizer
245
- .PlaybackRateChangeBlocked
246
- );
247
- break;
248
- }
249
- };
250
-
251
- // Register for coordinator state changes
252
- this._mediaSession.coordinator.on(
253
- MediaSessionCoordinatorEvents.coordinatorstatechange,
254
- (evt) => this.emit(evt.type, evt)
255
- );
256
-
257
- // Register media session actions
258
- for (const action of MediaPlayerSynchronizer.SESSION_ACTIONS) {
259
- this._mediaSession.setActionHandler(
260
- action,
261
- async (details: ExtendedMediaSessionActionDetails) => {
262
- let error: Error | undefined;
263
- try {
264
- switch (details.action) {
265
- case "play":
266
- this._expectedPlaybackState = "playing";
267
- if (this._player.paused) {
268
- this._logger.sendTelemetryEvent(
269
- TelemetryEvents.MediaPlayerSynchronizer
270
- .PlayAction
271
- );
272
- if (
273
- typeof details.seekTime == "number" &&
274
- this._player.currentTime < 1.0
275
- ) {
276
- this._logger.sendTelemetryEvent(
277
- TelemetryEvents
278
- .MediaPlayerSynchronizer
279
- .SeekingPlayerToStartPosition,
280
- null,
281
- { position: details.seekTime }
282
- );
283
- this._player.currentTime =
284
- details.seekTime!;
285
- }
286
- // Reference: https://developer.mozilla.org/docs/Web/API/HTMLMediaElement/play#exceptions
287
- await this._player.play();
288
- }
289
- break;
290
- case "pause":
291
- this._logger.sendTelemetryEvent(
292
- TelemetryEvents.MediaPlayerSynchronizer
293
- .PauseAction
294
- );
295
- this._expectedPlaybackState = "paused";
296
- if (
297
- typeof details.seekTime == "number" &&
298
- this._player.currentTime < 1.0
299
- ) {
300
- this._logger.sendTelemetryEvent(
301
- TelemetryEvents.MediaPlayerSynchronizer
302
- .SeekingPlayerToStartPosition,
303
- null,
304
- { position: details.seekTime }
305
- );
306
- this._player.currentTime =
307
- details.seekTime!;
308
- }
309
- this._player.pause();
310
- break;
311
- case "seekto":
312
- if (typeof details.seekTime == "number") {
313
- this._logger.sendTelemetryEvent(
314
- TelemetryEvents.MediaPlayerSynchronizer
315
- .SeekToAction,
316
- null,
317
- { position: details.seekTime }
318
- );
319
- this._player.currentTime =
320
- details.seekTime!;
321
- }
322
- break;
323
- case "settrack":
324
- this._logger.sendTelemetryEvent(
325
- TelemetryEvents.MediaPlayerSynchronizer
326
- .SetTrackAction
327
- );
328
- this._expectedPlaybackState = "none";
329
- this._metadata = details.metadata ?? null;
330
- if (
331
- this._player.src ===
332
- details.metadata?.trackIdentifier
333
- ) {
334
- // Don't set duplicate src and load again.
335
- break;
336
- }
337
- this._player.src =
338
- details.metadata!.trackIdentifier;
339
- this._player.load();
340
- break;
341
- case "datachange":
342
- this._logger.sendTelemetryEvent(
343
- TelemetryEvents.MediaPlayerSynchronizer
344
- .DataChangeAction
345
- );
346
- break;
347
- case "catchup":
348
- if (typeof details.seekTime == "number") {
349
- this._logger.sendTelemetryEvent(
350
- TelemetryEvents.MediaPlayerSynchronizer
351
- .CatchupAction,
352
- null,
353
- { position: details.seekTime }
354
- );
355
- this.catchupPlayer(details.seekTime!);
356
- }
357
- break;
358
- }
359
- } catch (err: any) {
360
- if (err instanceof Error) {
361
- error = err;
362
- } else {
363
- error = new Error(
364
- err?.message ??
365
- "An unknown error occurred after processing the group session action."
366
- );
367
- }
368
- }
369
-
370
- this.dispatchGroupAction(details, error);
371
- }
372
- );
373
- }
374
-
375
- // Subscribe to player events
376
- for (const event of MediaPlayerSynchronizer.PLAYER_EVENTS) {
377
- this._player.addEventListener(event, this._onPlayerEvent);
378
- }
379
-
380
- this._logger.sendTelemetryEvent(
381
- TelemetryEvents.MediaPlayerSynchronizer.SynchronizationStarted
382
- );
383
- }
384
-
385
- /**
386
- * Media player being synchronized.
387
- */
388
- public get player(): IMediaPlayer {
389
- return this._player;
390
- }
391
-
392
- /**
393
- * Synchronizers media session.
394
- */
395
- public get mediaSession(): LiveMediaSession {
396
- return this._mediaSession;
397
- }
398
-
399
- /**
400
- * If true the client is in a view only mode.
401
- *
402
- * @remarks
403
- * Toggling this value to true results in `mediaSession.coordinator.canPlayPause`,
404
- * `mediaSession.coordinator.canSeek`, `mediaSession.coordinator.canSetTrack`, and
405
- * `mediaSession.coordinator.canSetTrackData` all being set to false. For more fine
406
- * grained control over the local clients policies, call the `mediaSession.coordinator`
407
- * directly.
408
- */
409
- public get viewOnly(): boolean {
410
- return this._viewOnly;
411
- }
412
-
413
- public set viewOnly(value: boolean) {
414
- this._viewOnly = value;
415
- this.mediaSession.coordinator.canPlayPause = !value;
416
- this.mediaSession.coordinator.canSeek = !value;
417
- this.mediaSession.coordinator.canSetTrack = !value;
418
- this.mediaSession.coordinator.canSetTrackData = !value;
419
- }
420
-
421
- /**
422
- * @beta
423
- * If true, pause and play actions will be ignored unless explicitly invoked from the corresponding play() and pause() functions.
424
- *
425
- * @remarks
426
- * True is considered non-beta. If you set to false, this is an experimental beta behavior.
427
- * Setting it to false may cause unintended side effects, such as local buffer events sending a pause event to remote clients.
428
- * While setting this to false may prevent the need to add custom media controls for frameworks like video.js, we encourage you to be mindful of this and test thoroughly before using this setting.
429
- */
430
- public get blockUnexpectedPlayerEvents(): boolean {
431
- return this._blockUnexpectedPlayerEvents;
432
- }
433
-
434
- public set blockUnexpectedPlayerEvents(value: boolean) {
435
- this._blockUnexpectedPlayerEvents = value;
436
- }
437
-
438
- /**
439
- * Volume limiter used to temporarily reduce the videos volume when someone speaks in a meeting.
440
- */
441
- public get volumeManager(): VolumeManager {
442
- return this._volumeManager;
443
- }
444
-
445
- /**
446
- * Registers a new event listener.
447
- * @param event Name of the event to add.
448
- * @param listener Function to call when the event is triggered.
449
- */
450
- public addEventListener(
451
- event: MediaPlayerSynchronizerEvents,
452
- listener: (evt: IMediaPlayerSynchronizerEvent) => void
453
- ): this {
454
- this.on(event, listener);
455
- return this;
456
- }
457
-
458
- /**
459
- * Un-registers an existing event listener.
460
- * @param event Name of the event to remove.
461
- * @param listener Function that was registered in call to `addEventListener()`.
462
- */
463
- public removeEventListener(
464
- event: MediaPlayerSynchronizerEvents,
465
- listener: (evt: IMediaPlayerSynchronizerEvent) => void
466
- ): this {
467
- this.off(event, listener);
468
- return this;
469
- }
470
-
471
- /**
472
- * Ends synchronization of the current media player.
473
- */
474
- public end(): void {
475
- if (this._onEnd) {
476
- try {
477
- // Clear media session actions
478
- for (const action of MediaPlayerSynchronizer.SESSION_ACTIONS) {
479
- this._mediaSession.setActionHandler(action, null);
480
- }
481
-
482
- // Subscribe to player events
483
- for (const event of MediaPlayerSynchronizer.PLAYER_EVENTS) {
484
- this._player.removeEventListener(
485
- event,
486
- this._onPlayerEvent
487
- );
488
- }
489
-
490
- // Notify parent
491
- this._onEnd();
492
- } finally {
493
- this._onEnd = undefined;
494
- }
495
- }
496
- }
497
-
498
- /**
499
- * Begin a local seek operation.
500
- *
501
- * @remarks
502
- * UI can call this when a user grabs a timeline scrubber and starts scrubbing the video to a
503
- * new playback position. The synchronizer will being a new suspension which temporarily
504
- * disconnects the client for the rest of the group for synchronization purposes. Calling
505
- * `endSeek()` will end the suspension and seek the group to the users final seek position.
506
- */
507
- public beginSeek(): void {
508
- if (this._seekSuspension) {
509
- throw new Error(
510
- "MediaPlayerSynchronizer:beginSeek - cannot begin seek since a seek is already in progress.\nTo prevent this error, ensure you first call `endSeek()` before calling `beginSeek()` again."
511
- );
512
- }
513
-
514
- this._logger.sendTelemetryEvent(
515
- TelemetryEvents.MediaPlayerSynchronizer.BeginSeekCalled
516
- );
517
- this._seekSuspension = this._mediaSession.coordinator.beginSuspension();
518
- }
519
-
520
- /**
521
- * Ends a seek operation that was started by calling `beginSeek()`.
522
- * @param seekTo Playback position in seconds to seek the group to.
523
- */
524
- public endSeek(seekTo: number): void {
525
- if (!this._seekSuspension) {
526
- throw new Error(
527
- "MediaPlayerSynchronizer:endSeek - cannot end seek while no seek is in progress.\nTo prevent this error, ensure you first call `beginSeek()`."
528
- );
529
- }
530
-
531
- // Seek player to new time immediately.
532
- // - This resolves an issue where the timeline scrubber can temporarily snap back to the original
533
- // location.
534
- this._player.currentTime = seekTo;
535
-
536
- const suspension = this._seekSuspension;
537
- this._seekSuspension = undefined;
538
-
539
- this._logger.sendTelemetryEvent(
540
- TelemetryEvents.MediaPlayerSynchronizer.EndSeekCalled,
541
- null,
542
- {
543
- position: seekTo,
544
- }
545
- );
546
- suspension.end(seekTo);
547
-
548
- waitUntilConnected(this._runtime).then((clientId: string) => {
549
- this.dispatchUserAction({
550
- action: "seekto",
551
- source: "user",
552
- clientId,
553
- local: true,
554
- seekTime: seekTo,
555
- });
556
- });
557
- }
558
-
559
- /**
560
- * Tells the group to begin playing the current video.
561
- *
562
- * @remarks
563
- * For proper operation apps should avoid calling `mediaSession.coordinator.play()` directly
564
- * and instead use the synchronizers `play()` method.
565
- *
566
- * @returns a void promise that resolves once complete, throws if user does not have proper roles
567
- */
568
- public async play(): Promise<void> {
569
- this._logger.sendTelemetryEvent(
570
- TelemetryEvents.MediaPlayerSynchronizer.PlayCalled
571
- );
572
- this._expectedPlaybackState = "playing";
573
- await this._mediaSession.coordinator.play();
574
-
575
- this.dispatchUserAction({
576
- action: "play",
577
- source: "user",
578
- clientId: await waitUntilConnected(this._runtime),
579
- local: true,
580
- });
581
- }
582
-
583
- /**
584
- * Tells the group to pause the current video.
585
- *
586
- * @remarks
587
- * For proper operation apps should avoid calling `mediaSession.coordinator.pause()` directly
588
- * and instead use the synchronizers `pause()` method.
589
- *
590
- * @returns a void promise that resolves once complete, throws if user does not have proper roles
591
- */
592
- public async pause(): Promise<void> {
593
- this._logger.sendTelemetryEvent(
594
- TelemetryEvents.MediaPlayerSynchronizer.PauseCalled
595
- );
596
- this._expectedPlaybackState = "paused";
597
- await this._mediaSession.coordinator.pause();
598
-
599
- this.dispatchUserAction({
600
- action: "pause",
601
- source: "user",
602
- clientId: await waitUntilConnected(this._runtime),
603
- local: true,
604
- });
605
- }
606
-
607
- /**
608
- * Tells the group to seek the current video to a new playback position.
609
- *
610
- * @remarks
611
- * For proper operation apps should avoid calling `mediaSession.coordinator.seekTo()` directly
612
- * and instead use the synchronizers `seekTo()` method.
613
- *
614
- * @returns a void promise that resolves once complete, throws if user does not have proper roles
615
- */
616
- public async seekTo(time: number): Promise<void> {
617
- // Always seek player to new time.
618
- // - This resolves an issue where the timeline scrubber can temporarily snap back to the original
619
- // location.
620
- this._player.currentTime = time;
621
-
622
- this._logger.sendTelemetryEvent(
623
- TelemetryEvents.MediaPlayerSynchronizer.SeekToCalled,
624
- null,
625
- { position: time }
626
- );
627
- await this._mediaSession.coordinator.seekTo(time);
628
-
629
- this.dispatchUserAction({
630
- action: "seekto",
631
- source: "user",
632
- clientId: await waitUntilConnected(this._runtime),
633
- seekTime: time,
634
- local: true,
635
- });
636
- }
637
-
638
- /**
639
- * Tells the group to change to a new track.
640
- *
641
- * @remarks
642
- * For proper operation apps should avoid calling `mediaSession.coordinator.setTrack()` directly
643
- * and instead use the synchronizers `setTrack()` method.
644
- *
645
- * @returns a void promise that resolves once complete, throws if user does not have proper roles
646
- */
647
- public async setTrack(
648
- track: ExtendedMediaMetadata,
649
- waitPoints?: CoordinationWaitPoint[]
650
- ): Promise<void> {
651
- this._logger.sendTelemetryEvent(
652
- TelemetryEvents.MediaPlayerSynchronizer.SetTrackCalled
653
- );
654
- await this._mediaSession.coordinator.setTrack(track, waitPoints);
655
-
656
- this.dispatchUserAction({
657
- action: "settrack",
658
- source: "user",
659
- clientId: await waitUntilConnected(this._runtime),
660
- metadata: track,
661
- local: true,
662
- });
663
- }
664
-
665
- /**
666
- * Updates the current tracks data object.
667
- *
668
- * @remarks
669
- * For proper operation apps should avoid calling `mediaSession.coordinator.setTrackData()` directly
670
- * and instead use the synchronizers `setTrackData()` method.
671
- *
672
- * @returns a void promise that resolves once complete, throws if user does not have proper roles
673
- */
674
- public async setTrackData(data: object | null): Promise<void> {
675
- this._logger.sendTelemetryEvent(
676
- TelemetryEvents.MediaPlayerSynchronizer.SetTrackDataCalled
677
- );
678
- this._trackData = data;
679
- await this._mediaSession.coordinator.setTrackData(data);
680
-
681
- this.dispatchUserAction({
682
- action: "datachange",
683
- source: "user",
684
- clientId: await waitUntilConnected(this._runtime),
685
- local: true,
686
- data: data,
687
- });
688
- }
689
-
690
- // TODO: what is delay useful for? we do not use, delete?
691
- private dispatchGroupAction(
692
- details: ExtendedMediaSessionActionDetails,
693
- error: Error | undefined
694
- ): void {
695
- this.emit(MediaPlayerSynchronizerEvents.groupaction, {
696
- type: MediaPlayerSynchronizerEvents.groupaction,
697
- details: details,
698
- error,
699
- });
700
- }
701
-
702
- private dispatchUserAction(
703
- details: ExtendedMediaSessionActionDetails
704
- ): void {
705
- this.emit(MediaPlayerSynchronizerEvents.useraction, {
706
- type: MediaPlayerSynchronizerEvents.useraction,
707
- details: details,
708
- });
709
- }
710
-
711
- private async catchupPlayer(time: number): Promise<void> {
712
- this._player.currentTime = time;
713
- }
714
- }
1
+ /**
2
+ * Copyright (c) Microsoft Corporation. All rights reserved.
3
+ * Licensed under the Microsoft Live Share SDK License.
4
+ */
5
+ import {
6
+ LiveTelemetryLogger,
7
+ ILiveEvent,
8
+ IRuntimeSignaler,
9
+ } from "@microsoft/live-share";
10
+ import EventEmitter from "events";
11
+ import {
12
+ ExtendedMediaSessionAction,
13
+ ExtendedMediaSessionPlaybackState,
14
+ ExtendedMediaMetadata,
15
+ CoordinationWaitPoint,
16
+ ExtendedMediaSessionActionDetails,
17
+ MediaSessionCoordinatorEvents,
18
+ MediaSessionCoordinatorSuspension,
19
+ } from "./MediaSessionExtensions";
20
+ import { VolumeManager } from "./VolumeManager";
21
+ import { LiveMediaSession } from "./LiveMediaSession";
22
+ import { IMediaPlayer } from "./IMediaPlayer";
23
+ import { TelemetryEvents } from "./internals";
24
+ import { waitUntilConnected } from "@microsoft/live-share/bin/internals";
25
+
26
+ /**
27
+ * Event data returned by `MediaPlayerSynchronizer` object.
28
+ */
29
+ export interface IMediaPlayerSynchronizerEvent extends ILiveEvent {
30
+ /**
31
+ * Event details.
32
+ */
33
+ details: ExtendedMediaSessionActionDetails;
34
+ /**
35
+ * Action errors, including DOMException errors thrown by HTMLMediaElement
36
+ */
37
+ error?: Error;
38
+ }
39
+
40
+ /**
41
+ * Events supported by `MediaPlayerSynchronizer` object.
42
+ */
43
+ export enum MediaPlayerSynchronizerEvents {
44
+ /**
45
+ *
46
+ */
47
+ coordinatorstatechange = "coordinatorstatechange",
48
+ groupaction = "groupaction",
49
+ useraction = "useraction",
50
+ }
51
+
52
+ /**
53
+ * Synchronizes a local HTML Media Element with a group of remote HTML Media Elements.
54
+ *
55
+ * @remarks
56
+ * All of an apps transport control commands should be routed through the synchronizer. If the
57
+ * app is not currently joined to the group media session, the commands will be applied directly
58
+ * to the local player. When the group session is joined the commands will be broadcast to the
59
+ * group in addition to being applied to the local player.
60
+ */
61
+ export class MediaPlayerSynchronizer extends EventEmitter {
62
+ private static SESSION_ACTIONS: ExtendedMediaSessionAction[] = [
63
+ "play",
64
+ "pause",
65
+ "seekto",
66
+ "settrack",
67
+ "datachange",
68
+ "catchup",
69
+ "wait",
70
+ ];
71
+ private static PLAYER_EVENTS: string[] = [
72
+ "playing",
73
+ "pause",
74
+ "ratechange",
75
+ "timeupdate",
76
+ "ended",
77
+ "loadedmetadata",
78
+ "blocked",
79
+ ];
80
+
81
+ private _logger: LiveTelemetryLogger;
82
+ private _player: IMediaPlayer;
83
+ private _mediaSession: LiveMediaSession;
84
+ private _runtime: IRuntimeSignaler;
85
+ private _volumeManager: VolumeManager;
86
+ private _onEnd?: () => void;
87
+ private _onPlayerEvent: EventListener;
88
+ private _seekSuspension?: MediaSessionCoordinatorSuspension;
89
+ private _viewOnly = false;
90
+ private _blockUnexpectedPlayerEvents = true;
91
+ private _expectedPlaybackState: ExtendedMediaSessionPlaybackState = "none";
92
+ private _metadata: ExtendedMediaMetadata | null = null;
93
+ private _trackData: object | null = null;
94
+
95
+ /**
96
+ * Creates a new `MediaElementSynchronizer` instance.
97
+ * @param player Media player element. This can be an HTML Media Element or any player that looks like an HTML Media Element.
98
+ * @param mediaSession Group MediaSession object being used.
99
+ * @param onEnd Optional. Function to call when synchronizers `end()` method is called.
100
+ */
101
+ constructor(
102
+ player: IMediaPlayer,
103
+ mediaSession: LiveMediaSession,
104
+ runtime: IRuntimeSignaler,
105
+ onEnd: () => void
106
+ ) {
107
+ super();
108
+ this._player = player;
109
+ this._mediaSession = mediaSession;
110
+ this._runtime = runtime;
111
+ this._logger = mediaSession.logger;
112
+ this._volumeManager = new VolumeManager(player);
113
+ this._onEnd = onEnd;
114
+
115
+ // Listen for player state requests
116
+ mediaSession.setRequestPlayerStateHandler(() => {
117
+ // Find playback state
118
+ let playbackState: ExtendedMediaSessionPlaybackState;
119
+ const src = this._player.currentSrc || this._player.src;
120
+ if (this._player.ended) {
121
+ playbackState = "ended";
122
+ } else if (
123
+ !src ||
124
+ (this._player.paused && this._player.currentTime < 1.0)
125
+ ) {
126
+ playbackState = "none";
127
+ } else if (this._player.paused) {
128
+ playbackState = "paused";
129
+ } else {
130
+ playbackState = "playing";
131
+ }
132
+
133
+ // Metadata equality check in `GroupPlaybackTrack` would return false if only providing src.
134
+ // Use full metadata if this._metadata has same src
135
+ const metadata =
136
+ this._metadata && this._metadata.trackIdentifier === src
137
+ ? this._metadata
138
+ : ({ trackIdentifier: src } as ExtendedMediaMetadata);
139
+
140
+ const state = {
141
+ metadata: metadata,
142
+ playbackState: playbackState,
143
+ positionState: {
144
+ position: this._player.currentTime,
145
+ playbackRate: this._player.playbackRate,
146
+ duration: this._player.duration,
147
+ },
148
+ trackData: this._trackData,
149
+ };
150
+
151
+ return state;
152
+ });
153
+
154
+ // Listen for player events
155
+ this._onPlayerEvent = (evt: Event) => {
156
+ if (evt.type != "timeupdate") {
157
+ this._logger.sendTelemetryEvent(
158
+ TelemetryEvents.MediaPlayerSynchronizer.PlayerEvent,
159
+ null,
160
+ {
161
+ type: evt.type,
162
+ }
163
+ );
164
+ }
165
+
166
+ switch (evt.type) {
167
+ case "loadedmetadata":
168
+ // eslint-disable-next-line no-case-declarations
169
+ const src = this._player.currentSrc || this._player.src;
170
+ this._trackData = null;
171
+ break;
172
+ case "playing":
173
+ // Handle case for YouTube player where user can pause/play video by clicking on it.
174
+ // - Videos don't always start at 0.0 seconds.
175
+ if (
176
+ this._blockUnexpectedPlayerEvents &&
177
+ this._expectedPlaybackState != "playing"
178
+ ) {
179
+ if (
180
+ this._mediaSession.coordinator.canPlayPause &&
181
+ this._player.currentTime < 1.0
182
+ ) {
183
+ this._logger.sendTelemetryEvent(
184
+ TelemetryEvents.MediaPlayerSynchronizer
185
+ .UserTappedVideoToPlay
186
+ );
187
+ this.play().catch((err) => {
188
+ this._logger.sendErrorEvent(
189
+ TelemetryEvents.MediaPlayerSynchronizer
190
+ .UserTappedVideoToPlayError,
191
+ err
192
+ );
193
+ });
194
+ }
195
+ }
196
+ // block play if player state is playing when expected synced state is paused and coordinator is not suspended.
197
+ // needed because cannot tell if its a user initiated event, so disallow play
198
+ if (
199
+ this._blockUnexpectedPlayerEvents &&
200
+ this._expectedPlaybackState === "paused" &&
201
+ !this._mediaSession.coordinator.isSuspended
202
+ ) {
203
+ this._player.pause();
204
+ }
205
+
206
+ // block play if player state is playing when expected synced state is none and coordinator is not suspended.
207
+ // needed because user who is not in control should not be able to start, so disallow play
208
+ if (
209
+ this._blockUnexpectedPlayerEvents &&
210
+ this._expectedPlaybackState === "none" &&
211
+ !this._mediaSession.coordinator.isSuspended
212
+ ) {
213
+ this._player.pause();
214
+ }
215
+ break;
216
+ case "pause":
217
+ // block pause if player state is paused when expected synced state is playing and coordinator is not suspended.
218
+ // needed because cannot tell if its a user initiated event, so disallow pause
219
+ if (
220
+ this._blockUnexpectedPlayerEvents &&
221
+ this._expectedPlaybackState === "playing" &&
222
+ !this._mediaSession.coordinator.isSuspended &&
223
+ // some players have ended, but emit a pause event immediately before ended event. Do not play if ended
224
+ !this._player.ended
225
+ ) {
226
+ this._player.play();
227
+ }
228
+ break;
229
+ case "ratechange":
230
+ // Block rate changes unless suspended.
231
+ if (
232
+ this._player.playbackRate != 1.0 &&
233
+ !this._mediaSession.coordinator.isSuspended
234
+ ) {
235
+ this._logger.sendTelemetryEvent(
236
+ TelemetryEvents.MediaPlayerSynchronizer
237
+ .PlaybackRateChangeBlocked
238
+ );
239
+ this._player.playbackRate = 1.0;
240
+ }
241
+ break;
242
+ case "blocked":
243
+ this._logger.sendTelemetryEvent(
244
+ TelemetryEvents.MediaPlayerSynchronizer
245
+ .PlaybackRateChangeBlocked
246
+ );
247
+ break;
248
+ }
249
+ };
250
+
251
+ // Register for coordinator state changes
252
+ this._mediaSession.coordinator.on(
253
+ MediaSessionCoordinatorEvents.coordinatorstatechange,
254
+ (evt) => this.emit(evt.type, evt)
255
+ );
256
+
257
+ // Register media session actions
258
+ for (const action of MediaPlayerSynchronizer.SESSION_ACTIONS) {
259
+ this._mediaSession.setActionHandler(
260
+ action,
261
+ async (details: ExtendedMediaSessionActionDetails) => {
262
+ let error: Error | undefined;
263
+ try {
264
+ switch (details.action) {
265
+ case "play":
266
+ this._expectedPlaybackState = "playing";
267
+ if (this._player.paused) {
268
+ this._logger.sendTelemetryEvent(
269
+ TelemetryEvents.MediaPlayerSynchronizer
270
+ .PlayAction
271
+ );
272
+ if (
273
+ typeof details.seekTime == "number" &&
274
+ this._player.currentTime < 1.0
275
+ ) {
276
+ this._logger.sendTelemetryEvent(
277
+ TelemetryEvents
278
+ .MediaPlayerSynchronizer
279
+ .SeekingPlayerToStartPosition,
280
+ null,
281
+ { position: details.seekTime }
282
+ );
283
+ this._player.currentTime =
284
+ details.seekTime!;
285
+ }
286
+ // Reference: https://developer.mozilla.org/docs/Web/API/HTMLMediaElement/play#exceptions
287
+ await this._player.play();
288
+ }
289
+ break;
290
+ case "pause":
291
+ this._logger.sendTelemetryEvent(
292
+ TelemetryEvents.MediaPlayerSynchronizer
293
+ .PauseAction
294
+ );
295
+ this._expectedPlaybackState = "paused";
296
+ if (
297
+ typeof details.seekTime == "number" &&
298
+ this._player.currentTime < 1.0
299
+ ) {
300
+ this._logger.sendTelemetryEvent(
301
+ TelemetryEvents.MediaPlayerSynchronizer
302
+ .SeekingPlayerToStartPosition,
303
+ null,
304
+ { position: details.seekTime }
305
+ );
306
+ this._player.currentTime =
307
+ details.seekTime!;
308
+ }
309
+ this._player.pause();
310
+ break;
311
+ case "seekto":
312
+ if (typeof details.seekTime == "number") {
313
+ this._logger.sendTelemetryEvent(
314
+ TelemetryEvents.MediaPlayerSynchronizer
315
+ .SeekToAction,
316
+ null,
317
+ { position: details.seekTime }
318
+ );
319
+ this._player.currentTime =
320
+ details.seekTime!;
321
+ }
322
+ break;
323
+ case "settrack":
324
+ this._logger.sendTelemetryEvent(
325
+ TelemetryEvents.MediaPlayerSynchronizer
326
+ .SetTrackAction
327
+ );
328
+ this._expectedPlaybackState = "none";
329
+ this._metadata = details.metadata ?? null;
330
+ if (
331
+ this._player.src ===
332
+ details.metadata?.trackIdentifier
333
+ ) {
334
+ // Don't set duplicate src and load again.
335
+ break;
336
+ }
337
+ this._player.src =
338
+ details.metadata!.trackIdentifier;
339
+ this._player.load();
340
+ break;
341
+ case "datachange":
342
+ this._logger.sendTelemetryEvent(
343
+ TelemetryEvents.MediaPlayerSynchronizer
344
+ .DataChangeAction
345
+ );
346
+ break;
347
+ case "catchup":
348
+ if (typeof details.seekTime == "number") {
349
+ this._logger.sendTelemetryEvent(
350
+ TelemetryEvents.MediaPlayerSynchronizer
351
+ .CatchupAction,
352
+ null,
353
+ { position: details.seekTime }
354
+ );
355
+ this.catchupPlayer(details.seekTime!);
356
+ }
357
+ break;
358
+ }
359
+ } catch (err: any) {
360
+ if (err instanceof Error) {
361
+ error = err;
362
+ } else {
363
+ error = new Error(
364
+ err?.message ??
365
+ "An unknown error occurred after processing the group session action."
366
+ );
367
+ }
368
+ }
369
+
370
+ this.dispatchGroupAction(details, error);
371
+ }
372
+ );
373
+ }
374
+
375
+ // Subscribe to player events
376
+ for (const event of MediaPlayerSynchronizer.PLAYER_EVENTS) {
377
+ this._player.addEventListener(event, this._onPlayerEvent);
378
+ }
379
+
380
+ this._logger.sendTelemetryEvent(
381
+ TelemetryEvents.MediaPlayerSynchronizer.SynchronizationStarted
382
+ );
383
+ }
384
+
385
+ /**
386
+ * Media player being synchronized.
387
+ */
388
+ public get player(): IMediaPlayer {
389
+ return this._player;
390
+ }
391
+
392
+ /**
393
+ * Synchronizers media session.
394
+ */
395
+ public get mediaSession(): LiveMediaSession {
396
+ return this._mediaSession;
397
+ }
398
+
399
+ /**
400
+ * If true the client is in a view only mode.
401
+ *
402
+ * @remarks
403
+ * Toggling this value to true results in `mediaSession.coordinator.canPlayPause`,
404
+ * `mediaSession.coordinator.canSeek`, `mediaSession.coordinator.canSetTrack`, and
405
+ * `mediaSession.coordinator.canSetTrackData` all being set to false. For more fine
406
+ * grained control over the local clients policies, call the `mediaSession.coordinator`
407
+ * directly.
408
+ */
409
+ public get viewOnly(): boolean {
410
+ return this._viewOnly;
411
+ }
412
+
413
+ public set viewOnly(value: boolean) {
414
+ this._viewOnly = value;
415
+ this.mediaSession.coordinator.canPlayPause = !value;
416
+ this.mediaSession.coordinator.canSeek = !value;
417
+ this.mediaSession.coordinator.canSetTrack = !value;
418
+ this.mediaSession.coordinator.canSetTrackData = !value;
419
+ }
420
+
421
+ /**
422
+ * @beta
423
+ * If true, pause and play actions will be ignored unless explicitly invoked from the corresponding play() and pause() functions.
424
+ *
425
+ * @remarks
426
+ * True is considered non-beta. If you set to false, this is an experimental beta behavior.
427
+ * Setting it to false may cause unintended side effects, such as local buffer events sending a pause event to remote clients.
428
+ * While setting this to false may prevent the need to add custom media controls for frameworks like video.js, we encourage you to be mindful of this and test thoroughly before using this setting.
429
+ */
430
+ public get blockUnexpectedPlayerEvents(): boolean {
431
+ return this._blockUnexpectedPlayerEvents;
432
+ }
433
+
434
+ public set blockUnexpectedPlayerEvents(value: boolean) {
435
+ this._blockUnexpectedPlayerEvents = value;
436
+ }
437
+
438
+ /**
439
+ * Volume limiter used to temporarily reduce the videos volume when someone speaks in a meeting.
440
+ */
441
+ public get volumeManager(): VolumeManager {
442
+ return this._volumeManager;
443
+ }
444
+
445
+ /**
446
+ * Registers a new event listener.
447
+ * @param event Name of the event to add.
448
+ * @param listener Function to call when the event is triggered.
449
+ */
450
+ public addEventListener(
451
+ event: MediaPlayerSynchronizerEvents,
452
+ listener: (evt: IMediaPlayerSynchronizerEvent) => void
453
+ ): this {
454
+ this.on(event, listener);
455
+ return this;
456
+ }
457
+
458
+ /**
459
+ * Un-registers an existing event listener.
460
+ * @param event Name of the event to remove.
461
+ * @param listener Function that was registered in call to `addEventListener()`.
462
+ */
463
+ public removeEventListener(
464
+ event: MediaPlayerSynchronizerEvents,
465
+ listener: (evt: IMediaPlayerSynchronizerEvent) => void
466
+ ): this {
467
+ this.off(event, listener);
468
+ return this;
469
+ }
470
+
471
+ /**
472
+ * Ends synchronization of the current media player.
473
+ */
474
+ public end(): void {
475
+ if (this._onEnd) {
476
+ try {
477
+ // Clear media session actions
478
+ for (const action of MediaPlayerSynchronizer.SESSION_ACTIONS) {
479
+ this._mediaSession.setActionHandler(action, null);
480
+ }
481
+
482
+ // Subscribe to player events
483
+ for (const event of MediaPlayerSynchronizer.PLAYER_EVENTS) {
484
+ this._player.removeEventListener(
485
+ event,
486
+ this._onPlayerEvent
487
+ );
488
+ }
489
+
490
+ // Notify parent
491
+ this._onEnd();
492
+ } finally {
493
+ this._onEnd = undefined;
494
+ }
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Begin a local seek operation.
500
+ *
501
+ * @remarks
502
+ * UI can call this when a user grabs a timeline scrubber and starts scrubbing the video to a
503
+ * new playback position. The synchronizer will being a new suspension which temporarily
504
+ * disconnects the client for the rest of the group for synchronization purposes. Calling
505
+ * `endSeek()` will end the suspension and seek the group to the users final seek position.
506
+ */
507
+ public beginSeek(): void {
508
+ if (this._seekSuspension) {
509
+ throw new Error(
510
+ "MediaPlayerSynchronizer:beginSeek - cannot begin seek since a seek is already in progress.\nTo prevent this error, ensure you first call `endSeek()` before calling `beginSeek()` again."
511
+ );
512
+ }
513
+
514
+ this._logger.sendTelemetryEvent(
515
+ TelemetryEvents.MediaPlayerSynchronizer.BeginSeekCalled
516
+ );
517
+ this._seekSuspension = this._mediaSession.coordinator.beginSuspension();
518
+ }
519
+
520
+ /**
521
+ * Ends a seek operation that was started by calling `beginSeek()`.
522
+ * @param seekTo Playback position in seconds to seek the group to.
523
+ */
524
+ public endSeek(seekTo: number): void {
525
+ if (!this._seekSuspension) {
526
+ throw new Error(
527
+ "MediaPlayerSynchronizer:endSeek - cannot end seek while no seek is in progress.\nTo prevent this error, ensure you first call `beginSeek()`."
528
+ );
529
+ }
530
+
531
+ // Seek player to new time immediately.
532
+ // - This resolves an issue where the timeline scrubber can temporarily snap back to the original
533
+ // location.
534
+ this._player.currentTime = seekTo;
535
+
536
+ const suspension = this._seekSuspension;
537
+ this._seekSuspension = undefined;
538
+
539
+ this._logger.sendTelemetryEvent(
540
+ TelemetryEvents.MediaPlayerSynchronizer.EndSeekCalled,
541
+ null,
542
+ {
543
+ position: seekTo,
544
+ }
545
+ );
546
+ suspension.end(seekTo);
547
+
548
+ waitUntilConnected(this._runtime).then((clientId: string) => {
549
+ this.dispatchUserAction({
550
+ action: "seekto",
551
+ source: "user",
552
+ clientId,
553
+ local: true,
554
+ seekTime: seekTo,
555
+ });
556
+ });
557
+ }
558
+
559
+ /**
560
+ * Tells the group to begin playing the current video.
561
+ *
562
+ * @remarks
563
+ * For proper operation apps should avoid calling `mediaSession.coordinator.play()` directly
564
+ * and instead use the synchronizers `play()` method.
565
+ *
566
+ * @returns a void promise that resolves once complete, throws if user does not have proper roles
567
+ */
568
+ public async play(): Promise<void> {
569
+ this._logger.sendTelemetryEvent(
570
+ TelemetryEvents.MediaPlayerSynchronizer.PlayCalled
571
+ );
572
+ this._expectedPlaybackState = "playing";
573
+ await this._mediaSession.coordinator.play();
574
+
575
+ this.dispatchUserAction({
576
+ action: "play",
577
+ source: "user",
578
+ clientId: await waitUntilConnected(this._runtime),
579
+ local: true,
580
+ });
581
+ }
582
+
583
+ /**
584
+ * Tells the group to pause the current video.
585
+ *
586
+ * @remarks
587
+ * For proper operation apps should avoid calling `mediaSession.coordinator.pause()` directly
588
+ * and instead use the synchronizers `pause()` method.
589
+ *
590
+ * @returns a void promise that resolves once complete, throws if user does not have proper roles
591
+ */
592
+ public async pause(): Promise<void> {
593
+ this._logger.sendTelemetryEvent(
594
+ TelemetryEvents.MediaPlayerSynchronizer.PauseCalled
595
+ );
596
+ this._expectedPlaybackState = "paused";
597
+ await this._mediaSession.coordinator.pause();
598
+
599
+ this.dispatchUserAction({
600
+ action: "pause",
601
+ source: "user",
602
+ clientId: await waitUntilConnected(this._runtime),
603
+ local: true,
604
+ });
605
+ }
606
+
607
+ /**
608
+ * Tells the group to seek the current video to a new playback position.
609
+ *
610
+ * @remarks
611
+ * For proper operation apps should avoid calling `mediaSession.coordinator.seekTo()` directly
612
+ * and instead use the synchronizers `seekTo()` method.
613
+ *
614
+ * @returns a void promise that resolves once complete, throws if user does not have proper roles
615
+ */
616
+ public async seekTo(time: number): Promise<void> {
617
+ // Always seek player to new time.
618
+ // - This resolves an issue where the timeline scrubber can temporarily snap back to the original
619
+ // location.
620
+ this._player.currentTime = time;
621
+
622
+ this._logger.sendTelemetryEvent(
623
+ TelemetryEvents.MediaPlayerSynchronizer.SeekToCalled,
624
+ null,
625
+ { position: time }
626
+ );
627
+ await this._mediaSession.coordinator.seekTo(time);
628
+
629
+ this.dispatchUserAction({
630
+ action: "seekto",
631
+ source: "user",
632
+ clientId: await waitUntilConnected(this._runtime),
633
+ seekTime: time,
634
+ local: true,
635
+ });
636
+ }
637
+
638
+ /**
639
+ * Tells the group to change to a new track.
640
+ *
641
+ * @remarks
642
+ * For proper operation apps should avoid calling `mediaSession.coordinator.setTrack()` directly
643
+ * and instead use the synchronizers `setTrack()` method.
644
+ *
645
+ * @returns a void promise that resolves once complete, throws if user does not have proper roles
646
+ */
647
+ public async setTrack(
648
+ track: ExtendedMediaMetadata,
649
+ waitPoints?: CoordinationWaitPoint[]
650
+ ): Promise<void> {
651
+ this._logger.sendTelemetryEvent(
652
+ TelemetryEvents.MediaPlayerSynchronizer.SetTrackCalled
653
+ );
654
+ await this._mediaSession.coordinator.setTrack(track, waitPoints);
655
+
656
+ this.dispatchUserAction({
657
+ action: "settrack",
658
+ source: "user",
659
+ clientId: await waitUntilConnected(this._runtime),
660
+ metadata: track,
661
+ local: true,
662
+ });
663
+ }
664
+
665
+ /**
666
+ * Updates the current tracks data object.
667
+ *
668
+ * @remarks
669
+ * For proper operation apps should avoid calling `mediaSession.coordinator.setTrackData()` directly
670
+ * and instead use the synchronizers `setTrackData()` method.
671
+ *
672
+ * @returns a void promise that resolves once complete, throws if user does not have proper roles
673
+ */
674
+ public async setTrackData(data: object | null): Promise<void> {
675
+ this._logger.sendTelemetryEvent(
676
+ TelemetryEvents.MediaPlayerSynchronizer.SetTrackDataCalled
677
+ );
678
+ this._trackData = data;
679
+ await this._mediaSession.coordinator.setTrackData(data);
680
+
681
+ this.dispatchUserAction({
682
+ action: "datachange",
683
+ source: "user",
684
+ clientId: await waitUntilConnected(this._runtime),
685
+ local: true,
686
+ data: data,
687
+ });
688
+ }
689
+
690
+ // TODO: what is delay useful for? we do not use, delete?
691
+ private dispatchGroupAction(
692
+ details: ExtendedMediaSessionActionDetails,
693
+ error: Error | undefined
694
+ ): void {
695
+ this.emit(MediaPlayerSynchronizerEvents.groupaction, {
696
+ type: MediaPlayerSynchronizerEvents.groupaction,
697
+ details: details,
698
+ error,
699
+ });
700
+ }
701
+
702
+ private dispatchUserAction(
703
+ details: ExtendedMediaSessionActionDetails
704
+ ): void {
705
+ this.emit(MediaPlayerSynchronizerEvents.useraction, {
706
+ type: MediaPlayerSynchronizerEvents.useraction,
707
+ details: details,
708
+ });
709
+ }
710
+
711
+ private async catchupPlayer(time: number): Promise<void> {
712
+ this._player.currentTime = time;
713
+ }
714
+ }