@microsoft/live-share-media 1.3.0 → 1.4.0

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 (78) 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 +9 -1
  6. package/bin/LiveMediaSession.js.map +1 -1
  7. package/bin/LiveMediaSessionCoordinator.d.ts +18 -2
  8. package/bin/LiveMediaSessionCoordinator.d.ts.map +1 -1
  9. package/bin/LiveMediaSessionCoordinator.js +52 -13
  10. package/bin/LiveMediaSessionCoordinator.js.map +1 -1
  11. package/bin/MediaPlayerSynchronizer.d.ts +6 -4
  12. package/bin/MediaPlayerSynchronizer.d.ts.map +1 -1
  13. package/bin/MediaPlayerSynchronizer.js +41 -12
  14. package/bin/MediaPlayerSynchronizer.js.map +1 -1
  15. package/bin/MediaSessionExtensions.d.ts +17 -8
  16. package/bin/MediaSessionExtensions.d.ts.map +1 -1
  17. package/bin/MediaSessionExtensions.js +4 -0
  18. package/bin/MediaSessionExtensions.js.map +1 -1
  19. package/bin/internals/GroupCoordinatorState.d.ts +15 -1
  20. package/bin/internals/GroupCoordinatorState.d.ts.map +1 -1
  21. package/bin/internals/GroupCoordinatorState.js +123 -57
  22. package/bin/internals/GroupCoordinatorState.js.map +1 -1
  23. package/bin/internals/GroupPlaybackPosition.d.ts +3 -1
  24. package/bin/internals/GroupPlaybackPosition.d.ts.map +1 -1
  25. package/bin/internals/GroupPlaybackPosition.js +7 -3
  26. package/bin/internals/GroupPlaybackPosition.js.map +1 -1
  27. package/bin/internals/GroupPlaybackRate.d.ts +39 -0
  28. package/bin/internals/GroupPlaybackRate.d.ts.map +1 -0
  29. package/bin/internals/GroupPlaybackRate.js +62 -0
  30. package/bin/internals/GroupPlaybackRate.js.map +1 -0
  31. package/bin/internals/GroupPlaybackTrack.d.ts +2 -2
  32. package/bin/internals/GroupPlaybackTrack.d.ts.map +1 -1
  33. package/bin/internals/GroupPlaybackTrack.js +3 -2
  34. package/bin/internals/GroupPlaybackTrack.js.map +1 -1
  35. package/bin/internals/GroupPlaybackTrackData.d.ts +3 -3
  36. package/bin/internals/GroupPlaybackTrackData.d.ts.map +1 -1
  37. package/bin/internals/GroupPlaybackTrackData.js +12 -11
  38. package/bin/internals/GroupPlaybackTrackData.js.map +1 -1
  39. package/bin/internals/GroupTransportState.d.ts +6 -3
  40. package/bin/internals/GroupTransportState.d.ts.map +1 -1
  41. package/bin/internals/GroupTransportState.js +16 -11
  42. package/bin/internals/GroupTransportState.js.map +1 -1
  43. package/bin/internals/consts.d.ts +10 -3
  44. package/bin/internals/consts.d.ts.map +1 -1
  45. package/bin/internals/consts.js +10 -3
  46. package/bin/internals/consts.js.map +1 -1
  47. package/bin/internals/index.d.ts +1 -0
  48. package/bin/internals/index.d.ts.map +1 -1
  49. package/bin/internals/index.js +1 -0
  50. package/bin/internals/index.js.map +1 -1
  51. package/bin/internals/interfaces.d.ts +10 -0
  52. package/bin/internals/interfaces.d.ts.map +1 -0
  53. package/bin/internals/interfaces.js +3 -0
  54. package/bin/internals/interfaces.js.map +1 -0
  55. package/package.json +45 -45
  56. package/src/IMediaPlayer.ts +29 -29
  57. package/src/LiveMediaSession.ts +349 -336
  58. package/src/LiveMediaSessionCoordinator.ts +760 -678
  59. package/src/LiveMediaSessionCoordinatorSuspension.ts +39 -39
  60. package/src/MediaPlayerSynchronizer.ts +754 -714
  61. package/src/MediaSessionActionThrottler.ts +19 -19
  62. package/src/MediaSessionExtensions.ts +198 -187
  63. package/src/RepeatedActionThrottler.ts +76 -76
  64. package/src/VolumeManager.ts +200 -200
  65. package/src/index.ts +14 -14
  66. package/src/internals/GroupCoordinatorState.ts +802 -685
  67. package/src/internals/GroupPlaybackPosition.ts +235 -228
  68. package/src/internals/GroupPlaybackRate.ts +90 -0
  69. package/src/internals/GroupPlaybackTrack.ts +183 -182
  70. package/src/internals/GroupPlaybackTrackData.ts +105 -104
  71. package/src/internals/GroupTransportState.ts +179 -170
  72. package/src/internals/consts.ts +115 -102
  73. package/src/internals/errors.ts +42 -42
  74. package/src/internals/index.ts +13 -12
  75. package/src/internals/interfaces.ts +10 -0
  76. package/tsconfig.build.json +8 -8
  77. package/tsconfig.json +76 -76
  78. package/typedoc.json +10 -10
@@ -1,685 +1,802 @@
1
- /**
2
- * Copyright (c) Microsoft Corporation. All rights reserved.
3
- * Licensed under the Microsoft Live Share SDK License.
4
- */
5
-
6
- import {
7
- ILiveEvent,
8
- TimeInterval,
9
- IRuntimeSignaler,
10
- LiveTelemetryLogger,
11
- LiveShareRuntime,
12
- IEvent,
13
- } from "@microsoft/live-share";
14
- import EventEmitter from "events";
15
- import {
16
- ExtendedMediaMetadata,
17
- CoordinationWaitPoint,
18
- ExtendedMediaSessionPlaybackState,
19
- ExtendedMediaSessionActionDetails,
20
- ExtendedMediaSessionActionSource,
21
- } from "../MediaSessionExtensions";
22
- import {
23
- GroupPlaybackTrack,
24
- GroupPlaybackTrackEvents,
25
- IPlaybackTrack,
26
- } from "./GroupPlaybackTrack";
27
- import { GroupTransportState, ITransportState } from "./GroupTransportState";
28
- import {
29
- GroupPlaybackPosition,
30
- ICurrentPlaybackPosition,
31
- } from "./GroupPlaybackPosition";
32
- import { IMediaPlayerState } from "../LiveMediaSessionCoordinator";
33
- import { GroupTransportStateEvents } from "./GroupTransportState";
34
- import {
35
- GroupPlaybackTrackData,
36
- PlaybackTrackDataEvents,
37
- IPlaybackTrackData,
38
- } from "./GroupPlaybackTrackData";
39
- import { TelemetryEvents } from "./consts";
40
- import { waitUntilConnected } from "@microsoft/live-share/bin/internals";
41
-
42
- /**
43
- * @hidden
44
- */
45
- export interface IPositionUpdateEvent {
46
- track: IPlaybackTrack;
47
- trackData: IPlaybackTrackData;
48
- transport: ITransportState;
49
- playbackState: ExtendedMediaSessionPlaybackState;
50
- position: number;
51
- waitPoint?: CoordinationWaitPoint;
52
- }
53
-
54
- /**
55
- * @hidden
56
- */
57
- export interface ITransportCommandEvent {
58
- track: IPlaybackTrack;
59
- position: number;
60
- }
61
-
62
- /**
63
- * @hidden
64
- */
65
- export interface ISetTrackEvent {
66
- metadata: ExtendedMediaMetadata | null;
67
- waitPoints: CoordinationWaitPoint[];
68
- }
69
-
70
- /**
71
- * @hidden
72
- */
73
- export interface ISetTrackDataEvent {
74
- data: object | null;
75
- }
76
-
77
- /**
78
- * @hidden
79
- */
80
- export interface ITriggerActionEvent extends IEvent {
81
- details: ExtendedMediaSessionActionDetails;
82
- }
83
-
84
- /**
85
- * @hidden
86
- */
87
- export enum GroupCoordinatorStateEvents {
88
- newwaitpoint = "newwaitpoint",
89
- triggeraction = "triggeraction",
90
- }
91
-
92
- /**
93
- * @hidden
94
- */
95
- export class GroupCoordinatorState extends EventEmitter {
96
- private readonly _runtime: IRuntimeSignaler;
97
- private readonly _liveRuntime: LiveShareRuntime;
98
- private readonly _logger: LiveTelemetryLogger;
99
- private readonly _maxPlaybackDrift: TimeInterval;
100
- private _getMediaPlayerState: () => IMediaPlayerState;
101
-
102
- // Shared group state
103
- private _playbackTrack: GroupPlaybackTrack;
104
- private _playbackTrackData: GroupPlaybackTrackData;
105
- private _transportState: GroupTransportState;
106
- private _playbackPosition: GroupPlaybackPosition;
107
-
108
- // Suspension tracking
109
- private _suspensionCnt: number = 0;
110
- private _waitPoint?: CoordinationWaitPoint;
111
-
112
- // "Soft Suspension" tracking
113
- // - Soft suspensions temporarily disconnect the local media session when transitioning between
114
- // paused, playing, and none states. This is to give the local client time to finish any inprogress
115
- // operations like sending a transport command or ending a suspension.
116
- private _lastStateChange: ExtendedMediaSessionPlaybackState = "none";
117
- private _lastStateChangeTime: number = 0;
118
-
119
- constructor(
120
- runtime: IRuntimeSignaler,
121
- liveRuntime: LiveShareRuntime,
122
- maxPlaybackDrift: TimeInterval,
123
- positionUpdateInterval: TimeInterval,
124
- getMediaPlayerState: () => IMediaPlayerState
125
- ) {
126
- super();
127
- this._runtime = runtime;
128
- this._liveRuntime = liveRuntime;
129
- this._logger = new LiveTelemetryLogger(runtime, liveRuntime);
130
- this._maxPlaybackDrift = maxPlaybackDrift;
131
- this._playbackTrack = new GroupPlaybackTrack(getMediaPlayerState);
132
- this._playbackTrackData = new GroupPlaybackTrackData(
133
- this._playbackTrack
134
- );
135
- this._transportState = new GroupTransportState(
136
- this._playbackTrack,
137
- getMediaPlayerState,
138
- this._liveRuntime
139
- );
140
- this._playbackPosition = new GroupPlaybackPosition(
141
- this._transportState,
142
- this._runtime,
143
- this._liveRuntime,
144
- positionUpdateInterval
145
- );
146
- this._getMediaPlayerState = getMediaPlayerState;
147
-
148
- // Listen track related events
149
- this._playbackTrack.on(GroupPlaybackTrackEvents.trackChange, (evt) => {
150
- if (!this.isSuspended) {
151
- this._logger.sendTelemetryEvent(
152
- TelemetryEvents.GroupCoordinator.TrackChanged
153
- );
154
- this.emitSetTrack(evt.clientId, evt.metadata!, evt.source);
155
- } else {
156
- this._logger.sendTelemetryEvent(
157
- TelemetryEvents.GroupCoordinator.TrackChangeDelayed
158
- );
159
- }
160
- });
161
-
162
- // Listen for track data changes
163
- this._playbackTrackData.on(
164
- PlaybackTrackDataEvents.dataChange,
165
- async (evt) => {
166
- if (!this.isSuspended && !this.isWaiting) {
167
- this._logger.sendTelemetryEvent(
168
- TelemetryEvents.GroupCoordinator.TrackDataChanged
169
- );
170
- const clientId = await waitUntilConnected(this._runtime);
171
- this.emitTriggerAction({
172
- action: "datachange",
173
- source: evt.source,
174
- data: evt.data,
175
- clientId: evt.clientId,
176
- local: evt.clientId === clientId,
177
- });
178
- } else {
179
- this._logger.sendTelemetryEvent(
180
- TelemetryEvents.GroupCoordinator.TrackDataChangeDelayed
181
- );
182
- }
183
- }
184
- );
185
-
186
- // Listen to transport related events
187
- this._transportState.on(
188
- GroupTransportStateEvents.transportStateChange,
189
- async (evt) => {
190
- if (!this.isSuspended && !this.isWaiting) {
191
- this._logger.sendTelemetryEvent(
192
- TelemetryEvents.GroupCoordinator.TransportStateChanged,
193
- null,
194
- {
195
- action: evt.action,
196
- seekTime: evt.seekTime,
197
- }
198
- );
199
-
200
- const localClientId = await waitUntilConnected(
201
- this._runtime
202
- );
203
- const local = evt.clientId === localClientId;
204
- // Trigger action
205
- switch (evt.action) {
206
- case "play":
207
- this.emitTriggerAction({
208
- action: "play",
209
- source: evt.source,
210
- clientId: evt.clientId,
211
- local,
212
- seekTime: evt.seekTime,
213
- });
214
- break;
215
-
216
- case "pause":
217
- this.emitTriggerAction({
218
- action: "pause",
219
- source: evt.source,
220
- clientId: evt.clientId,
221
- local,
222
- seekTime: evt.seekTime,
223
- });
224
- break;
225
-
226
- case "seekto":
227
- this.emitTriggerAction({
228
- action: "seekto",
229
- source: evt.source,
230
- clientId: evt.clientId,
231
- local,
232
- seekTime: evt.seekTime,
233
- });
234
- break;
235
- }
236
- } else {
237
- this._logger.sendTelemetryEvent(
238
- TelemetryEvents.GroupCoordinator
239
- .TransportStateChangeDelayed,
240
- null,
241
- {
242
- action: evt.action,
243
- seekTime: evt.seekTime,
244
- }
245
- );
246
- }
247
- }
248
- );
249
- }
250
-
251
- public get playbackTrack(): GroupPlaybackTrack {
252
- return this._playbackTrack;
253
- }
254
-
255
- public get playbackTrackData(): GroupPlaybackTrackData {
256
- return this._playbackTrackData;
257
- }
258
-
259
- public get transportState(): GroupTransportState {
260
- return this._transportState;
261
- }
262
-
263
- public get playbackPosition(): GroupPlaybackPosition {
264
- return this._playbackPosition;
265
- }
266
-
267
- public get waitingAt(): CoordinationWaitPoint | undefined {
268
- return this._waitPoint;
269
- }
270
-
271
- public get isSuspended(): boolean {
272
- return this._suspensionCnt > 0;
273
- }
274
-
275
- public get isWaiting(): boolean {
276
- return !!this._waitPoint;
277
- }
278
-
279
- public beginSuspension(waitPoint?: CoordinationWaitPoint): void {
280
- this._suspensionCnt++;
281
-
282
- if (waitPoint) {
283
- // Save most recent wait point
284
- this._waitPoint = waitPoint;
285
-
286
- // Add to track state if dynamic wait point
287
- this.playbackTrack.addWaitPoint(waitPoint);
288
- }
289
- }
290
-
291
- public endSuspension(syncState: boolean): void {
292
- if (this._suspensionCnt == 0) {
293
- throw new Error(
294
- `GroupCoordinatorState:endSuspension - cannot end suspension when there are no active suspensions. The expected value is > 0 but the actual count is ${this._suspensionCnt}`
295
- );
296
- }
297
-
298
- this._suspensionCnt--;
299
- if (this._suspensionCnt == 0 && syncState) {
300
- // Sync media session to group
301
- this.syncLocalMediaSession();
302
- }
303
- }
304
-
305
- public createPositionUpdateEvent(
306
- state: IMediaPlayerState
307
- ): IPositionUpdateEvent {
308
- const { positionState, playbackState } = state;
309
- if (this.isSuspended) {
310
- // Report suspension state
311
- return {
312
- track: this.playbackTrack.current,
313
- trackData: this.playbackTrackData.current,
314
- transport: this.transportState.current,
315
- playbackState: "suspended",
316
- position: positionState?.position || 0.0,
317
- waitPoint: this._waitPoint,
318
- };
319
- } else {
320
- // Report current position
321
- return {
322
- track: this.playbackTrack.current,
323
- trackData: this.playbackTrackData.current,
324
- transport: this.transportState.current,
325
- playbackState: this._waitPoint ? "waiting" : playbackState,
326
- position: positionState?.position || 0.0,
327
- waitPoint: this._waitPoint,
328
- };
329
- }
330
- }
331
-
332
- public handleSetTrack(
333
- event: ILiveEvent<ISetTrackEvent>,
334
- local: boolean
335
- ): void {
336
- // Update shared track
337
- // - Will trigger 'trackChange' event to update media session
338
- const updated = this.playbackTrack.updateTrack(
339
- {
340
- metadata: event.data.metadata,
341
- waitPoints: event.data.waitPoints,
342
- timestamp: event.timestamp,
343
- clientId: event.clientId || "",
344
- },
345
- "user"
346
- );
347
-
348
- if (updated) {
349
- this._logger.sendTelemetryEvent(
350
- TelemetryEvents.SessionCoordinator.RemoteSetTrackReceived,
351
- null,
352
- {
353
- correlationId: LiveTelemetryLogger.formatCorrelationId(
354
- event.clientId,
355
- event.timestamp
356
- ),
357
- }
358
- );
359
- }
360
- }
361
-
362
- public handleSetTrackData(
363
- event: ILiveEvent<ISetTrackDataEvent>,
364
- local: boolean
365
- ): void {
366
- // Update shared track data
367
- // - Will trigger 'dataChange' event to update media session
368
- const updated = this.playbackTrackData.updateData(
369
- {
370
- data: event.data.data,
371
- timestamp: event.timestamp,
372
- clientId: event.clientId || "",
373
- },
374
- "user"
375
- );
376
-
377
- if (updated) {
378
- this._logger.sendTelemetryEvent(
379
- TelemetryEvents.SessionCoordinator.RemoteSetTrackReceived,
380
- null,
381
- {
382
- correlationId: LiveTelemetryLogger.formatCorrelationId(
383
- event.clientId,
384
- event.timestamp
385
- ),
386
- }
387
- );
388
- }
389
- }
390
-
391
- public handleTransportCommand(
392
- event: ILiveEvent<ITransportCommandEvent>,
393
- local: boolean
394
- ): void {
395
- // Ensure change is for current track
396
- // - Will trigger a 'trackChange' event if newer track.
397
- this.playbackTrack.updateTrack(event.data.track, "system");
398
- if (this.playbackTrack.compare(event.data.track.metadata)) {
399
- // Update playback state
400
- let playbackState = this.transportState.playbackState;
401
- switch (event.name) {
402
- case "play":
403
- playbackState = "playing";
404
- break;
405
- case "pause":
406
- playbackState = "paused";
407
- break;
408
- }
409
-
410
- // Try to update playback state
411
- const newState: ITransportState = {
412
- playbackState: playbackState,
413
- startPosition: event.data.position,
414
- timestamp: event.timestamp,
415
- clientId: event.clientId || "",
416
- };
417
- const updated = this.transportState.updateState(newState, "user");
418
-
419
- if (updated) {
420
- const correlationId = LiveTelemetryLogger.formatCorrelationId(
421
- event.clientId,
422
- event.timestamp
423
- );
424
- switch (event.name) {
425
- case "play":
426
- this._logger.sendTelemetryEvent(
427
- TelemetryEvents.SessionCoordinator
428
- .RemotePlayReceived,
429
- null,
430
- {
431
- correlationId: correlationId,
432
- }
433
- );
434
- break;
435
- case "pause":
436
- this._logger.sendTelemetryEvent(
437
- TelemetryEvents.SessionCoordinator
438
- .RemotePauseReceived,
439
- null,
440
- {
441
- correlationId: correlationId,
442
- }
443
- );
444
- break;
445
- case "seekTo":
446
- this._logger.sendTelemetryEvent(
447
- TelemetryEvents.SessionCoordinator
448
- .RemoteSeekToReceived,
449
- null,
450
- {
451
- correlationId: correlationId,
452
- }
453
- );
454
- break;
455
- }
456
- }
457
- }
458
- }
459
-
460
- public handlePositionUpdate(
461
- event: ILiveEvent<IPositionUpdateEvent>,
462
- local: boolean
463
- ): void {
464
- // Ensure change is for current track
465
- // - Will trigger a 'trackChange' event if newer track.
466
- this.playbackTrack.updateTrack(event.data.track, "system");
467
- if (this.playbackTrack.compare(event.data.track.metadata)) {
468
- // Ensure we have the latest track data
469
- this.playbackTrackData.updateData(event.data.trackData, "system");
470
-
471
- // Update transport state if needed
472
- // - Ignore transport state changes if the client has ended as this will cause the local
473
- // player to start playback even though the video may have ended for everyone.
474
- if (event.data.playbackState != "ended") {
475
- this.transportState.updateState(event.data.transport, "system");
476
- }
477
-
478
- // Ensure change is for current transport state
479
- if (this.transportState.compare(event.data.transport)) {
480
- // Update playback position
481
- const position: ICurrentPlaybackPosition = {
482
- playbackState: event.data.playbackState,
483
- waitPoint: event.data.waitPoint,
484
- position: event.data.position,
485
- timestamp: event.timestamp,
486
- clientId: event.clientId || "",
487
- };
488
- this.playbackPosition.UpdatePlaybackPosition(position);
489
-
490
- // Ensure local media session is in sync with group
491
- if (local && !this.isSuspended) {
492
- // Begin a "soft suspension" on select state changes
493
- // - This is needed because the catchup logic can try to sync the client with
494
- // the rest of the group while the local player is trying to to seek to a
495
- // new position.
496
- switch (event.data.playbackState) {
497
- case "none":
498
- case "paused":
499
- case "playing":
500
- if (
501
- event.data.playbackState !=
502
- this._lastStateChange
503
- ) {
504
- this._logger.sendTelemetryEvent(
505
- TelemetryEvents.GroupCoordinator
506
- .BeginSoftSuspension,
507
- null,
508
- { playbackState: event.data.playbackState }
509
- );
510
- this._lastStateChange =
511
- event.data.playbackState;
512
- this._lastStateChangeTime =
513
- this._liveRuntime.getTimestamp();
514
- }
515
- break;
516
- }
517
-
518
- // Are we waiting?
519
- if (this.isWaiting) {
520
- // Check for end of waiting
521
- if (this.playbackPosition.clientsWaiting == 0) {
522
- this._waitPoint = undefined;
523
-
524
- // Start syncing again
525
- this.syncLocalMediaSession();
526
- }
527
- } else {
528
- // Sync client with group
529
- this.syncLocalMediaSession();
530
- }
531
- }
532
- }
533
- }
534
- }
535
-
536
- public async syncLocalMediaSession(): Promise<void> {
537
- // Skip further syncs if we're waiting or in a "soft suspension".
538
- const softSuspensionDelta =
539
- this._liveRuntime.getTimestamp() - this._lastStateChangeTime;
540
- if (!this.isWaiting && softSuspensionDelta >= 1000) {
541
- let { metadata, trackData, positionState, playbackState } =
542
- this._getMediaPlayerState();
543
- this._logger.sendTelemetryEvent(
544
- TelemetryEvents.GroupCoordinator.CheckingForSyncIssues
545
- );
546
-
547
- // Check for track change
548
- if (!this.playbackTrack.compare(metadata)) {
549
- this._logger.sendTelemetryEvent(
550
- TelemetryEvents.GroupCoordinator.TrackOutOfSync
551
- );
552
- this.emitSetTrack(
553
- this.playbackTrack.current.clientId,
554
- this.playbackTrack.metadata!,
555
- "system"
556
- );
557
- return;
558
- }
559
-
560
- // Once local playback has ended there's no need to further sync the client. In fact
561
- // doing so could cause the player to loop.
562
- // - The playbackPosition.trackEnded check is to catch late joiners and prevents syncing if everyone
563
- // else has finished the video.
564
- // - Should another user seek or press play after the video has ended that will cause
565
- // a transport action to trigger which will take the player out of the ended state.
566
- if (
567
- playbackState != "ended" &&
568
- !this._playbackPosition.trackEnded
569
- ) {
570
- // Ensure we have a position
571
- let position = positionState?.position;
572
- if (position == undefined) {
573
- position = 0.0;
574
- }
575
-
576
- // Check for catchup
577
- // - Target can return -1 in cases where there is no position tracking data
578
- const target = this.playbackPosition.targetPosition;
579
- if (
580
- target >= 0.0 &&
581
- Math.abs(target - position) >=
582
- this._maxPlaybackDrift.seconds
583
- ) {
584
- this._logger.sendTelemetryEvent(
585
- TelemetryEvents.GroupCoordinator.PositionOutOfSync,
586
- null,
587
- {
588
- current: position,
589
- target: target,
590
- }
591
- );
592
- await this.emitTriggerAction({
593
- action: "catchup",
594
- source: "system",
595
- clientId: await waitUntilConnected(this._runtime),
596
- local: true,
597
- seekTime: target,
598
- });
599
- }
600
-
601
- const localClientId = await waitUntilConnected(this._runtime);
602
- // Sync transport state
603
- if (this.transportState.playbackState != playbackState) {
604
- this._logger.sendTelemetryEvent(
605
- TelemetryEvents.GroupCoordinator.TransportOutOfSync,
606
- null,
607
- {
608
- current: playbackState,
609
- target: this.transportState.playbackState,
610
- }
611
- );
612
- const local =
613
- localClientId === this.transportState.current.clientId;
614
- switch (this.transportState.playbackState) {
615
- case "playing":
616
- await this.emitTriggerAction({
617
- action: "play",
618
- source: "system",
619
- clientId: this.transportState.current.clientId,
620
- local,
621
- });
622
- break;
623
- case "paused":
624
- await this.emitTriggerAction({
625
- action: "pause",
626
- source: "system",
627
- clientId: this.transportState.current.clientId,
628
- local,
629
- });
630
- break;
631
- }
632
- }
633
-
634
- // Sync track data
635
- if (
636
- JSON.stringify(this.playbackTrackData.data) !=
637
- JSON.stringify(trackData)
638
- ) {
639
- this._logger.sendTelemetryEvent(
640
- TelemetryEvents.GroupCoordinator.TrackDataOutOfSync
641
- );
642
- const local =
643
- localClientId ===
644
- this.playbackTrackData.current.clientId;
645
- await this.emitTriggerAction({
646
- action: "datachange",
647
- source: "system",
648
- clientId: this.playbackTrackData.current.clientId,
649
- local,
650
- data: this.playbackTrackData.data,
651
- });
652
- }
653
- }
654
- }
655
- }
656
-
657
- private async emitSetTrack(
658
- clientId: string,
659
- metadata: ExtendedMediaMetadata,
660
- source: ExtendedMediaSessionActionSource
661
- ): Promise<void> {
662
- // Reset tracking states
663
- this._waitPoint = undefined;
664
-
665
- const localClientId = await waitUntilConnected(this._runtime);
666
- // Trigger settrack action
667
- this.emitTriggerAction({
668
- action: "settrack",
669
- source,
670
- clientId,
671
- local: localClientId === clientId,
672
- metadata,
673
- });
674
- }
675
-
676
- private emitTriggerAction(
677
- details: ExtendedMediaSessionActionDetails
678
- ): void {
679
- const evt: ITriggerActionEvent = {
680
- name: GroupCoordinatorStateEvents.triggeraction,
681
- details: details,
682
- };
683
- this.emit(GroupCoordinatorStateEvents.triggeraction, evt);
684
- }
685
- }
1
+ /**
2
+ * Copyright (c) Microsoft Corporation. All rights reserved.
3
+ * Licensed under the Microsoft Live Share SDK License.
4
+ */
5
+
6
+ import {
7
+ ILiveEvent,
8
+ TimeInterval,
9
+ IRuntimeSignaler,
10
+ LiveTelemetryLogger,
11
+ LiveShareRuntime,
12
+ IEvent,
13
+ } from "@microsoft/live-share";
14
+ import EventEmitter from "events";
15
+ import {
16
+ ExtendedMediaMetadata,
17
+ CoordinationWaitPoint,
18
+ ExtendedMediaSessionPlaybackState,
19
+ ExtendedMediaSessionActionDetails,
20
+ ExtendedMediaSessionActionSource,
21
+ } from "../MediaSessionExtensions";
22
+ import {
23
+ GroupPlaybackTrack,
24
+ GroupPlaybackTrackEvents,
25
+ IPlaybackTrack,
26
+ IPlaybackTrackChangeEvent,
27
+ } from "./GroupPlaybackTrack";
28
+ import {
29
+ GroupTransportState,
30
+ ITransportState,
31
+ ITransportStateChangeEvent,
32
+ } from "./GroupTransportState";
33
+ import {
34
+ GroupPlaybackPosition,
35
+ ICurrentPlaybackPosition,
36
+ } from "./GroupPlaybackPosition";
37
+ import { IMediaPlayerState } from "../LiveMediaSessionCoordinator";
38
+ import { GroupTransportStateEvents } from "./GroupTransportState";
39
+ import {
40
+ GroupPlaybackTrackData,
41
+ PlaybackTrackDataEvents,
42
+ IPlaybackTrackData,
43
+ IPlaybackTrackDataChangeEvent,
44
+ } from "./GroupPlaybackTrackData";
45
+ import { TelemetryEvents } from "./consts";
46
+ import { waitUntilConnected } from "@microsoft/live-share/bin/internals";
47
+ import {
48
+ GroupPlaybackRate,
49
+ IPlaybackRate,
50
+ IPlaybackRateChangeEvent,
51
+ PlaybackRateEvents,
52
+ } from "./GroupPlaybackRate";
53
+
54
+ /**
55
+ * @hidden
56
+ */
57
+ export interface IPositionUpdateEvent {
58
+ track: IPlaybackTrack;
59
+ trackData: IPlaybackTrackData;
60
+ transport: ITransportState;
61
+ playbackRate: IPlaybackRate;
62
+ playbackState: ExtendedMediaSessionPlaybackState;
63
+ position: number;
64
+ waitPoint?: CoordinationWaitPoint;
65
+ }
66
+
67
+ /**
68
+ * @hidden
69
+ */
70
+ export interface ITransportCommandEvent {
71
+ track: IPlaybackTrack;
72
+ position: number;
73
+ }
74
+
75
+ /**
76
+ * @hidden
77
+ */
78
+ export interface IRateChangeCommandEvent {
79
+ playbackRate: number;
80
+ }
81
+
82
+ /**
83
+ * @hidden
84
+ */
85
+ export interface ISetTrackEvent {
86
+ metadata: ExtendedMediaMetadata | null;
87
+ waitPoints: CoordinationWaitPoint[];
88
+ }
89
+
90
+ /**
91
+ * @hidden
92
+ */
93
+ export interface ISetTrackDataEvent {
94
+ data: object | null;
95
+ }
96
+
97
+ /**
98
+ * @hidden
99
+ */
100
+ export interface ITriggerActionEvent extends IEvent {
101
+ details: ExtendedMediaSessionActionDetails;
102
+ }
103
+
104
+ /**
105
+ * @hidden
106
+ */
107
+ export enum GroupCoordinatorStateEvents {
108
+ newwaitpoint = "newwaitpoint",
109
+ triggeraction = "triggeraction",
110
+ triggeractionignored = "triggeractionignored",
111
+ }
112
+
113
+ /**
114
+ * @hidden
115
+ */
116
+ export class GroupCoordinatorState extends EventEmitter {
117
+ private readonly _runtime: IRuntimeSignaler;
118
+ private readonly _liveRuntime: LiveShareRuntime;
119
+ private readonly _logger: LiveTelemetryLogger;
120
+ private readonly _maxPlaybackDrift: TimeInterval;
121
+ private _getMediaPlayerState: () => IMediaPlayerState;
122
+
123
+ // Shared group state
124
+ private _playbackTrack: GroupPlaybackTrack;
125
+ private _playbackTrackData: GroupPlaybackTrackData;
126
+ private _transportState: GroupTransportState;
127
+ private _playbackPosition: GroupPlaybackPosition;
128
+ private _playbackRate: GroupPlaybackRate;
129
+
130
+ // Suspension tracking
131
+ private _suspensionCnt: number = 0;
132
+ private _waitPoint?: CoordinationWaitPoint;
133
+
134
+ // "Soft Suspension" tracking
135
+ // - Soft suspensions temporarily disconnect the local media session when transitioning between
136
+ // paused, playing, and none states. This is to give the local client time to finish any inprogress
137
+ // operations like sending a transport command or ending a suspension.
138
+ private _lastStateChange: ExtendedMediaSessionPlaybackState = "none";
139
+ private _lastStateChangeTime: number = 0;
140
+
141
+ constructor(
142
+ runtime: IRuntimeSignaler,
143
+ liveRuntime: LiveShareRuntime,
144
+ maxPlaybackDrift: TimeInterval,
145
+ positionUpdateInterval: TimeInterval,
146
+ getMediaPlayerState: () => IMediaPlayerState
147
+ ) {
148
+ super();
149
+ this._runtime = runtime;
150
+ this._liveRuntime = liveRuntime;
151
+ this._logger = new LiveTelemetryLogger(runtime, liveRuntime);
152
+ this._maxPlaybackDrift = maxPlaybackDrift;
153
+ this._playbackTrack = new GroupPlaybackTrack(getMediaPlayerState);
154
+ this._playbackTrackData = new GroupPlaybackTrackData(
155
+ this._playbackTrack
156
+ );
157
+ this._playbackRate = new GroupPlaybackRate();
158
+ this._transportState = new GroupTransportState(
159
+ this._playbackTrack,
160
+ this._playbackRate,
161
+ getMediaPlayerState,
162
+ this._liveRuntime
163
+ );
164
+ this._playbackPosition = new GroupPlaybackPosition(
165
+ this._transportState,
166
+ this._playbackRate,
167
+ this._runtime,
168
+ this._liveRuntime,
169
+ positionUpdateInterval
170
+ );
171
+ this._getMediaPlayerState = getMediaPlayerState;
172
+
173
+ // Listen track related events
174
+ this._playbackTrack.on(
175
+ GroupPlaybackTrackEvents.trackChange,
176
+ (evt: IPlaybackTrackChangeEvent) => {
177
+ if (!this.isSuspended) {
178
+ this._logger.sendTelemetryEvent(
179
+ TelemetryEvents.GroupCoordinator.TrackChanged
180
+ );
181
+ this.emitSetTrack(evt.clientId, evt.metadata!, evt.source);
182
+ } else {
183
+ this._logger.sendTelemetryEvent(
184
+ TelemetryEvents.GroupCoordinator.TrackChangeDelayed
185
+ );
186
+ }
187
+ }
188
+ );
189
+
190
+ // Listen for track data changes
191
+ this._playbackTrackData.on(
192
+ PlaybackTrackDataEvents.dataChange,
193
+ async (evt: IPlaybackTrackDataChangeEvent) => {
194
+ if (!this.isSuspended && !this.isWaiting) {
195
+ this._logger.sendTelemetryEvent(
196
+ TelemetryEvents.GroupCoordinator.TrackDataChanged
197
+ );
198
+ } else {
199
+ this._logger.sendTelemetryEvent(
200
+ TelemetryEvents.GroupCoordinator.TrackDataChangeDelayed
201
+ );
202
+ }
203
+ const clientId = await waitUntilConnected(this._runtime);
204
+ this.emitTriggerActionOrIgnored({
205
+ action: "datachange",
206
+ source: evt.source,
207
+ data: evt.data,
208
+ clientId: evt.clientId,
209
+ local: evt.clientId === clientId,
210
+ });
211
+ }
212
+ );
213
+
214
+ this._playbackRate.on(
215
+ PlaybackRateEvents.rateChange,
216
+ async (evt: IPlaybackRateChangeEvent) => {
217
+ if (!this.isSuspended && !this.isWaiting) {
218
+ this._logger.sendTelemetryEvent(
219
+ TelemetryEvents.GroupCoordinator.PlaybackRateChanged
220
+ );
221
+ } else {
222
+ this._logger.sendTelemetryEvent(
223
+ TelemetryEvents.GroupCoordinator
224
+ .PlaybackRateChangedDelayed
225
+ );
226
+ }
227
+
228
+ const localClientId = await waitUntilConnected(this._runtime);
229
+ const local = evt.clientId === localClientId;
230
+ this.emitTriggerActionOrIgnored({
231
+ action: "ratechange",
232
+ source: evt.source,
233
+ clientId: evt.clientId,
234
+ local,
235
+ playbackRate: evt.playbackRate,
236
+ });
237
+ }
238
+ );
239
+
240
+ // Listen to transport related events
241
+ this._transportState.on(
242
+ GroupTransportStateEvents.transportStateChange,
243
+ async (evt: ITransportStateChangeEvent) => {
244
+ const tag =
245
+ this.isSuspended || this.isWaiting
246
+ ? TelemetryEvents.GroupCoordinator
247
+ .TransportStateChangeDelayed
248
+ : TelemetryEvents.GroupCoordinator
249
+ .TransportStateChanged;
250
+
251
+ this._logger.sendTelemetryEvent(tag, null, {
252
+ action: evt.action,
253
+ seekTime: evt.seekTime,
254
+ });
255
+
256
+ const localClientId = await waitUntilConnected(this._runtime);
257
+ const local = evt.clientId === localClientId;
258
+ // Trigger action
259
+ switch (evt.action) {
260
+ case "play":
261
+ this.emitTriggerActionOrIgnored({
262
+ action: "play",
263
+ source: evt.source,
264
+ clientId: evt.clientId,
265
+ local,
266
+ seekTime: evt.seekTime,
267
+ });
268
+ break;
269
+
270
+ case "pause":
271
+ this.emitTriggerActionOrIgnored({
272
+ action: "pause",
273
+ source: evt.source,
274
+ clientId: evt.clientId,
275
+ local,
276
+ seekTime: evt.seekTime,
277
+ });
278
+ break;
279
+
280
+ case "seekto":
281
+ this.emitTriggerActionOrIgnored({
282
+ action: "seekto",
283
+ source: evt.source,
284
+ clientId: evt.clientId,
285
+ local,
286
+ seekTime: evt.seekTime,
287
+ });
288
+ break;
289
+ }
290
+ }
291
+ );
292
+ }
293
+
294
+ public get playbackTrack(): GroupPlaybackTrack {
295
+ return this._playbackTrack;
296
+ }
297
+
298
+ public get playbackTrackData(): GroupPlaybackTrackData {
299
+ return this._playbackTrackData;
300
+ }
301
+
302
+ public get transportState(): GroupTransportState {
303
+ return this._transportState;
304
+ }
305
+
306
+ public get playbackPosition(): GroupPlaybackPosition {
307
+ return this._playbackPosition;
308
+ }
309
+
310
+ public get playbackRate(): GroupPlaybackRate {
311
+ return this._playbackRate;
312
+ }
313
+
314
+ public get waitingAt(): CoordinationWaitPoint | undefined {
315
+ return this._waitPoint;
316
+ }
317
+
318
+ public get isSuspended(): boolean {
319
+ return this._suspensionCnt > 0;
320
+ }
321
+
322
+ public get isWaiting(): boolean {
323
+ return !!this._waitPoint;
324
+ }
325
+
326
+ public beginSuspension(waitPoint?: CoordinationWaitPoint): void {
327
+ this._suspensionCnt++;
328
+
329
+ if (waitPoint) {
330
+ // Save most recent wait point
331
+ this._waitPoint = waitPoint;
332
+
333
+ // Add to track state if dynamic wait point
334
+ this.playbackTrack.addWaitPoint(waitPoint);
335
+ }
336
+ }
337
+
338
+ public endSuspension(syncState: boolean): void {
339
+ if (this._suspensionCnt == 0) {
340
+ throw new Error(
341
+ `GroupCoordinatorState:endSuspension - cannot end suspension when there are no active suspensions. The expected value is > 0 but the actual count is ${this._suspensionCnt}`
342
+ );
343
+ }
344
+
345
+ this._suspensionCnt--;
346
+ if (this._suspensionCnt == 0 && syncState) {
347
+ // Sync media session to group
348
+ this.syncLocalMediaSession();
349
+ }
350
+ }
351
+
352
+ public createPositionUpdateEvent(
353
+ state: IMediaPlayerState
354
+ ): IPositionUpdateEvent {
355
+ const { positionState, playbackState } = state;
356
+ if (this.isSuspended) {
357
+ // Report suspension state
358
+ return {
359
+ track: this.playbackTrack.current,
360
+ trackData: this.playbackTrackData.current,
361
+ transport: this.transportState.current,
362
+ playbackRate: this.playbackRate.current,
363
+ playbackState: "suspended",
364
+ position: positionState?.position || 0.0,
365
+ waitPoint: this._waitPoint,
366
+ };
367
+ } else {
368
+ // Report current position
369
+ return {
370
+ track: this.playbackTrack.current,
371
+ trackData: this.playbackTrackData.current,
372
+ transport: this.transportState.current,
373
+ playbackRate: this.playbackRate.current,
374
+ playbackState: this._waitPoint ? "waiting" : playbackState,
375
+ position: positionState?.position || 0.0,
376
+ waitPoint: this._waitPoint,
377
+ };
378
+ }
379
+ }
380
+
381
+ public handleSetTrack(
382
+ event: ILiveEvent<ISetTrackEvent>,
383
+ local: boolean
384
+ ): void {
385
+ // Update shared track
386
+ // - Will trigger 'trackChange' event to update media session
387
+ const updated = this.playbackTrack.updateTrack(
388
+ {
389
+ metadata: event.data.metadata,
390
+ waitPoints: event.data.waitPoints,
391
+ timestamp: event.timestamp,
392
+ clientId: event.clientId || "",
393
+ },
394
+ "user"
395
+ );
396
+
397
+ if (updated) {
398
+ this._logger.sendTelemetryEvent(
399
+ TelemetryEvents.SessionCoordinator.RemoteSetTrackReceived,
400
+ null,
401
+ {
402
+ correlationId: LiveTelemetryLogger.formatCorrelationId(
403
+ event.clientId,
404
+ event.timestamp
405
+ ),
406
+ }
407
+ );
408
+ }
409
+ }
410
+
411
+ public handleSetTrackData(
412
+ event: ILiveEvent<ISetTrackDataEvent>,
413
+ local: boolean
414
+ ): void {
415
+ // Update shared track data
416
+ // - Will trigger 'dataChange' event to update media session
417
+ const updated = this.playbackTrackData.updateData(
418
+ {
419
+ data: event.data.data,
420
+ timestamp: event.timestamp,
421
+ clientId: event.clientId || "",
422
+ },
423
+ "user"
424
+ );
425
+
426
+ if (updated) {
427
+ this._logger.sendTelemetryEvent(
428
+ TelemetryEvents.SessionCoordinator.RemoteSetTrackReceived,
429
+ null,
430
+ {
431
+ correlationId: LiveTelemetryLogger.formatCorrelationId(
432
+ event.clientId,
433
+ event.timestamp
434
+ ),
435
+ }
436
+ );
437
+ }
438
+ }
439
+
440
+ public handleRateChangeCommand(
441
+ event: ILiveEvent<IRateChangeCommandEvent>,
442
+ local: boolean
443
+ ): void {
444
+ const newState: IPlaybackRate = {
445
+ playbackRate: event.data.playbackRate,
446
+ clientId: event.clientId,
447
+ timestamp: event.timestamp,
448
+ };
449
+ const updated = this.playbackRate.updatePlaybackRate(newState, "user");
450
+
451
+ if (updated) {
452
+ const correlationId = LiveTelemetryLogger.formatCorrelationId(
453
+ event.clientId,
454
+ event.timestamp
455
+ );
456
+ this._logger.sendTelemetryEvent(
457
+ TelemetryEvents.SessionCoordinator.RemoteRateChangeReceived,
458
+ null,
459
+ {
460
+ correlationId: correlationId,
461
+ }
462
+ );
463
+ }
464
+ }
465
+
466
+ public handleTransportCommand(
467
+ event: ILiveEvent<ITransportCommandEvent>,
468
+ local: boolean
469
+ ): void {
470
+ // Ensure change is for current track
471
+ // - Will trigger a 'trackChange' event if newer track.
472
+ this.playbackTrack.updateTrack(event.data.track, "system");
473
+ if (this.playbackTrack.compare(event.data.track.metadata)) {
474
+ // Update playback state
475
+ let playbackState = this.transportState.playbackState;
476
+ switch (event.name) {
477
+ case "play":
478
+ playbackState = "playing";
479
+ break;
480
+ case "pause":
481
+ playbackState = "paused";
482
+ break;
483
+ }
484
+
485
+ // Try to update playback state
486
+ const newState: ITransportState = {
487
+ playbackState: playbackState,
488
+ startPosition: event.data.position,
489
+ timestamp: event.timestamp,
490
+ clientId: event.clientId || "",
491
+ };
492
+ const updated = this.transportState.updateState(newState, "user");
493
+
494
+ if (updated) {
495
+ const correlationId = LiveTelemetryLogger.formatCorrelationId(
496
+ event.clientId,
497
+ event.timestamp
498
+ );
499
+ switch (event.name) {
500
+ case "play":
501
+ this._logger.sendTelemetryEvent(
502
+ TelemetryEvents.SessionCoordinator
503
+ .RemotePlayReceived,
504
+ null,
505
+ {
506
+ correlationId: correlationId,
507
+ }
508
+ );
509
+ break;
510
+ case "pause":
511
+ this._logger.sendTelemetryEvent(
512
+ TelemetryEvents.SessionCoordinator
513
+ .RemotePauseReceived,
514
+ null,
515
+ {
516
+ correlationId: correlationId,
517
+ }
518
+ );
519
+ break;
520
+ case "seekTo":
521
+ this._logger.sendTelemetryEvent(
522
+ TelemetryEvents.SessionCoordinator
523
+ .RemoteSeekToReceived,
524
+ null,
525
+ {
526
+ correlationId: correlationId,
527
+ }
528
+ );
529
+ break;
530
+ }
531
+ }
532
+ }
533
+ }
534
+
535
+ public handlePositionUpdate(
536
+ event: ILiveEvent<IPositionUpdateEvent>,
537
+ local: boolean
538
+ ): void {
539
+ // Ensure change is for current track
540
+ // - Will trigger a 'trackChange' event if newer track.
541
+ this.playbackTrack.updateTrack(event.data.track, "system");
542
+ if (this.playbackTrack.compare(event.data.track.metadata)) {
543
+ // Ensure we have the latest track data
544
+ this.playbackTrackData.updateData(event.data.trackData, "system");
545
+
546
+ // Update transport state if needed
547
+ // - Ignore transport state changes if the client has ended as this will cause the local
548
+ // player to start playback even though the video may have ended for everyone.
549
+ if (event.data.playbackState != "ended") {
550
+ this.transportState.updateState(event.data.transport, "system");
551
+ }
552
+
553
+ // Ensure change is for current transport state
554
+ if (this.transportState.compare(event.data.transport)) {
555
+ // Update playback position
556
+ const position: ICurrentPlaybackPosition = {
557
+ playbackState: event.data.playbackState,
558
+ waitPoint: event.data.waitPoint,
559
+ position: event.data.position,
560
+ timestamp: event.timestamp,
561
+ clientId: event.clientId || "",
562
+ };
563
+ this.playbackPosition.UpdatePlaybackPosition(position);
564
+
565
+ // Ensure local media session is in sync with group
566
+ if (local && !this.isSuspended) {
567
+ // Begin a "soft suspension" on select state changes
568
+ // - This is needed because the catchup logic can try to sync the client with
569
+ // the rest of the group while the local player is trying to to seek to a
570
+ // new position.
571
+ switch (event.data.playbackState) {
572
+ case "none":
573
+ case "paused":
574
+ case "playing":
575
+ if (
576
+ event.data.playbackState !=
577
+ this._lastStateChange
578
+ ) {
579
+ this._logger.sendTelemetryEvent(
580
+ TelemetryEvents.GroupCoordinator
581
+ .BeginSoftSuspension,
582
+ null,
583
+ { playbackState: event.data.playbackState }
584
+ );
585
+ this._lastStateChange =
586
+ event.data.playbackState;
587
+ this._lastStateChangeTime =
588
+ this._liveRuntime.getTimestamp();
589
+ }
590
+ break;
591
+ }
592
+
593
+ // Are we waiting?
594
+ if (this.isWaiting) {
595
+ // Check for end of waiting
596
+ if (this.playbackPosition.clientsWaiting == 0) {
597
+ this._waitPoint = undefined;
598
+
599
+ // Start syncing again
600
+ this.syncLocalMediaSession();
601
+ }
602
+ } else {
603
+ // Sync client with group
604
+ this.syncLocalMediaSession();
605
+ }
606
+ }
607
+ }
608
+ }
609
+ }
610
+
611
+ public async syncLocalMediaSession(): Promise<void> {
612
+ // Skip further syncs if we're waiting or in a "soft suspension".
613
+ const softSuspensionDelta =
614
+ this._liveRuntime.getTimestamp() - this._lastStateChangeTime;
615
+ if (!this.isWaiting && softSuspensionDelta >= 1000) {
616
+ let { metadata, trackData, positionState, playbackState } =
617
+ this._getMediaPlayerState();
618
+ this._logger.sendTelemetryEvent(
619
+ TelemetryEvents.GroupCoordinator.CheckingForSyncIssues
620
+ );
621
+
622
+ // Check for track change
623
+ if (!this.playbackTrack.compare(metadata)) {
624
+ this._logger.sendTelemetryEvent(
625
+ TelemetryEvents.GroupCoordinator.TrackOutOfSync
626
+ );
627
+ this.emitSetTrack(
628
+ this.playbackTrack.current.clientId,
629
+ this.playbackTrack.metadata!,
630
+ "system"
631
+ );
632
+ return;
633
+ }
634
+
635
+ // Once local playback has ended there's no need to further sync the client. In fact
636
+ // doing so could cause the player to loop.
637
+ // - The playbackPosition.trackEnded check is to catch late joiners and prevents syncing if everyone
638
+ // else has finished the video.
639
+ // - Should another user seek or press play after the video has ended that will cause
640
+ // a transport action to trigger which will take the player out of the ended state.
641
+ if (
642
+ playbackState != "ended" &&
643
+ !this._playbackPosition.trackEnded
644
+ ) {
645
+ // Ensure we have a position
646
+ let position = positionState?.position;
647
+ if (position == undefined) {
648
+ position = 0.0;
649
+ }
650
+
651
+ // Check for catchup
652
+ // - Target can return -1 in cases where there is no position tracking data
653
+ const target = this.playbackPosition.targetPosition;
654
+ if (
655
+ target >= 0.0 &&
656
+ Math.abs(target - position) >=
657
+ Math.max(
658
+ this._maxPlaybackDrift.seconds,
659
+ this._maxPlaybackDrift.seconds *
660
+ this._playbackRate.rate
661
+ )
662
+ ) {
663
+ this._logger.sendTelemetryEvent(
664
+ TelemetryEvents.GroupCoordinator.PositionOutOfSync,
665
+ null,
666
+ {
667
+ current: position,
668
+ target: target,
669
+ }
670
+ );
671
+ this.emitTriggerAction({
672
+ action: "catchup",
673
+ source: "system",
674
+ clientId: await waitUntilConnected(this._runtime),
675
+ local: true,
676
+ seekTime: target,
677
+ });
678
+ }
679
+
680
+ const localClientId = await waitUntilConnected(this._runtime);
681
+ // Sync transport state
682
+ if (this.transportState.playbackState != playbackState) {
683
+ this._logger.sendTelemetryEvent(
684
+ TelemetryEvents.GroupCoordinator.TransportOutOfSync,
685
+ null,
686
+ {
687
+ current: playbackState,
688
+ target: this.transportState.playbackState,
689
+ }
690
+ );
691
+ const local =
692
+ localClientId === this.transportState.current.clientId;
693
+ switch (this.transportState.playbackState) {
694
+ case "playing":
695
+ this.emitTriggerAction({
696
+ action: "play",
697
+ source: "system",
698
+ clientId: this.transportState.current.clientId,
699
+ local,
700
+ });
701
+ break;
702
+ case "paused":
703
+ this.emitTriggerAction({
704
+ action: "pause",
705
+ source: "system",
706
+ clientId: this.transportState.current.clientId,
707
+ local,
708
+ });
709
+ break;
710
+ }
711
+ }
712
+
713
+ // Sync track data
714
+ if (
715
+ JSON.stringify(this.playbackTrackData.data) !=
716
+ JSON.stringify(trackData)
717
+ ) {
718
+ this._logger.sendTelemetryEvent(
719
+ TelemetryEvents.GroupCoordinator.TrackDataOutOfSync
720
+ );
721
+ const local =
722
+ localClientId ===
723
+ this.playbackTrackData.current.clientId;
724
+ this.emitTriggerAction({
725
+ action: "datachange",
726
+ source: "system",
727
+ clientId: this.playbackTrackData.current.clientId,
728
+ local,
729
+ data: this.playbackTrackData.data,
730
+ });
731
+ }
732
+
733
+ // Sync playback rate
734
+ if (this.playbackRate.rate != positionState?.playbackRate) {
735
+ this._logger.sendTelemetryEvent(
736
+ TelemetryEvents.GroupCoordinator.PlaybackRateOutOfSync
737
+ );
738
+ const local =
739
+ localClientId ===
740
+ this.playbackTrackData.current.clientId;
741
+ this.emitTriggerAction({
742
+ action: "ratechange",
743
+ source: "system",
744
+ clientId: this.playbackTrackData.current.clientId,
745
+ local,
746
+ playbackRate: this.playbackRate.rate,
747
+ });
748
+ }
749
+ }
750
+ }
751
+ }
752
+
753
+ private async emitSetTrack(
754
+ clientId: string,
755
+ metadata: ExtendedMediaMetadata,
756
+ source: ExtendedMediaSessionActionSource
757
+ ): Promise<void> {
758
+ // Reset tracking states
759
+ this._waitPoint = undefined;
760
+
761
+ const localClientId = await waitUntilConnected(this._runtime);
762
+ // Trigger settrack action
763
+ this.emitTriggerAction({
764
+ action: "settrack",
765
+ source,
766
+ clientId,
767
+ local: localClientId === clientId,
768
+ metadata,
769
+ });
770
+ }
771
+
772
+ private emitTriggerActionOrIgnored(
773
+ details: ExtendedMediaSessionActionDetails
774
+ ): void {
775
+ if (this.isSuspended || this.isWaiting) {
776
+ return this.emitTriggerActionIgnored(details);
777
+ } else {
778
+ return this.emitTriggerAction(details);
779
+ }
780
+ }
781
+
782
+ private emitTriggerAction(
783
+ details: ExtendedMediaSessionActionDetails
784
+ ): void {
785
+ const evt: ITriggerActionEvent = {
786
+ name: GroupCoordinatorStateEvents.triggeraction,
787
+ details: details,
788
+ };
789
+ this.emit(GroupCoordinatorStateEvents.triggeraction, evt);
790
+ }
791
+
792
+ private emitTriggerActionIgnored(
793
+ details: ExtendedMediaSessionActionDetails
794
+ ): void {
795
+ details.ignoreReason = "localusersuspended";
796
+ const evt: ITriggerActionEvent = {
797
+ name: GroupCoordinatorStateEvents.triggeractionignored,
798
+ details: details,
799
+ };
800
+ this.emit(GroupCoordinatorStateEvents.triggeractionignored, evt);
801
+ }
802
+ }