@microsoft/live-share-media 0.5.1 → 1.0.0-preview.10

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 (88) hide show
  1. package/.nycrc.json +10 -10
  2. package/LICENSE +86 -86
  3. package/README.md +141 -137
  4. package/bin/IMediaPlayer.d.ts.map +1 -1
  5. package/bin/LiveMediaSession.d.ts +95 -0
  6. package/bin/LiveMediaSession.d.ts.map +1 -0
  7. package/bin/LiveMediaSession.js +232 -0
  8. package/bin/LiveMediaSession.js.map +1 -0
  9. package/bin/LiveMediaSessionCoordinator.d.ts +228 -0
  10. package/bin/LiveMediaSessionCoordinator.d.ts.map +1 -0
  11. package/bin/LiveMediaSessionCoordinator.js +408 -0
  12. package/bin/LiveMediaSessionCoordinator.js.map +1 -0
  13. package/bin/LiveMediaSessionCoordinatorSuspension.d.ts +20 -0
  14. package/bin/LiveMediaSessionCoordinatorSuspension.d.ts.map +1 -0
  15. package/bin/LiveMediaSessionCoordinatorSuspension.js +28 -0
  16. package/bin/LiveMediaSessionCoordinatorSuspension.js.map +1 -0
  17. package/bin/MediaPlayerSynchronizer.d.ts +11 -11
  18. package/bin/MediaPlayerSynchronizer.d.ts.map +1 -1
  19. package/bin/MediaPlayerSynchronizer.js +136 -76
  20. package/bin/MediaPlayerSynchronizer.js.map +1 -1
  21. package/bin/MediaSessionActionThrottler.d.ts +1 -1
  22. package/bin/MediaSessionActionThrottler.d.ts.map +1 -1
  23. package/bin/MediaSessionActionThrottler.js.map +1 -1
  24. package/bin/MediaSessionExtensions.d.ts.map +1 -1
  25. package/bin/RepeatedActionThrottler.d.ts +2 -2
  26. package/bin/RepeatedActionThrottler.d.ts.map +1 -1
  27. package/bin/RepeatedActionThrottler.js +9 -3
  28. package/bin/RepeatedActionThrottler.js.map +1 -1
  29. package/bin/VolumeManager.d.ts +84 -0
  30. package/bin/VolumeManager.d.ts.map +1 -0
  31. package/bin/VolumeManager.js +175 -0
  32. package/bin/VolumeManager.js.map +1 -0
  33. package/bin/index.d.ts +9 -9
  34. package/bin/index.d.ts.map +1 -1
  35. package/bin/index.js +4 -4
  36. package/bin/index.js.map +1 -1
  37. package/bin/internals/GroupCoordinatorState.d.ts +12 -12
  38. package/bin/internals/GroupCoordinatorState.d.ts.map +1 -1
  39. package/bin/internals/GroupCoordinatorState.js +94 -56
  40. package/bin/internals/GroupCoordinatorState.js.map +1 -1
  41. package/bin/internals/GroupPlaybackPosition.d.ts +3 -3
  42. package/bin/internals/GroupPlaybackPosition.d.ts.map +1 -1
  43. package/bin/internals/GroupPlaybackPosition.js +24 -15
  44. package/bin/internals/GroupPlaybackPosition.js.map +1 -1
  45. package/bin/internals/GroupPlaybackTrack.d.ts +4 -4
  46. package/bin/internals/GroupPlaybackTrack.d.ts.map +1 -1
  47. package/bin/internals/GroupPlaybackTrack.js +14 -4
  48. package/bin/internals/GroupPlaybackTrack.js.map +1 -1
  49. package/bin/internals/GroupPlaybackTrackData.d.ts +3 -3
  50. package/bin/internals/GroupPlaybackTrackData.d.ts.map +1 -1
  51. package/bin/internals/GroupPlaybackTrackData.js +8 -4
  52. package/bin/internals/GroupPlaybackTrackData.js.map +1 -1
  53. package/bin/internals/GroupTransportState.d.ts +5 -5
  54. package/bin/internals/GroupTransportState.d.ts.map +1 -1
  55. package/bin/internals/GroupTransportState.js +28 -13
  56. package/bin/internals/GroupTransportState.js.map +1 -1
  57. package/bin/internals/consts.d.ts.map +1 -1
  58. package/bin/internals/consts.js +54 -54
  59. package/bin/internals/consts.js.map +1 -1
  60. package/bin/internals/index.d.ts +6 -6
  61. package/package.json +38 -38
  62. package/src/IMediaPlayer.ts +29 -21
  63. package/src/LiveMediaSession.ts +305 -0
  64. package/src/LiveMediaSessionCoordinator.ts +621 -0
  65. package/src/{EphemeralMediaSessionCoordinatorSuspension.ts → LiveMediaSessionCoordinatorSuspension.ts} +39 -31
  66. package/src/MediaPlayerSynchronizer.ts +636 -450
  67. package/src/MediaSessionActionThrottler.ts +16 -14
  68. package/src/MediaSessionExtensions.ts +52 -42
  69. package/src/RepeatedActionThrottler.ts +74 -56
  70. package/src/VolumeManager.ts +197 -0
  71. package/src/index.ts +14 -15
  72. package/src/internals/GroupCoordinatorState.ts +606 -446
  73. package/src/internals/GroupPlaybackPosition.ts +227 -193
  74. package/src/internals/GroupPlaybackTrack.ts +176 -147
  75. package/src/internals/GroupPlaybackTrackData.ts +98 -90
  76. package/src/internals/GroupTransportState.ts +157 -127
  77. package/src/internals/consts.ts +98 -80
  78. package/src/internals/index.ts +11 -11
  79. package/src/test/GroupPlaybackPosition.spec.ts +600 -340
  80. package/src/test/GroupPlaybackTrack.spec.ts +122 -66
  81. package/src/test/SharedMediaSession.spec.ts +149 -113
  82. package/src/test/SharedMediaSessionCoordinator.spec.ts +271 -231
  83. package/src/test/VolumeManager.spec.ts +219 -0
  84. package/tsconfig.build.json +8 -8
  85. package/tsconfig.json +76 -75
  86. package/src/EphemeralMediaSession.ts +0 -248
  87. package/src/EphemeralMediaSessionCoordinator.ts +0 -461
  88. package/src/VolumeLimiter.ts +0 -182
