@cycjimmy/jsmpeg-player 6.1.6 → 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
@@ -47,7 +47,7 @@ new JSMpeg.VideoElement(videoWrapper, videoUrl [, options] [, overlayOptions])
47
47
  * `autoplay`: [Boolean] Whether to start playing immediately. Default `false`.
48
48
  * `autoSetWrapperSize`: [Boolean] Whether to set the wrapper element size automatically when the video loaded. Default `false`.
49
49
  * `loop`: [Boolean] Whether to loop the video (static files only). Default `false`.**[overwrite]**
50
- * `control`: [Boolean] Whether the user can control. Default `true`.
50
+ * `control`: [Boolean] Whether the user can control. The built-in play button and click-to-toggle apply to static files only: `ws://` / `wss://` live sources have no built-in playback control and must be driven with `play()` / `pause()`. Default `true`.
51
51
  * `decodeFirstFrame`: [Boolean] Whether to decode and display the first frame of the video. Default `true`.
52
52
  * `picMode`: [Boolean] Picture mode (no playButton). Default `false`.
53
53
  * `progressive`: [Boolean] whether to load data in chunks (static files only). Default `true`.
@@ -58,7 +58,7 @@ new JSMpeg.VideoElement(videoWrapper, videoUrl [, options] [, overlayOptions])
58
58
  * `stop`: [Function] The hook function when the video stop.
59
59
  * `load`: [Function] The hook function when the video established.
60
60
  * `overlayOptions`: [Object] More options can view the [jsmpeg options](https://github.com/phoboslab/jsmpeg#usage)
61
- * `protocols`: [String | Array<String>] WebSocket subprotocols, forwarded to `new WebSocket(url, protocols)` (`wss://` URLs only). Can carry a Bearer token. Default `undefined`. See [WebSocket authentication](#websocket-authentication).
61
+ * `protocols`: [String | Array<String>] WebSocket subprotocols, forwarded to `new WebSocket(url, protocols)` (`ws://` and `wss://` URLs only). Can carry a Bearer token. Default `undefined`. See [WebSocket authentication](#websocket-authentication).
62
62
  * `preserveDrawingBuffer`: [Boolean] Keep the WebGL drawing buffer so the canvas can be read back, e.g. to take a snapshot with `canvas.toDataURL()`. Default `false`. See [Snapshot](#snapshot).
63
63
 
64
64
  * `JSMpeg.VideoElement` instance supports the following methods:
@@ -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() {
@@ -393,6 +404,11 @@ var AjaxSource = /*#__PURE__*/function () {
393
404
  // Nothing to do here
394
405
  };
395
406
  _proto.destroy = function destroy() {
407
+ // destroy() may run before start(), e.g. when a player is torn down while
408
+ // the WASM module is still compiling and the source was never started.
409
+ if (!this.request) {
410
+ return;
411
+ }
396
412
  this.request.abort();
397
413
  };
398
414
  _proto.onProgress = function onProgress(ev) {
@@ -587,6 +603,12 @@ var WSSource = /*#__PURE__*/function () {
587
603
  _proto.destroy = function destroy() {
588
604
  clearTimeout(this.reconnectTimeoutId);
589
605
  this.shouldAttemptReconnect = false;
606
+
607
+ // destroy() may run before start(), e.g. when a player is torn down while
608
+ // the WASM module is still compiling and the socket was never opened.
609
+ if (!this.socket) {
610
+ return;
611
+ }
590
612
  this.socket.close();
591
613
  };
592
614
  _proto.start = function start() {
@@ -4038,11 +4060,15 @@ var MP2WASM = /*#__PURE__*/function (_BaseDecoder) {
4038
4060
  _this.bufferSize = options.audioBufferSize || 128 * 1024;
4039
4061
  _this.bufferMode = options.streaming ? BitBuffer.MODE.EVICT : BitBuffer.MODE.EXPAND;
4040
4062
  _this.sampleRate = 0;
4063
+ _this.destroyed = false;
4041
4064
  return _this;
4042
4065
  }
4043
4066
  _inheritsLoose(MP2WASM, _BaseDecoder);
4044
4067
  var _proto = MP2WASM.prototype;
4045
4068
  _proto.initializeWasmDecoder = function initializeWasmDecoder() {
4069
+ if (this.destroyed) {
4070
+ return;
4071
+ }
4046
4072
  if (!this.module.instance) {
4047
4073
  console.warn('JSMpeg: WASM module not compiled yet');
4048
4074
  return;
@@ -4052,10 +4078,23 @@ var MP2WASM = /*#__PURE__*/function (_BaseDecoder) {
4052
4078
  this.decoder = this.functions._mp2_decoder_create(this.bufferSize, this.bufferMode);
4053
4079
  };
4054
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;
4055
4084
  if (!this.decoder) {
4056
4085
  return;
4057
4086
  }
4058
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);
4059
4098
  };
4060
4099
  _proto.bufferGetIndex = function bufferGetIndex() {
4061
4100
  if (!this.decoder) {
@@ -4071,9 +4110,16 @@ var MP2WASM = /*#__PURE__*/function (_BaseDecoder) {
4071
4110
  this.functions._mp2_decoder_set_index(this.decoder, index);
4072
4111
  };
4073
4112
  _proto.bufferWrite = function bufferWrite(buffers) {
4113
+ if (this.destroyed) {
4114
+ return 0;
4115
+ }
4074
4116
  if (!this.decoder) {
4075
4117
  this.initializeWasmDecoder();
4076
4118
  }
4119
+ if (!this.decoder) {
4120
+ // The module is not compiled yet; nothing can be written.
4121
+ return 0;
4122
+ }
4077
4123
  var totalLength = 0;
4078
4124
  for (var i = 0; i < buffers.length; i++) {
4079
4125
  totalLength += buffers[i].length;
@@ -4430,6 +4476,9 @@ var CanvasRenderer = /*#__PURE__*/function () {
4430
4476
  return CanvasRenderer;
4431
4477
  }();
4432
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'];
4433
4482
  var WebAudioOut = /*#__PURE__*/function () {
4434
4483
  function WebAudioOut() {
4435
4484
  this.context = WebAudioOut.CachedContext = WebAudioOut.CachedContext || new (window.AudioContext || window.webkitAudioContext)();
@@ -4445,13 +4494,27 @@ var WebAudioOut = /*#__PURE__*/function () {
4445
4494
  this.wallclockStartTime = 0;
4446
4495
  this.volume = 1;
4447
4496
  this.enabled = true;
4448
- 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);
4449
4504
  Object.defineProperty(this, 'enqueuedTime', {
4450
4505
  get: this.getEnqueuedTime
4451
4506
  });
4507
+ if (!this.unlocked) {
4508
+ this.listenForGesture();
4509
+ }
4452
4510
  }
4453
4511
  var _proto = WebAudioOut.prototype;
4454
4512
  _proto.destroy = function destroy() {
4513
+ this.destroyed = true;
4514
+ this.unlockCallback = null;
4515
+ this.onUnlock = null;
4516
+ this.removeGestureListener();
4517
+ this.stopSources();
4455
4518
  this.gain.disconnect();
4456
4519
  this.context._connections--;
4457
4520
  if (this.context._connections === 0) {
@@ -4460,20 +4523,46 @@ var WebAudioOut = /*#__PURE__*/function () {
4460
4523
  }
4461
4524
  };
4462
4525
  _proto.play = function play(sampleRate, left, right) {
4463
- if (!this.enabled) {
4526
+ var _this = this;
4527
+ if (!this.enabled || this.destroyed) {
4464
4528
  return;
4465
4529
  }
4466
4530
 
4467
- // If the context is not unlocked yet, we simply advance the start time
4468
- // to "fake" actually playing audio. This will keep the video in sync.
4469
- 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();
4470
4547
  var ts = Now();
4471
4548
  if (this.wallclockStartTime < ts) {
4472
4549
  this.wallclockStartTime = ts;
4473
4550
  }
4474
4551
  this.wallclockStartTime += left.length / sampleRate;
4552
+ this.skippedAudio = true;
4475
4553
  return;
4476
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
+ }
4477
4566
  this.gain.gain.value = this.volume;
4478
4567
  var buffer = this.context.createBuffer(2, left.length, sampleRate);
4479
4568
  buffer.getChannelData(0).set(left);
@@ -4481,6 +4570,13 @@ var WebAudioOut = /*#__PURE__*/function () {
4481
4570
  var source = this.context.createBufferSource();
4482
4571
  source.buffer = buffer;
4483
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);
4484
4580
  var now = this.context.currentTime;
4485
4581
  var duration = buffer.duration;
4486
4582
  if (this.startTime < now) {
@@ -4492,11 +4588,13 @@ var WebAudioOut = /*#__PURE__*/function () {
4492
4588
  this.wallclockStartTime += duration;
4493
4589
  };
4494
4590
  _proto.stop = function stop() {
4495
- // Meh; there seems to be no simple way to get a list of currently
4496
- // active source nodes from the Audio Context, and maintaining this
4497
- // list ourselfs would be a pain, so we just set the gain to 0
4498
- // 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.
4499
4595
  this.gain.gain.value = 0;
4596
+ this.stopSources();
4597
+ this.resetEnqueuedTime();
4500
4598
  };
4501
4599
  _proto.getEnqueuedTime = function getEnqueuedTime() {
4502
4600
  // The AudioContext.currentTime is only updated every so often, so if we
@@ -4507,16 +4605,65 @@ var WebAudioOut = /*#__PURE__*/function () {
4507
4605
  this.startTime = this.context.currentTime;
4508
4606
  this.wallclockStartTime = Now();
4509
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
+ };
4510
4653
  _proto.unlock = function unlock(callback) {
4511
- 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') {
4512
4658
  if (callback) {
4513
4659
  callback();
4514
4660
  }
4515
4661
  return;
4516
4662
  }
4517
- this.unlockCallback = callback;
4663
+ this.unlockCallback = callback || this.unlockCallback;
4518
4664
 
4519
- // 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.
4520
4667
  var buffer = this.context.createBuffer(1, 1, 22050);
4521
4668
  var source = this.context.createBufferSource();
4522
4669
  source.buffer = buffer;
@@ -4528,23 +4675,73 @@ var WebAudioOut = /*#__PURE__*/function () {
4528
4675
  } else {
4529
4676
  source.noteOn(0);
4530
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
+ }
4531
4697
  setTimeout(this.checkIfUnlocked.bind(this, source, 0), 0);
4532
4698
  };
4533
4699
  _proto.checkIfUnlocked = function checkIfUnlocked(source, attempt) {
4534
- if (source.playbackState === source.PLAYING_STATE || source.playbackState === source.FINISHED_STATE) {
4535
- this.unlocked = true;
4536
- if (this.unlockCallback) {
4537
- this.unlockCallback();
4538
- this.unlockCallback = null;
4539
- }
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();
4540
4713
  } else if (attempt < 10) {
4541
4714
  // Jeez, what a shit show. Thanks iOS!
4542
4715
  setTimeout(this.checkIfUnlocked.bind(this, source, attempt + 1), 100);
4543
4716
  }
4544
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
+ };
4545
4737
  WebAudioOut.NeedsUnlocking = function NeedsUnlocking() {
4546
4738
  return /iPhone|iPad|iPod/i.test(navigator.userAgent);
4547
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
+ };
4548
4745
  WebAudioOut.IsSupported = function IsSupported() {
4549
4746
  return window.AudioContext || window.webkitAudioContext;
4550
4747
  };
@@ -4812,6 +5009,7 @@ var Player = /*#__PURE__*/function () {
4812
5009
  set: this.setVolume
4813
5010
  });
4814
5011
  this.paused = true;
5012
+ this.destroyed = false;
4815
5013
  this.unpauseOnShow = false;
4816
5014
  if (options.pauseWhenHidden !== false) {
4817
5015
  // Keep the bound reference so destroy() can remove this listener again,
@@ -4837,6 +5035,13 @@ var Player = /*#__PURE__*/function () {
4837
5035
  }
4838
5036
  var _proto = Player.prototype;
4839
5037
  _proto.startLoading = function startLoading() {
5038
+ // The WASM module is compiled asynchronously, so this may still run through
5039
+ // the module's init callback after destroy() has already torn the player
5040
+ // down. Starting the source then would open a connection that nothing owns
5041
+ // any more and restart the decode loop.
5042
+ if (this.destroyed) {
5043
+ return;
5044
+ }
4840
5045
  this.source.start();
4841
5046
  if (this.autoplay) {
4842
5047
  this.play();
@@ -4868,10 +5073,11 @@ var Player = /*#__PURE__*/function () {
4868
5073
  this.isPlaying = false;
4869
5074
  this.paused = true;
4870
5075
  if (this.audio && this.audio.canPlay) {
4871
- // Seek to the currentTime again - audio may already be enqueued a bit
4872
- // 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;
4873
5079
  this.audioOut.stop();
4874
- this.seek(this.currentTime);
5080
+ this.seek(time);
4875
5081
  }
4876
5082
  if (this.hooks.pause) {
4877
5083
  this.hooks.pause();
@@ -4899,6 +5105,9 @@ var Player = /*#__PURE__*/function () {
4899
5105
  }
4900
5106
  };
4901
5107
  _proto.destroy = function destroy() {
5108
+ // Nothing may start the source from here on: the WASM init callback may
5109
+ // still fire later, and destroy() itself may run before the source started.
5110
+ this.destroyed = true;
4902
5111
  this.pause();
4903
5112
  this.source.destroy();
4904
5113
  this.video && this.video.destroy();
@@ -5105,6 +5314,12 @@ var FetchSource = /*#__PURE__*/function () {
5105
5314
  _proto.abort = function abort() {
5106
5315
  this.aborted = true;
5107
5316
  };
5317
+ _proto.destroy = function destroy() {
5318
+ // The player always calls source.destroy(); without this the Fetch source
5319
+ // would throw on teardown. The pump loop cancels the reader once it sees
5320
+ // the flag on the next chunk.
5321
+ this.aborted = true;
5322
+ };
5108
5323
  return FetchSource;
5109
5324
  }();
5110
5325