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

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