@grfzhl/vue-hls-player 1.1.25 → 1.1.27

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
@@ -207,6 +207,15 @@ At the moment the following attribute are supported:
207
207
  ```
208
208
 
209
209
  ### Last release:
210
+ v1.1.27
211
+ - Expose BasePlayer fullscreen control through wrapper components.
212
+ - Add support for forced fullscreen re-entry from parent integrations.
213
+ - Avoid fullscreen request errors when no user activation is available.
214
+ - Keep fullscreen state and emitted events in sync across native and document fullscreen modes.
215
+ v1.1.26
216
+ - Prevent HLS chunk preloading during player initialization.
217
+ - Start HLS loading only after user interaction.
218
+ - Fallback to the first video frame when no preview image is available.
210
219
  v1.1.25
211
220
  - Decouple audio language switching from subtitle selection.
212
221
  - Preserve user-selected subtitle language across audio changes and HLS source reloads.
@@ -8,7 +8,7 @@
8
8
  </div>
9
9
  <div class="media-overlay" v-if="initialPlayButton">
10
10
  <div class="initial-play" :class="{'hide-playbutton': hideInitialPlayButton}">
11
- <media-play-button mediapaused="" class="media-button" aria-label="play" tabindex="0" role="button" @click="video.play()">
11
+ <media-play-button mediapaused="" class="media-button" aria-label="play" tabindex="0" role="button" @click="handleInitialPlay">
12
12
  <svg slot="icon" viewBox="0 0 32 32">
13
13
  <g>
14
14
  <path id="icon-play" d="M20.7131 14.6976C21.7208 15.2735 21.7208 16.7265 20.7131 17.3024L12.7442 21.856C11.7442 22.4274 10.5 21.7054 10.5 20.5536L10.5 11.4464C10.5 10.2946 11.7442 9.57257 12.7442 10.144L20.7131 14.6976Z"></path>
@@ -31,6 +31,7 @@
31
31
  ref="video"
32
32
  :poster="previewImageLink"
33
33
  :controls="false"
34
+ preload="none"
34
35
  :title="title"
35
36
  controlslist="nodownload"
36
37
  playsinline
@@ -44,7 +45,7 @@
44
45
  <track
45
46
  v-if="subtitles.length"
46
47
  v-for="(subtitle, i) in subtitles"
47
- :key="subtitle.lang + '-' + currentLang"
48
+ :key="subtitle.lang + '-' + currentAudioLang"
48
49
  :src="subtitle.link"
49
50
  kind="subtitles"
50
51
  :srclang="subtitle.lang"
@@ -71,7 +72,7 @@
71
72
  <slot name="between-video-and-transcript"></slot>
72
73
  <slot name="before-transcripts"></slot>
73
74
  <SubtitleBlock
74
- :key="`${currentLang}-${currentSubtitleLang}`"
75
+ :key="`${currentAudioLang}-${currentSubtitleLang}`"
75
76
  ref="transcriptRef"
76
77
  :subtitle="currentSubtitle"
77
78
  :cursor="videoCursor"
@@ -215,6 +216,12 @@ const isUserInitiatedLangChange = ref(false)
215
216
 
216
217
  let initialLoad = true;
217
218
  let defaultApplied = false
219
+ const hlsInitialized = ref(false)
220
+ const hlsInitializing = ref(false)
221
+ const pendingPlayRequest = ref(false)
222
+ const pendingSeekTime = ref(null)
223
+ const previewFramePrimed = ref(false)
224
+ const previewFrameLoading = ref(false)
218
225
 
219
226
  watch(
220
227
  () => props.defaultLang,
@@ -348,8 +355,15 @@ function emitPointerUpdate() {
348
355
 
349
356
  const videoElement = defineModel()
350
357
 
358
+ defineExpose({ startFullscreen })
359
+
351
360
  onMounted(() => {
352
- prepareVideoPlayer(props.link)
361
+ if (video.value) {
362
+ video.value.muted = mutedAttr.value
363
+ video.value.currentTime = props.progress
364
+ }
365
+ initVideo()
366
+ maybeLoadFirstFrame()
353
367
  })
354
368
 
355
369
  onUnmounted(() => {
@@ -384,75 +398,100 @@ const currentSubtitle = computed(() => {
384
398
  return props.subtitles[0];
385
399
  })
386
400
 
387
- watch(() => props.autoplay, (a) => {
388
- if(a[0].autoplay && a[1] && a[1].paused) {
389
- // autoplay is only possible when muted
390
- a[1].muted = true
391
- setTimeout(() => {
392
- a[1].play().catch(err => console.warn("Autoplay-Error:", err));
393
- }, 200)
401
+ watch(() => props.autoplay, (autoplay) => {
402
+ if (!autoplay || !video.value || !video.value.paused) {
403
+ return
394
404
  }
405
+
406
+ video.value.muted = true
407
+ setTimeout(() => {
408
+ handleInitialPlay().catch(err => console.warn("Autoplay-Error:", err))
409
+ }, 200)
395
410
  })
396
411
 
397
412
  watch(
398
413
  () => props.link,
399
414
  (newLink, oldLink) => {
400
- if (newLink !== oldLink) {
401
- prepareVideoPlayer(newLink);
415
+ if (newLink !== oldLink) {
416
+ resetPlayer()
417
+ maybeLoadFirstFrame()
418
+ }
419
+ }
420
+ )
421
+
422
+ function updateFullscreenButtonState(isActive) {
423
+ if (!buttonElement) return
424
+
425
+ buttonElement.setAttribute(
426
+ 'aria-label',
427
+ isActive ? 'Exit fullscreen mode' : 'Enter fullscreen mode'
428
+ )
429
+
430
+ if (isActive) {
431
+ buttonElement.setAttribute('mediaIsFullscreen', '')
432
+ } else {
433
+ buttonElement.removeAttribute('mediaIsFullscreen')
402
434
  }
403
- })
404
435
 
405
- async function startFullscreen() {
436
+ const tooltip = buttonElement.shadowRoot?.querySelector('media-tooltip')
437
+ const enterTooltip = tooltip?.querySelector('slot[name="tooltip-enter"]')
438
+ const exitTooltip = tooltip?.querySelector('slot[name="tooltip-exit"]')
439
+
440
+ if (enterTooltip) {
441
+ enterTooltip.style.display = isActive ? 'none' : 'block'
442
+ }
443
+
444
+ if (exitTooltip) {
445
+ exitTooltip.style.display = isActive ? 'block' : 'none'
446
+ }
447
+ }
448
+
449
+ async function startFullscreen(forceEnter = false) {
406
450
  let vpVideoBlock = document.getElementById(props.fullScreenElement);
451
+ const hasDocumentFullscreen = !!document.fullscreenElement;
452
+ const hasNativeVideoFullscreen = !!video.value?.webkitDisplayingFullscreen;
453
+ const hasUserActivation =
454
+ typeof navigator === 'undefined' ||
455
+ !navigator.userActivation ||
456
+ navigator.userActivation.isActive;
407
457
  if(video.value) {
408
458
  currentTime = video.value.currentTime
409
459
  }
410
- if (document.fullscreenElement) {
460
+ if (forceEnter && (hasDocumentFullscreen || hasNativeVideoFullscreen)) {
461
+ isFullscreen.value = true;
462
+ emit('video-fullscreen-change', true)
463
+ return;
464
+ }
465
+
466
+ // Browsers only allow fullscreen requests from a short-lived user interaction.
467
+ if (forceEnter && !hasUserActivation) {
468
+ return;
469
+ }
470
+
471
+ if (hasDocumentFullscreen || hasNativeVideoFullscreen) {
411
472
  if (screen.orientation && screen.orientation.unlock) {
412
473
  screen.orientation.unlock();
413
474
  }
414
- await document.exitFullscreen();
475
+ if (document.fullscreenElement && typeof document.exitFullscreen === 'function') {
476
+ await document.exitFullscreen();
477
+ }
415
478
  if (/iPhone|iPad|AppleWebKit/i.test(navigator.userAgent)) {
416
- document.webkitExitFullscreen();
417
- }
418
- isFullscreen.value = false;
419
- buttonElement.setAttribute('aria-label', "Enter fullscreen mode")
420
- buttonElement.removeAttribute('mediaIsFullscreen');
421
- const tooltip = buttonElement.shadowRoot?.querySelector('media-tooltip');
422
- if (tooltip) {
423
- // Slots für Tooltip-Enter und Tooltip-Exit finden
424
- const enterTooltip = tooltip.querySelector('slot[name="tooltip-enter"]');
425
- const exitTooltip = tooltip.querySelector('slot[name="tooltip-exit"]');
426
-
427
- if (enterTooltip && exitTooltip) {
428
- enterTooltip.style.display = 'block';
429
- exitTooltip.style.display = 'none';
430
- } else {
431
- console.warn("Tooltip-Slots nicht gefunden!");
479
+ if (typeof video.value?.webkitExitFullscreen === 'function') {
480
+ video.value.webkitExitFullscreen();
481
+ } else if (typeof video.value?.webkitExitFullScreen === 'function') {
482
+ video.value.webkitExitFullScreen();
483
+ } else if (typeof document.webkitExitFullscreen === 'function') {
484
+ document.webkitExitFullscreen();
432
485
  }
433
- } else {
434
- console.warn("Kein media-tooltip gefunden!");
435
486
  }
487
+ isFullscreen.value = false;
488
+ emit('video-fullscreen-change', false)
489
+ updateFullscreenButtonState(false)
436
490
  } else {
437
491
  isFullscreen.value = true;
492
+ emit('video-fullscreen-change', true)
438
493
  try {
439
- buttonElement.setAttribute('aria-label', "Exit fullscreen mode")
440
- buttonElement.setAttribute('mediaIsFullscreen', '');
441
- const tooltip = buttonElement.shadowRoot?.querySelector('media-tooltip');
442
- if (tooltip) {
443
- // Slots für Tooltip-Enter und Tooltip-Exit finden
444
- const enterTooltip = tooltip.querySelector('slot[name="tooltip-enter"]');
445
- const exitTooltip = tooltip.querySelector('slot[name="tooltip-exit"]');
446
-
447
- if (enterTooltip && exitTooltip) {
448
- enterTooltip.style.display = 'none';
449
- exitTooltip.style.display = 'block';
450
- } else {
451
- console.warn("Tooltip-Slots nicht gefunden!");
452
- }
453
- } else {
454
- console.warn("Kein media-tooltip gefunden!");
455
- }
494
+ updateFullscreenButtonState(true)
456
495
 
457
496
  if (vpVideoBlock.requestFullscreen) {
458
497
  await vpVideoBlock.requestFullscreen();
@@ -465,6 +504,7 @@ async function startFullscreen() {
465
504
  vpVideoBlock.style.height = "auto";
466
505
  }, 100);
467
506
  isFullscreen.value = false;
507
+ emit('video-fullscreen-change', false)
468
508
  vpVideoBlock.removeEventListener("webkitendfullscreen")
469
509
  });
470
510
  } else if (vpVideoBlock.mozRequestFullScreen) {
@@ -476,14 +516,15 @@ async function startFullscreen() {
476
516
  try {
477
517
  await screen.orientation.lock("landscape");
478
518
  } catch (error) {
479
- console.warn("Orientation lock failed", error);
519
+ // ignore unsupported mobile/browser orientation lock failures
480
520
  }
481
- } else {
482
- console.warn("Orientation lock not supported.");
483
521
  }
484
522
  } catch (error) {
485
- console.error("Fullscreen could not be activated", error);
523
+ if (!forceEnter) {
524
+ console.error("Fullscreen could not be activated", error);
525
+ }
486
526
  isFullscreen.value = false;
527
+ emit('video-fullscreen-change', false)
487
528
  }
488
529
 
489
530
  video.value.currentTime = currentTime;
@@ -505,6 +546,7 @@ function onFullscreenChange(e) {
505
546
  autoHideIntroTitle.value = false;
506
547
  }
507
548
  isFullscreen.value = !!document.fullscreenElement;
549
+ emit('video-fullscreen-change', isFullscreen.value)
508
550
  };
509
551
 
510
552
  function onOrientationChange(e) {
@@ -547,13 +589,130 @@ function toggleTranscript() {
547
589
  props.showTranscriptBlock = !props.showTranscriptBlock
548
590
  }
549
591
 
550
- function seekVideo(time) {
551
- video.value.currentTime = time;
552
- video.value.play()
592
+ async function handleInitialPlay() {
593
+ await startPlayback()
553
594
  }
554
595
 
555
- function prepareVideoPlayer(link) {
556
- if (!video.value) return;
596
+ async function startPlayback() {
597
+ if (!video.value) return
598
+
599
+ pendingPlayRequest.value = true
600
+ pendingSeekTime.value = null
601
+ initialPlayButton.value = false
602
+
603
+ if (!hlsInitialized.value) {
604
+ initPlayer(props.link)
605
+ return
606
+ }
607
+
608
+ if (previewFrameLoading.value && !previewFramePrimed.value) {
609
+ return
610
+ }
611
+
612
+ resumePreviewFrameStream()
613
+
614
+ try {
615
+ await video.value.play()
616
+ pendingPlayRequest.value = false
617
+ } catch (err) {
618
+ console.warn('[HLS] Play start failed:', err)
619
+ initialPlayButton.value = true
620
+ }
621
+ }
622
+
623
+ function initPlayer(src, { previewOnly = false } = {}) {
624
+ if (!video.value || !src || hlsInitialized.value || hlsInitializing.value) return
625
+
626
+ hlsInitializing.value = true
627
+
628
+ try {
629
+ prepareVideoPlayer(src, { previewOnly })
630
+ hlsInitialized.value = true
631
+ } finally {
632
+ hlsInitializing.value = false
633
+ }
634
+ }
635
+
636
+ function maybeLoadFirstFrame() {
637
+ if (
638
+ props.previewImageLink ||
639
+ !props.link ||
640
+ !video.value ||
641
+ hlsInitialized.value ||
642
+ hlsInitializing.value
643
+ ) {
644
+ return
645
+ }
646
+
647
+ initPlayer(props.link, { previewOnly: true })
648
+ }
649
+
650
+ function resumePreviewFrameStream() {
651
+ if (!previewFramePrimed.value || !hls || !video.value) return
652
+
653
+ previewFramePrimed.value = false
654
+ hls.startLoad(video.value.currentTime || 0)
655
+ }
656
+
657
+ function resetPlayer() {
658
+ hlsInitialized.value = false
659
+ hlsInitializing.value = false
660
+ pendingPlayRequest.value = false
661
+ pendingSeekTime.value = null
662
+ previewFramePrimed.value = false
663
+ previewFrameLoading.value = false
664
+ initialPlayButton.value = true
665
+ hideInitialPlayButton.value = false
666
+
667
+ if (hls) {
668
+ hls.detachMedia()
669
+ hls.destroy()
670
+ hls = null
671
+ }
672
+
673
+ if (!video.value) return
674
+
675
+ video.value.pause()
676
+ video.value.removeAttribute('src')
677
+ const source = video.value.querySelector('source')
678
+ if (source) {
679
+ source.removeAttribute('src')
680
+ }
681
+ video.value.load()
682
+ video.value.currentTime = props.progress
683
+ video.value.muted = mutedAttr.value
684
+ }
685
+
686
+ async function seekVideo(time) {
687
+ if (!video.value) return
688
+
689
+ if (!hlsInitialized.value) {
690
+ pendingSeekTime.value = time
691
+ pendingPlayRequest.value = true
692
+ initPlayer(props.link)
693
+ return
694
+ }
695
+
696
+ if (previewFrameLoading.value && !previewFramePrimed.value) {
697
+ pendingSeekTime.value = time
698
+ pendingPlayRequest.value = true
699
+ return
700
+ }
701
+
702
+ resumePreviewFrameStream()
703
+ video.value.currentTime = time
704
+
705
+ try {
706
+ await video.value.play()
707
+ pendingPlayRequest.value = false
708
+ pendingSeekTime.value = null
709
+ } catch (err) {
710
+ console.warn('[HLS] Seek play failed:', err)
711
+ }
712
+ }
713
+
714
+ function prepareVideoPlayer(link, { previewOnly = false } = {}) {
715
+ if (!video.value || !link) return;
557
716
 
558
717
  // Reset previous HLS instance
559
718
  if (hls) {
@@ -561,15 +720,68 @@ function prepareVideoPlayer(link) {
561
720
  hls.destroy();
562
721
  }
563
722
 
723
+ const playerHlsConfig = {
724
+ ...hlsConfig,
725
+ autoStartLoad: !previewOnly,
726
+ };
727
+
564
728
  // Preparing video player with link: ${link}
565
- hls = new Hls(hlsConfig);
729
+ hls = new Hls(playerHlsConfig);
566
730
  // Attach HLS
567
731
  hls.loadSource(link);
568
732
  hls.attachMedia(video.value);
569
- video.value.textTracks.addEventListener('change', () => {
570
- Array.from(video.value?.textTracks || []).forEach(track => {
571
- console.log('[TrackChange] Track', track.language || track.srclang, '→', track.mode);
572
- });
733
+ hls.once(Hls.Events.MANIFEST_PARSED, async () => {
734
+ if (!video.value) return;
735
+
736
+ video.value.muted = mutedAttr.value;
737
+ video.value.currentTime = pendingSeekTime.value ?? props.progress;
738
+
739
+ if (previewOnly) {
740
+ previewFrameLoading.value = true;
741
+ video.value.addEventListener('loadeddata', async () => {
742
+ previewFrameLoading.value = false;
743
+
744
+ if (!video.value || !hls) return;
745
+
746
+ video.value.pause();
747
+
748
+ if (!pendingPlayRequest.value) {
749
+ previewFramePrimed.value = true;
750
+ hls.stopLoad();
751
+ return;
752
+ }
753
+
754
+ previewFramePrimed.value = false;
755
+ hls.startLoad(video.value.currentTime || 0);
756
+
757
+ if (pendingSeekTime.value != null) {
758
+ video.value.currentTime = pendingSeekTime.value;
759
+ }
760
+
761
+ try {
762
+ await video.value.play();
763
+ pendingPlayRequest.value = false;
764
+ pendingSeekTime.value = null;
765
+ } catch (err) {
766
+ console.warn('[HLS] Play after first frame failed:', err);
767
+ initialPlayButton.value = true;
768
+ }
769
+ }, { once: true });
770
+
771
+ hls.startLoad(0);
772
+ return;
773
+ }
774
+
775
+ if (!pendingPlayRequest.value) return;
776
+
777
+ try {
778
+ await video.value.play();
779
+ pendingPlayRequest.value = false;
780
+ pendingSeekTime.value = null;
781
+ } catch (err) {
782
+ console.warn('[HLS] Play after manifest failed:', err);
783
+ initialPlayButton.value = true;
784
+ }
573
785
  });
574
786
  // Native subtitle handling – without polling
575
787
  Array.from(video.value?.textTracks || []).forEach(track => {
@@ -615,7 +827,6 @@ function prepareVideoPlayer(link) {
615
827
  video.value.currentTime = props.progress;
616
828
  // Chrome-like: update menu whenever track mode changes
617
829
  video.value.textTracks.addEventListener('change', updateLangMenuState);
618
- initVideo(); // Init controls etc.
619
830
  }
620
831
 
621
832
 
@@ -118,7 +118,7 @@ function onFullscreenChange(data) {
118
118
  function onLanguageChanged(data) {
119
119
  emit('language-changed', data);
120
120
  }
121
- function startFullscreen() {
122
- childRef.value.startFullscreen();
121
+ function startFullscreen(forceEnter = false) {
122
+ return childRef.value?.startFullscreen?.(forceEnter)
123
123
  }
124
124
  </script>
@@ -120,7 +120,7 @@ function onVideoFullScreenChange(data) {
120
120
  function onVideoEnd(data) {
121
121
  emit('video-ended', data);
122
122
  }
123
- function startFullscreen() {
124
- childRef.value.startFullscreen();
123
+ function startFullscreen(forceEnter = false) {
124
+ return childRef.value?.startFullscreen?.(forceEnter)
125
125
  }
126
126
  </script>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@grfzhl/vue-hls-player",
3
3
  "private": false,
4
- "version": "1.1.25",
4
+ "version": "1.1.27",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist"