@grfzhl/vue-hls-player 1.1.17 → 1.1.20

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,18 @@ At the moment the following attribute are supported:
207
207
  ```
208
208
 
209
209
  ### Last release:
210
+ v1.1.20
211
+ - Stability and UI style improvements for Language switch
212
+ v1.1.19
213
+ - Switcher supports both single and multi-language HLS sources
214
+ - Automatically syncs subtitle language with selected audio track
215
+ - Works with videos that include or omit subtitles
216
+ - Includes native cuechange event handling for subtitle updates
217
+ - Removed the default media-captions (CC) button from the player
218
+ v1.1.18
219
+ - Added new slot `between-video-and-transcript` to `BasePlayer.vue`, `VDefaultVideoPlayer.vue` and `index.vue`
220
+ to allow injection of custom UI between video and transcript.
221
+ - Introduced a continuous **Frame Pointer Loop** that emits a `pointer-update` event with the current playback time and calculated frame number (30 fps) for real-time frame tracking.
210
222
  v1.1.17
211
223
  - Keep query params for transcription when getting from .vtt file to .txt
212
224
  v1.1.16
@@ -44,6 +44,7 @@
44
44
  <track
45
45
  v-if="subtitles.length"
46
46
  v-for="(subtitle, i) in subtitles"
47
+ :key="subtitle.lang + '-' + currentLang"
47
48
  :src="subtitle.link"
48
49
  kind="subtitles"
49
50
  :srclang="subtitle.lang"
@@ -67,8 +68,10 @@
67
68
  <slot name="after-media"></slot>
68
69
  </div>
69
70
  </div>
71
+ <slot name="between-video-and-transcript"></slot>
70
72
  <slot name="before-transcripts"></slot>
71
73
  <SubtitleBlock
74
+ :key="currentLang"
72
75
  ref="transcriptRef"
73
76
  :subtitle="currentSubtitle"
74
77
  :cursor="videoCursor"
@@ -86,6 +89,7 @@ import { onMounted, onUpdated, ref, onUnmounted, computed, watch, toRef } from '
86
89
  import Hls from 'hls.js'
87
90
  import 'player.style/sutro';
88
91
  import SubtitleBlock from './SubtitleBlock.vue';
92
+ // import 'media-chrome';
89
93
 
90
94
  const props = defineProps({
91
95
  introTitle: {
@@ -152,10 +156,18 @@ const props = defineProps({
152
156
  options: {
153
157
  type: Object,
154
158
  default: {}
159
+ },
160
+ multiLangSources: {
161
+ type: Array,
162
+ default: () => []
163
+ },
164
+ defaultLang: {
165
+ type: String,
166
+ default: 'en'
155
167
  }
156
168
  })
157
169
 
158
- const emit = defineEmits(['pause', 'video-ended', 'video-fullscreen-change'])
170
+ const emit = defineEmits(['pause', 'video-ended', 'video-fullscreen-change', 'pointer-update'])
159
171
  const video = ref(null)
160
172
  const subtitlesContainer = ref(null)
161
173
  const currentSubtitleLang = ref(null)
@@ -171,6 +183,144 @@ const link = toRef(props, 'link');
171
183
  let currentTime = 0
172
184
  let hls = null
173
185
  let buttonElement = null
186
+ // --- lang switcher ---
187
+ const currentLang = ref(props.defaultLang || 'en')
188
+ // --- Remember and restore last subtitle language ---
189
+ async function selectLang(lang) {
190
+ if (!video.value || !hls) return;
191
+ if (lang === currentLang.value) return;
192
+
193
+ const newSource = props.multiLangSources?.find(s => s.lang === lang);
194
+ if (!newSource?.file_url) {
195
+ //Missing HLS source for ${lang}
196
+ return;
197
+ }
198
+
199
+ // Remember playback state
200
+ const wasPaused = video.value.paused;
201
+ const time = video.value.currentTime;
202
+ const wasMuted = video.value.muted;
203
+ const rate = video.value.playbackRate;
204
+
205
+ currentLang.value = lang;
206
+ // Switching to ${lang}...
207
+
208
+ try {
209
+ hls.stopLoad();
210
+ hls = new Hls(hlsConfig);
211
+ hls.attachMedia(video.value);
212
+
213
+ hls.once(Hls.Events.MANIFEST_PARSED, () => {
214
+ // Manifest loaded for ${lang}
215
+
216
+ // Restore playback
217
+ video.value.currentTime = Math.max(0, time - 0.1);
218
+ video.value.playbackRate = rate;
219
+ if (!wasPaused) video.value.play();
220
+ setTimeout(() => (video.value.muted = wasMuted), 400);
221
+
222
+ // Match subtitle language with audio
223
+ const matchLang = lang.toLowerCase();
224
+ // Matching subtitles for matchLang
225
+ Array.from(video.value?.textTracks || []).forEach(track => {
226
+ const tLang = (track.language || track.srclang || '').toLowerCase();
227
+ console.log('[LangSwitch] Track', tLang, 'current mode:', track.mode);
228
+ track.mode = tLang === matchLang ? 'showing' : 'disabled';
229
+ console.log('[LangSwitch] Track', tLang, '→ new mode:', track.mode);
230
+ });
231
+
232
+ currentSubtitleLang.value = lang;
233
+ console.log('[LangSwitch] currentSubtitleLang set to', currentSubtitleLang.value);
234
+ });
235
+ await hls.loadSource(newSource.file_url);
236
+ video.value.muted = true;
237
+ } catch (err) {
238
+ console.error("[LangSwitch] Reload failed:", err);
239
+ }
240
+ }
241
+
242
+ function updateLangMenuState() {
243
+ const mediaTheme = document.querySelector('.video-player-theme-container');
244
+ const controlBar = mediaTheme?.shadowRoot?.querySelector('media-control-bar');
245
+ const langSwitcher = controlBar?.querySelector('.lang-switcher');
246
+ const menu = langSwitcher?.querySelector('.lang-menu');
247
+ if (!menu || !video.value) return;
248
+
249
+ const audioCol = menu.querySelector('.audio-col');
250
+ const subCol = menu.querySelector('.sub-col');
251
+
252
+ // --- Audio ---
253
+ audioCol?.querySelectorAll('li').forEach(li => {
254
+ const langElement = li.querySelector('span[data-lang]')
255
+ const liLang = langElement?.dataset?.lang;
256
+ // const liLang = li.textContent.trim().toLowerCase();
257
+ console.log("-- audio li lang", currentLang.value, liLang)
258
+ li.classList.toggle('active', liLang === currentLang.value.toLowerCase());
259
+ });
260
+
261
+ // --- Subtitles ---
262
+ const activeTrack = Array.from(video.value?.textTracks || []).find(t => t.mode === 'showing');
263
+ const activeSubLang = activeTrack
264
+ ? (activeTrack.language || activeTrack.srclang || '').toLowerCase()
265
+ : 'aus';
266
+ subCol?.querySelectorAll('li').forEach(li => {
267
+ const subElement = li.querySelector('span[data-lang]')
268
+ const liLang = subElement?.dataset?.lang;
269
+ li.classList.toggle('active', liLang === activeSubLang);
270
+ });
271
+ }
272
+
273
+ watch(currentSubtitleLang, () => updateLangMenuState());
274
+
275
+ /* function selectLang(lang) {
276
+ currentLang.value = lang;
277
+ showLangMenu.value = false;
278
+
279
+ const newSource = props.multiLangSources.find(s => s.lang === lang);
280
+ if (newSource) {
281
+ hls.stopLoad();
282
+ hls.detachMedia();
283
+ hls.loadSource(newSource.file_url);
284
+ hls.attachMedia(video.value);
285
+ hls.startLoad();
286
+ }
287
+
288
+ // Subtitle sync
289
+ const textTracks = video.value.textTracks;
290
+ for (const track of textTracks) {
291
+ track.mode = track.language === lang ? 'showing' : 'disabled';
292
+ }
293
+ } */
294
+
295
+ // --- Frame Pointer Loop ---
296
+ let rafId = null
297
+ const FPS = 30
298
+ const hlsConfig = {
299
+ fetchSetup: async (context, init) => {
300
+ init = init || {};
301
+ init.headers = new Headers(init.headers || {});
302
+ for (const [key, value] of Object.entries(props.additionHeaders)) {
303
+ init.headers.set(key, value);
304
+ }
305
+ return new Request(context.url, init);
306
+ },
307
+ xhrSetup: async (xhr, url) => {
308
+ const additionHeaders = props.additionHeaders || {};
309
+ for (const [key, value] of Object.entries(additionHeaders)) {
310
+ const val = typeof value === 'function' ? await value(url) : value;
311
+ if (val != null) xhr.setRequestHeader(key, val);
312
+ }
313
+ }
314
+ };
315
+
316
+ function emitPointerUpdate() {
317
+ if (video.value) {
318
+ const t = video.value.currentTime || 0
319
+ const frame = Math.floor(t * FPS)
320
+ emit('pointer-update', { currentTime: t, frame })
321
+ }
322
+ rafId = requestAnimationFrame(emitPointerUpdate)
323
+ }
174
324
 
175
325
  const videoElement = defineModel()
176
326
 
@@ -185,6 +335,8 @@ onUnmounted(() => {
185
335
  document.removeEventListener('fullscreenchange', onFullscreenChange);
186
336
  document.removeEventListener('orientationchange', onOrientationChange);
187
337
  window.screen.orientation.removeEventListener("change", onOrientationChange);
338
+ // --- Stop pointer-update loop ---
339
+ if (rafId) cancelAnimationFrame(rafId)
188
340
  });
189
341
 
190
342
  const mutedAttr = computed(() => {
@@ -206,7 +358,7 @@ const currentSubtitle = computed(() => {
206
358
  return null
207
359
  })
208
360
 
209
- watch([props, videoElement], (a) => {
361
+ watch(() => props.autoplay, (a) => {
210
362
  if(a[0].autoplay && a[1] && a[1].paused) {
211
363
  // autoplay is only possible when muted
212
364
  a[1].muted = true
@@ -242,8 +394,6 @@ async function startFullscreen() {
242
394
  buttonElement.removeAttribute('mediaIsFullscreen');
243
395
  const tooltip = buttonElement.shadowRoot?.querySelector('media-tooltip');
244
396
  if (tooltip) {
245
- console.log("Tooltip gefunden:", tooltip);
246
-
247
397
  // Slots für Tooltip-Enter und Tooltip-Exit finden
248
398
  const enterTooltip = tooltip.querySelector('slot[name="tooltip-enter"]');
249
399
  const exitTooltip = tooltip.querySelector('slot[name="tooltip-exit"]');
@@ -264,8 +414,6 @@ async function startFullscreen() {
264
414
  buttonElement.setAttribute('mediaIsFullscreen', '');
265
415
  const tooltip = buttonElement.shadowRoot?.querySelector('media-tooltip');
266
416
  if (tooltip) {
267
- console.log("Tooltip gefunden:", tooltip);
268
-
269
417
  // Slots für Tooltip-Enter und Tooltip-Exit finden
270
418
  const enterTooltip = tooltip.querySelector('slot[name="tooltip-enter"]');
271
419
  const exitTooltip = tooltip.querySelector('slot[name="tooltip-exit"]');
@@ -379,81 +527,78 @@ function seekVideo(time) {
379
527
  }
380
528
 
381
529
  function prepareVideoPlayer(link) {
382
- let initiallyLoaded = true;
383
- if (video.value) {
384
- if (hls) {
385
- hls.detachMedia();
386
- hls.destroy();
387
- initiallyLoaded = false;
388
- }
389
- hls = new Hls({
390
- fetchSetup: async (context, init) => {
391
- init = init || {};
392
- init.headers = new Headers(init.headers || {});
393
- // set headers
394
- for (const [key, value] of Object.entries(props.additionHeaders)) {
395
- init.headers.set(key, value);
396
- }
397
- return new Request(context.url, init);
398
- },
399
- xhrSetup: async (xhr, url) => {
400
- const additionHeaders = props.additionHeaders || {};
401
- for (const [key, value] of Object.entries(additionHeaders)) {
402
- const val = typeof value === 'function' ? await value(url) : value;
403
- // set headers
404
- if (val != null) xhr.setRequestHeader(key, val);
530
+ if (!video.value) return;
531
+
532
+ // Reset previous HLS instance
533
+ if (hls) {
534
+ hls.detachMedia();
535
+ hls.destroy();
536
+ }
537
+
538
+ // Preparing video player with link: ${link}
539
+ hls = new Hls(hlsConfig);
540
+
541
+ // Attach HLS
542
+ hls.loadSource(link);
543
+ hls.attachMedia(video.value);
544
+ video.value.textTracks.addEventListener('change', () => {
545
+ Array.from(video.value?.textTracks || []).forEach(track => {
546
+ console.log('[TrackChange] Track', track.language || track.srclang, '→', track.mode);
547
+ });
548
+ });
549
+ // Native subtitle handling without polling
550
+ Array.from(video.value?.textTracks || []).forEach(track => {
551
+ track.addEventListener("cuechange", e => {
552
+ const cues = e.target.activeCues;
553
+ if (subtitlesContainer.value) {
554
+ if (cues && cues.length > 0) {
555
+ subtitlesContainer.value.style.display = "block";
556
+ subtitlesContainer.value.textContent = cues[0].text;
557
+ } else {
558
+ subtitlesContainer.value.style.display = "none";
405
559
  }
406
560
  }
407
561
  });
408
- hls.loadSource(link)
409
- hls.attachMedia(video.value)
410
- hls.recoverMediaError()
411
-
412
- video.value.muted = props.isMuted
413
- video.value.currentTime = props.progress
414
-
415
- const textTracks = video.value.textTracks;
416
- let previousModes = Array.from(textTracks).map((track) => track.mode);
417
- function checkTrackModeChanges() {
418
- Array.from(textTracks).forEach((track, index) => {
419
- track.addEventListener("cuechange", () => {
420
- const activeCues = track.activeCues;
421
- currentSubtitleLang.value = track.language
422
- if(subtitlesContainer.value) {
423
- if (activeCues && activeCues.length > 0) {
424
- subtitlesContainer.value.textContent = activeCues[0].text
425
- subtitlesContainer.value.style.display = "block";
426
- } else {
427
- subtitlesContainer.value.style.display = "none";
428
- }
429
- }
430
- });
431
- if (track.mode !== previousModes[index]) {
432
- if (track.mode === "showing") {
433
- const activeCues = track.activeCues;
434
- currentSubtitleLang.value = track.language
435
- if(subtitlesContainer.value) {
436
- if (activeCues && activeCues.length > 0) {
437
- subtitlesContainer.value.style.display = "block";
438
- subtitlesContainer.value.textContent = activeCues[0].text
439
- } else {
440
- subtitlesContainer.value.style.display = "none";
441
- }
442
- }
443
- } else {
444
- if(subtitlesContainer.value) {
445
- subtitlesContainer.value.style.display = "none";
446
- }
447
- }
448
- previousModes[index] = track.mode;
562
+ });
563
+
564
+ // Automatically attach new tracks (after language switch)
565
+ video.value.textTracks.onaddtrack = (e) => {
566
+ const track = e.track;
567
+ track.addEventListener("cuechange", evt => {
568
+ const cues = evt.target.activeCues;
569
+ if (subtitlesContainer.value) {
570
+ if (cues && cues.length > 0) {
571
+ subtitlesContainer.value.style.display = "block";
572
+ subtitlesContainer.value.textContent = cues[0].text;
573
+ } else {
574
+ subtitlesContainer.value.style.display = "none";
449
575
  }
450
- });
451
- }
452
- setInterval(checkTrackModeChanges, 100);
453
- initVideo();
576
+ }
577
+ });
578
+ };
579
+
580
+ // Initialize subtitles
581
+ if (props.subtitles?.length > 0) {
582
+ const defaultSub = props.subtitles.find(s => s.lang === props.defaultLang);
583
+ currentSubtitleLang.value = defaultSub ? defaultSub.lang : props.subtitles[0].lang;
584
+ Array.from(video.value?.textTracks || []).forEach(track => {
585
+ const tLang = (track.language || track.srclang || '').toLowerCase();
586
+ console.log('[SubtitleInit] Track found:', tLang, '->', tLang === currentSubtitleLang.value.toLowerCase() ? 'showing' : 'disabled');
587
+ track.mode = tLang === currentSubtitleLang.value.toLowerCase() ? 'showing' : 'disabled';
588
+ });
454
589
  }
590
+
591
+ // HLS attached to <video>
592
+ hls.recoverMediaError();
593
+
594
+ video.value.muted = props.isMuted;
595
+ video.value.currentTime = props.progress;
596
+ // Chrome-like: update menu whenever track mode changes
597
+ video.value.textTracks.addEventListener('change', updateLangMenuState);
598
+ initVideo(); // Init controls etc.
455
599
  }
456
600
 
601
+
457
602
  function initVideo() {
458
603
  if (video.value) {
459
604
 
@@ -497,6 +642,8 @@ function initVideo() {
497
642
  const observer = new MutationObserver((mutationsList, observer) => {
498
643
  const mediaTheme = document.querySelector('.video-player-theme-container');
499
644
  if (mediaTheme && mediaTheme.shadowRoot) {
645
+ // controllbar
646
+ const controlBar = mediaTheme.shadowRoot.querySelector('media-control-bar');
500
647
  const fullscreenButton = mediaTheme.shadowRoot.querySelector('media-fullscreen-button');
501
648
  buttonElement = fullscreenButton
502
649
  const playbackRateButton = mediaTheme.shadowRoot.querySelector('media-playback-rate-menu');
@@ -508,6 +655,253 @@ function initVideo() {
508
655
  event.stopImmediatePropagation();
509
656
  startFullscreen();
510
657
  }
658
+ // --- Remove default CC button ---
659
+ const ccButton = mediaTheme.shadowRoot.querySelector('media-captions-button');
660
+ if (ccButton) {
661
+ ccButton.remove();
662
+ }
663
+
664
+ // --- Amazon Prime Style Language Switcher ---
665
+ if (controlBar && !controlBar.querySelector('.lang-switcher')) {
666
+ const langDiv = document.createElement('div');
667
+ langDiv.className = 'lang-switcher';
668
+ langDiv.innerHTML = `
669
+ <style>
670
+ .lang-switcher {
671
+ position: relative;
672
+ display: flex;
673
+ align-items: center;
674
+ justify-content: center;
675
+ margin-left: var(--media-control-spacing, 6px);
676
+ }
677
+
678
+ .lang-switcher button {
679
+ all: unset;
680
+ display: flex;
681
+ align-items: center;
682
+ justify-content: center;
683
+ cursor: pointer;
684
+ height: var(--media-button-height, 32px);
685
+ border-radius: 25%;
686
+ min-width: var(--media-button-height, 32px);
687
+ padding: var(--media-button-padding, 0 5px);
688
+ background: transparent;
689
+ transition: background 0.15s ease, transform 0.1s ease;
690
+ }
691
+
692
+ .lang-switcher button svg {
693
+ width: var(--media-icon-size, 24px);
694
+ height: var(--media-icon-size, 24px);
695
+ color: var(--media-icon-color, white);
696
+ stroke: currentColor;
697
+ opacity: var(--media-icon-opacity, 0.9);
698
+ pointer-events: none;
699
+ transition: opacity 0.15s ease;
700
+ }
701
+
702
+ .lang-switcher button:hover svg {
703
+ transform: scale(1.1);
704
+ }
705
+
706
+ .lang-switcher button:hover {
707
+ opacity: var(--media-icon-opacity-hover, 1);
708
+ background: var(--media-control-hover-background, rgba(50 50 70 / .7));
709
+ transition: backdrop-filter 0.3s, -webkit-backdrop-filter 0.3s;
710
+ box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 5px;
711
+ backdrop-filter: blur(10px) invert(15%) brightness(80%) opacity(1);
712
+ padding: 5px;
713
+ color: var(--media-text-color, var(--media-primary-color, rgb(238 238 238)));
714
+ }
715
+
716
+ .lang-btn[title]:hover::after {
717
+ content: attr(title);
718
+ position: fixed;
719
+ bottom: 122%;
720
+ right: -100%;
721
+ white-space: nowrap;
722
+ background: var(--media-tooltip-background, rgba(0,0,0,0.2));
723
+ color: var(--media-tooltip-color, #fff);
724
+ border-radius: 4px;
725
+ padding: 4px 15px;
726
+ font-size: var(--media-font-size, 12px);
727
+ box-shadow: 0 2px 8px rgba(0,0,0,0.4);
728
+ line-height: calc(1.2 * var(--base));
729
+ }
730
+
731
+ .lang-menu {
732
+ position: absolute;
733
+ bottom: calc(var(--media-button-height, 32px) * 1.7);
734
+ right: -48px;
735
+ background: var(--media-control-bar-background, rgba(20,20,20,0.4));
736
+ backdrop-filter: blur(12px);
737
+ border-radius: 12px;
738
+ box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 5px;
739
+ padding: 10px;
740
+ min-width: 240px;
741
+ display: none;
742
+ animation: fadeUp 0.15s ease-out;
743
+ color: var(--media-text-color, #fff);
744
+ font-family: var(--media-font-family, system-ui, sans-serif);
745
+ font-size: var(--media-font-size, 13px);
746
+ z-index: 9999;
747
+ }
748
+
749
+ @keyframes fadeUp {
750
+ from { opacity: 0; transform: translateY(8px); }
751
+ to { opacity: 1; transform: translateY(0); }
752
+ }
753
+
754
+ .lang-columns {
755
+ display: flex;
756
+ justify-content: space-between;
757
+ gap: 1rem;
758
+ }
759
+
760
+ .lang-col {
761
+ flex: 1;
762
+ display: flex;
763
+ flex-direction: column;
764
+ margin: 0;
765
+ padding: 0;
766
+ }
767
+
768
+ .lang-col .title {
769
+ line-height: calc(1.2 * var(--base));
770
+ font-weight: 600;
771
+ font-size: calc(var(--media-font-size, 13px) - 1px);
772
+ margin-bottom: 4px;
773
+ border-bottom: 1px solid rgba(255,255,255,0.1);
774
+ padding-bottom: 2px;
775
+ }
776
+
777
+ .lang-col ul {
778
+ list-style: none;
779
+ margin: 0;
780
+ padding: 0;
781
+ }
782
+
783
+ .lang-col li {
784
+ display: flex;
785
+ align-items: center;
786
+ justify-content: space-between;
787
+ cursor: pointer;
788
+ border-radius: 4px;
789
+ padding: 2px 12px;
790
+ transition: background 0.15s ease, color 0.15s ease;
791
+ }
792
+
793
+ .lang-col li span {
794
+ line-height: calc(1.2 * var(--base));
795
+ }
796
+
797
+ .lang-col li:hover {
798
+ background: var(--media-control-hover-background, rgba(255,255,255,0.15));
799
+ }
800
+
801
+ .lang-col li.active {
802
+ font-weight: 500;
803
+ color: var(--media-accent-color, #fff);
804
+ }
805
+
806
+ .lang-col li .icon {
807
+ opacity: 0;
808
+ transition: opacity 0.15s ease, transform 0.15s ease;
809
+ transform: translateX(2px);
810
+ }
811
+
812
+ .lang-col li.active .icon {
813
+ opacity: 1;
814
+ transform: translateX(0);
815
+ }
816
+ </style>
817
+
818
+ <button title="Audio & Subtitles" class="lang-btn">
819
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26" fill="none" stroke="currentColor" stroke-width="1.5"
820
+ stroke-linecap="round" stroke-linejoin="round">
821
+ <path d="m5 8 6 6"/>
822
+ <path d="m4 14 6-6 2-3"/>
823
+ <path d="M2 5h12"/>
824
+ <path d="M7 2h1"/>
825
+ <path d="m22 22-5-10-5 10"/>
826
+ <path d="M14 18h6"/>
827
+ </svg>
828
+ </button>
829
+
830
+ <div class="lang-menu">
831
+ <div class="lang-columns">
832
+ <ul class="lang-col audio-col"><li class="title">Audio</li></ul>
833
+ <ul class="lang-col sub-col"><li class="title">Subtitles</li></ul>
834
+ </div>
835
+ </div>
836
+ `;
837
+
838
+ const menu = langDiv.querySelector('.lang-menu');
839
+ const audioCol = menu.querySelector('.audio-col');
840
+ const subCol = menu.querySelector('.sub-col');
841
+ const button = langDiv.querySelector('button');
842
+ menu.addEventListener('click', e => e.stopPropagation());
843
+ // Chrome Media Player style check icon
844
+ const renderIcon = () => `
845
+ <svg aria-hidden="true" viewBox="0 1 24 24" width="16" height="16" part="checked-indicator indicator">
846
+ <path fill="currentColor" d="m10 15.17 9.193-9.191 1.414 1.414-10.606 10.606-6.364-6.364 1.414-1.414 4.95 4.95Z"></path>
847
+ </svg>
848
+ `;
849
+
850
+ // Audio options
851
+ props.multiLangSources.forEach(src => {
852
+ const li = document.createElement('li');
853
+ li.innerHTML = `
854
+ <span data-lang="${ src.lang }">${src.label || src.lang.toUpperCase()}</span>
855
+ <span class="icon">${renderIcon()}</span>
856
+ `;
857
+ if (src.lang === currentLang.value) li.classList.add('active');
858
+ li.addEventListener('click', () => {
859
+ audioCol.querySelectorAll('li').forEach(el => el.classList.remove('active'));
860
+ li.classList.add('active');
861
+ selectLang(src.lang);
862
+ menu.style.display = 'none';
863
+ });
864
+ audioCol.appendChild(li);
865
+ });
866
+
867
+ props.subtitles.forEach(sub => {
868
+ const li = document.createElement('li');
869
+ li.innerHTML = `
870
+ <span data-lang="${ sub.lang }">${sub.label || sub.lang.toUpperCase()}</span>
871
+ <span class="icon">${renderIcon()}</span>
872
+ `;
873
+
874
+ if (sub.lang === currentSubtitleLang.value) li.classList.add('active');
875
+ li.addEventListener('click', () => {
876
+ Array.from(video.value?.textTracks || []).forEach(track => {
877
+ const tLang = (track.language || track.srclang || '').toLowerCase();
878
+ track.mode = tLang === sub.lang.toLowerCase() ? 'showing' : 'disabled';
879
+ });
880
+ subCol.querySelectorAll('li').forEach(el => el.classList.remove('active'));
881
+ li.classList.add('active');
882
+ menu.style.display = 'none';
883
+ currentSubtitleLang.value = sub.lang;
884
+ });
885
+ subCol.appendChild(li);
886
+ });
887
+
888
+ button.addEventListener('click', e => {
889
+ e.stopPropagation();
890
+ const isOpen = menu.style.display === 'block';
891
+ menu.style.display = isOpen ? 'none' : 'block';
892
+ updateLangMenuState();
893
+ });
894
+ document.addEventListener('click', e => {
895
+ if (!menu.contains(e.target) && !button.contains(e.target)) {
896
+ menu.style.display = 'none';
897
+ button.setAttribute('aria-expanded', 'false');
898
+ updateLangMenuState();
899
+ }
900
+ });
901
+
902
+ controlBar.insertBefore(langDiv, fullscreenButton);
903
+ }
904
+
511
905
  observer.disconnect();
512
906
  } else {
513
907
  console.error('Button not found in Shadow DOM!');
@@ -516,6 +910,11 @@ function initVideo() {
516
910
  console.error('Shadow Root not found!');
517
911
  }
518
912
  })
913
+
914
+ // --- Start pointer-update loop ---
915
+ if (!rafId) {
916
+ emitPointerUpdate()
917
+ }
519
918
  observer.observe(document.body, { childList: true, subtree: true });
520
919
  }
521
920
  }
@@ -538,7 +937,16 @@ function changeSpeed(e) {
538
937
  video.value.playbackRate = video.value.playbackRate - 0.25
539
938
  }
540
939
  }
541
- defineExpose({ startFullscreen });
940
+ defineExpose({ startFullscreen, getHls: () => hls, getVideo: () => video.value,
941
+ changeSource: (newLink) => {
942
+ if (!newLink) return;
943
+ hls.stopLoad();
944
+ hls.detachMedia();
945
+ hls.loadSource(newLink);
946
+ hls.attachMedia(video.value);
947
+ hls.startLoad();
948
+ }
949
+ });
542
950
 
543
951
  </script>
544
952
  <style>
@@ -78,6 +78,8 @@
78
78
 
79
79
  .subtitles li .content {
80
80
  max-width: 800px;
81
+ display: flex;
82
+ flex-direction: column;
81
83
  }
82
84
 
83
85
  .subtitles li .content .meta {
@@ -21,6 +21,7 @@
21
21
  >
22
22
  <template v-slot:before-media><slot name="before-media"></slot></template>
23
23
  <template v-slot:after-media><slot name="after-media"></slot></template>
24
+ <template v-slot:between-video-and-transcript><slot name="between-video-and-transcript"></slot></template>
24
25
  <template v-slot:before-transcripts><slot name="before-transcripts"></slot></template>
25
26
  <template v-slot:after-transcripts><slot name="after-transcripts"></slot></template>
26
27
  </BasePlayer>
@@ -20,6 +20,7 @@
20
20
  >
21
21
  <template v-slot:before-media><slot name="before-media"></slot></template>
22
22
  <template v-slot:after-media><slot name="after-media"></slot></template>
23
+ <template v-slot:between-video-and-transcript><slot name="between-video-and-transcript"></slot></template>
23
24
  <template v-slot:before-transcripts><slot name="before-transcripts"></slot></template>
24
25
  <template v-slot:after-transcripts><slot name="after-transcripts"></slot></template>
25
26
  </VDefaultVideoPlayer>
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.17",
4
+ "version": "1.1.20",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist"