@grfzhl/vue-hls-player 1.1.18 → 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,14 @@ 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
210
218
  v1.1.18
211
219
  - Added new slot `between-video-and-transcript` to `BasePlayer.vue`, `VDefaultVideoPlayer.vue` and `index.vue`
212
220
  to allow injection of custom UI between video and transcript.
@@ -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"
@@ -70,6 +71,7 @@
70
71
  <slot name="between-video-and-transcript"></slot>
71
72
  <slot name="before-transcripts"></slot>
72
73
  <SubtitleBlock
74
+ :key="currentLang"
73
75
  ref="transcriptRef"
74
76
  :subtitle="currentSubtitle"
75
77
  :cursor="videoCursor"
@@ -87,6 +89,7 @@ import { onMounted, onUpdated, ref, onUnmounted, computed, watch, toRef } from '
87
89
  import Hls from 'hls.js'
88
90
  import 'player.style/sutro';
89
91
  import SubtitleBlock from './SubtitleBlock.vue';
92
+ // import 'media-chrome';
90
93
 
91
94
  const props = defineProps({
92
95
  introTitle: {
@@ -153,6 +156,14 @@ const props = defineProps({
153
156
  options: {
154
157
  type: Object,
155
158
  default: {}
159
+ },
160
+ multiLangSources: {
161
+ type: Array,
162
+ default: () => []
163
+ },
164
+ defaultLang: {
165
+ type: String,
166
+ default: 'en'
156
167
  }
157
168
  })
158
169
 
@@ -172,10 +183,135 @@ const link = toRef(props, 'link');
172
183
  let currentTime = 0
173
184
  let hls = null
174
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
+ } */
175
294
 
176
295
  // --- Frame Pointer Loop ---
177
296
  let rafId = null
178
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
+ };
179
315
 
180
316
  function emitPointerUpdate() {
181
317
  if (video.value) {
@@ -222,7 +358,7 @@ const currentSubtitle = computed(() => {
222
358
  return null
223
359
  })
224
360
 
225
- watch([props, videoElement], (a) => {
361
+ watch(() => props.autoplay, (a) => {
226
362
  if(a[0].autoplay && a[1] && a[1].paused) {
227
363
  // autoplay is only possible when muted
228
364
  a[1].muted = true
@@ -258,8 +394,6 @@ async function startFullscreen() {
258
394
  buttonElement.removeAttribute('mediaIsFullscreen');
259
395
  const tooltip = buttonElement.shadowRoot?.querySelector('media-tooltip');
260
396
  if (tooltip) {
261
- console.log("Tooltip gefunden:", tooltip);
262
-
263
397
  // Slots für Tooltip-Enter und Tooltip-Exit finden
264
398
  const enterTooltip = tooltip.querySelector('slot[name="tooltip-enter"]');
265
399
  const exitTooltip = tooltip.querySelector('slot[name="tooltip-exit"]');
@@ -280,8 +414,6 @@ async function startFullscreen() {
280
414
  buttonElement.setAttribute('mediaIsFullscreen', '');
281
415
  const tooltip = buttonElement.shadowRoot?.querySelector('media-tooltip');
282
416
  if (tooltip) {
283
- console.log("Tooltip gefunden:", tooltip);
284
-
285
417
  // Slots für Tooltip-Enter und Tooltip-Exit finden
286
418
  const enterTooltip = tooltip.querySelector('slot[name="tooltip-enter"]');
287
419
  const exitTooltip = tooltip.querySelector('slot[name="tooltip-exit"]');
@@ -395,81 +527,78 @@ function seekVideo(time) {
395
527
  }
396
528
 
397
529
  function prepareVideoPlayer(link) {
398
- let initiallyLoaded = true;
399
- if (video.value) {
400
- if (hls) {
401
- hls.detachMedia();
402
- hls.destroy();
403
- initiallyLoaded = false;
404
- }
405
- hls = new Hls({
406
- fetchSetup: async (context, init) => {
407
- init = init || {};
408
- init.headers = new Headers(init.headers || {});
409
- // set headers
410
- for (const [key, value] of Object.entries(props.additionHeaders)) {
411
- init.headers.set(key, value);
412
- }
413
- return new Request(context.url, init);
414
- },
415
- xhrSetup: async (xhr, url) => {
416
- const additionHeaders = props.additionHeaders || {};
417
- for (const [key, value] of Object.entries(additionHeaders)) {
418
- const val = typeof value === 'function' ? await value(url) : value;
419
- // set headers
420
- 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";
421
559
  }
422
560
  }
423
561
  });
424
- hls.loadSource(link)
425
- hls.attachMedia(video.value)
426
- hls.recoverMediaError()
427
-
428
- video.value.muted = props.isMuted
429
- video.value.currentTime = props.progress
430
-
431
- const textTracks = video.value.textTracks;
432
- let previousModes = Array.from(textTracks).map((track) => track.mode);
433
- function checkTrackModeChanges() {
434
- Array.from(textTracks).forEach((track, index) => {
435
- track.addEventListener("cuechange", () => {
436
- const activeCues = track.activeCues;
437
- currentSubtitleLang.value = track.language
438
- if(subtitlesContainer.value) {
439
- if (activeCues && activeCues.length > 0) {
440
- subtitlesContainer.value.textContent = activeCues[0].text
441
- subtitlesContainer.value.style.display = "block";
442
- } else {
443
- subtitlesContainer.value.style.display = "none";
444
- }
445
- }
446
- });
447
- if (track.mode !== previousModes[index]) {
448
- if (track.mode === "showing") {
449
- const activeCues = track.activeCues;
450
- currentSubtitleLang.value = track.language
451
- if(subtitlesContainer.value) {
452
- if (activeCues && activeCues.length > 0) {
453
- subtitlesContainer.value.style.display = "block";
454
- subtitlesContainer.value.textContent = activeCues[0].text
455
- } else {
456
- subtitlesContainer.value.style.display = "none";
457
- }
458
- }
459
- } else {
460
- if(subtitlesContainer.value) {
461
- subtitlesContainer.value.style.display = "none";
462
- }
463
- }
464
- 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";
465
575
  }
466
- });
467
- }
468
- setInterval(checkTrackModeChanges, 100);
469
- 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
+ });
470
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.
471
599
  }
472
600
 
601
+
473
602
  function initVideo() {
474
603
  if (video.value) {
475
604
 
@@ -513,6 +642,8 @@ function initVideo() {
513
642
  const observer = new MutationObserver((mutationsList, observer) => {
514
643
  const mediaTheme = document.querySelector('.video-player-theme-container');
515
644
  if (mediaTheme && mediaTheme.shadowRoot) {
645
+ // controllbar
646
+ const controlBar = mediaTheme.shadowRoot.querySelector('media-control-bar');
516
647
  const fullscreenButton = mediaTheme.shadowRoot.querySelector('media-fullscreen-button');
517
648
  buttonElement = fullscreenButton
518
649
  const playbackRateButton = mediaTheme.shadowRoot.querySelector('media-playback-rate-menu');
@@ -524,6 +655,253 @@ function initVideo() {
524
655
  event.stopImmediatePropagation();
525
656
  startFullscreen();
526
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
+
527
905
  observer.disconnect();
528
906
  } else {
529
907
  console.error('Button not found in Shadow DOM!');
@@ -532,6 +910,7 @@ function initVideo() {
532
910
  console.error('Shadow Root not found!');
533
911
  }
534
912
  })
913
+
535
914
  // --- Start pointer-update loop ---
536
915
  if (!rafId) {
537
916
  emitPointerUpdate()
@@ -558,7 +937,16 @@ function changeSpeed(e) {
558
937
  video.value.playbackRate = video.value.playbackRate - 0.25
559
938
  }
560
939
  }
561
- 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
+ });
562
950
 
563
951
  </script>
564
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 {
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.18",
4
+ "version": "1.1.20",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist"