@microsoft/live-share-media 1.2.2 → 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 (54) 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 +4 -6
  6. package/bin/LiveMediaSession.js.map +1 -1
  7. package/bin/LiveMediaSessionCoordinator.d.ts +4 -3
  8. package/bin/LiveMediaSessionCoordinator.d.ts.map +1 -1
  9. package/bin/LiveMediaSessionCoordinator.js +56 -69
  10. package/bin/LiveMediaSessionCoordinator.js.map +1 -1
  11. package/bin/MediaPlayerSynchronizer.d.ts +12 -0
  12. package/bin/MediaPlayerSynchronizer.d.ts.map +1 -1
  13. package/bin/MediaPlayerSynchronizer.js +26 -6
  14. package/bin/MediaPlayerSynchronizer.js.map +1 -1
  15. package/bin/MediaSessionExtensions.d.ts +0 -4
  16. package/bin/MediaSessionExtensions.d.ts.map +1 -1
  17. package/bin/MediaSessionExtensions.js.map +1 -1
  18. package/bin/internals/GroupCoordinatorState.d.ts.map +1 -1
  19. package/bin/internals/GroupCoordinatorState.js +1 -1
  20. package/bin/internals/GroupCoordinatorState.js.map +1 -1
  21. package/bin/internals/consts.d.ts +2 -2
  22. package/bin/internals/consts.d.ts.map +1 -1
  23. package/bin/internals/consts.js +2 -2
  24. package/bin/internals/consts.js.map +1 -1
  25. package/bin/internals/errors.d.ts +17 -0
  26. package/bin/internals/errors.d.ts.map +1 -0
  27. package/bin/internals/errors.js +34 -0
  28. package/bin/internals/errors.js.map +1 -0
  29. package/bin/internals/index.d.ts +1 -0
  30. package/bin/internals/index.d.ts.map +1 -1
  31. package/bin/internals/index.js +1 -0
  32. package/bin/internals/index.js.map +1 -1
  33. package/package.json +45 -45
  34. package/src/IMediaPlayer.ts +29 -29
  35. package/src/LiveMediaSession.ts +337 -334
  36. package/src/LiveMediaSessionCoordinator.ts +711 -671
  37. package/src/LiveMediaSessionCoordinatorSuspension.ts +39 -39
  38. package/src/MediaPlayerSynchronizer.ts +714 -690
  39. package/src/MediaSessionActionThrottler.ts +19 -19
  40. package/src/MediaSessionExtensions.ts +182 -187
  41. package/src/RepeatedActionThrottler.ts +76 -76
  42. package/src/VolumeManager.ts +200 -200
  43. package/src/index.ts +14 -14
  44. package/src/internals/GroupCoordinatorState.ts +685 -683
  45. package/src/internals/GroupPlaybackPosition.ts +228 -228
  46. package/src/internals/GroupPlaybackTrack.ts +182 -182
  47. package/src/internals/GroupPlaybackTrackData.ts +104 -104
  48. package/src/internals/GroupTransportState.ts +170 -170
  49. package/src/internals/consts.ts +103 -102
  50. package/src/internals/errors.ts +42 -0
  51. package/src/internals/index.ts +12 -11
  52. package/tsconfig.build.json +8 -8
  53. package/tsconfig.json +76 -76
  54. package/typedoc.json +10 -10
