@grfzhl/vue-hls-player 1.1.22 → 1.1.23

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,71 +207,75 @@ At the moment the following attribute are supported:
207
207
  ```
208
208
 
209
209
  ### Last release:
210
+ v1.1.23
211
+ - Fix missing property for subtitles in vue definition
212
+ - Clean up code
213
+ - Set mutation observer to body and the video element itself - whatever loads first
210
214
  v1.1.22
211
- - Only show language selection if subtitles or audio options are > 1
215
+ - Only show language selection if subtitles or audio options are > 1
212
216
  v1.1.21
213
- - Added more tolerant processing for .txt transcripts to allow empty lines as narrators
217
+ - Added more tolerant processing for .txt transcripts to allow empty lines as narrators
214
218
  v1.1.20
215
- - Stability and UI style improvements for Language switch
219
+ - Stability and UI style improvements for Language switch
216
220
  v1.1.19
217
- - Switcher supports both single and multi-language HLS sources
218
- - Automatically syncs subtitle language with selected audio track
219
- - Works with videos that include or omit subtitles
220
- - Includes native cuechange event handling for subtitle updates
221
- - Removed the default media-captions (CC) button from the player
221
+ - Switcher supports both single and multi-language HLS sources
222
+ - Automatically syncs subtitle language with selected audio track
223
+ - Works with videos that include or omit subtitles
224
+ - Includes native cuechange event handling for subtitle updates
225
+ - Removed the default media-captions (CC) button from the player
222
226
  v1.1.18
223
- - Added new slot `between-video-and-transcript` to `BasePlayer.vue`, `VDefaultVideoPlayer.vue` and `index.vue`
227
+ - Added new slot `between-video-and-transcript` to `BasePlayer.vue`, `VDefaultVideoPlayer.vue` and `index.vue`
224
228
  to allow injection of custom UI between video and transcript.
225
- - 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.
229
+ - 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.
226
230
  v1.1.17
227
- - Keep query params for transcription when getting from .vtt file to .txt
231
+ - Keep query params for transcription when getting from .vtt file to .txt
228
232
  v1.1.16
229
- - Add the options prop to both index.vue and VpDefaultVideoPlayer.vue to pass fullscreen label settings to the BasePlayer component.
230
- - Update fullscreen toggle logic: adjust aria-label, add or remove mediaIsFullscreen attribute, and safely access media-tooltip via shadowRoot to ensure proper icon and tooltip state handling.
233
+ - Add the options prop to both index.vue and VpDefaultVideoPlayer.vue to pass fullscreen label settings to the BasePlayer component.
234
+ - Update fullscreen toggle logic: adjust aria-label, add or remove mediaIsFullscreen attribute, and safely access media-tooltip via shadowRoot to ensure proper icon and tooltip state handling.
231
235
  v1.1.13 - v1.1.15
232
- - Update the hls.js package
233
- - Fixes
236
+ - Update the hls.js package
237
+ - Fixes
234
238
 
235
239
  v1.1.12
236
- - added component property to make it easier adding headers (like Authorization header into every hls request)
240
+ - added component property to make it easier adding headers (like Authorization header into every hls request)
237
241
 
238
242
  v1.0.14 - v1.1.11
239
- - Fixes
240
- - iOS specific improvements
241
- - New Options to customize the component
242
- - Fix problem with not updating transcript highlighting
243
- - Extending overloaded functions for fullscreen mode (WIP)
243
+ - Fixes
244
+ - iOS specific improvements
245
+ - New Options to customize the component
246
+ - Fix problem with not updating transcript highlighting
247
+ - Extending overloaded functions for fullscreen mode (WIP)
244
248
 
245
249
  v1.0.9 - v1.0.14
246
- - Fixes
247
- - Small styling improvements
250
+ - Fixes
251
+ - Small styling improvements
248
252
 
249
253
  v1.0.9
250
- - Fix sizes in fullscreen mode for video
251
- - Hide transcript block completely when hidden
254
+ - Fix sizes in fullscreen mode for video
255
+ - Hide transcript block completely when hidden
252
256
 
253
257
  v1.0.8
254
- - Add slots to inject own elements nearby video element
255
- - Add prop for autoplay video
258
+ - Add slots to inject own elements nearby video element
259
+ - Add prop for autoplay video
256
260
 
257
261
  v1.0.7
258
- - Add function to handle own logic for fullscreen
262
+ - Add function to handle own logic for fullscreen
259
263
 
260
264
  v1.0.6
261
- - Small fixes
262
- - Remove debug log
265
+ - Small fixes
266
+ - Remove debug log
263
267
 
264
268
  v1.0.5
265
- - Load transcriptions additionally to subtitles
266
- - Add styled transcription block for better readability
267
- - Improve interaction and dynamic params
269
+ - Load transcriptions additionally to subtitles
270
+ - Add styled transcription block for better readability
271
+ - Improve interaction and dynamic params
268
272
 
269
273
  v1.0.4
270
- - Make subtitles dynamic
271
- - Add new switch to disable the subtitle block
272
- - Fix some minor issues
274
+ - Make subtitles dynamic
275
+ - Add new switch to disable the subtitle block
276
+ - Fix some minor issues
273
277
 
274
278
  v1.0.3
275
- - Removed controls in favour of themable overlay by `player.style`.
276
- - Updated hls library
277
- - Added styled caption overlays. Added separate container to show all captions.
279
+ - Removed controls in favour of themable overlay by `player.style`.
280
+ - Updated hls library
281
+ - Added styled caption overlays. Added separate container to show all captions.
@@ -179,6 +179,7 @@ const initialPlayButton = ref(false);
179
179
  const hideInitialPlayButton = ref(false);
180
180
  const transcriptRef = ref(null);
181
181
  const previewImageLink = toRef(props, 'previewImageLink');
182
+ const subtitles = toRef(props, 'subtitles');
182
183
  const link = toRef(props, 'link');
183
184
  let currentTime = 0
184
185
  let hls = null
@@ -232,6 +233,7 @@ async function selectLang(lang) {
232
233
  currentSubtitleLang.value = lang;
233
234
  console.log('[LangSwitch] currentSubtitleLang set to', currentSubtitleLang.value);
234
235
  });
236
+ // Attach HLS
235
237
  await hls.loadSource(newSource.file_url);
236
238
  video.value.muted = true;
237
239
  } catch (err) {
@@ -254,7 +256,6 @@ function updateLangMenuState() {
254
256
  const langElement = li.querySelector('span[data-lang]')
255
257
  const liLang = langElement?.dataset?.lang;
256
258
  // const liLang = li.textContent.trim().toLowerCase();
257
- console.log("-- audio li lang", currentLang.value, liLang)
258
259
  li.classList.toggle('active', liLang === currentLang.value.toLowerCase());
259
260
  });
260
261
 
@@ -272,26 +273,6 @@ function updateLangMenuState() {
272
273
 
273
274
  watch(currentSubtitleLang, () => updateLangMenuState());
274
275
 
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
276
  // --- Frame Pointer Loop ---
296
277
  let rafId = null
297
278
  const FPS = 30
@@ -537,7 +518,6 @@ function prepareVideoPlayer(link) {
537
518
 
538
519
  // Preparing video player with link: ${link}
539
520
  hls = new Hls(hlsConfig);
540
-
541
521
  // Attach HLS
542
522
  hls.loadSource(link);
543
523
  hls.attachMedia(video.value);
@@ -588,6 +568,7 @@ function prepareVideoPlayer(link) {
588
568
  });
589
569
  }
590
570
 
571
+ selectLang(props.defaultLang);
591
572
  // HLS attached to <video>
592
573
  hls.recoverMediaError();
593
574
 
@@ -639,284 +620,283 @@ function initVideo() {
639
620
  * overwrite player.style video fullscreen button
640
621
  * to inject own fullscreen logic
641
622
  */
642
- const observer = new MutationObserver((mutationsList, observer) => {
643
- const mediaTheme = document.querySelector('.video-player-theme-container');
644
- if (mediaTheme && mediaTheme.shadowRoot) {
645
- // controllbar
646
- const controlBar = mediaTheme.shadowRoot.querySelector('media-control-bar');
647
- const fullscreenButton = mediaTheme.shadowRoot.querySelector('media-fullscreen-button');
648
- buttonElement = fullscreenButton
649
- const playbackRateButton = mediaTheme.shadowRoot.querySelector('media-playback-rate-menu');
650
- playbackRateButton.setAttribute('rates', '0.25 0.5 0.75 1 1.5 2 3');
651
- if (fullscreenButton) {
652
- fullscreenButton.handleClick = async (event) => {
653
- event.preventDefault();
654
- event.stopPropagation();
655
- event.stopImmediatePropagation();
656
- startFullscreen();
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
- && (props.multiLangSources.length > 1 || props.subtitles.length > 1)) {
667
- const langDiv = document.createElement('div');
668
- langDiv.className = 'lang-switcher';
669
- langDiv.innerHTML = `
670
- <style>
671
- .lang-switcher {
672
- position: relative;
673
- display: flex;
674
- align-items: center;
675
- justify-content: center;
676
- margin-left: var(--media-control-spacing, 6px);
677
- }
678
-
679
- .lang-switcher button {
680
- all: unset;
681
- display: flex;
682
- align-items: center;
683
- justify-content: center;
684
- cursor: pointer;
685
- height: var(--media-button-height, 32px);
686
- border-radius: 25%;
687
- min-width: var(--media-button-height, 32px);
688
- padding: var(--media-button-padding, 0 5px);
689
- background: transparent;
690
- transition: background 0.15s ease, transform 0.1s ease;
691
- }
692
-
693
- .lang-switcher button svg {
694
- width: var(--media-icon-size, 24px);
695
- height: var(--media-icon-size, 24px);
696
- color: var(--media-icon-color, white);
697
- stroke: currentColor;
698
- opacity: var(--media-icon-opacity, 0.9);
699
- pointer-events: none;
700
- transition: opacity 0.15s ease;
701
- }
702
-
703
- .lang-switcher button:hover svg {
704
- transform: scale(1.1);
705
- }
706
-
707
- .lang-switcher button:hover {
708
- opacity: var(--media-icon-opacity-hover, 1);
709
- background: var(--media-control-hover-background, rgba(50 50 70 / .7));
710
- transition: backdrop-filter 0.3s, -webkit-backdrop-filter 0.3s;
711
- box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 5px;
712
- backdrop-filter: blur(10px) invert(15%) brightness(80%) opacity(1);
713
- padding: 5px;
714
- color: var(--media-text-color, var(--media-primary-color, rgb(238 238 238)));
715
- }
716
-
717
- .lang-btn[title]:hover::after {
718
- content: attr(title);
719
- position: fixed;
720
- bottom: 122%;
721
- right: -100%;
722
- white-space: nowrap;
723
- background: var(--media-tooltip-background, rgba(0,0,0,0.2));
724
- color: var(--media-tooltip-color, #fff);
725
- border-radius: 4px;
726
- padding: 4px 15px;
727
- font-size: var(--media-font-size, 12px);
728
- box-shadow: 0 2px 8px rgba(0,0,0,0.4);
729
- line-height: calc(1.2 * var(--base));
730
- }
731
-
732
- .lang-menu {
733
- position: absolute;
734
- bottom: calc(var(--media-button-height, 32px) * 1.7);
735
- right: -48px;
736
- background: var(--media-control-bar-background, rgba(20,20,20,0.4));
737
- backdrop-filter: blur(12px);
738
- border-radius: 12px;
739
- box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 5px;
740
- padding: 10px;
741
- min-width: 240px;
742
- display: none;
743
- animation: fadeUp 0.15s ease-out;
744
- color: var(--media-text-color, #fff);
745
- font-family: var(--media-font-family, system-ui, sans-serif);
746
- font-size: var(--media-font-size, 13px);
747
- z-index: 9999;
748
- }
749
-
750
- @keyframes fadeUp {
751
- from { opacity: 0; transform: translateY(8px); }
752
- to { opacity: 1; transform: translateY(0); }
753
- }
754
-
755
- .lang-columns {
756
- display: flex;
757
- justify-content: space-between;
758
- gap: 1rem;
759
- }
760
-
761
- .lang-col {
762
- flex: 1;
763
- display: flex;
764
- flex-direction: column;
765
- margin: 0;
766
- padding: 0;
767
- }
768
-
769
- .lang-col .title {
770
- line-height: calc(1.2 * var(--base));
771
- font-weight: 600;
772
- font-size: calc(var(--media-font-size, 13px) - 1px);
773
- margin-bottom: 4px;
774
- border-bottom: 1px solid rgba(255,255,255,0.1);
775
- padding-bottom: 2px;
776
- }
777
-
778
- .lang-col ul {
779
- list-style: none;
780
- margin: 0;
781
- padding: 0;
782
- }
783
-
784
- .lang-col li {
785
- display: flex;
786
- align-items: center;
787
- justify-content: space-between;
788
- cursor: pointer;
789
- border-radius: 4px;
790
- padding: 2px 12px;
791
- transition: background 0.15s ease, color 0.15s ease;
792
- }
793
-
794
- .lang-col li span {
795
- line-height: calc(1.2 * var(--base));
796
- }
797
-
798
- .lang-col li:hover {
799
- background: var(--media-control-hover-background, rgba(255,255,255,0.15));
800
- }
801
-
802
- .lang-col li.active {
803
- font-weight: 500;
804
- color: var(--media-accent-color, #fff);
805
- }
806
-
807
- .lang-col li .icon {
808
- opacity: 0;
809
- transition: opacity 0.15s ease, transform 0.15s ease;
810
- transform: translateX(2px);
811
- }
812
-
813
- .lang-col li.active .icon {
814
- opacity: 1;
815
- transform: translateX(0);
816
- }
817
- </style>
818
-
819
- <button title="Audio & Subtitles" class="lang-btn">
820
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26" fill="none" stroke="currentColor" stroke-width="1.5"
821
- stroke-linecap="round" stroke-linejoin="round">
822
- <path d="m5 8 6 6"/>
823
- <path d="m4 14 6-6 2-3"/>
824
- <path d="M2 5h12"/>
825
- <path d="M7 2h1"/>
826
- <path d="m22 22-5-10-5 10"/>
827
- <path d="M14 18h6"/>
828
- </svg>
829
- </button>
830
-
831
- <div class="lang-menu">
832
- <div class="lang-columns">
833
- <ul class="lang-col audio-col"><li class="title">Audio</li></ul>
834
- <ul class="lang-col sub-col"><li class="title">Subtitles</li></ul>
835
- </div>
836
- </div>
837
- `;
838
-
839
- const menu = langDiv.querySelector('.lang-menu');
840
- const audioCol = menu.querySelector('.audio-col');
841
- const subCol = menu.querySelector('.sub-col');
842
- const button = langDiv.querySelector('button');
843
- menu.addEventListener('click', e => e.stopPropagation());
844
- // Chrome Media Player style check icon
845
- const renderIcon = () => `
846
- <svg aria-hidden="true" viewBox="0 1 24 24" width="16" height="16" part="checked-indicator indicator">
847
- <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>
848
- </svg>
849
- `;
850
-
851
- // Audio options
852
- props.multiLangSources.forEach(src => {
853
- const li = document.createElement('li');
854
- li.innerHTML = `
855
- <span data-lang="${ src.lang }">${src.label || src.lang.toUpperCase()}</span>
856
- <span class="icon">${renderIcon()}</span>
857
- `;
858
- if (src.lang === currentLang.value) li.classList.add('active');
859
- li.addEventListener('click', () => {
860
- audioCol.querySelectorAll('li').forEach(el => el.classList.remove('active'));
861
- li.classList.add('active');
862
- selectLang(src.lang);
863
- menu.style.display = 'none';
864
- });
865
- audioCol.appendChild(li);
866
- });
867
-
868
- props.subtitles.forEach(sub => {
869
- const li = document.createElement('li');
870
- li.innerHTML = `
871
- <span data-lang="${ sub.lang }">${sub.label || sub.lang.toUpperCase()}</span>
872
- <span class="icon">${renderIcon()}</span>
873
- `;
874
-
875
- if (sub.lang === currentSubtitleLang.value) li.classList.add('active');
876
- li.addEventListener('click', () => {
877
- Array.from(video.value?.textTracks || []).forEach(track => {
878
- const tLang = (track.language || track.srclang || '').toLowerCase();
879
- track.mode = tLang === sub.lang.toLowerCase() ? 'showing' : 'disabled';
880
- });
881
- subCol.querySelectorAll('li').forEach(el => el.classList.remove('active'));
882
- li.classList.add('active');
883
- menu.style.display = 'none';
884
- currentSubtitleLang.value = sub.lang;
885
- });
886
- subCol.appendChild(li);
887
- });
623
+ const observer = new MutationObserver(mutationObserver)
624
+ // --- Start pointer-update loop ---
625
+ if (!rafId) {
626
+ emitPointerUpdate()
627
+ }
628
+ observer.observe(document.querySelector('.video-player-theme-container'), { childList: true, subtree: true });
629
+ observer.observe(document.body, { childList: true, subtree: true });
630
+ }
631
+ }
888
632
 
889
- button.addEventListener('click', e => {
890
- e.stopPropagation();
891
- const isOpen = menu.style.display === 'block';
892
- menu.style.display = isOpen ? 'none' : 'block';
893
- updateLangMenuState();
894
- });
895
- document.addEventListener('click', e => {
896
- if (!menu.contains(e.target) && !button.contains(e.target)) {
897
- menu.style.display = 'none';
898
- button.setAttribute('aria-expanded', 'false');
899
- updateLangMenuState();
900
- }
633
+ const mutationObserver = (mutationsList, observer) => {
634
+ const mediaTheme = document.querySelector('.video-player-theme-container');
635
+ if (mediaTheme && mediaTheme.shadowRoot) {
636
+ // controllbar
637
+ const controlBar = mediaTheme.shadowRoot.querySelector('media-control-bar');
638
+ const fullscreenButton = mediaTheme.shadowRoot.querySelector('media-fullscreen-button');
639
+ buttonElement = fullscreenButton
640
+ const playbackRateButton = mediaTheme.shadowRoot.querySelector('media-playback-rate-menu');
641
+ playbackRateButton.setAttribute('rates', '0.25 0.5 0.75 1 1.5 2 3');
642
+ if (fullscreenButton) {
643
+ fullscreenButton.handleClick = async (event) => {
644
+ event.preventDefault();
645
+ event.stopPropagation();
646
+ event.stopImmediatePropagation();
647
+ startFullscreen();
648
+ }
649
+ // --- Remove default CC button ---
650
+ const ccButton = mediaTheme.shadowRoot.querySelector('media-captions-button');
651
+ if (ccButton) {
652
+ ccButton.remove();
653
+ }
654
+ // --- Amazon Prime Style Language Switcher ---
655
+ if (controlBar && !controlBar.querySelector('.lang-switcher')
656
+ && (props.multiLangSources.length > 1 || props.subtitles.length > 1)) {
657
+ const langDiv = document.createElement('div');
658
+ langDiv.className = 'lang-switcher';
659
+ langDiv.innerHTML = `
660
+ <style>
661
+ .lang-switcher {
662
+ position: relative;
663
+ display: flex;
664
+ align-items: center;
665
+ justify-content: center;
666
+ margin-left: var(--media-control-spacing, 6px);
667
+ }
668
+
669
+ .lang-switcher button {
670
+ all: unset;
671
+ display: flex;
672
+ align-items: center;
673
+ justify-content: center;
674
+ cursor: pointer;
675
+ height: var(--media-button-height, 32px);
676
+ border-radius: 25%;
677
+ min-width: var(--media-button-height, 32px);
678
+ padding: var(--media-button-padding, 0 5px);
679
+ background: transparent;
680
+ transition: background 0.15s ease, transform 0.1s ease;
681
+ }
682
+
683
+ .lang-switcher button svg {
684
+ width: var(--media-icon-size, 24px);
685
+ height: var(--media-icon-size, 24px);
686
+ color: var(--media-icon-color, white);
687
+ stroke: currentColor;
688
+ opacity: var(--media-icon-opacity, 0.9);
689
+ pointer-events: none;
690
+ transition: opacity 0.15s ease;
691
+ }
692
+
693
+ .lang-switcher button:hover svg {
694
+ transform: scale(1.1);
695
+ }
696
+
697
+ .lang-switcher button:hover {
698
+ opacity: var(--media-icon-opacity-hover, 1);
699
+ background: var(--media-control-hover-background, rgba(50 50 70 / .7));
700
+ transition: backdrop-filter 0.3s, -webkit-backdrop-filter 0.3s;
701
+ box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 5px;
702
+ backdrop-filter: blur(10px) invert(15%) brightness(80%) opacity(1);
703
+ padding: 5px;
704
+ color: var(--media-text-color, var(--media-primary-color, rgb(238 238 238)));
705
+ }
706
+
707
+ .lang-btn[title]:hover::after {
708
+ content: attr(title);
709
+ position: fixed;
710
+ bottom: 122%;
711
+ right: -100%;
712
+ white-space: nowrap;
713
+ background: var(--media-tooltip-background, rgba(0,0,0,0.2));
714
+ color: var(--media-tooltip-color, #fff);
715
+ border-radius: 4px;
716
+ padding: 4px 15px;
717
+ font-size: var(--media-font-size, 12px);
718
+ box-shadow: 0 2px 8px rgba(0,0,0,0.4);
719
+ line-height: calc(1.2 * var(--base));
720
+ }
721
+
722
+ .lang-menu {
723
+ position: absolute;
724
+ bottom: calc(var(--media-button-height, 32px) * 1.7);
725
+ right: -48px;
726
+ background: var(--media-control-bar-background, rgba(20,20,20,0.4));
727
+ backdrop-filter: blur(12px);
728
+ border-radius: 12px;
729
+ box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 5px;
730
+ padding: 10px;
731
+ min-width: 240px;
732
+ display: none;
733
+ animation: fadeUp 0.15s ease-out;
734
+ color: var(--media-text-color, #fff);
735
+ font-family: var(--media-font-family, system-ui, sans-serif);
736
+ font-size: var(--media-font-size, 13px);
737
+ z-index: 9999;
738
+ }
739
+
740
+ @keyframes fadeUp {
741
+ from { opacity: 0; transform: translateY(8px); }
742
+ to { opacity: 1; transform: translateY(0); }
743
+ }
744
+
745
+ .lang-columns {
746
+ display: flex;
747
+ justify-content: space-between;
748
+ gap: 1rem;
749
+ }
750
+
751
+ .lang-col {
752
+ flex: 1;
753
+ display: flex;
754
+ flex-direction: column;
755
+ margin: 0;
756
+ padding: 0;
757
+ }
758
+
759
+ .lang-col .title {
760
+ line-height: calc(1.2 * var(--base));
761
+ font-weight: 600;
762
+ font-size: calc(var(--media-font-size, 13px) - 1px);
763
+ margin-bottom: 4px;
764
+ border-bottom: 1px solid rgba(255,255,255,0.1);
765
+ padding-bottom: 2px;
766
+ }
767
+
768
+ .lang-col ul {
769
+ list-style: none;
770
+ margin: 0;
771
+ padding: 0;
772
+ }
773
+
774
+ .lang-col li {
775
+ display: flex;
776
+ align-items: center;
777
+ justify-content: space-between;
778
+ cursor: pointer;
779
+ border-radius: 4px;
780
+ padding: 2px 12px;
781
+ transition: background 0.15s ease, color 0.15s ease;
782
+ }
783
+
784
+ .lang-col li span {
785
+ line-height: calc(1.2 * var(--base));
786
+ }
787
+
788
+ .lang-col li:hover {
789
+ background: var(--media-control-hover-background, rgba(255,255,255,0.15));
790
+ }
791
+
792
+ .lang-col li.active {
793
+ font-weight: 500;
794
+ color: var(--media-accent-color, #fff);
795
+ }
796
+
797
+ .lang-col li .icon {
798
+ opacity: 0;
799
+ transition: opacity 0.15s ease, transform 0.15s ease;
800
+ transform: translateX(2px);
801
+ }
802
+
803
+ .lang-col li.active .icon {
804
+ opacity: 1;
805
+ transform: translateX(0);
806
+ }
807
+ </style>
808
+
809
+ <button title="Audio & Subtitles" class="lang-btn">
810
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26" fill="none" stroke="currentColor" stroke-width="1.5"
811
+ stroke-linecap="round" stroke-linejoin="round">
812
+ <path d="m5 8 6 6"/>
813
+ <path d="m4 14 6-6 2-3"/>
814
+ <path d="M2 5h12"/>
815
+ <path d="M7 2h1"/>
816
+ <path d="m22 22-5-10-5 10"/>
817
+ <path d="M14 18h6"/>
818
+ </svg>
819
+ </button>
820
+
821
+ <div class="lang-menu">
822
+ <div class="lang-columns">
823
+ <ul class="lang-col audio-col"><li class="title">Audio</li></ul>
824
+ <ul class="lang-col sub-col"><li class="title">Subtitles</li></ul>
825
+ </div>
826
+ </div>
827
+ `;
828
+ const menu = langDiv.querySelector('.lang-menu');
829
+ const audioCol = menu.querySelector('.audio-col');
830
+ const subCol = menu.querySelector('.sub-col');
831
+ const button = langDiv.querySelector('button');
832
+ menu.addEventListener('click', e => e.stopPropagation());
833
+ // Chrome Media Player style check icon
834
+ const renderIcon = () => `
835
+ <svg aria-hidden="true" viewBox="0 1 24 24" width="16" height="16" part="checked-indicator indicator">
836
+ <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>
837
+ </svg>
838
+ `;
839
+
840
+ // Audio options
841
+ props.multiLangSources.forEach(src => {
842
+ const li = document.createElement('li');
843
+ li.innerHTML = `
844
+ <span data-lang="${ src.lang }">${src.label || src.lang.toUpperCase()}</span>
845
+ <span class="icon">${renderIcon()}</span>
846
+ `;
847
+ if (src.lang === currentLang.value) li.classList.add('active');
848
+ li.addEventListener('click', () => {
849
+ audioCol.querySelectorAll('li').forEach(el => el.classList.remove('active'));
850
+ li.classList.add('active');
851
+ selectLang(src.lang);
852
+ menu.style.display = 'none';
853
+ });
854
+ audioCol.appendChild(li);
855
+ });
856
+ props.subtitles.forEach(sub => {
857
+ const li = document.createElement('li');
858
+ li.innerHTML = `
859
+ <span data-lang="${ sub.lang }">${sub.label || sub.lang.toUpperCase()}</span>
860
+ <span class="icon">${renderIcon()}</span>
861
+ `;
862
+
863
+ if (sub.lang === currentSubtitleLang.value) li.classList.add('active');
864
+ li.addEventListener('click', () => {
865
+ Array.from(video.value?.textTracks || []).forEach(track => {
866
+ const tLang = (track.language || track.srclang || '').toLowerCase();
867
+ track.mode = tLang === sub.lang.toLowerCase() ? 'showing' : 'disabled';
901
868
  });
869
+ subCol.querySelectorAll('li').forEach(el => el.classList.remove('active'));
870
+ li.classList.add('active');
871
+ menu.style.display = 'none';
872
+ currentSubtitleLang.value = sub.lang;
873
+ });
874
+ subCol.appendChild(li);
875
+ });
902
876
 
903
- controlBar.insertBefore(langDiv, fullscreenButton);
877
+ button.addEventListener('click', e => {
878
+ e.stopPropagation();
879
+ const isOpen = menu.style.display === 'block';
880
+ menu.style.display = isOpen ? 'none' : 'block';
881
+ updateLangMenuState();
882
+ });
883
+ document.addEventListener('click', e => {
884
+ if (!menu.contains(e.target) && !button.contains(e.target)) {
885
+ menu.style.display = 'none';
886
+ button.setAttribute('aria-expanded', 'false');
887
+ updateLangMenuState();
904
888
  }
889
+ });
905
890
 
906
- observer.disconnect();
907
- } else {
908
- console.error('Button not found in Shadow DOM!');
909
- }
910
- } else {
911
- console.error('Shadow Root not found!');
891
+ controlBar.insertBefore(langDiv, fullscreenButton);
912
892
  }
913
- })
914
-
915
- // --- Start pointer-update loop ---
916
- if (!rafId) {
917
- emitPointerUpdate()
893
+
894
+ observer.disconnect();
895
+ } else {
896
+ console.error('Button not found in Shadow DOM!');
918
897
  }
919
- observer.observe(document.body, { childList: true, subtree: true });
898
+ } else {
899
+ console.error('Shadow Root not found!');
920
900
  }
921
901
  }
922
902
 
@@ -938,16 +918,6 @@ function changeSpeed(e) {
938
918
  video.value.playbackRate = video.value.playbackRate - 0.25
939
919
  }
940
920
  }
941
- defineExpose({ startFullscreen, getHls: () => hls, getVideo: () => video.value,
942
- changeSource: (newLink) => {
943
- if (!newLink) return;
944
- hls.stopLoad();
945
- hls.detachMedia();
946
- hls.loadSource(newLink);
947
- hls.attachMedia(video.value);
948
- hls.startLoad();
949
- }
950
- });
951
921
 
952
922
  </script>
953
923
  <style>
@@ -9,6 +9,7 @@
9
9
  :autoplay="autoplay"
10
10
  :isControls="isControls"
11
11
  :onVideoEnd="onVideoEnd"
12
+ :subtitles="subtitles"
12
13
  :additionHeaders="additionHeaders"
13
14
  :isFullscreen="isFullscreen"
14
15
  :showTranscriptBlock="showTranscriptBlock"
@@ -88,6 +89,10 @@ const props = defineProps({
88
89
  additionHeaders: {
89
90
  type: Object,
90
91
  default: {}
92
+ },
93
+ subtitles: {
94
+ type: Array,
95
+ default: []
91
96
  }
92
97
  })
93
98
 
@@ -11,6 +11,7 @@
11
11
  :isFullscreen="isFullscreen"
12
12
  :link="link"
13
13
  :progress="progress"
14
+ :subtitles="subtitles"
14
15
  :isMuted="isMuted"
15
16
  :autoplay="autoplay"
16
17
  v-model="videoElement"
@@ -98,6 +99,10 @@ const props = defineProps({
98
99
  additionHeaders: {
99
100
  type: Object,
100
101
  default: {}
102
+ },
103
+ subtitles: {
104
+ type: Array,
105
+ default: []
101
106
  }
102
107
  })
103
108
 
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.22",
4
+ "version": "1.1.23",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist"