@oat-sa/tao-core-ui 1.6.2 → 1.6.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.
@@ -23,22 +23,30 @@ import support from 'ui/mediaplayer/support';
23
23
  import audioTpl from 'ui/mediaplayer/tpl/audio';
24
24
  import videoTpl from 'ui/mediaplayer/tpl/video';
25
25
  import sourceTpl from 'ui/mediaplayer/tpl/source';
26
+ import reminderManagerFactory from 'ui/mediaplayer/utils/reminder';
27
+ import timeObserverFactory from 'ui/mediaplayer/utils/timeObserver';
26
28
 
27
29
  /**
28
30
  * CSS namespace
29
- * @type {String}
31
+ * @type {string}
30
32
  */
31
33
  const ns = '.mediaplayer';
32
34
 
33
35
  /**
34
36
  * Range value of the volume
35
- * @type {Number}
37
+ * @type {number}
36
38
  */
37
39
  const volumeRange = 100;
38
40
 
41
+ /**
42
+ * Delay before considering a media stalled
43
+ * @type {number}
44
+ */
45
+ const stalledDetectionDelay = 2000;
46
+
39
47
  /**
40
48
  * List of media events that can be listened to for debugging
41
- * @type {String[]}
49
+ * @type {string[]}
42
50
  */
43
51
  const mediaEvents = [
44
52
  'abort',
@@ -71,40 +79,32 @@ const mediaEvents = [
71
79
 
72
80
  /**
73
81
  * List of player events that can be listened to for debugging
74
- * @type {String[]}
82
+ * @type {string[]}
75
83
  */
76
- const playerEvents = [
77
- 'end',
78
- 'error',
79
- 'pause',
80
- 'play',
81
- 'playing',
82
- 'ready',
83
- 'recovererror',
84
- 'resize',
85
- 'stalled',
86
- 'timeupdate'
87
- ];
84
+ const playerEvents = ['end', 'error', 'pause', 'play', 'playing', 'ready', 'resize', 'stalled', 'timeupdate'];
88
85
 
89
86
  /**
90
87
  * Defines a player object dedicated to the native HTML5 player
91
88
  * @param {jQuery} $container - Where to render the player
92
- * @param {Object} config - The list of config entries
89
+ * @param {object} config - The list of config entries
93
90
  * @param {Array} config.sources - The list of media sources
94
- * @param {String} [config.type] - The type of player (video or audio) (default: video)
95
- * @param {Boolean} [config.preview] - Enables the media preview (load media metadata)
96
- * @param {Boolean} [config.debug] - Enables the debug mode
97
- * @returns {Object} player
91
+ * @param {string} [config.type] - The type of player (video or audio) (default: video)
92
+ * @param {boolean} [config.preview] - Enables the media preview (load media metadata)
93
+ * @param {boolean} [config.debug] - Enables the debug mode
94
+ * @param {number} [config.config.stalledDetectionDelay] - The delay before considering a media is stalled
95
+ * @returns {object} player
98
96
  */
99
97
  export default function html5PlayerFactory($container, config = {}) {
100
98
  const type = config.type || 'video';
101
99
  const sources = config.sources || [];
100
+ const updateObserver = reminderManagerFactory();
101
+ const timeObserver = timeObserverFactory();
102
+
103
+ config.stalledDetectionDelay = config.stalledDetectionDelay || stalledDetectionDelay;
102
104
 
103
105
  let $media;
104
106
  let media;
105
- let playback = false;
106
- let loaded = false;
107
- let stalled = false;
107
+ let state = {};
108
108
 
109
109
  const getDebugContext = action => {
110
110
  const networkState = media && media.networkState;
@@ -112,9 +112,10 @@ export default function html5PlayerFactory($container, config = {}) {
112
112
  return `[html5-${type}(networkState=${networkState},readyState=${readyState}):${action}]`;
113
113
  };
114
114
  // eslint-disable-next-line
115
- const debug = (action, ...args) => config.debug && window.console.log(getDebugContext(action), ...args);
115
+ const debug = (action, ...args) =>
116
+ (config.debug === true || config.debug === action) && window.console.log(getDebugContext(action), ...args);
116
117
 
117
- const player = {
118
+ return eventifier({
118
119
  init() {
119
120
  const tpl = 'audio' === type ? audioTpl : videoTpl;
120
121
  const page = new UrlParser(window.location);
@@ -124,6 +125,8 @@ export default function html5PlayerFactory($container, config = {}) {
124
125
  let link = '';
125
126
  let result = false;
126
127
 
128
+ state = {};
129
+
127
130
  sources.forEach(source => {
128
131
  if (!page.sameDomain(source.src)) {
129
132
  cors = true;
@@ -139,38 +142,65 @@ export default function html5PlayerFactory($container, config = {}) {
139
142
  $media = $(tpl({ cors, preload, poster, link }));
140
143
  $container.append($media);
141
144
 
142
- playback = false;
143
- loaded = false;
144
- stalled = false;
145
-
146
145
  media = $media.get(0);
147
146
  result = !!(media && support.checkSupport(media));
148
147
 
149
- // remove the browser native controls if we can use the API instead
148
+ // Remove the browser native controls if we can use the API instead
150
149
  if (support.canControl()) {
151
150
  $media.removeAttr('controls');
152
151
  }
153
152
 
153
+ // Detect stalled video when the timer suddenly jump to the end
154
+ timeObserver.removeAllListeners().on('irregularity', position => {
155
+ if (state.playback && state.stallDetection) {
156
+ this.stalled(position);
157
+ }
158
+ });
159
+
154
160
  $media
155
161
  .on(`play${ns}`, () => {
156
- playback = true;
162
+ state.playback = true;
163
+ state.playedViaApi = false;
164
+ timeObserver.init(media.currentTime, media.duration);
157
165
  this.trigger('play');
158
166
  })
159
167
  .on(`pause${ns}`, () => {
168
+ if (
169
+ state.stallDetection &&
170
+ !state.pausedViaApi &&
171
+ updateObserver.running &&
172
+ updateObserver.elapsed < 100
173
+ ) {
174
+ // The pause event may be triggered after a connectivity issue as the player is out of data
175
+ this.stalled();
176
+ }
177
+ state.pausedViaApi = false;
178
+ state.playing = false;
179
+ updateObserver.stop();
160
180
  this.trigger('pause');
161
181
  })
182
+ .on(`seeked${ns}`, () => {
183
+ // When the user try changing the current playing position while the network is down,
184
+ // the player will end the playback by moving straight to the end.
185
+ if (state.seekedViaApi && Math.floor(state.seekAt) !== Math.floor(media.currentTime)) {
186
+ state.stallDetection = true;
187
+ }
188
+ state.seekedViaApi = false;
189
+ })
162
190
  .on(`ended${ns}`, () => {
163
- playback = false;
191
+ updateObserver.forget().stop();
192
+ timeObserver.update(media.currentTime);
193
+ state.playback = false;
194
+ state.playing = false;
164
195
  this.trigger('end');
165
196
  })
166
197
  .on(`timeupdate${ns}`, () => {
198
+ state.playing = true;
199
+ updateObserver.start();
200
+ timeObserver.update(media.currentTime);
167
201
  this.trigger('timeupdate');
168
202
  })
169
203
  .on('loadstart', () => {
170
- if (stalled) {
171
- return;
172
- }
173
-
174
204
  if (media.networkState === HTMLMediaElement.NETWORK_NO_SOURCE) {
175
205
  this.trigger('error');
176
206
  }
@@ -178,24 +208,54 @@ export default function html5PlayerFactory($container, config = {}) {
178
208
  if (!config.preview && media.networkState === HTMLMediaElement.NETWORK_IDLE) {
179
209
  this.trigger('ready');
180
210
  }
211
+
212
+ // The media may be unreachable straight from the beginning
213
+ this.detectStalledNetwork();
214
+ })
215
+ .on(`waiting${ns}`, () => {
216
+ // The "waiting" event means the player is pending data,
217
+ // it may be the symptom of a connectivity issue
218
+ this.detectStalledNetwork();
181
219
  })
182
220
  .on(`error${ns}`, () => {
183
- if (media.networkState === HTMLMediaElement.NETWORK_NO_SOURCE) {
221
+ if (
222
+ media.networkState === HTMLMediaElement.NETWORK_NO_SOURCE ||
223
+ (media.error instanceof MediaError &&
224
+ media.error.code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED)
225
+ ) {
226
+ // No source means the player does not support the supplied media.
227
+ // Or it can be more explicit with the not supported error.
228
+ // There is nothing that we can do from this stage.
184
229
  this.trigger('error');
185
230
  } else {
186
- this.trigger('recovererror', media.networkState === HTMLMediaElement.NETWORK_LOADING);
231
+ // Other errors need special attention as they can be recoverable
232
+ this.handleError(media.error);
187
233
  }
188
234
  })
235
+ .on('loadedmetadata', () => {
236
+ timeObserver.init(media.currentTime, media.duration);
237
+ this.ready();
238
+ })
189
239
  .on(`canplay${ns}`, () => {
190
- loaded = true;
191
- this.trigger('ready');
240
+ if (!state.stalled) {
241
+ this.ready();
242
+ }
192
243
  })
193
244
  .on(`stalled${ns}`, () => {
194
- stalled = true;
195
- this.trigger('stalled');
245
+ // The "stalled" event may be triggered once the player is halted after initialisation,
246
+ // but this does not mean the playback is actually stalled, hence we only take care of the playing state
247
+ if (state.playing && !media.paused) {
248
+ this.handleError(media.error);
249
+ }
196
250
  })
197
251
  .on(`playing${ns}`, () => {
198
- stalled = false;
252
+ if (state.stallDetection) {
253
+ // The "playing" event may occur after a connectivity issue.
254
+ // For the sake of the stall detection, we need to discard this event
255
+ return;
256
+ }
257
+ updateObserver.forget().start();
258
+ state.playing = true;
199
259
  this.trigger('playing');
200
260
  });
201
261
 
@@ -212,19 +272,133 @@ export default function html5PlayerFactory($container, config = {}) {
212
272
  });
213
273
  }
214
274
 
215
- sources.forEach(source => {
216
- const { src, type } = source;
217
- this.addMedia(src, type);
218
- });
275
+ result =
276
+ result &&
277
+ sources.reduce((supported, source) => this.addMedia(source.src, source.type) || supported, false);
219
278
 
220
279
  return result;
221
280
  },
222
281
 
282
+ handleError(error) {
283
+ // Discard legitimate and non-blocking errors
284
+ switch (error && error.name) {
285
+ case 'NotAllowedError':
286
+ debug('api call', 'handleError', 'the autoplay is not allowed without a user interaction', error);
287
+ return;
288
+
289
+ case 'AbortError':
290
+ debug('api call', 'handleError', 'the action has been aborted for some reason', error);
291
+ return;
292
+ }
293
+
294
+ debug('api call', 'handleError', error);
295
+
296
+ // Detect if the playback can continue a bit
297
+ const canContinueTemporarily =
298
+ media &&
299
+ (media.readyState === HTMLMediaElement.HAVE_ENOUGH_DATA ||
300
+ media.readyState === HTMLMediaElement.HAVE_FUTURE_DATA ||
301
+ media.readyState === HTMLMediaElement.HAVE_CURRENT_DATA);
302
+
303
+ // If a connectivity error occurs we may need to enter in stalled mode unless we can wait a bit
304
+ if (
305
+ error instanceof MediaError &&
306
+ (error.code === MediaError.MEDIA_ERR_NETWORK || error.code === MediaError.MEDIA_ERR_DECODE) &&
307
+ !canContinueTemporarily
308
+ ) {
309
+ this.stalled();
310
+ return;
311
+ }
312
+
313
+ // To this point, there is a big chance the media is stalled.
314
+ // We start an observer to remind as soon as an irregularity occurs on the time update
315
+ state.stallDetection = true;
316
+ updateObserver.remind(() => {
317
+ // The last time update is a bit old, the media is most probably stalled now
318
+ if (updateObserver.elapsed >= config.stalledDetectionDelay) {
319
+ this.stalled();
320
+ }
321
+ }, config.stalledDetectionDelay);
322
+
323
+ updateObserver.start();
324
+ },
325
+
326
+ ready() {
327
+ if (!state.ready) {
328
+ state.ready = true;
329
+ this.trigger('ready');
330
+ }
331
+ },
332
+
333
+ detectStalledNetwork() {
334
+ // Install an observer that will watch the network state after a small delay.
335
+ // It is needed since the network state may need time to settle.
336
+ setTimeout(() => {
337
+ if (
338
+ media &&
339
+ media.networkState === HTMLMediaElement.NETWORK_NO_SOURCE &&
340
+ media.readyState === HTMLMediaElement.HAVE_NOTHING
341
+ ) {
342
+ if (!state.ready) {
343
+ this.trigger('ready');
344
+ }
345
+ this.stalled();
346
+ }
347
+ }, config.stalledDetectionDelay);
348
+ },
349
+
350
+ stalled(position) {
351
+ debug('api call', 'stalled');
352
+
353
+ if (media) {
354
+ if ('undefined' !== typeof position) {
355
+ state.stalledAt = position;
356
+ } else {
357
+ state.stalledAt = timeObserver.position;
358
+ }
359
+ }
360
+ state.stalled = true;
361
+ state.stallDetection = false;
362
+ updateObserver.forget().stop();
363
+
364
+ this.pause();
365
+ this.trigger('stalled');
366
+ },
367
+
368
+ recover() {
369
+ debug('api call', 'recover');
370
+
371
+ state.stalled = false;
372
+ state.stallDetection = false;
373
+ if (media) {
374
+ // Special processing of video player to prevent visual glitch while reloading
375
+ if (media.tagName === 'VIDEO') {
376
+ // Temporarily set the size of the media to prevent a shrink while reloading it
377
+ $media.width($media.width());
378
+ $media.height($media.height());
379
+ $media.on('loadedmetadata.recover', () => {
380
+ $media.off('loadedmetadata.recover');
381
+ $media.css({ width: '', height: '' });
382
+ });
383
+ }
384
+
385
+ media.load();
386
+ if (state.stalledAt) {
387
+ this.seek(state.stalledAt);
388
+ }
389
+ if ((state.playback && !state.playing) || state.playedViaApi) {
390
+ this.play();
391
+ }
392
+ }
393
+ },
394
+
223
395
  destroy() {
224
396
  debug('api call', 'destroy');
225
397
 
226
398
  this.stop();
227
399
  this.removeAllListeners();
400
+ updateObserver.forget();
401
+ timeObserver.removeAllListeners();
228
402
 
229
403
  if ($media) {
230
404
  $media.off(ns).remove();
@@ -232,9 +406,7 @@ export default function html5PlayerFactory($container, config = {}) {
232
406
 
233
407
  $media = void 0;
234
408
  media = void 0;
235
- playback = false;
236
- loaded = false;
237
- stalled = false;
409
+ state = {};
238
410
  },
239
411
 
240
412
  getMedia() {
@@ -305,7 +477,10 @@ export default function html5PlayerFactory($container, config = {}) {
305
477
 
306
478
  if (media) {
307
479
  media.currentTime = parseFloat(time);
308
- if (!playback) {
480
+ state.seekedViaApi = true;
481
+ state.seekAt = media.currentTime;
482
+ timeObserver.seek(media.currentTime);
483
+ if (!state.playback) {
309
484
  this.play();
310
485
  }
311
486
  }
@@ -315,7 +490,11 @@ export default function html5PlayerFactory($container, config = {}) {
315
490
  debug('api call', 'play');
316
491
 
317
492
  if (media) {
318
- media.play().catch(err => debug('playback error', err));
493
+ state.playedViaApi = true;
494
+ const startPlayPromise = media.play();
495
+ if ('undefined' !== typeof startPlayPromise) {
496
+ startPlayPromise.catch(error => this.handleError(error));
497
+ }
319
498
  }
320
499
  },
321
500
 
@@ -323,6 +502,9 @@ export default function html5PlayerFactory($container, config = {}) {
323
502
  debug('api call', 'pause');
324
503
 
325
504
  if (media) {
505
+ if (!media.paused) {
506
+ state.pausedViaApi = true;
507
+ }
326
508
  media.pause();
327
509
  }
328
510
  },
@@ -330,16 +512,16 @@ export default function html5PlayerFactory($container, config = {}) {
330
512
  stop() {
331
513
  debug('api call', 'stop');
332
514
 
333
- if (media && playback) {
515
+ if (media && media.duration && state.playback && !state.stalled) {
334
516
  media.currentTime = media.duration;
335
517
  }
336
518
  },
337
519
 
338
- mute(state) {
339
- debug('api call', 'mute', state);
520
+ mute(muted) {
521
+ debug('api call', 'mute', muted);
340
522
 
341
523
  if (media) {
342
- media.muted = !!state;
524
+ media.muted = !!muted;
343
525
  }
344
526
  },
345
527
 
@@ -353,32 +535,30 @@ export default function html5PlayerFactory($container, config = {}) {
353
535
  return mute;
354
536
  },
355
537
 
356
- addMedia(src, type) {
357
- debug('api call', 'addMedia', src, type);
538
+ addMedia(src, srcType) {
539
+ debug('api call', 'addMedia', src, srcType);
358
540
 
359
541
  if (media) {
360
- if (!support.checkSupport(media, type)) {
542
+ if (!support.checkSupport(media, srcType)) {
361
543
  return false;
362
544
  }
363
545
  }
364
546
 
365
547
  if (src && $media) {
366
- $media.append(sourceTpl({ src, type }));
548
+ $media.append(sourceTpl({ src, type: srcType }));
367
549
  return true;
368
550
  }
369
551
  return false;
370
552
  },
371
553
 
372
- setMedia(src, type) {
373
- debug('api call', 'setMedia', src, type);
554
+ setMedia(src, srcType) {
555
+ debug('api call', 'setMedia', src, srcType);
374
556
 
375
557
  if ($media) {
376
558
  $media.empty();
377
- return this.addMedia(src, type);
559
+ return this.addMedia(src, srcType);
378
560
  }
379
561
  return false;
380
562
  }
381
- };
382
-
383
- return eventifier(player);
563
+ });
384
564
  }
@@ -343,6 +343,43 @@ $controlsHeight: 36px;
343
343
  bottom: 0;
344
344
  }
345
345
  }
346
+
347
+ &.stalled {
348
+ .player {
349
+ .player-overlay {
350
+ [data-control="reload"] {
351
+ display: flex;
352
+ align-items: center;
353
+ background-color: #000;
354
+ margin: 0;
355
+ flex-wrap: wrap;
356
+ padding: 5px 5px 5px 50px;
357
+ text-align: left;
358
+ line-height: 2.3rem;
359
+ &.reload {
360
+ width: calc(100% + 2px);
361
+ font-size: 20px;
362
+ line-height: 20px;
363
+ min-height: 36px;
364
+
365
+ .icon {
366
+ text-shadow: none;
367
+ position: absolute;
368
+ left: 0;
369
+ font-size: 2rem;
370
+ font-weight: bold;
371
+ }
372
+
373
+ .message {
374
+ text-shadow: none;
375
+ font-size: 1.3rem;
376
+ margin-right: 5px;
377
+ }
378
+ }
379
+ }
380
+ }
381
+ }
382
+ }
346
383
  }
347
384
 
348
385
  &.ready {
@@ -355,9 +392,11 @@ $controlsHeight: 36px;
355
392
  cursor: pointer;
356
393
  }
357
394
 
358
- .player:hover {
359
- [data-control="play"] {
360
- display: inline-block;
395
+ &:not(.audio) {
396
+ .player:hover {
397
+ [data-control="play"] {
398
+ display: inline-block;
399
+ }
361
400
  }
362
401
  }
363
402
 
@@ -375,9 +414,11 @@ $controlsHeight: 36px;
375
414
  cursor: pointer;
376
415
  }
377
416
 
378
- .player:hover {
379
- [data-control="pause"] {
380
- display: inline-block;
417
+ &:not(.audio) {
418
+ .player:hover {
419
+ [data-control="pause"] {
420
+ display: inline-block;
421
+ }
381
422
  }
382
423
  }
383
424
  }
@@ -19,12 +19,14 @@
19
19
  /**
20
20
  * A Regex to detect Apple mobile browsers
21
21
  * @type {RegExp}
22
+ * @private
22
23
  */
23
24
  const reAppleMobiles = /ip(hone|od)/i;
24
25
 
25
26
  /**
26
27
  * A list of MIME types with codec declaration
27
28
  * @type {Object}
29
+ * @private
28
30
  */
29
31
  const supportedMimeTypes = {
30
32
  // video
@@ -38,6 +40,15 @@ const supportedMimeTypes = {
38
40
  'audio/wav': 'audio/wav; codecs="1"'
39
41
  };
40
42
 
43
+ /**
44
+ * Checks support for a MIME type.
45
+ * @param {HTMLMediaElement} media The media element on which check support
46
+ * @param {String} mimeType A MIME type to check the support for
47
+ * @returns {string}
48
+ * @private
49
+ */
50
+ const findSupport = (media, mimeType) => media.canPlayType(mimeType).replace(/no/, '');
51
+
41
52
  /**
42
53
  * Support detection
43
54
  * @type {Object}
@@ -51,13 +62,15 @@ export default {
51
62
  * @private
52
63
  */
53
64
  checkSupport(media, mimeType) {
54
- const support = !!media.canPlayType;
55
-
65
+ const support = media.canPlayType;
56
66
  if (support && mimeType) {
57
- return !!media.canPlayType(supportedMimeTypes[mimeType] || mimeType).replace(/no/, '');
67
+ return !!(
68
+ (supportedMimeTypes[mimeType] && findSupport(media, supportedMimeTypes[mimeType])) ||
69
+ findSupport(media, mimeType)
70
+ );
58
71
  }
59
72
 
60
- return support;
73
+ return !!support;
61
74
  },
62
75
 
63
76
  /**
@@ -9,8 +9,7 @@
9
9
  </a>
10
10
  <a class="action reload" data-control="reload">
11
11
  <div class="icon icon-reload" title="{{__ 'Reload'}}"></div>
12
- <div class="message">{{__ 'You are encountering a prolonged connectivity loss.'}}</div>
13
- <div class="message">{{__ 'Click to reload.'}}</div>
12
+ <div class="message">{{__ 'You are encountering a prolonged connectivity loss.'}} {{__ 'Click to reload.'}}</div>
14
13
  </a>
15
14
  </div>
16
15
  </div>