@@ -1,446 +1,606 @@
1
- /**
2
- * Copyright (c) Microsoft Corporation. All rights reserved.
3
- * Licensed under the Microsoft Live Share SDK License.
4
- */
5
-
6
- import { IEvent, IEphemeralEvent, EphemeralEvent, TimeInterval, IRuntimeSignaler, EphemeralTelemetryLogger } from '@microsoft/live-share';
7
- import EventEmitter from 'events';
8
- import { ExtendedMediaMetadata, CoordinationWaitPoint, ExtendedMediaSessionPlaybackState, ExtendedMediaSessionActionDetails } from '../MediaSessionExtensions';
9
- import { GroupPlaybackTrack, GroupPlaybackTrackEvents, IPlaybackTrack } from './GroupPlaybackTrack';
10
- import { GroupTransportState, ITransportState } from './GroupTransportState';
11
- import { GroupPlaybackPosition, ICurrentPlaybackPosition } from './GroupPlaybackPosition';
12
- import { IMediaPlayerState } from '../EphemeralMediaSessionCoordinator';
13
- import { GroupTransportStateEvents } from './GroupTransportState';
14
- import { GroupPlaybackTrackData, PlaybackTrackDataEvents, IPlaybackTrackData } from './GroupPlaybackTrackData';
15
- import { TelemetryEvents } from './consts';
16
-
17
- /**
18
- * @hidden
19
- */
20
- export interface IPositionUpdateEvent extends IEphemeralEvent {
21
- track: IPlaybackTrack;
22
- trackData: IPlaybackTrackData;
23
- transport: ITransportState;
24
- playbackState: ExtendedMediaSessionPlaybackState;
25
- position: number;
26
- waitPoint?: CoordinationWaitPoint;
27
- }
28
-
29
- /**
30
- * @hidden
31
- */
32
- export interface ITransportCommandEvent extends IEphemeralEvent {
33
- track: IPlaybackTrack;
34
- position: number;
35
- }
36
-
37
- /**
38
- * @hidden
39
- */
40
- export interface ISetTrackEvent extends IEphemeralEvent {
41
- metadata: ExtendedMediaMetadata|null;
42
- waitPoints: CoordinationWaitPoint[];
43
- }
44
-
45
- /**
46
- * @hidden
47
- */
48
- export interface ISetTrackDataEvent extends IEphemeralEvent {
49
- data: object|null;
50
- }
51
-
52
- /**
53
- * @hidden
54
- */
55
- export interface ITriggerActionEvent extends IEvent {
56
- details: ExtendedMediaSessionActionDetails;
57
- }
58
-
59
- /**
60
- * @hidden
61
- */
62
- export enum GroupCoordinatorStateEvents {
63
- newwaitpoint = 'newwaitpoint',
64
- triggeraction = 'triggeraction'
65
- }
66
-
67
- /**
68
- * @hidden
69
- */
70
- export class GroupCoordinatorState extends EventEmitter {
71
- private readonly _runtime: IRuntimeSignaler;
72
- private readonly _logger: EphemeralTelemetryLogger;
73
- private readonly _maxPlaybackDrift: TimeInterval;
74
- private _getMediaPlayerState: () => IMediaPlayerState;
75
-
76
- // Shared group state
77
- private _playbackTrack: GroupPlaybackTrack;
78
- private _playbackTrackData: GroupPlaybackTrackData;
79
- private _transportState: GroupTransportState;
80
- private _playbackPosition: GroupPlaybackPosition;
81
-
82
- // Suspension tracking
83
- private _suspensionCnt: number = 0;
84
- private _waitPoint?: CoordinationWaitPoint;
85
-
86
- // "Soft Suspension" tracking
87
- // - Soft suspensions temporarily disconnect the local media session when transitioning between
88
- // paused, playing, and none states. This is to give the local client time to finish any inprogress
89
- // operations like sending a transport command or ending a suspension.
90
- private _lastStateChange: ExtendedMediaSessionPlaybackState = 'none';
91
- private _lastStateChangeTime: number = 0;
92
-
93
- constructor (runtime: IRuntimeSignaler, maxPlaybackDrift: TimeInterval, positionUpdateInterval: TimeInterval, getMediaPlayerState: () => IMediaPlayerState) {
94
- super();
95
- this._runtime = runtime;
96
- this._logger = new EphemeralTelemetryLogger(runtime);
97
- this._maxPlaybackDrift = maxPlaybackDrift;
98
- this._playbackTrack = new GroupPlaybackTrack(getMediaPlayerState);
99
- this._playbackTrackData = new GroupPlaybackTrackData(this._playbackTrack);
100
- this._transportState = new GroupTransportState(this._playbackTrack, getMediaPlayerState);
101
- this._playbackPosition = new GroupPlaybackPosition(this._transportState, this._runtime, positionUpdateInterval);
102
- this._getMediaPlayerState = getMediaPlayerState;
103
-
104
- // Listen track related events
105
- this._playbackTrack.on(GroupPlaybackTrackEvents.trackChange, evt => {
106
- if (!this.isSuspended) {
107
- this._logger.sendTelemetryEvent(TelemetryEvents.GroupCoordinator.TrackChanged);
108
- this.emitSetTrack(evt.metadata!);
109
- } else {
110
- this._logger.sendTelemetryEvent(TelemetryEvents.GroupCoordinator.TrackChangeDelayed);
111
- }
112
- });
113
-
114
- // Listen for track data changes
115
- this._playbackTrackData.on(PlaybackTrackDataEvents.dataChange, evt => {
116
- if (!this.isSuspended && !this.isWaiting) {
117
- this._logger.sendTelemetryEvent(TelemetryEvents.GroupCoordinator.TrackDataChanged);
118
- this.emitTriggerAction({action: 'datachange', data: evt.data});
119
- } else {
120
- this._logger.sendTelemetryEvent(TelemetryEvents.GroupCoordinator.TrackDataChangeDelayed);
121
- }
122
- });
123
-
124
- // Listen to transport related events
125
- this._transportState.on(GroupTransportStateEvents.transportStateChange, evt => {
126
- if (!this.isSuspended && !this.isWaiting) {
127
- this._logger.sendTelemetryEvent(TelemetryEvents.GroupCoordinator.TransportStateChanged, null, {
128
- action: evt.action,
129
- seekTime: evt.seekTime
130
- });
131
-
132
- // Trigger action
133
- switch (evt.action) {
134
- case 'play':
135
- this.emitTriggerAction({action: 'play', seekTime: evt.seekTime});
136
- break;
137
-
138
- case 'pause':
139
- this.emitTriggerAction({action: 'pause', seekTime: evt.seekTime});
140
- break;
141
-
142
- case 'seekto':
143
- this.emitTriggerAction({action: 'seekto', seekTime: evt.seekTime});
144
- break;
145
- }
146
- } else {
147
- this._logger.sendTelemetryEvent(TelemetryEvents.GroupCoordinator.TransportStateChangeDelayed, null, {
148
- action: evt.action,
149
- seekTime: evt.seekTime
150
- });
151
- }
152
- });
153
- }
154
-
155
- public get playbackTrack(): GroupPlaybackTrack {
156
- return this._playbackTrack;
157
- }
158
-
159
- public get playbackTrackData(): GroupPlaybackTrackData {
160
- return this._playbackTrackData;
161
- }
162
-
163
- public get transportState(): GroupTransportState {
164
- return this._transportState;
165
- }
166
-
167
- public get playbackPosition(): GroupPlaybackPosition {
168
- return this._playbackPosition;
169
- }
170
-
171
- public get waitingAt(): CoordinationWaitPoint|undefined {
172
- return this._waitPoint;
173
- }
174
-
175
- public get isSuspended(): boolean {
176
- return this._suspensionCnt > 0;
177
- }
178
-
179
- public get isWaiting(): boolean {
180
- return !!this._waitPoint;
181
- }
182
-
183
- public beginSuspension(waitPoint?: CoordinationWaitPoint): void {
184
- this._suspensionCnt++;
185
-
186
- if (waitPoint) {
187
- // Save most recent wait point
188
- this._waitPoint = waitPoint;
189
-
190
- // Add to track state if dynamic wait point
191
- this.playbackTrack.addWaitPoint(waitPoint);
192
- }
193
- }
194
-
195
- public endSuspension(syncState: boolean): void {
196
- if (this._suspensionCnt == 0) {
197
- throw new Error(`Too many suspensions.`);
198
- }
199
-
200
- this._suspensionCnt--;
201
- if (this._suspensionCnt == 0 && syncState) {
202
- // Sync media session to group
203
- this.syncLocalMediaSession();
204
- }
205
- }
206
-
207
- public createPositionUpdateEvent(state: IMediaPlayerState): Partial<IPositionUpdateEvent> {
208
- const { positionState, playbackState } = state;
209
- if (this.isSuspended) {
210
- // Report suspension state
211
- return {
212
- track: this.playbackTrack.current,
213
- trackData: this.playbackTrackData.current,
214
- transport: this.transportState.current,
215
- playbackState: 'suspended',
216
- position: positionState?.position || 0.0,
217
- waitPoint: this._waitPoint
218
- };
219
- } else {
220
- // Report current position
221
- return {
222
- track: this.playbackTrack.current,
223
- trackData: this.playbackTrackData.current,
224
- transport: this.transportState.current,
225
- playbackState: this._waitPoint ? 'waiting' : playbackState,
226
- position: positionState?.position || 0.0,
227
- waitPoint: this._waitPoint
228
- };
229
- }
230
- }
231
-
232
- public handleSetTrack(event: ISetTrackEvent, local: boolean): void {
233
- // Update shared track
234
- // - Will trigger 'trackChange' event to update media session
235
- const updated = this.playbackTrack.updateTrack({
236
- metadata: event.metadata,
237
- waitPoints: event.waitPoints,
238
- timestamp: event.timestamp,
239
- clientId: event.clientId || ''
240
- });
241
-
242
- if (updated) {
243
- this._logger.sendTelemetryEvent(TelemetryEvents.SessionCoordinator.RemoteSetTrackReceived, null, {correlationId: EphemeralTelemetryLogger.formatCorrelationId(event.clientId, event.timestamp)});
244
- }
245
- }
246
-
247
- public handleSetTrackData(event: ISetTrackDataEvent, local: boolean): void {
248
- // Update shared track data
249
- // - Will trigger 'dataChange' event to update media session
250
- const updated = this.playbackTrackData.updateData({
251
- data: event.data,
252
- timestamp: event.timestamp,
253
- clientId: event.clientId || ''
254
- });
255
-
256
- if (updated) {
257
- this._logger.sendTelemetryEvent(TelemetryEvents.SessionCoordinator.RemoteSetTrackReceived, null, {correlationId: EphemeralTelemetryLogger.formatCorrelationId(event.clientId, event.timestamp)});
258
- }
259
- }
260
-
261
- public handleTransportCommand(event: ITransportCommandEvent, local: boolean): void {
262
- // Ensure change is for current track
263
- // - Will trigger a 'trackChange' event if newer track.
264
- this.playbackTrack.updateTrack(event.track);
265
- if (this.playbackTrack.compare(event.track.metadata)) {
266
- // Update playback state
267
- let playbackState = this.transportState.playbackState;
268
- switch (event.name) {
269
- case 'play':
270
- playbackState = 'playing';
271
- break;
272
- case 'pause':
273
- playbackState = 'paused';
274
- break;
275
- }
276
-
277
- // Try to update playback state
278
- const newState: ITransportState = {
279
- playbackState: playbackState,
280
- startPosition: event.position,
281
- timestamp: event.timestamp,
282
- clientId: event.clientId || ''
283
- };
284
- const updated = this.transportState.updateState(newState);
285
-
286
- if (updated) {
287
- const correlationId = EphemeralTelemetryLogger.formatCorrelationId(event.clientId, event.timestamp);
288
- switch (event.name) {
289
- case 'play':
290
- this._logger.sendTelemetryEvent(TelemetryEvents.SessionCoordinator.RemotePlayReceived, null, {correlationId: correlationId});
291
- break;
292
- case 'pause':
293
- this._logger.sendTelemetryEvent(TelemetryEvents.SessionCoordinator.RemotePauseReceived, null, {correlationId: correlationId});
294
- break;
295
- case 'seekTo':
296
- this._logger.sendTelemetryEvent(TelemetryEvents.SessionCoordinator.RemoteSeekToReceived, null, {correlationId: correlationId});
297
- break;
298
- }
299
-
300
- }
301
- }
302
- }
303
-
304
- public handlePositionUpdate(event: IPositionUpdateEvent, local: boolean): void {
305
- // Ensure change is for current track
306
- // - Will trigger a 'trackChange' event if newer track.
307
- this.playbackTrack.updateTrack(event.track);
308
- if (this.playbackTrack.compare(event.track.metadata)) {
309
- // Ensure we have the latest track data
310
- this.playbackTrackData.updateData(event.trackData);
311
-
312
- // Update transport state if needed
313
- // - Ignore transport state changes if the client has ended as this will cause the local
314
- // player to start playback even though the video may have ended for everyone.
315
- if (event.playbackState != 'ended') {
316
- this.transportState.updateState(event.transport);
317
- }
318
-
319
- // Ensure change is for current transport state
320
- if (this.transportState.compare(event.transport)) {
321
- // Update playback position
322
- const position: ICurrentPlaybackPosition = {
323
- playbackState: event.playbackState,
324
- waitPoint: event.waitPoint,
325
- position: event.position,
326
- timestamp: event.timestamp,
327
- clientId: event.clientId || ''
328
- };
329
- this.playbackPosition.UpdatePlaybackPosition(position);
330
-
331
- // Ensure local media session is in sync with group
332
- if (local && !this.isSuspended) {
333
- // Begin a "soft suspension" on select state changes
334
- // - This is needed because the catchup logic can try to sink the client with
335
- // the rest of the group while the local player is trying to to seek to a
336
- // new position.
337
- switch (event.playbackState) {
338
- case 'none':
339
- case 'paused':
340
- case 'playing':
341
- if (event.playbackState != this._lastStateChange) {
342
- this._logger.sendTelemetryEvent(TelemetryEvents.GroupCoordinator.BeginSoftSuspension, null, {playbackState: event.playbackState});
343
- this._lastStateChange = event.playbackState;
344
- this._lastStateChangeTime = EphemeralEvent.getTimestamp();
345
- }
346
- break;
347
- }
348
-
349
- // Are we waiting?
350
- if (this.isWaiting) {
351
- // Check for end of waiting
352
- if (this.playbackPosition.clientsWaiting == 0) {
353
- this._waitPoint = undefined;
354
-
355
- // Start syncing again
356
- this.syncLocalMediaSession();
357
- }
358
- } else {
359
- // Sync client with group
360
- this.syncLocalMediaSession();
361
- }
362
- }
363
- }
364
- }
365
- }
366
-
367
- public async syncLocalMediaSession(): Promise<void> {
368
- // Skip further syncs if we're waiting or in a "soft suspension".
369
- const softSuspensionDelta = EphemeralEvent.getTimestamp() - this._lastStateChangeTime;
370
- if (!this.isWaiting && softSuspensionDelta >= 1000) {
371
- let { metadata, trackData, positionState, playbackState } = this._getMediaPlayerState();
372
- this._logger.sendTelemetryEvent(TelemetryEvents.GroupCoordinator.CheckingForSyncIssues);
373
-
374
- // Check for track change
375
- if (!this.playbackTrack.compare(metadata)) {
376
- this._logger.sendTelemetryEvent(TelemetryEvents.GroupCoordinator.TrackOutOfSync);
377
- this.emitSetTrack(this.playbackTrack.metadata!);
378
- return;
379
- }
380
-
381
- // Once local playback has ended there's no need to further sync the client. In fact
382
- // doing so could cause the player to loop.
383
- // - The playbackPosition.trackEnded check is to catch late joiners and prevents syncing if everyone
384
- // else has finished the video.
385
- // - Should another user seek or press play after the video has ended that will cause
386
- // a transport action to trigger which will take the player out of the ended state.
387
- if (playbackState != "ended" && !this._playbackPosition.trackEnded) {
388
- // Ensure we have a position
389
- let position = positionState?.position;
390
- if (position == undefined) {
391
- position = 0.0;
392
- }
393
-
394
- // Check for catchup
395
- // - Target can return -1 in cases where there is no position tracking data
396
- const target = this.playbackPosition.targetPosition;
397
- if (target >= 0.0 && Math.abs(target - position) >= this._maxPlaybackDrift.seconds) {
398
- this._logger.sendTelemetryEvent(TelemetryEvents.GroupCoordinator.PositionOutOfSync, null, {
399
- current: position,
400
- target: target
401
- });
402
- await this.emitTriggerAction({action: 'catchup', seekTime: target});
403
- }
404
-
405
- // Sync transport state
406
- if (this.transportState.playbackState != playbackState) {
407
- this._logger.sendTelemetryEvent(TelemetryEvents.GroupCoordinator.TransportOutOfSync, null, {
408
- current: playbackState,
409
- target: this.transportState.playbackState
410
- });
411
- switch (this.transportState.playbackState) {
412
- case 'playing':
413
- await this.emitTriggerAction({action: 'play'});
414
- break;
415
- case 'paused':
416
- await this.emitTriggerAction({action: 'pause'});
417
- break;
418
- }
419
- }
420
-
421
- // Sync track data
422
- if (JSON.stringify(this.playbackTrackData.data) != JSON.stringify(trackData)) {
423
- this._logger.sendTelemetryEvent(TelemetryEvents.GroupCoordinator.TrackDataOutOfSync);
424
- await this.emitTriggerAction({action: 'datachange', data: this.playbackTrackData.data});
425
- }
426
- }
427
-
428
- }
429
- }
430
-
431
- private emitSetTrack(metadata: ExtendedMediaMetadata): void {
432
- // Reset tracking states
433
- this._waitPoint = undefined;
434
-
435
- // Trigger settrack action
436
- this.emitTriggerAction({action: 'settrack', metadata: metadata});
437
- }
438
-
439
- private emitTriggerAction(details: ExtendedMediaSessionActionDetails): void {
440
- const evt: ITriggerActionEvent = {
441
- name: GroupCoordinatorStateEvents.triggeraction,
442
- details: details
443
- };
444
- this.emit(GroupCoordinatorStateEvents.triggeraction, evt);
445
- }
446
- }
1
+ /**
2
+ * Copyright (c) Microsoft Corporation. All rights reserved.
3
+ * Licensed under the Microsoft Live Share SDK License.
4
+ */
5
+
6
+ import {
7
+ IEvent,
8
+ ILiveEvent,
9
+ LiveEvent,
10
+ TimeInterval,
11
+ IRuntimeSignaler,
12
+ LiveTelemetryLogger,
13
+ } from "@microsoft/live-share";
14
+ import EventEmitter from "events";
15
+ import {
16
+ ExtendedMediaMetadata,
17
+ CoordinationWaitPoint,
18
+ ExtendedMediaSessionPlaybackState,
19
+ ExtendedMediaSessionActionDetails,
20
+ } from "../MediaSessionExtensions";
21
+ import {
22
+ GroupPlaybackTrack,
23
+ GroupPlaybackTrackEvents,
24
+ IPlaybackTrack,
25
+ } from "./GroupPlaybackTrack";
26
+ import { GroupTransportState, ITransportState } from "./GroupTransportState";
27
+ import {
28
+ GroupPlaybackPosition,
29
+ ICurrentPlaybackPosition,
30
+ } from "./GroupPlaybackPosition";
31
+ import { IMediaPlayerState } from "../LiveMediaSessionCoordinator";
32
+ import { GroupTransportStateEvents } from "./GroupTransportState";
33
+ import {
34
+ GroupPlaybackTrackData,
35
+ PlaybackTrackDataEvents,
36
+ IPlaybackTrackData,
37
+ } from "./GroupPlaybackTrackData";
38
+ import { TelemetryEvents } from "./consts";
39
+
40
+ /**
41
+ * @hidden
42
+ */
43
+ export interface IPositionUpdateEvent extends ILiveEvent {
44
+ track: IPlaybackTrack;
45
+ trackData: IPlaybackTrackData;
46
+ transport: ITransportState;
47
+ playbackState: ExtendedMediaSessionPlaybackState;
48
+ position: number;
49
+ waitPoint?: CoordinationWaitPoint;
50
+ }
51
+
52
+ /**
53
+ * @hidden
54
+ */
55
+ export interface ITransportCommandEvent extends ILiveEvent {
56
+ track: IPlaybackTrack;
57
+ position: number;
58
+ }
59
+
60
+ /**
61
+ * @hidden
62
+ */
63
+ export interface ISetTrackEvent extends ILiveEvent {
64
+ metadata: ExtendedMediaMetadata | null;
65
+ waitPoints: CoordinationWaitPoint[];
66
+ }
67
+
68
+ /**
69
+ * @hidden
70
+ */
71
+ export interface ISetTrackDataEvent extends ILiveEvent {
72
+ data: object | null;
73
+ }
74
+
75
+ /**
76
+ * @hidden
77
+ */
78
+ export interface ITriggerActionEvent extends IEvent {
79
+ details: ExtendedMediaSessionActionDetails;
80
+ }
81
+
82
+ /**
83
+ * @hidden
84
+ */
85
+ export enum GroupCoordinatorStateEvents {
86
+ newwaitpoint = "newwaitpoint",
87
+ triggeraction = "triggeraction",
88
+ }
89
+
90
+ /**
91
+ * @hidden
92
+ */
93
+ export class GroupCoordinatorState extends EventEmitter {
94
+ private readonly _runtime: IRuntimeSignaler;
95
+ private readonly _logger: LiveTelemetryLogger;
96
+ private readonly _maxPlaybackDrift: TimeInterval;
97
+ private _getMediaPlayerState: () => IMediaPlayerState;
98
+
99
+ // Shared group state
100
+ private _playbackTrack: GroupPlaybackTrack;
101
+ private _playbackTrackData: GroupPlaybackTrackData;
102
+ private _transportState: GroupTransportState;
103
+ private _playbackPosition: GroupPlaybackPosition;
104
+
105
+ // Suspension tracking
106
+ private _suspensionCnt: number = 0;
107
+ private _waitPoint?: CoordinationWaitPoint;
108
+
109
+ // "Soft Suspension" tracking
110
+ // - Soft suspensions temporarily disconnect the local media session when transitioning between
111
+ // paused, playing, and none states. This is to give the local client time to finish any inprogress
112
+ // operations like sending a transport command or ending a suspension.
113
+ private _lastStateChange: ExtendedMediaSessionPlaybackState = "none";
114
+ private _lastStateChangeTime: number = 0;
115
+
116
+ constructor(
117
+ runtime: IRuntimeSignaler,
118
+ maxPlaybackDrift: TimeInterval,
119
+ positionUpdateInterval: TimeInterval,
120
+ getMediaPlayerState: () => IMediaPlayerState
121
+ ) {
122
+ super();
123
+ this._runtime = runtime;
124
+ this._logger = new LiveTelemetryLogger(runtime);
125
+ this._maxPlaybackDrift = maxPlaybackDrift;
126
+ this._playbackTrack = new GroupPlaybackTrack(getMediaPlayerState);
127
+ this._playbackTrackData = new GroupPlaybackTrackData(
128
+ this._playbackTrack
129
+ );
130
+ this._transportState = new GroupTransportState(
131
+ this._playbackTrack,
132
+ getMediaPlayerState
133
+ );
134
+ this._playbackPosition = new GroupPlaybackPosition(
135
+ this._transportState,
136
+ this._runtime,
137
+ positionUpdateInterval
138
+ );
139
+ this._getMediaPlayerState = getMediaPlayerState;
140
+
141
+ // Listen track related events
142
+ this._playbackTrack.on(GroupPlaybackTrackEvents.trackChange, (evt) => {
143
+ if (!this.isSuspended) {
144
+ this._logger.sendTelemetryEvent(
145
+ TelemetryEvents.GroupCoordinator.TrackChanged
146
+ );
147
+ this.emitSetTrack(evt.metadata!);
148
+ } else {
149
+ this._logger.sendTelemetryEvent(
150
+ TelemetryEvents.GroupCoordinator.TrackChangeDelayed
151
+ );
152
+ }
153
+ });
154
+
155
+ // Listen for track data changes
156
+ this._playbackTrackData.on(
157
+ PlaybackTrackDataEvents.dataChange,
158
+ (evt) => {
159
+ if (!this.isSuspended && !this.isWaiting) {
160
+ this._logger.sendTelemetryEvent(
161
+ TelemetryEvents.GroupCoordinator.TrackDataChanged
162
+ );
163
+ this.emitTriggerAction({
164
+ action: "datachange",
165
+ data: evt.data,
166
+ });
167
+ } else {
168
+ this._logger.sendTelemetryEvent(
169
+ TelemetryEvents.GroupCoordinator.TrackDataChangeDelayed
170
+ );
171
+ }
172
+ }
173
+ );
174
+
175
+ // Listen to transport related events
176
+ this._transportState.on(
177
+ GroupTransportStateEvents.transportStateChange,
178
+ (evt) => {
179
+ if (!this.isSuspended && !this.isWaiting) {
180
+ this._logger.sendTelemetryEvent(
181
+ TelemetryEvents.GroupCoordinator.TransportStateChanged,
182
+ null,
183
+ {
184
+ action: evt.action,
185
+ seekTime: evt.seekTime,
186
+ }
187
+ );
188
+
189
+ // Trigger action
190
+ switch (evt.action) {
191
+ case "play":
192
+ this.emitTriggerAction({
193
+ action: "play",
194
+ seekTime: evt.seekTime,
195
+ });
196
+ break;
197
+
198
+ case "pause":
199
+ this.emitTriggerAction({
200
+ action: "pause",
201
+ seekTime: evt.seekTime,
202
+ });
203
+ break;
204
+
205
+ case "seekto":
206
+ this.emitTriggerAction({
207
+ action: "seekto",
208
+ seekTime: evt.seekTime,
209
+ });
210
+ break;
211
+ }
212
+ } else {
213
+ this._logger.sendTelemetryEvent(
214
+ TelemetryEvents.GroupCoordinator
215
+ .TransportStateChangeDelayed,
216
+ null,
217
+ {
218
+ action: evt.action,
219
+ seekTime: evt.seekTime,
220
+ }
221
+ );
222
+ }
223
+ }
224
+ );
225
+ }
226
+
227
+ public get playbackTrack(): GroupPlaybackTrack {
228
+ return this._playbackTrack;
229
+ }
230
+
231
+ public get playbackTrackData(): GroupPlaybackTrackData {
232
+ return this._playbackTrackData;
233
+ }
234
+
235
+ public get transportState(): GroupTransportState {
236
+ return this._transportState;
237
+ }
238
+
239
+ public get playbackPosition(): GroupPlaybackPosition {
240
+ return this._playbackPosition;
241
+ }
242
+
243
+ public get waitingAt(): CoordinationWaitPoint | undefined {
244
+ return this._waitPoint;
245
+ }
246
+
247
+ public get isSuspended(): boolean {
248
+ return this._suspensionCnt > 0;
249
+ }
250
+
251
+ public get isWaiting(): boolean {
252
+ return !!this._waitPoint;
253
+ }
254
+
255
+ public beginSuspension(waitPoint?: CoordinationWaitPoint): void {
256
+ this._suspensionCnt++;
257
+
258
+ if (waitPoint) {
259
+ // Save most recent wait point
260
+ this._waitPoint = waitPoint;
261
+
262
+ // Add to track state if dynamic wait point
263
+ this.playbackTrack.addWaitPoint(waitPoint);
264
+ }
265
+ }
266
+
267
+ public endSuspension(syncState: boolean): void {
268
+ if (this._suspensionCnt == 0) {
269
+ throw new Error(`Too many suspensions.`);
270
+ }
271
+
272
+ this._suspensionCnt--;
273
+ if (this._suspensionCnt == 0 && syncState) {
274
+ // Sync media session to group
275
+ this.syncLocalMediaSession();
276
+ }
277
+ }
278
+
279
+ public createPositionUpdateEvent(
280
+ state: IMediaPlayerState
281
+ ): Partial<IPositionUpdateEvent> {
282
+ const { positionState, playbackState } = state;
283
+ if (this.isSuspended) {
284
+ // Report suspension state
285
+ return {
286
+ track: this.playbackTrack.current,
287
+ trackData: this.playbackTrackData.current,
288
+ transport: this.transportState.current,
289
+ playbackState: "suspended",
290
+ position: positionState?.position || 0.0,
291
+ waitPoint: this._waitPoint,
292
+ };
293
+ } else {
294
+ // Report current position
295
+ return {
296
+ track: this.playbackTrack.current,
297
+ trackData: this.playbackTrackData.current,
298
+ transport: this.transportState.current,
299
+ playbackState: this._waitPoint ? "waiting" : playbackState,
300
+ position: positionState?.position || 0.0,
301
+ waitPoint: this._waitPoint,
302
+ };
303
+ }
304
+ }
305
+
306
+ public handleSetTrack(event: ISetTrackEvent, local: boolean): void {
307
+ // Update shared track
308
+ // - Will trigger 'trackChange' event to update media session
309
+ const updated = this.playbackTrack.updateTrack({
310
+ metadata: event.metadata,
311
+ waitPoints: event.waitPoints,
312
+ timestamp: event.timestamp,
313
+ clientId: event.clientId || "",
314
+ });
315
+
316
+ if (updated) {
317
+ this._logger.sendTelemetryEvent(
318
+ TelemetryEvents.SessionCoordinator.RemoteSetTrackReceived,
319
+ null,
320
+ {
321
+ correlationId: LiveTelemetryLogger.formatCorrelationId(
322
+ event.clientId,
323
+ event.timestamp
324
+ ),
325
+ }
326
+ );
327
+ }
328
+ }
329
+
330
+ public handleSetTrackData(event: ISetTrackDataEvent, local: boolean): void {
331
+ // Update shared track data
332
+ // - Will trigger 'dataChange' event to update media session
333
+ const updated = this.playbackTrackData.updateData({
334
+ data: event.data,
335
+ timestamp: event.timestamp,
336
+ clientId: event.clientId || "",
337
+ });
338
+
339
+ if (updated) {
340
+ this._logger.sendTelemetryEvent(
341
+ TelemetryEvents.SessionCoordinator.RemoteSetTrackReceived,
342
+ null,
343
+ {
344
+ correlationId: LiveTelemetryLogger.formatCorrelationId(
345
+ event.clientId,
346
+ event.timestamp
347
+ ),
348
+ }
349
+ );
350
+ }
351
+ }
352
+
353
+ public handleTransportCommand(
354
+ event: ITransportCommandEvent,
355
+ local: boolean
356
+ ): void {
357
+ // Ensure change is for current track
358
+ // - Will trigger a 'trackChange' event if newer track.
359
+ this.playbackTrack.updateTrack(event.track);
360
+ if (this.playbackTrack.compare(event.track.metadata)) {
361
+ // Update playback state
362
+ let playbackState = this.transportState.playbackState;
363
+ switch (event.name) {
364
+ case "play":
365
+ playbackState = "playing";
366
+ break;
367
+ case "pause":
368
+ playbackState = "paused";
369
+ break;
370
+ }
371
+
372
+ // Try to update playback state
373
+ const newState: ITransportState = {
374
+ playbackState: playbackState,
375
+ startPosition: event.position,
376
+ timestamp: event.timestamp,
377
+ clientId: event.clientId || "",
378
+ };
379
+ const updated = this.transportState.updateState(newState);
380
+
381
+ if (updated) {
382
+ const correlationId = LiveTelemetryLogger.formatCorrelationId(
383
+ event.clientId,
384
+ event.timestamp
385
+ );
386
+ switch (event.name) {
387
+ case "play":
388
+ this._logger.sendTelemetryEvent(
389
+ TelemetryEvents.SessionCoordinator
390
+ .RemotePlayReceived,
391
+ null,
392
+ {
393
+ correlationId: correlationId,
394
+ }
395
+ );
396
+ break;
397
+ case "pause":
398
+ this._logger.sendTelemetryEvent(
399
+ TelemetryEvents.SessionCoordinator
400
+ .RemotePauseReceived,
401
+ null,
402
+ {
403
+ correlationId: correlationId,
404
+ }
405
+ );
406
+ break;
407
+ case "seekTo":
408
+ this._logger.sendTelemetryEvent(
409
+ TelemetryEvents.SessionCoordinator
410
+ .RemoteSeekToReceived,
411
+ null,
412
+ {
413
+ correlationId: correlationId,
414
+ }
415
+ );
416
+ break;
417
+ }
418
+ }
419
+ }
420
+ }
421
+
422
+ public handlePositionUpdate(
423
+ event: IPositionUpdateEvent,
424
+ local: boolean
425
+ ): void {
426
+ // Ensure change is for current track
427
+ // - Will trigger a 'trackChange' event if newer track.
428
+ this.playbackTrack.updateTrack(event.track);
429
+ if (this.playbackTrack.compare(event.track.metadata)) {
430
+ // Ensure we have the latest track data
431
+ this.playbackTrackData.updateData(event.trackData);
432
+
433
+ // Update transport state if needed
434
+ // - Ignore transport state changes if the client has ended as this will cause the local
435
+ // player to start playback even though the video may have ended for everyone.
436
+ if (event.playbackState != "ended") {
437
+ this.transportState.updateState(event.transport);
438
+ }
439
+
440
+ // Ensure change is for current transport state
441
+ if (this.transportState.compare(event.transport)) {
442
+ // Update playback position
443
+ const position: ICurrentPlaybackPosition = {
444
+ playbackState: event.playbackState,
445
+ waitPoint: event.waitPoint,
446
+ position: event.position,
447
+ timestamp: event.timestamp,
448
+ clientId: event.clientId || "",
449
+ };
450
+ this.playbackPosition.UpdatePlaybackPosition(position);
451
+
452
+ // Ensure local media session is in sync with group
453
+ if (local && !this.isSuspended) {
454
+ // Begin a "soft suspension" on select state changes
455
+ // - This is needed because the catchup logic can try to sink the client with
456
+ // the rest of the group while the local player is trying to to seek to a
457
+ // new position.
458
+ switch (event.playbackState) {
459
+ case "none":
460
+ case "paused":
461
+ case "playing":
462
+ if (event.playbackState != this._lastStateChange) {
463
+ this._logger.sendTelemetryEvent(
464
+ TelemetryEvents.GroupCoordinator
465
+ .BeginSoftSuspension,
466
+ null,
467
+ { playbackState: event.playbackState }
468
+ );
469
+ this._lastStateChange = event.playbackState;
470
+ this._lastStateChangeTime =
471
+ LiveEvent.getTimestamp();
472
+ }
473
+ break;
474
+ }
475
+
476
+ // Are we waiting?
477
+ if (this.isWaiting) {
478
+ // Check for end of waiting
479
+ if (this.playbackPosition.clientsWaiting == 0) {
480
+ this._waitPoint = undefined;
481
+
482
+ // Start syncing again
483
+ this.syncLocalMediaSession();
484
+ }
485
+ } else {
486
+ // Sync client with group
487
+ this.syncLocalMediaSession();
488
+ }
489
+ }
490
+ }
491
+ }
492
+ }
493
+
494
+ public async syncLocalMediaSession(): Promise<void> {
495
+ // Skip further syncs if we're waiting or in a "soft suspension".
496
+ const softSuspensionDelta =
497
+ LiveEvent.getTimestamp() - this._lastStateChangeTime;
498
+ if (!this.isWaiting && softSuspensionDelta >= 1000) {
499
+ let { metadata, trackData, positionState, playbackState } =
500
+ this._getMediaPlayerState();
501
+ this._logger.sendTelemetryEvent(
502
+ TelemetryEvents.GroupCoordinator.CheckingForSyncIssues
503
+ );
504
+
505
+ // Check for track change
506
+ if (!this.playbackTrack.compare(metadata)) {
507
+ this._logger.sendTelemetryEvent(
508
+ TelemetryEvents.GroupCoordinator.TrackOutOfSync
509
+ );
510
+ this.emitSetTrack(this.playbackTrack.metadata!);
511
+ return;
512
+ }
513
+
514
+ // Once local playback has ended there's no need to further sync the client. In fact
515
+ // doing so could cause the player to loop.
516
+ // - The playbackPosition.trackEnded check is to catch late joiners and prevents syncing if everyone
517
+ // else has finished the video.
518
+ // - Should another user seek or press play after the video has ended that will cause
519
+ // a transport action to trigger which will take the player out of the ended state.
520
+ if (
521
+ playbackState != "ended" &&
522
+ !this._playbackPosition.trackEnded
523
+ ) {
524
+ // Ensure we have a position
525
+ let position = positionState?.position;
526
+ if (position == undefined) {
527
+ position = 0.0;
528
+ }
529
+
530
+ // Check for catchup
531
+ // - Target can return -1 in cases where there is no position tracking data
532
+ const target = this.playbackPosition.targetPosition;
533
+ if (
534
+ target >= 0.0 &&
535
+ Math.abs(target - position) >=
536
+ this._maxPlaybackDrift.seconds
537
+ ) {
538
+ this._logger.sendTelemetryEvent(
539
+ TelemetryEvents.GroupCoordinator.PositionOutOfSync,
540
+ null,
541
+ {
542
+ current: position,
543
+ target: target,
544
+ }
545
+ );
546
+ await this.emitTriggerAction({
547
+ action: "catchup",
548
+ seekTime: target,
549
+ });
550
+ }
551
+
552
+ // Sync transport state
553
+ if (this.transportState.playbackState != playbackState) {
554
+ this._logger.sendTelemetryEvent(
555
+ TelemetryEvents.GroupCoordinator.TransportOutOfSync,
556
+ null,
557
+ {
558
+ current: playbackState,
559
+ target: this.transportState.playbackState,
560
+ }
561
+ );
562
+ switch (this.transportState.playbackState) {
563
+ case "playing":
564
+ await this.emitTriggerAction({ action: "play" });
565
+ break;
566
+ case "paused":
567
+ await this.emitTriggerAction({ action: "pause" });
568
+ break;
569
+ }
570
+ }
571
+
572
+ // Sync track data
573
+ if (
574
+ JSON.stringify(this.playbackTrackData.data) !=
575
+ JSON.stringify(trackData)
576
+ ) {
577
+ this._logger.sendTelemetryEvent(
578
+ TelemetryEvents.GroupCoordinator.TrackDataOutOfSync
579
+ );
580
+ await this.emitTriggerAction({
581
+ action: "datachange",
582
+ data: this.playbackTrackData.data,
583
+ });
584
+ }
585
+ }
586
+ }
587
+ }
588
+
589
+ private emitSetTrack(metadata: ExtendedMediaMetadata): void {
590
+ // Reset tracking states
591
+ this._waitPoint = undefined;
592
+
593
+ // Trigger settrack action
594
+ this.emitTriggerAction({ action: "settrack", metadata: metadata });
595
+ }
596
+
597
+ private emitTriggerAction(
598
+ details: ExtendedMediaSessionActionDetails
599
+ ): void {
600
+ const evt: ITriggerActionEvent = {
601
+ name: GroupCoordinatorStateEvents.triggeraction,
602
+ details: details,
603
+ };
604
+ this.emit(GroupCoordinatorStateEvents.triggeraction, evt);
605
+ }
606
+ }