@@ -1,671 +1,711 @@
1
- /**
2
- * Copyright (c) Microsoft Corporation. All rights reserved.
3
- * Licensed under the Microsoft Live Share SDK License.
4
- */
5
-
6
- import {
7
- LiveEventScope,
8
- LiveTelemetryLogger,
9
- LiveEventTarget,
10
- IRuntimeSignaler,
11
- TimeInterval,
12
- UserMeetingRole,
13
- LiveShareRuntime,
14
- } from "@microsoft/live-share";
15
- import {
16
- CoordinationWaitPoint,
17
- ExtendedMediaMetadata,
18
- ExtendedMediaSessionPlaybackState,
19
- MediaSessionCoordinatorSuspension,
20
- } from "./MediaSessionExtensions";
21
- import {
22
- TelemetryEvents,
23
- ITransportCommandEvent,
24
- ISetTrackEvent,
25
- IPositionUpdateEvent,
26
- GroupCoordinatorState,
27
- GroupCoordinatorStateEvents,
28
- ISetTrackDataEvent,
29
- } from "./internals";
30
- import { LiveMediaSessionCoordinatorSuspension } from "./LiveMediaSessionCoordinatorSuspension";
31
- import EventEmitter from "events";
32
- import { isErrorLike } from "@microsoft/live-share/bin/internals";
33
-
34
- /**
35
- * Most recent state of the media session.
36
- */
37
- export interface IMediaPlayerState {
38
- /**
39
- * Metadata for the sessions current track.
40
- */
41
- metadata: ExtendedMediaMetadata | null;
42
-
43
- /**
44
- * Optional track data object being synchronized.
45
- *
46
- * @remarks
47
- * This can be used to sync things like pitch, roll, and yaw when watching 360 videos together.
48
- */
49
- trackData: object | null;
50
-
51
- /**
52
- * Sessions current playback state.
53
- */
54
- playbackState: ExtendedMediaSessionPlaybackState;
55
-
56
- /**
57
- * Sessions current position state if known.
58
- */
59
- positionState?: MediaPositionState;
60
- }
61
-
62
- /**
63
- * The `LiveMediaSessionCoordinator` tracks the playback & position state of all other
64
- * clients being synchronized with. It is responsible for keeping the local media player
65
- * in sync with the group.
66
- */
67
- export class LiveMediaSessionCoordinator extends EventEmitter {
68
- private readonly _runtime: IRuntimeSignaler;
69
- private readonly _liveRuntime: LiveShareRuntime;
70
- private readonly _logger: LiveTelemetryLogger;
71
- private readonly _getPlayerState: () => IMediaPlayerState;
72
- private _positionUpdateInterval = new TimeInterval(2000);
73
- private _maxPlaybackDrift = new TimeInterval(1000);
74
- private _lastWaitPoint?: CoordinationWaitPoint;
75
- private _hasInitialized = false;
76
-
77
- // Broadcast events
78
- private _playEvent?: LiveEventTarget<ITransportCommandEvent>;
79
- private _pauseEvent?: LiveEventTarget<ITransportCommandEvent>;
80
- private _seekToEvent?: LiveEventTarget<ITransportCommandEvent>;
81
- private _setTrackEvent?: LiveEventTarget<ISetTrackEvent>;
82
- private _setTrackDataEvent?: LiveEventTarget<ISetTrackDataEvent>;
83
- private _positionUpdateEvent?: LiveEventTarget<IPositionUpdateEvent>;
84
- private _joinedEvent?: LiveEventTarget<undefined>;
85
-
86
- // Distributed state
87
- private _groupState?: GroupCoordinatorState;
88
-
89
- /**
90
- * @hidden
91
- * Applications shouldn't directly create new coordinator instances.
92
- */
93
- constructor(
94
- runtime: IRuntimeSignaler,
95
- liveRuntime: LiveShareRuntime,
96
- getPlayerState: () => IMediaPlayerState
97
- ) {
98
- super();
99
- this._runtime = runtime;
100
- this._liveRuntime = liveRuntime;
101
- this._logger = new LiveTelemetryLogger(runtime, liveRuntime);
102
- this._getPlayerState = getPlayerState;
103
- }
104
-
105
- /**
106
- * Controls whether or not the local client is allowed to instruct the group to play or pause.
107
- *
108
- * @remarks
109
- * This flag largely meant to influence decisions made by the coordinator and can be used by
110
- * the UI to determine what controls should be shown to the user. It does not provide any
111
- * security in itself.
112
- *
113
- * If your app is running in a semi-trusted environment where only some clients are allowed
114
- * to play/pause media, you should use "role based verification" to enforce those policies.
115
- */
116
- public canPlayPause: boolean = true;
117
-
118
- /**
119
- * Controls whether or not the local client is allowed to seek the group to a new playback
120
- * position.
121
- *
122
- * @remarks
123
- * This flag largely meant to influence decisions made by the coordinator and can be used by
124
- * the UI to determine what controls should be shown to the user. It does not provide any
125
- * security in itself.
126
- *
127
- * If your app is running in a semi-trusted environment where only some clients are allowed
128
- * to change the playback position, you should use "role based verification" to enforce those policies.
129
- */
130
- public canSeek: boolean = true;
131
-
132
- /**
133
- * Controls whether or not the local client is allowed to change tracks.
134
- *
135
- * @remarks
136
- * This flag largely meant to influence decisions made by the coordinator and can be used by
137
- * the UI to determine what controls should be shown to the user. It does not provide any
138
- * security in itself.
139
- *
140
- * If your app is running in a semi-trusted environment where only some clients are allowed
141
- * to change tracks, you should use "role based verification" to enforce those policies.
142
- */
143
- public canSetTrack: boolean = true;
144
-
145
- /**
146
- * Controls whether or not the local client is allowed to change the tracks custom data object.
147
- *
148
- * @remarks
149
- * This flag largely meant to influence decisions made by the coordinator and can be used by
150
- * the UI to determine what controls should be shown to the user. It does not provide any
151
- * security in itself.
152
- *
153
- * If your app is running in a semi-trusted environment where only some clients are allowed
154
- * to change the tracks data object, you should use "role based verification" to enforce those
155
- * policies.
156
- */
157
- public canSetTrackData: boolean = true;
158
-
159
- /**
160
- * Controls whether or not the local client is allowed to send position updates to the group.
161
- *
162
- * @remarks
163
- * This flag largely meant to limit the number of signals sent to the group for performance reasons.
164
- * It does not provide any security in itself.
165
- */
166
- public canSendPositionUpdates: boolean = true;
167
-
168
- /**
169
- * Returns true if the local client is in a suspended state.
170
- */
171
- public get isSuspended(): boolean {
172
- return this._groupState ? this._groupState.isSuspended : false;
173
- }
174
-
175
- /**
176
- * Max amount of playback drift allowed in seconds.
177
- *
178
- * @remarks
179
- * Should the local clients playback position lag by more than the specified value, the
180
- * coordinator will trigger a `catchup` action.
181
- *
182
- * Defaults to a value of `1` second.
183
- */
184
- public get maxPlaybackDrift(): number {
185
- return this._maxPlaybackDrift.seconds;
186
- }
187
-
188
- public set maxPlaybackDrift(value: number) {
189
- this._maxPlaybackDrift.seconds = value;
190
- }
191
-
192
- /**
193
- * Frequency with which position updates are broadcast to the rest of the group in
194
- * seconds.
195
- *
196
- * @remarks
197
- * Defaults to a value of `2` seconds.
198
- */
199
- public get positionUpdateInterval(): number {
200
- return this._positionUpdateInterval.seconds;
201
- }
202
-
203
- public set positionUpdateInterval(value: number) {
204
- this._positionUpdateInterval.seconds = value;
205
- }
206
-
207
- /**
208
- * Instructs the group to play the current track.
209
- *
210
- * @remarks
211
- * Throws an exception if the session/coordinator hasn't been initialized, no track has been
212
- * loaded, or `canPlayPause` is false.
213
- *
214
- * @returns a void promise that resolves once complete, throws if user does not have proper roles
215
- */
216
- public async play(): Promise<void> {
217
- if (!this._hasInitialized) {
218
- throw new Error(
219
- `LiveMediaSessionCoordinator.play() called before initialize() called.`
220
- );
221
- }
222
-
223
- if (!this._groupState?.playbackTrack.current.metadata) {
224
- throw new Error(
225
- `LiveMediaSessionCoordinator.play() called before MediaSession.metadata assigned.`
226
- );
227
- }
228
-
229
- if (!this.canPlayPause) {
230
- throw new Error(
231
- `LiveMediaSessionCoordinator.play() operation blocked.`
232
- );
233
- }
234
-
235
- // Get projected position
236
- const position = this.getPlayerPosition();
237
-
238
- // Send transport command
239
- this._logger.sendTelemetryEvent(
240
- TelemetryEvents.SessionCoordinator.PlayCalled,
241
- null,
242
- { position: position }
243
- );
244
- await this._playEvent!.sendEvent({
245
- track: this._groupState.playbackTrack.current,
246
- position: position,
247
- });
248
- }
249
-
250
- /**
251
- * Instructs the group to pause the current track.
252
- *
253
- * @remarks
254
- * Throws an exception if the session/coordinator hasn't been initialized, no track has been
255
- * loaded, or `canPlayPause` is false.
256
- *
257
- * @returns a void promise that resolves once complete, throws if user does not have proper roles
258
- */
259
- public async pause(): Promise<void> {
260
- if (!this._hasInitialized) {
261
- throw new Error(
262
- `LiveMediaSessionCoordinator.pause() called before initialize() called.`
263
- );
264
- }
265
-
266
- if (!this._groupState?.playbackTrack.current.metadata) {
267
- throw new Error(
268
- `LiveMediaSessionCoordinator.pause() called before MediaSession.metadata assigned.`
269
- );
270
- }
271
-
272
- if (!this.canPlayPause) {
273
- throw new Error(
274
- `LiveMediaSessionCoordinator.pause() operation blocked.`
275
- );
276
- }
277
-
278
- // Get projected position
279
- const position = this.getPlayerPosition();
280
-
281
- // Send transport command
282
- this._logger.sendTelemetryEvent(
283
- TelemetryEvents.SessionCoordinator.PauseCalled,
284
- null,
285
- { position: position }
286
- );
287
- await this._pauseEvent!.sendEvent({
288
- track: this._groupState.playbackTrack.current,
289
- position: position,
290
- });
291
- }
292
-
293
- /**
294
- * Instructs the group to seek to a new position within the current track.
295
- *
296
- * @remarks
297
- * Throws an exception if the session/coordinator hasn't been initialized, no track has been
298
- * loaded, or `canSeek` is false.
299
- * @param time Playback position in seconds to seek to.
300
- * @returns a void promise that resolves once complete, throws if user does not have proper roles
301
- */
302
- public async seekTo(time: number): Promise<void> {
303
- if (!this._hasInitialized) {
304
- throw new Error(
305
- `LiveMediaSessionCoordinator.seekTo() called before initialize() called.`
306
- );
307
- }
308
-
309
- if (!this._groupState?.playbackTrack.current.metadata) {
310
- throw new Error(
311
- `LiveMediaSessionCoordinator.seekTo() called before MediaSession.metadata assigned.`
312
- );
313
- }
314
-
315
- if (!this.canSeek) {
316
- throw new Error(
317
- `LiveMediaSessionCoordinator.seekTo() operation blocked.`
318
- );
319
- }
320
-
321
- // Send transport command
322
- this._logger.sendTelemetryEvent(
323
- TelemetryEvents.SessionCoordinator.SeekToCalled,
324
- null,
325
- { position: time }
326
- );
327
- try {
328
- await this._seekToEvent!.sendEvent({
329
- track: this._groupState.playbackTrack.current,
330
- position: time,
331
- });
332
- } catch (err) {
333
- await this._groupState!.syncLocalMediaSession();
334
- throw err;
335
- }
336
- }
337
-
338
- /**
339
- * Instructs the group to load a new track.
340
- *
341
- * @remarks
342
- * Throws an exception if the session/coordinator hasn't been initialized or `canSetTrack` is
343
- * false.
344
- * @param metadata The track to load or `null` to indicate that the end of the track is reached.
345
- * @param waitPoints Optional. List of static wait points to configure for the track. Dynamic wait points can be added via the `beginSuspension()` call.
346
- * @returns a void promise that resolves once complete, throws if user does not have proper roles
347
- */
348
- public async setTrack(
349
- metadata: ExtendedMediaMetadata | null,
350
- waitPoints?: CoordinationWaitPoint[]
351
- ): Promise<void> {
352
- if (!this._hasInitialized) {
353
- throw new Error(
354
- `LiveMediaSessionCoordinator.setTrack() called before initialize() called.`
355
- );
356
- }
357
-
358
- if (!this.canSetTrack) {
359
- throw new Error(
360
- `LiveMediaSessionCoordinator.setTrack() operation blocked.`
361
- );
362
- }
363
-
364
- // Send transport command
365
- this._logger.sendTelemetryEvent(
366
- TelemetryEvents.SessionCoordinator.SetTrackCalled
367
- );
368
- await this._setTrackEvent!.sendEvent({
369
- metadata: metadata,
370
- waitPoints: waitPoints || [],
371
- });
372
- }
373
-
374
- /**
375
- * Updates the track data object for the current track.
376
- *
377
- * @remarks
378
- * The track data object can be used by applications to synchronize things like pitch, roll,
379
- * and yaw of a 360 video. This data object will be reset to null anytime the track changes.
380
- *
381
- * Throws an exception if the session/coordinator hasn't been initialized or `canSetTrackData` is
382
- * false.
383
- * @param data New data object to sync with the group. This value will be synchronized using a last writer wins strategy.
384
- * @returns a void promise that resolves once complete, throws if user does not have proper roles
385
- */
386
- public async setTrackData(data: object | null): Promise<void> {
387
- if (!this._hasInitialized) {
388
- throw new Error(
389
- `LiveMediaSessionCoordinator.setTrackData() called before initialize() called.`
390
- );
391
- }
392
-
393
- if (!this.canSetTrackData) {
394
- throw new Error(
395
- `LiveMediaSessionCoordinator.setTrackData() operation blocked.`
396
- );
397
- }
398
-
399
- // Send transport command
400
- this._logger.sendTelemetryEvent(
401
- TelemetryEvents.SessionCoordinator.SetTrackDataCalled
402
- );
403
- await this._setTrackDataEvent!.sendEvent({
404
- data: data,
405
- });
406
- }
407
-
408
- /**
409
- * Begins a new local suspension.
410
- *
411
- * @remarks
412
- * Suspension temporarily suspend the clients local synchronization with the group. This can
413
- * be useful for displaying ads to users or temporarily disconnecting from the session while
414
- * the user seeks the video using a timeline scrubber.
415
- *
416
- * Multiple simultaneous suspensions are allowed and when the last suspension ends the local
417
- * client will be immediately re-synchronized with the group.
418
- *
419
- * A "Dynamic Wait Point" can be specified when `beginSuspension()` is called and the wait
420
- * point will be broadcast to all other clients in the group. Those clients will then
421
- * automatically enter a suspension state once they reach the positions specified by the
422
- * wait point. Clients that are passed the wait point will immediately suspend.
423
- *
424
- * Any wait point based suspension (dynamic or static) will result in all clients remaining
425
- * in a suspension state until the list client ends their suspension. This behavior can be
426
- * conditionally bypassed by settings the wait points `maxClients` value.
427
- *
428
- * Throws an exception if the session/coordinator hasn't been initialized.
429
- * @param waitPoint Optional. Dynamic wait point to broadcast to all of the clients.
430
- * @returns The suspension object. Call `end()` on the returned suspension to end the suspension.
431
- */
432
- public beginSuspension(
433
- waitPoint?: CoordinationWaitPoint
434
- ): MediaSessionCoordinatorSuspension {
435
- if (!this._hasInitialized) {
436
- throw new Error(
437
- `LiveMediaSessionCoordinator.beginSuspension() called before initialize() called.`
438
- );
439
- }
440
-
441
- if (!this._groupState?.playbackTrack.current.metadata) {
442
- throw new Error(
443
- `LiveMediaSessionCoordinator.beginSuspension() called before MediaSession.metadata assigned.`
444
- );
445
- }
446
-
447
- // Tell group state that suspension is started
448
- if (waitPoint) {
449
- this._logger.sendTelemetryEvent(
450
- TelemetryEvents.SessionCoordinator.BeginSuspensionAndWait,
451
- null,
452
- {
453
- position: waitPoint.position,
454
- maxClients: waitPoint.maxClients,
455
- }
456
- );
457
- this._lastWaitPoint = waitPoint;
458
- this._groupState.beginSuspension(waitPoint);
459
- } else {
460
- this._logger.sendTelemetryEvent(
461
- TelemetryEvents.SessionCoordinator.BeginSuspension
462
- );
463
- this._groupState.beginSuspension();
464
- }
465
-
466
- // Return new suspension object
467
- return new LiveMediaSessionCoordinatorSuspension(waitPoint, (time) => {
468
- if (waitPoint) {
469
- this._logger.sendTelemetryEvent(
470
- TelemetryEvents.SessionCoordinator.EndSuspensionAndWait,
471
- null,
472
- {
473
- position: waitPoint.position,
474
- maxClients: waitPoint.maxClients,
475
- }
476
- );
477
- } else {
478
- this._logger.sendTelemetryEvent(
479
- TelemetryEvents.SessionCoordinator.EndSuspension
480
- );
481
- }
482
- this._groupState!.endSuspension(time == undefined);
483
- if (
484
- time != undefined &&
485
- !this._groupState?.isSuspended &&
486
- !this._groupState?.isWaiting
487
- ) {
488
- // Seek to new position
489
- this.seekTo(Math.max(time, 0.0));
490
- }
491
- });
492
- }
493
-
494
- /**
495
- * @hidden
496
- * Called by MediaSession to verify the coordinator has been initialized.
497
- */
498
- public get isInitialized(): boolean {
499
- return this._hasInitialized;
500
- }
501
-
502
- /**
503
- * @hidden
504
- * Called by MediaSession to start coordinator.
505
- */
506
- public async initialize(
507
- acceptTransportChangesFrom?: UserMeetingRole[]
508
- ): Promise<void> {
509
- if (this._hasInitialized) {
510
- throw new Error(
511
- `LiveMediaSessionCoordinator.initialize() already initialized.`
512
- );
513
- }
514
-
515
- // Create children
516
- await this.createChildren(acceptTransportChangesFrom);
517
- this._hasInitialized = true;
518
- }
519
-
520
- /**
521
- * @hidden
522
- * Called by the MediaSession to detect if a wait point has been hit.
523
- */
524
- public findNextWaitPoint(): CoordinationWaitPoint | null {
525
- return (
526
- this._groupState?.playbackTrack.findNextWaitPoint(
527
- this._lastWaitPoint
528
- ) || null
529
- );
530
- }
531
-
532
- /**
533
- * @hidden
534
- * Called by MediaSession to trigger the sending of a position update.
535
- */
536
- public sendPositionUpdate(state: IMediaPlayerState): void {
537
- if (!this._hasInitialized) {
538
- throw new Error(
539
- `LiveMediaSessionCoordinator.sendPositionUpdate() called before initialize() called.`
540
- );
541
- }
542
-
543
- if (this.canSendPositionUpdates) {
544
- // Send position update event
545
- const evt = this._groupState!.createPositionUpdateEvent(state);
546
- this._positionUpdateEvent?.sendEvent(evt).catch((err) => {
547
- this._logger.sendErrorEvent(
548
- TelemetryEvents.SessionCoordinator.PositionUpdateEventError,
549
- err
550
- );
551
- });
552
- } else {
553
- // make sure position is synced to position of clients who can send position updates
554
- this._groupState!.syncLocalMediaSession();
555
- }
556
- }
557
-
558
- protected async createChildren(
559
- acceptTransportChangesFrom?: UserMeetingRole[]
560
- ): Promise<void> {
561
- // Create event scopes
562
- const scope = new LiveEventScope(
563
- this._runtime,
564
- this._liveRuntime,
565
- acceptTransportChangesFrom
566
- );
567
- const unrestrictedScope = new LiveEventScope(
568
- this._runtime,
569
- this._liveRuntime
570
- );
571
-
572
- // Initialize internal coordinator state
573
- this._groupState = new GroupCoordinatorState(
574
- this._runtime,
575
- this._liveRuntime,
576
- this._maxPlaybackDrift,
577
- this._positionUpdateInterval,
578
- this._getPlayerState
579
- );
580
- this._groupState.on(GroupCoordinatorStateEvents.triggeraction, (evt) =>
581
- this.emit(evt.name, evt)
582
- );
583
-
584
- // Listen for track changes
585
- this._setTrackEvent = new LiveEventTarget<ISetTrackEvent>(
586
- scope,
587
- "setTrack",
588
- (event, local) => this._groupState!.handleSetTrack(event, local)
589
- );
590
- this._setTrackDataEvent = new LiveEventTarget<ISetTrackDataEvent>(
591
- scope,
592
- "setTrackData",
593
- (event, local) => this._groupState!.handleSetTrackData(event, local)
594
- );
595
-
596
- // Listen for transport commands
597
- this._playEvent = new LiveEventTarget(scope, "play", (event, local) =>
598
- this._groupState!.handleTransportCommand(event, local)
599
- );
600
- this._pauseEvent = new LiveEventTarget(scope, "pause", (event, local) =>
601
- this._groupState!.handleTransportCommand(event, local)
602
- );
603
- this._seekToEvent = new LiveEventTarget(
604
- scope,
605
- "seekTo",
606
- (event, local) =>
607
- this._groupState!.handleTransportCommand(event, local)
608
- );
609
-
610
- // Listen for position updates
611
- this._positionUpdateEvent = new LiveEventTarget(
612
- unrestrictedScope,
613
- "positionUpdate",
614
- (event, local) =>
615
- this._groupState!.handlePositionUpdate(event, local)
616
- );
617
-
618
- // Listen for joined event
619
- this._joinedEvent = new LiveEventTarget(
620
- unrestrictedScope,
621
- "joined",
622
- (evt, local) => {
623
- // Immediately send a position update
624
- this._logger.sendTelemetryEvent(
625
- TelemetryEvents.SessionCoordinator.RemoteJoinReceived,
626
- null,
627
- {
628
- correlationId: LiveTelemetryLogger.formatCorrelationId(
629
- evt.clientId,
630
- evt.timestamp
631
- ),
632
- }
633
- );
634
- try {
635
- const state = this._getPlayerState();
636
- this.sendPositionUpdate(state);
637
- } catch (err: any) {
638
- // if player is not setup yet, local client might have also just joined and can't send its position.
639
- const playerNotSetup =
640
- isErrorLike(err) &&
641
- err.message.includes(
642
- "LiveMediaSession: no getPlayerState callback configured."
643
- );
644
- if (!playerNotSetup) {
645
- throw err;
646
- }
647
- }
648
- }
649
- );
650
- // Send initial joined event
651
- this._joinedEvent?.sendEvent(undefined).catch((err) => {
652
- this._logger.sendErrorEvent(
653
- TelemetryEvents.SessionCoordinator.SendJoinedEventError,
654
- err
655
- );
656
- });
657
- }
658
-
659
- private getPlayerPosition(): number {
660
- const { positionState, playbackState } = this._getPlayerState();
661
- switch (playbackState) {
662
- case "none":
663
- case "ended":
664
- return 0.0;
665
- default:
666
- return positionState && positionState.position != undefined
667
- ? positionState.position
668
- : 0.0;
669
- }
670
- }
671
- }
1
+ /**
2
+ * Copyright (c) Microsoft Corporation. All rights reserved.
3
+ * Licensed under the Microsoft Live Share SDK License.
4
+ */
5
+
6
+ import {
7
+ LiveEventScope,
8
+ LiveTelemetryLogger,
9
+ LiveEventTarget,
10
+ IRuntimeSignaler,
11
+ TimeInterval,
12
+ UserMeetingRole,
13
+ LiveShareRuntime,
14
+ LiveDataObjectInitializeState,
15
+ LiveDataObjectInitializeNotNeededError,
16
+ LiveDataObjectNotInitializedError,
17
+ LiveObjectSynchronizer,
18
+ ILiveEvent,
19
+ } from "@microsoft/live-share";
20
+ import {
21
+ CoordinationWaitPoint,
22
+ ExtendedMediaMetadata,
23
+ ExtendedMediaSessionPlaybackState,
24
+ MediaSessionCoordinatorSuspension,
25
+ } from "./MediaSessionExtensions";
26
+ import {
27
+ TelemetryEvents,
28
+ ITransportCommandEvent,
29
+ ISetTrackEvent,
30
+ IPositionUpdateEvent,
31
+ GroupCoordinatorState,
32
+ GroupCoordinatorStateEvents,
33
+ ISetTrackDataEvent,
34
+ TrackMetadataNotSetError,
35
+ ActionBlockedError,
36
+ } from "./internals";
37
+ import { LiveMediaSessionCoordinatorSuspension } from "./LiveMediaSessionCoordinatorSuspension";
38
+ import EventEmitter from "events";
39
+ import { isErrorLike } from "@microsoft/live-share/bin/internals";
40
+
41
+ /**
42
+ * Most recent state of the media session.
43
+ */
44
+ export interface IMediaPlayerState {
45
+ /**
46
+ * Metadata for the sessions current track.
47
+ */
48
+ metadata: ExtendedMediaMetadata | null;
49
+
50
+ /**
51
+ * Optional track data object being synchronized.
52
+ *
53
+ * @remarks
54
+ * This can be used to sync things like pitch, roll, and yaw when watching 360 videos together.
55
+ */
56
+ trackData: object | null;
57
+
58
+ /**
59
+ * Sessions current playback state.
60
+ */
61
+ playbackState: ExtendedMediaSessionPlaybackState;
62
+
63
+ /**
64
+ * Sessions current position state if known.
65
+ */
66
+ positionState?: MediaPositionState;
67
+ }
68
+
69
+ /**
70
+ * The `LiveMediaSessionCoordinator` tracks the playback & position state of all other
71
+ * clients being synchronized with. It is responsible for keeping the local media player
72
+ * in sync with the group.
73
+ */
74
+ export class LiveMediaSessionCoordinator extends EventEmitter {
75
+ private readonly _id: string;
76
+ private readonly _runtime: IRuntimeSignaler;
77
+ private readonly _liveRuntime: LiveShareRuntime;
78
+ private readonly _logger: LiveTelemetryLogger;
79
+ private readonly _getPlayerState: () => IMediaPlayerState;
80
+ private _positionUpdateInterval = new TimeInterval(2000);
81
+ private _maxPlaybackDrift = new TimeInterval(1000);
82
+ private _lastWaitPoint?: CoordinationWaitPoint;
83
+ private initializeState: LiveDataObjectInitializeState =
84
+ LiveDataObjectInitializeState.needed;
85
+
86
+ // Broadcast events
87
+ private _playEvent?: LiveEventTarget<ITransportCommandEvent>;
88
+ private _pauseEvent?: LiveEventTarget<ITransportCommandEvent>;
89
+ private _seekToEvent?: LiveEventTarget<ITransportCommandEvent>;
90
+ private _setTrackEvent?: LiveEventTarget<ISetTrackEvent>;
91
+ private _setTrackDataEvent?: LiveEventTarget<ISetTrackDataEvent>;
92
+ private _positionUpdateEvent?: LiveEventTarget<IPositionUpdateEvent>;
93
+
94
+ // Synchronizer does not play a significant role in LiveMediaSession beyond being able to
95
+ // use "connect" events to replace old "joined" events when sending position data to new clients.
96
+ private _synchronizer?: LiveObjectSynchronizer<undefined>;
97
+
98
+ // Distributed state
99
+ private _groupState?: GroupCoordinatorState;
100
+
101
+ /**
102
+ * @hidden
103
+ * Applications shouldn't directly create new coordinator instances.
104
+ */
105
+ constructor(
106
+ id: string,
107
+ runtime: IRuntimeSignaler,
108
+ liveRuntime: LiveShareRuntime,
109
+ getPlayerState: () => IMediaPlayerState
110
+ ) {
111
+ super();
112
+ this._id = id;
113
+ this._runtime = runtime;
114
+ this._liveRuntime = liveRuntime;
115
+ this._logger = new LiveTelemetryLogger(runtime, liveRuntime);
116
+ this._getPlayerState = getPlayerState;
117
+ }
118
+
119
+ /**
120
+ * Controls whether or not the local client is allowed to instruct the group to play or pause.
121
+ *
122
+ * @remarks
123
+ * This flag largely meant to influence decisions made by the coordinator and can be used by
124
+ * the UI to determine what controls should be shown to the user. It does not provide any
125
+ * security in itself.
126
+ *
127
+ * If your app is running in a semi-trusted environment where only some clients are allowed
128
+ * to play/pause media, you should use "role based verification" to enforce those policies.
129
+ */
130
+ public canPlayPause: boolean = true;
131
+
132
+ /**
133
+ * Controls whether or not the local client is allowed to seek the group to a new playback
134
+ * position.
135
+ *
136
+ * @remarks
137
+ * This flag largely meant to influence decisions made by the coordinator and can be used by
138
+ * the UI to determine what controls should be shown to the user. It does not provide any
139
+ * security in itself.
140
+ *
141
+ * If your app is running in a semi-trusted environment where only some clients are allowed
142
+ * to change the playback position, you should use "role based verification" to enforce those policies.
143
+ */
144
+ public canSeek: boolean = true;
145
+
146
+ /**
147
+ * Controls whether or not the local client is allowed to change tracks.
148
+ *
149
+ * @remarks
150
+ * This flag largely meant to influence decisions made by the coordinator and can be used by
151
+ * the UI to determine what controls should be shown to the user. It does not provide any
152
+ * security in itself.
153
+ *
154
+ * If your app is running in a semi-trusted environment where only some clients are allowed
155
+ * to change tracks, you should use "role based verification" to enforce those policies.
156
+ */
157
+ public canSetTrack: boolean = true;
158
+
159
+ /**
160
+ * Controls whether or not the local client is allowed to change the tracks custom data object.
161
+ *
162
+ * @remarks
163
+ * This flag largely meant to influence decisions made by the coordinator and can be used by
164
+ * the UI to determine what controls should be shown to the user. It does not provide any
165
+ * security in itself.
166
+ *
167
+ * If your app is running in a semi-trusted environment where only some clients are allowed
168
+ * to change the tracks data object, you should use "role based verification" to enforce those
169
+ * policies.
170
+ */
171
+ public canSetTrackData: boolean = true;
172
+
173
+ /**
174
+ * Controls whether or not the local client is allowed to send position updates to the group.
175
+ *
176
+ * @remarks
177
+ * This flag largely meant to limit the number of signals sent to the group for performance reasons.
178
+ * It does not provide any security in itself.
179
+ */
180
+ public canSendPositionUpdates: boolean = true;
181
+
182
+ /**
183
+ * Returns true if the local client is in a suspended state.
184
+ */
185
+ public get isSuspended(): boolean {
186
+ return this._groupState ? this._groupState.isSuspended : false;
187
+ }
188
+
189
+ /**
190
+ * Max amount of playback drift allowed in seconds.
191
+ *
192
+ * @remarks
193
+ * Should the local clients playback position lag by more than the specified value, the
194
+ * coordinator will trigger a `catchup` action.
195
+ *
196
+ * Defaults to a value of `1` second.
197
+ */
198
+ public get maxPlaybackDrift(): number {
199
+ return this._maxPlaybackDrift.seconds;
200
+ }
201
+
202
+ public set maxPlaybackDrift(value: number) {
203
+ this._maxPlaybackDrift.seconds = value;
204
+ }
205
+
206
+ /**
207
+ * Frequency with which position updates are broadcast to the rest of the group in
208
+ * seconds.
209
+ *
210
+ * @remarks
211
+ * Defaults to a value of `2` seconds.
212
+ */
213
+ public get positionUpdateInterval(): number {
214
+ return this._positionUpdateInterval.seconds;
215
+ }
216
+
217
+ public set positionUpdateInterval(value: number) {
218
+ this._positionUpdateInterval.seconds = value;
219
+ }
220
+
221
+ /**
222
+ * Instructs the group to play the current track.
223
+ *
224
+ * @remarks
225
+ * Throws an exception if the session/coordinator hasn't been initialized, no track has been
226
+ * loaded, or `canPlayPause` is false.
227
+ *
228
+ * @returns a void promise that resolves once complete, throws if user does not have proper roles
229
+ */
230
+ public async play(): Promise<void> {
231
+ LiveDataObjectNotInitializedError.assert(
232
+ "LiveMediaSessionCoordinator:play",
233
+ "play",
234
+ this.initializeState
235
+ );
236
+ TrackMetadataNotSetError.assert(
237
+ !!this._groupState?.playbackTrack.current.metadata,
238
+ "LiveMediaSessionCoordinator:play",
239
+ "play"
240
+ );
241
+ ActionBlockedError.assert(
242
+ this.canPlayPause,
243
+ "LiveMediaSessionCoordinator:play",
244
+ "play",
245
+ "canPlayPause"
246
+ );
247
+
248
+ // Get projected position
249
+ const position = this.getPlayerPosition();
250
+
251
+ // Send transport command
252
+ this._logger.sendTelemetryEvent(
253
+ TelemetryEvents.SessionCoordinator.PlayCalled,
254
+ null,
255
+ { position: position }
256
+ );
257
+ await this._playEvent!.sendEvent({
258
+ track: this._groupState.playbackTrack.current,
259
+ position: position,
260
+ });
261
+ }
262
+
263
+ /**
264
+ * Instructs the group to pause the current track.
265
+ *
266
+ * @remarks
267
+ * Throws an exception if the session/coordinator hasn't been initialized, no track has been
268
+ * loaded, or `canPlayPause` is false.
269
+ *
270
+ * @returns a void promise that resolves once complete, throws if user does not have proper roles
271
+ */
272
+ public async pause(): Promise<void> {
273
+ LiveDataObjectNotInitializedError.assert(
274
+ "LiveMediaSessionCoordinator:pause",
275
+ "pause",
276
+ this.initializeState
277
+ );
278
+ TrackMetadataNotSetError.assert(
279
+ !!this._groupState?.playbackTrack.current.metadata,
280
+ "LiveMediaSessionCoordinator:pause",
281
+ "pause"
282
+ );
283
+ ActionBlockedError.assert(
284
+ this.canPlayPause,
285
+ "LiveMediaSessionCoordinator:pause",
286
+ "pause",
287
+ "canPlayPause"
288
+ );
289
+
290
+ // Get projected position
291
+ const position = this.getPlayerPosition();
292
+
293
+ // Send transport command
294
+ this._logger.sendTelemetryEvent(
295
+ TelemetryEvents.SessionCoordinator.PauseCalled,
296
+ null,
297
+ { position: position }
298
+ );
299
+ await this._pauseEvent!.sendEvent({
300
+ track: this._groupState.playbackTrack.current,
301
+ position: position,
302
+ });
303
+ }
304
+
305
+ /**
306
+ * Instructs the group to seek to a new position within the current track.
307
+ *
308
+ * @remarks
309
+ * Throws an exception if the session/coordinator hasn't been initialized, no track has been
310
+ * loaded, or `canSeek` is false.
311
+ * @param time Playback position in seconds to seek to.
312
+ * @returns a void promise that resolves once complete, throws if user does not have proper roles
313
+ */
314
+ public async seekTo(time: number): Promise<void> {
315
+ LiveDataObjectNotInitializedError.assert(
316
+ "LiveMediaSessionCoordinator:seekTo",
317
+ "seekTo",
318
+ this.initializeState
319
+ );
320
+ TrackMetadataNotSetError.assert(
321
+ !!this._groupState?.playbackTrack.current.metadata,
322
+ "LiveMediaSessionCoordinator:seekTo",
323
+ "seekTo"
324
+ );
325
+ ActionBlockedError.assert(
326
+ this.canSeek,
327
+ "LiveMediaSessionCoordinator:seekTo",
328
+ "seekTo",
329
+ "canSeek"
330
+ );
331
+
332
+ // Send transport command
333
+ this._logger.sendTelemetryEvent(
334
+ TelemetryEvents.SessionCoordinator.SeekToCalled,
335
+ null,
336
+ { position: time }
337
+ );
338
+ try {
339
+ await this._seekToEvent!.sendEvent({
340
+ track: this._groupState.playbackTrack.current,
341
+ position: time,
342
+ });
343
+ } catch (err) {
344
+ await this._groupState!.syncLocalMediaSession();
345
+ throw err;
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Instructs the group to load a new track.
351
+ *
352
+ * @remarks
353
+ * Throws an exception if the session/coordinator hasn't been initialized or `canSetTrack` is
354
+ * false.
355
+ * @param metadata The track to load or `null` to indicate that the end of the track is reached.
356
+ * @param waitPoints Optional. List of static wait points to configure for the track. Dynamic wait points can be added via the `beginSuspension()` call.
357
+ * @returns a void promise that resolves once complete, throws if user does not have proper roles
358
+ */
359
+ public async setTrack(
360
+ metadata: ExtendedMediaMetadata | null,
361
+ waitPoints?: CoordinationWaitPoint[]
362
+ ): Promise<void> {
363
+ LiveDataObjectNotInitializedError.assert(
364
+ "LiveMediaSessionCoordinator:setTrack",
365
+ "setTrack",
366
+ this.initializeState
367
+ );
368
+ ActionBlockedError.assert(
369
+ this.canSetTrack,
370
+ "LiveMediaSessionCoordinator:setTrack",
371
+ "setTrack",
372
+ "canSetTrack"
373
+ );
374
+
375
+ // Send transport command
376
+ this._logger.sendTelemetryEvent(
377
+ TelemetryEvents.SessionCoordinator.SetTrackCalled
378
+ );
379
+ await this._setTrackEvent!.sendEvent({
380
+ metadata: metadata,
381
+ waitPoints: waitPoints || [],
382
+ });
383
+ }
384
+
385
+ /**
386
+ * Updates the track data object for the current track.
387
+ *
388
+ * @remarks
389
+ * The track data object can be used by applications to synchronize things like pitch, roll,
390
+ * and yaw of a 360 video. This data object will be reset to null anytime the track changes.
391
+ *
392
+ * Throws an exception if the session/coordinator hasn't been initialized or `canSetTrackData` is
393
+ * false.
394
+ * @param data New data object to sync with the group. This value will be synchronized using a last writer wins strategy.
395
+ * @returns a void promise that resolves once complete, throws if user does not have proper roles
396
+ */
397
+ public async setTrackData(data: object | null): Promise<void> {
398
+ LiveDataObjectNotInitializedError.assert(
399
+ "LiveMediaSessionCoordinator:setTrackData",
400
+ "setTrackData",
401
+ this.initializeState
402
+ );
403
+ ActionBlockedError.assert(
404
+ this.canSetTrackData,
405
+ "LiveMediaSessionCoordinator:setTrackData",
406
+ "setTrackData",
407
+ "canSetTrackData"
408
+ );
409
+
410
+ // Send transport command
411
+ this._logger.sendTelemetryEvent(
412
+ TelemetryEvents.SessionCoordinator.SetTrackDataCalled
413
+ );
414
+ await this._setTrackDataEvent!.sendEvent({
415
+ data: data,
416
+ });
417
+ }
418
+
419
+ /**
420
+ * Begins a new local suspension.
421
+ *
422
+ * @remarks
423
+ * Suspension temporarily suspend the clients local synchronization with the group. This can
424
+ * be useful for displaying ads to users or temporarily disconnecting from the session while
425
+ * the user seeks the video using a timeline scrubber.
426
+ *
427
+ * Multiple simultaneous suspensions are allowed and when the last suspension ends the local
428
+ * client will be immediately re-synchronized with the group.
429
+ *
430
+ * A "Dynamic Wait Point" can be specified when `beginSuspension()` is called and the wait
431
+ * point will be broadcast to all other clients in the group. Those clients will then
432
+ * automatically enter a suspension state once they reach the positions specified by the
433
+ * wait point. Clients that are passed the wait point will immediately suspend.
434
+ *
435
+ * Any wait point based suspension (dynamic or static) will result in all clients remaining
436
+ * in a suspension state until the list client ends their suspension. This behavior can be
437
+ * conditionally bypassed by settings the wait points `maxClients` value.
438
+ *
439
+ * Throws an exception if the session/coordinator hasn't been initialized.
440
+ * @param waitPoint Optional. Dynamic wait point to broadcast to all of the clients.
441
+ * @returns The suspension object. Call `end()` on the returned suspension to end the suspension.
442
+ */
443
+ public beginSuspension(
444
+ waitPoint?: CoordinationWaitPoint
445
+ ): MediaSessionCoordinatorSuspension {
446
+ LiveDataObjectNotInitializedError.assert(
447
+ "LiveMediaSessionCoordinator:beginSuspension",
448
+ "beginSuspension",
449
+ this.initializeState
450
+ );
451
+ TrackMetadataNotSetError.assert(
452
+ !!this._groupState?.playbackTrack.current.metadata,
453
+ "LiveMediaSessionCoordinator:beginSuspension",
454
+ "beginSuspension"
455
+ );
456
+
457
+ // Tell group state that suspension is started
458
+ if (waitPoint) {
459
+ this._logger.sendTelemetryEvent(
460
+ TelemetryEvents.SessionCoordinator.BeginSuspensionAndWait,
461
+ null,
462
+ {
463
+ position: waitPoint.position,
464
+ maxClients: waitPoint.maxClients,
465
+ }
466
+ );
467
+ this._lastWaitPoint = waitPoint;
468
+ this._groupState.beginSuspension(waitPoint);
469
+ } else {
470
+ this._logger.sendTelemetryEvent(
471
+ TelemetryEvents.SessionCoordinator.BeginSuspension
472
+ );
473
+ this._groupState.beginSuspension();
474
+ }
475
+
476
+ // Return new suspension object
477
+ return new LiveMediaSessionCoordinatorSuspension(waitPoint, (time) => {
478
+ if (waitPoint) {
479
+ this._logger.sendTelemetryEvent(
480
+ TelemetryEvents.SessionCoordinator.EndSuspensionAndWait,
481
+ null,
482
+ {
483
+ position: waitPoint.position,
484
+ maxClients: waitPoint.maxClients,
485
+ }
486
+ );
487
+ } else {
488
+ this._logger.sendTelemetryEvent(
489
+ TelemetryEvents.SessionCoordinator.EndSuspension
490
+ );
491
+ }
492
+ this._groupState!.endSuspension(time == undefined);
493
+ if (
494
+ time != undefined &&
495
+ !this._groupState?.isSuspended &&
496
+ !this._groupState?.isWaiting
497
+ ) {
498
+ // Seek to new position
499
+ this.seekTo(Math.max(time, 0.0));
500
+ }
501
+ });
502
+ }
503
+
504
+ /**
505
+ * @hidden
506
+ * Called by MediaSession to verify the coordinator has been initialized.
507
+ */
508
+ public get isInitialized(): boolean {
509
+ return this.initializeState === LiveDataObjectInitializeState.succeeded;
510
+ }
511
+
512
+ /**
513
+ * @hidden
514
+ * Called by MediaSession to start coordinator.
515
+ */
516
+ public async initialize(
517
+ acceptTransportChangesFrom?: UserMeetingRole[]
518
+ ): Promise<void> {
519
+ LiveDataObjectInitializeNotNeededError.assert(
520
+ "LiveMediaSessionCoordinator:initialize",
521
+ this.initializeState
522
+ );
523
+ this.initializeState = LiveDataObjectInitializeState.pending;
524
+
525
+ try {
526
+ // Create children
527
+ await this.createChildren(acceptTransportChangesFrom);
528
+ } catch (error: unknown) {
529
+ this.initializeState = LiveDataObjectInitializeState.needed;
530
+ throw error;
531
+ }
532
+ this.initializeState = LiveDataObjectInitializeState.succeeded;
533
+
534
+ try {
535
+ // start needs to happen after setting initializedState to "succeeded"
536
+ await this._synchronizer?.start(
537
+ undefined,
538
+ async (evt, sender, local) => false,
539
+ async (connecting) => true,
540
+ false,
541
+ false
542
+ );
543
+ } catch (error: unknown) {
544
+ // not a fatal error for LiveMediaSession, only used for "connect" event to send new clients position updates.
545
+ this._logger.sendErrorEvent(
546
+ TelemetryEvents.SessionCoordinator
547
+ .LiveObjectSynchronizerStartError,
548
+ error
549
+ );
550
+ }
551
+ }
552
+
553
+ /**
554
+ * @hidden
555
+ * Called by the MediaSession to detect if a wait point has been hit.
556
+ */
557
+ public findNextWaitPoint(): CoordinationWaitPoint | null {
558
+ return (
559
+ this._groupState?.playbackTrack.findNextWaitPoint(
560
+ this._lastWaitPoint
561
+ ) || null
562
+ );
563
+ }
564
+
565
+ /**
566
+ * @hidden
567
+ * Called by MediaSession to trigger the sending of a position update.
568
+ */
569
+ public sendPositionUpdate(state: IMediaPlayerState): void {
570
+ LiveDataObjectNotInitializedError.assert(
571
+ "LiveMediaSessionCoordinator:sendPositionUpdate",
572
+ "sendPositionUpdate",
573
+ this.initializeState
574
+ );
575
+
576
+ if (this.canSendPositionUpdates) {
577
+ // Send position update event
578
+ const evt = this._groupState!.createPositionUpdateEvent(state);
579
+ this._positionUpdateEvent?.sendEvent(evt).catch((err) => {
580
+ this._logger.sendErrorEvent(
581
+ TelemetryEvents.SessionCoordinator.PositionUpdateEventError,
582
+ err
583
+ );
584
+ });
585
+ } else if (this.isSuspended) {
586
+ // send a local only position update event that was not sent as a signal, and use to handle local
587
+ // position update for clients that have canSendPositionUpdates==false, but are suspending
588
+ const evt = this._groupState!.createPositionUpdateEvent(state);
589
+ this._positionUpdateEvent?.sendLocalEvent(evt).catch((err) => {
590
+ this._logger.sendErrorEvent(
591
+ TelemetryEvents.SessionCoordinator.PositionUpdateEventError,
592
+ err
593
+ );
594
+ });
595
+ } else {
596
+ // make sure position is synced to position of clients who can send position updates
597
+ this._groupState!.syncLocalMediaSession();
598
+ }
599
+ }
600
+
601
+ protected async createChildren(
602
+ acceptTransportChangesFrom?: UserMeetingRole[]
603
+ ): Promise<void> {
604
+ // Create event scopes
605
+ const scope = new LiveEventScope(
606
+ this._runtime,
607
+ this._liveRuntime,
608
+ acceptTransportChangesFrom
609
+ );
610
+ const unrestrictedScope = new LiveEventScope(
611
+ this._runtime,
612
+ this._liveRuntime
613
+ );
614
+
615
+ // Initialize internal coordinator state
616
+ this._groupState = new GroupCoordinatorState(
617
+ this._runtime,
618
+ this._liveRuntime,
619
+ this._maxPlaybackDrift,
620
+ this._positionUpdateInterval,
621
+ this._getPlayerState
622
+ );
623
+ this._groupState.on(GroupCoordinatorStateEvents.triggeraction, (evt) =>
624
+ this.emit(evt.name, evt)
625
+ );
626
+
627
+ // Listen for track changes
628
+ this._setTrackEvent = new LiveEventTarget<ISetTrackEvent>(
629
+ scope,
630
+ "setTrack",
631
+ (event, local) => this._groupState!.handleSetTrack(event, local)
632
+ );
633
+ this._setTrackDataEvent = new LiveEventTarget<ISetTrackDataEvent>(
634
+ scope,
635
+ "setTrackData",
636
+ (event, local) => this._groupState!.handleSetTrackData(event, local)
637
+ );
638
+
639
+ // Listen for transport commands
640
+ this._playEvent = new LiveEventTarget(scope, "play", (event, local) =>
641
+ this._groupState!.handleTransportCommand(event, local)
642
+ );
643
+ this._pauseEvent = new LiveEventTarget(scope, "pause", (event, local) =>
644
+ this._groupState!.handleTransportCommand(event, local)
645
+ );
646
+ this._seekToEvent = new LiveEventTarget(
647
+ scope,
648
+ "seekTo",
649
+ (event, local) =>
650
+ this._groupState!.handleTransportCommand(event, local)
651
+ );
652
+
653
+ // Listen for position updates
654
+ this._positionUpdateEvent = new LiveEventTarget(
655
+ unrestrictedScope,
656
+ "positionUpdate",
657
+ (event, local) =>
658
+ this._groupState!.handlePositionUpdate(event, local)
659
+ );
660
+
661
+ this._synchronizer = new LiveObjectSynchronizer<undefined>(
662
+ this._id,
663
+ this._runtime,
664
+ this._liveRuntime
665
+ );
666
+
667
+ this._synchronizer.onJoinedListener = (
668
+ clientId: string,
669
+ timestamp: number
670
+ ) => {
671
+ this._logger.sendTelemetryEvent(
672
+ TelemetryEvents.SessionCoordinator.RemoteConnectReceived,
673
+ null,
674
+ {
675
+ correlationId: LiveTelemetryLogger.formatCorrelationId(
676
+ clientId,
677
+ timestamp
678
+ ),
679
+ }
680
+ );
681
+ // Immediately send a position update
682
+ try {
683
+ const state = this._getPlayerState();
684
+ this.sendPositionUpdate(state);
685
+ } catch (err: any) {
686
+ // if player is not setup yet, local client might have also just joined and can't send its position.
687
+ const playerNotSetup =
688
+ isErrorLike(err) &&
689
+ err.message.includes(
690
+ "LiveMediaSession:getCurrentPlayerState"
691
+ );
692
+ if (!playerNotSetup) {
693
+ throw err;
694
+ }
695
+ }
696
+ };
697
+ }
698
+
699
+ private getPlayerPosition(): number {
700
+ const { positionState, playbackState } = this._getPlayerState();
701
+ switch (playbackState) {
702
+ case "none":
703
+ case "ended":
704
+ return 0.0;
705
+ default:
706
+ return positionState && positionState.position != undefined
707
+ ? positionState.position
708
+ : 0.0;
709
+ }
710
+ }
711
+ }