@cycjimmy/jsmpeg-player 6.1.7 → 6.1.8

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.
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
  * **[jsmpeg-player](https://github.com/cycdpo/jsmpeg-player) has been renamed to @cycjimmy/jsmpeg-player for scoped NPM package.**
13
13
  * JSMpeg player is based on [jsmpeg](https://github.com/phoboslab/jsmpeg).
14
14
  * The video must be compressed into the TS format of MPEG1 / MP2.
15
- * Apple device automatically plays without sound, you need to guide the user to click on the video in the lower right corner of the video icon to unlock the sound. (no similar problem in non-autoplay mode)
15
+ * Autoplay with sound is blocked by the browser until the page receives a user gesture: Apple device automatically plays without sound, you need to guide the user to click on the video in the lower right corner of the video icon to unlock the sound. Chrome and Safari follow the same autoplay policy, where the sound stays in sync with the picture and starts as soon as the user interacts with the page, or can be unlocked right away through the same icon. (no similar problem in non-autoplay mode)
16
16
  * [Demo][github-pages-url]
17
17
 
18
18
  ## How to use
@@ -235,10 +235,10 @@ var VideoElement = /*#__PURE__*/function () {
235
235
  this.els.playButton.classList.add(_style.hidden);
236
236
  }
237
237
 
238
- // Set up the unlock audio button for iOS devices. iOS only allows us to
239
- // play audio after a user action has initiated playing. For autoplay or
240
- // streaming players we set up a muted speaker icon as the button. For all
241
- // others, we can simply use the play button.
238
+ // Set up the unlock audio button for the browsers that do not allow audio
239
+ // before a user action (iOS, and Chrome/Safari under their autoplay
240
+ // policy). For autoplay or streaming players we set up a muted speaker
241
+ // icon as the button. For all others, we can simply use the play button.
242
242
  if (this.player.audioOut && !this.player.audioOut.unlocked) {
243
243
  var unlockAudioElement = this.els.wrapper;
244
244
  if (this.options.autoplay || this.player.options.streaming) {
@@ -251,6 +251,12 @@ var VideoElement = /*#__PURE__*/function () {
251
251
  this.unlockAudioBound = this.onUnlockAudio.bind(this, unlockAudioElement);
252
252
  unlockAudioElement.addEventListener('touchstart', this.unlockAudioBound, false);
253
253
  unlockAudioElement.addEventListener('click', this.unlockAudioBound, true);
254
+
255
+ // Any gesture on the page may unlock the audio, so the icon has to go
256
+ // away even when it was not the element that was clicked.
257
+ this.player.audioOut.onUnlock = function () {
258
+ return _this.onAudioUnlocked(unlockAudioElement);
259
+ };
254
260
  }
255
261
  };
256
262
  _proto.initPlayButton = function initPlayButton() {
@@ -277,6 +283,15 @@ var VideoElement = /*#__PURE__*/function () {
277
283
  });
278
284
  });
279
285
  };
286
+ _proto.onAudioUnlocked = function onAudioUnlocked(element) {
287
+ if (this.els.unmuteButton) {
288
+ this.els.unmuteButton.classList.add(_style.hidden);
289
+ }
290
+ if (element && this.unlockAudioBound) {
291
+ element.removeEventListener('touchstart', this.unlockAudioBound);
292
+ element.removeEventListener('click', this.unlockAudioBound);
293
+ }
294
+ };
280
295
  _proto.onUnlockAudio = function onUnlockAudio(element, ev) {
281
296
  var _this3 = this;
282
297
  if (this.els.unmuteButton) {
@@ -284,11 +299,7 @@ var VideoElement = /*#__PURE__*/function () {
284
299
  ev.stopPropagation();
285
300
  }
286
301
  this.player.audioOut.unlock(function () {
287
- if (_this3.els.unmuteButton) {
288
- _this3.els.unmuteButton.classList.add(_style.hidden);
289
- }
290
- element.removeEventListener('touchstart', _this3.unlockAudioBound);
291
- element.removeEventListener('click', _this3.unlockAudioBound);
302
+ return _this3.onAudioUnlocked(element);
292
303
  });
293
304
  };
294
305
  _proto.onClick = function onClick() {
@@ -4049,11 +4060,15 @@ var MP2WASM = /*#__PURE__*/function (_BaseDecoder) {
4049
4060
  _this.bufferSize = options.audioBufferSize || 128 * 1024;
4050
4061
  _this.bufferMode = options.streaming ? BitBuffer.MODE.EVICT : BitBuffer.MODE.EXPAND;
4051
4062
  _this.sampleRate = 0;
4063
+ _this.destroyed = false;
4052
4064
  return _this;
4053
4065
  }
4054
4066
  _inheritsLoose(MP2WASM, _BaseDecoder);
4055
4067
  var _proto = MP2WASM.prototype;
4056
4068
  _proto.initializeWasmDecoder = function initializeWasmDecoder() {
4069
+ if (this.destroyed) {
4070
+ return;
4071
+ }
4057
4072
  if (!this.module.instance) {
4058
4073
  console.warn('JSMpeg: WASM module not compiled yet');
4059
4074
  return;
@@ -4063,10 +4078,23 @@ var MP2WASM = /*#__PURE__*/function (_BaseDecoder) {
4063
4078
  this.decoder = this.functions._mp2_decoder_create(this.bufferSize, this.bufferMode);
4064
4079
  };
4065
4080
  _proto.destroy = function destroy() {
4081
+ // A source can still deliver one last chunk inside the very callback that
4082
+ // destroys the player, so nothing may reach the module from now on.
4083
+ this.destroyed = true;
4066
4084
  if (!this.decoder) {
4067
4085
  return;
4068
4086
  }
4069
4087
  this.functions._mp2_decoder_destroy(this.decoder);
4088
+ // The pointer refers to a freed block now, and the module may hand the same
4089
+ // address to the decoder of the next player: it must never be used again.
4090
+ this.decoder = null;
4091
+ this.canPlay = false;
4092
+ };
4093
+ _proto.write = function write(pts, buffers) {
4094
+ if (this.destroyed) {
4095
+ return;
4096
+ }
4097
+ _BaseDecoder.prototype.write.call(this, pts, buffers);
4070
4098
  };
4071
4099
  _proto.bufferGetIndex = function bufferGetIndex() {
4072
4100
  if (!this.decoder) {
@@ -4082,9 +4110,16 @@ var MP2WASM = /*#__PURE__*/function (_BaseDecoder) {
4082
4110
  this.functions._mp2_decoder_set_index(this.decoder, index);
4083
4111
  };
4084
4112
  _proto.bufferWrite = function bufferWrite(buffers) {
4113
+ if (this.destroyed) {
4114
+ return 0;
4115
+ }
4085
4116
  if (!this.decoder) {
4086
4117
  this.initializeWasmDecoder();
4087
4118
  }
4119
+ if (!this.decoder) {
4120
+ // The module is not compiled yet; nothing can be written.
4121
+ return 0;
4122
+ }
4088
4123
  var totalLength = 0;
4089
4124
  for (var i = 0; i < buffers.length; i++) {
4090
4125
  totalLength += buffers[i].length;
@@ -4441,6 +4476,9 @@ var CanvasRenderer = /*#__PURE__*/function () {
4441
4476
  return CanvasRenderer;
4442
4477
  }();
4443
4478
 
4479
+ // Chrome grants the user activation on pointerup/touchend, not on the
4480
+ // touchstart/pointerdown that precedes them, so the touch events are paired.
4481
+ var UNLOCK_EVENTS = ['pointerdown', 'pointerup', 'keydown', 'touchend'];
4444
4482
  var WebAudioOut = /*#__PURE__*/function () {
4445
4483
  function WebAudioOut() {
4446
4484
  this.context = WebAudioOut.CachedContext = WebAudioOut.CachedContext || new (window.AudioContext || window.webkitAudioContext)();
@@ -4456,13 +4494,27 @@ var WebAudioOut = /*#__PURE__*/function () {
4456
4494
  this.wallclockStartTime = 0;
4457
4495
  this.volume = 1;
4458
4496
  this.enabled = true;
4459
- this.unlocked = !WebAudioOut.NeedsUnlocking();
4497
+ this.sources = [];
4498
+ this.skippedAudio = false;
4499
+ this.destroyed = false;
4500
+ this.gestureListener = null;
4501
+ this.unlockCallback = null;
4502
+ this.onUnlock = null;
4503
+ this.unlocked = !WebAudioOut.NeedsUnlocking() && WebAudioOut.IsRunning(this.context);
4460
4504
  Object.defineProperty(this, 'enqueuedTime', {
4461
4505
  get: this.getEnqueuedTime
4462
4506
  });
4507
+ if (!this.unlocked) {
4508
+ this.listenForGesture();
4509
+ }
4463
4510
  }
4464
4511
  var _proto = WebAudioOut.prototype;
4465
4512
  _proto.destroy = function destroy() {
4513
+ this.destroyed = true;
4514
+ this.unlockCallback = null;
4515
+ this.onUnlock = null;
4516
+ this.removeGestureListener();
4517
+ this.stopSources();
4466
4518
  this.gain.disconnect();
4467
4519
  this.context._connections--;
4468
4520
  if (this.context._connections === 0) {
@@ -4471,20 +4523,46 @@ var WebAudioOut = /*#__PURE__*/function () {
4471
4523
  }
4472
4524
  };
4473
4525
  _proto.play = function play(sampleRate, left, right) {
4474
- if (!this.enabled) {
4526
+ var _this = this;
4527
+ if (!this.enabled || this.destroyed) {
4475
4528
  return;
4476
4529
  }
4477
4530
 
4478
- // If the context is not unlocked yet, we simply advance the start time
4479
- // to "fake" actually playing audio. This will keep the video in sync.
4480
- if (!this.unlocked) {
4531
+ // Browsers that need no explicit unlock resume the context by themselves
4532
+ // once the page has been interacted with, so the unlock does not necessarily
4533
+ // go through unlock(). iOS keeps requiring the gesture.
4534
+ if (!this.unlocked && !WebAudioOut.NeedsUnlocking() && WebAudioOut.IsRunning(this.context)) {
4535
+ this.onUnlocked();
4536
+ }
4537
+
4538
+ // A context that is not running, or that has not been unlocked yet, cannot
4539
+ // play anything: the browser autoplay policy keeps it suspended until the
4540
+ // page has received a user gesture, and its currentTime does not advance
4541
+ // meanwhile. Scheduling buffers against it would queue all the audio
4542
+ // decoded in the meantime and play it back from the very beginning once
4543
+ // the context resumes. We simply advance the start time to "fake"
4544
+ // actually playing audio instead. This will keep the video in sync.
4545
+ if (!this.isPlayable()) {
4546
+ this.listenForGesture();
4481
4547
  var ts = Now();
4482
4548
  if (this.wallclockStartTime < ts) {
4483
4549
  this.wallclockStartTime = ts;
4484
4550
  }
4485
4551
  this.wallclockStartTime += left.length / sampleRate;
4552
+ this.skippedAudio = true;
4486
4553
  return;
4487
4554
  }
4555
+
4556
+ // The audio is available again: whatever was queued while it could not be
4557
+ // played is dropped and the time base is rebuilt, so the audio joins the
4558
+ // picture at the current point in time instead of staying behind it. The
4559
+ // audio decoded meanwhile is lost once, which is what keeps it in sync
4560
+ // with the picture.
4561
+ if (this.skippedAudio) {
4562
+ this.skippedAudio = false;
4563
+ this.stopSources();
4564
+ this.resetEnqueuedTime();
4565
+ }
4488
4566
  this.gain.gain.value = this.volume;
4489
4567
  var buffer = this.context.createBuffer(2, left.length, sampleRate);
4490
4568
  buffer.getChannelData(0).set(left);
@@ -4492,6 +4570,13 @@ var WebAudioOut = /*#__PURE__*/function () {
4492
4570
  var source = this.context.createBufferSource();
4493
4571
  source.buffer = buffer;
4494
4572
  source.connect(this.destination);
4573
+ source.onended = function () {
4574
+ var index = _this.sources.indexOf(source);
4575
+ if (index !== -1) {
4576
+ _this.sources.splice(index, 1);
4577
+ }
4578
+ };
4579
+ this.sources.push(source);
4495
4580
  var now = this.context.currentTime;
4496
4581
  var duration = buffer.duration;
4497
4582
  if (this.startTime < now) {
@@ -4503,11 +4588,13 @@ var WebAudioOut = /*#__PURE__*/function () {
4503
4588
  this.wallclockStartTime += duration;
4504
4589
  };
4505
4590
  _proto.stop = function stop() {
4506
- // Meh; there seems to be no simple way to get a list of currently
4507
- // active source nodes from the Audio Context, and maintaining this
4508
- // list ourselfs would be a pain, so we just set the gain to 0
4509
- // to cut off all enqueued audio instantly.
4591
+ // Cut off everything that is still queued: the gain alone would only mute
4592
+ // the buffers that are already scheduled, and they would become audible
4593
+ // again as soon as play() restores it. The time base is rebuilt so the
4594
+ // next buffer joins the current context time instead of the old queue.
4510
4595
  this.gain.gain.value = 0;
4596
+ this.stopSources();
4597
+ this.resetEnqueuedTime();
4511
4598
  };
4512
4599
  _proto.getEnqueuedTime = function getEnqueuedTime() {
4513
4600
  // The AudioContext.currentTime is only updated every so often, so if we
@@ -4518,16 +4605,65 @@ var WebAudioOut = /*#__PURE__*/function () {
4518
4605
  this.startTime = this.context.currentTime;
4519
4606
  this.wallclockStartTime = Now();
4520
4607
  };
4608
+ _proto.isPlayable = function isPlayable() {
4609
+ return this.unlocked && WebAudioOut.IsRunning(this.context);
4610
+ };
4611
+ _proto.stopSources = function stopSources() {
4612
+ var sources = this.sources;
4613
+ this.sources = [];
4614
+ sources.forEach(function (source) {
4615
+ try {
4616
+ source.stop();
4617
+ } catch (e) {
4618
+ // The source has already ended and cannot be stopped any more.
4619
+ }
4620
+ try {
4621
+ source.disconnect();
4622
+ } catch (e) {
4623
+ // It was never connected to anything.
4624
+ }
4625
+ });
4626
+ };
4627
+ _proto.listenForGesture = function listenForGesture() {
4628
+ var _this2 = this;
4629
+ if (this.gestureListener || this.isPlayable() || this.destroyed || WebAudioOut.NeedsUnlocking() || typeof document === 'undefined') {
4630
+ return;
4631
+ }
4632
+
4633
+ // Chrome and desktop Safari keep the context suspended until it is resumed
4634
+ // inside a user gesture, and they do not resume it by themselves when the
4635
+ // page is tapped somewhere else. iOS keeps requiring the unlock icon.
4636
+ this.gestureListener = function () {
4637
+ return _this2.unlock();
4638
+ };
4639
+ UNLOCK_EVENTS.forEach(function (type) {
4640
+ document.addEventListener(type, _this2.gestureListener, true);
4641
+ });
4642
+ };
4643
+ _proto.removeGestureListener = function removeGestureListener() {
4644
+ var _this3 = this;
4645
+ if (!this.gestureListener) {
4646
+ return;
4647
+ }
4648
+ UNLOCK_EVENTS.forEach(function (type) {
4649
+ document.removeEventListener(type, _this3.gestureListener, true);
4650
+ });
4651
+ this.gestureListener = null;
4652
+ };
4521
4653
  _proto.unlock = function unlock(callback) {
4522
- if (this.unlocked) {
4654
+ var _this4 = this;
4655
+ // Nothing to do while the audio plays; a context that was suspended again
4656
+ // still has to be resumed, and a destroyed one has nothing left to unlock.
4657
+ if (this.isPlayable() || this.destroyed || this.context.state === 'closed') {
4523
4658
  if (callback) {
4524
4659
  callback();
4525
4660
  }
4526
4661
  return;
4527
4662
  }
4528
- this.unlockCallback = callback;
4663
+ this.unlockCallback = callback || this.unlockCallback;
4529
4664
 
4530
- // Create empty buffer and play it
4665
+ // Create empty buffer and play it, so the browsers that only start their
4666
+ // audio pipeline from within a user gesture (iOS Safari) really unlock.
4531
4667
  var buffer = this.context.createBuffer(1, 1, 22050);
4532
4668
  var source = this.context.createBufferSource();
4533
4669
  source.buffer = buffer;
@@ -4539,23 +4675,73 @@ var WebAudioOut = /*#__PURE__*/function () {
4539
4675
  } else {
4540
4676
  source.noteOn(0);
4541
4677
  }
4678
+
4679
+ // Chrome and desktop Safari keep the context suspended until it is resumed
4680
+ // explicitly, and resume() only takes effect inside a user gesture - which
4681
+ // is exactly where unlock() is called from.
4682
+ var resumed = null;
4683
+ try {
4684
+ resumed = this.context.resume && this.context.resume();
4685
+ } catch (e) {
4686
+ // An unsupported or interrupted context: fall through to the polling
4687
+ // check, which also covers the browsers that resume on their own.
4688
+ }
4689
+ if (resumed && resumed.then) {
4690
+ resumed.then(function () {
4691
+ return _this4.checkIfUnlocked(source, 0);
4692
+ }, function () {
4693
+ return _this4.checkIfUnlocked(source, 0);
4694
+ });
4695
+ return;
4696
+ }
4542
4697
  setTimeout(this.checkIfUnlocked.bind(this, source, 0), 0);
4543
4698
  };
4544
4699
  _proto.checkIfUnlocked = function checkIfUnlocked(source, attempt) {
4545
- if (source.playbackState === source.PLAYING_STATE || source.playbackState === source.FINISHED_STATE) {
4546
- this.unlocked = true;
4547
- if (this.unlockCallback) {
4548
- this.unlockCallback();
4549
- this.unlockCallback = null;
4550
- }
4700
+ if (this.destroyed || this.context.state === 'closed') {
4701
+ // The player was destroyed while waiting for the unlock.
4702
+ this.unlockCallback = null;
4703
+ return;
4704
+ }
4705
+
4706
+ // `playbackState` and its constants were removed from the Web Audio API,
4707
+ // and comparing two undefined values is always true, so the legacy check
4708
+ // may only be used when the browser actually exposes it.
4709
+ var legacyUnlocked = source.playbackState !== undefined && (source.playbackState === source.PLAYING_STATE || source.playbackState === source.FINISHED_STATE);
4710
+ var unlocked = this.context.state ? this.context.state === 'running' : legacyUnlocked;
4711
+ if (unlocked) {
4712
+ this.onUnlocked();
4551
4713
  } else if (attempt < 10) {
4552
4714
  // Jeez, what a shit show. Thanks iOS!
4553
4715
  setTimeout(this.checkIfUnlocked.bind(this, source, attempt + 1), 100);
4554
4716
  }
4555
4717
  };
4718
+ _proto.onUnlocked = function onUnlocked() {
4719
+ if (this.destroyed) {
4720
+ return;
4721
+ }
4722
+ this.removeGestureListener();
4723
+
4724
+ // The output may be unlocked again after its context had been suspended, so
4725
+ // a pending callback is always served; only the onUnlock hook, which drives
4726
+ // the unlock UI, is limited to the first time.
4727
+ var firstUnlock = !this.unlocked;
4728
+ this.unlocked = true;
4729
+ var callbacks = [this.unlockCallback, firstUnlock ? this.onUnlock : null];
4730
+ this.unlockCallback = null;
4731
+ callbacks.forEach(function (callback) {
4732
+ if (callback) {
4733
+ callback();
4734
+ }
4735
+ });
4736
+ };
4556
4737
  WebAudioOut.NeedsUnlocking = function NeedsUnlocking() {
4557
4738
  return /iPhone|iPad|iPod/i.test(navigator.userAgent);
4558
4739
  };
4740
+ WebAudioOut.IsRunning = function IsRunning(context) {
4741
+ // Old WebKit versions expose no state at all; there, NeedsUnlocking()
4742
+ // alone decides whether the context has to be unlocked.
4743
+ return !context.state || context.state === 'running';
4744
+ };
4559
4745
  WebAudioOut.IsSupported = function IsSupported() {
4560
4746
  return window.AudioContext || window.webkitAudioContext;
4561
4747
  };
@@ -4887,10 +5073,11 @@ var Player = /*#__PURE__*/function () {
4887
5073
  this.isPlaying = false;
4888
5074
  this.paused = true;
4889
5075
  if (this.audio && this.audio.canPlay) {
4890
- // Seek to the currentTime again - audio may already be enqueued a bit
4891
- // further, so we have to rewind it.
5076
+ // Seek back to the frame that is being heard: stop() drops the queue that
5077
+ // was already enqueued further ahead, so the time has to be read first.
5078
+ var time = this.currentTime;
4892
5079
  this.audioOut.stop();
4893
- this.seek(this.currentTime);
5080
+ this.seek(time);
4894
5081
  }
4895
5082
  if (this.hooks.pause) {
4896
5083
  this.hooks.pause();