@grfzhl/vue-hls-player 1.1.21 → 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,69 +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
214
+ v1.1.22
215
+ - Only show language selection if subtitles or audio options are > 1
210
216
  v1.1.21
211
- - 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
212
218
  v1.1.20
213
- - Stability and UI style improvements for Language switch
219
+ - Stability and UI style improvements for Language switch
214
220
  v1.1.19
215
- - Switcher supports both single and multi-language HLS sources
216
- - Automatically syncs subtitle language with selected audio track
217
- - Works with videos that include or omit subtitles
218
- - Includes native cuechange event handling for subtitle updates
219
- - 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
220
226
  v1.1.18
221
- - 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`
222
228
  to allow injection of custom UI between video and transcript.
223
- - 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.
224
230
  v1.1.17
225
- - 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
226
232
  v1.1.16
227
- - Add the options prop to both index.vue and VpDefaultVideoPlayer.vue to pass fullscreen label settings to the BasePlayer component.
228
- - 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.
229
235
  v1.1.13 - v1.1.15
230
- - Update the hls.js package
231
- - Fixes
236
+ - Update the hls.js package
237
+ - Fixes
232
238
 
233
239
  v1.1.12
234
- - 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)
235
241
 
236
242
  v1.0.14 - v1.1.11
237
- - Fixes
238
- - iOS specific improvements
239
- - New Options to customize the component
240
- - Fix problem with not updating transcript highlighting
241
- - 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)
242
248
 
243
249
  v1.0.9 - v1.0.14
244
- - Fixes
245
- - Small styling improvements
250
+ - Fixes
251
+ - Small styling improvements
246
252
 
247
253
  v1.0.9
248
- - Fix sizes in fullscreen mode for video
249
- - Hide transcript block completely when hidden
254
+ - Fix sizes in fullscreen mode for video
255
+ - Hide transcript block completely when hidden
250
256
 
251
257
  v1.0.8
252
- - Add slots to inject own elements nearby video element
253
- - Add prop for autoplay video
258
+ - Add slots to inject own elements nearby video element
259
+ - Add prop for autoplay video
254
260
 
255
261
  v1.0.7
256
- - Add function to handle own logic for fullscreen
262
+ - Add function to handle own logic for fullscreen
257
263
 
258
264
  v1.0.6
259
- - Small fixes
260
- - Remove debug log
265
+ - Small fixes
266
+ - Remove debug log
261
267
 
262
268
  v1.0.5
263
- - Load transcriptions additionally to subtitles
264
- - Add styled transcription block for better readability
265
- - 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
266
272
 
267
273
  v1.0.4
268
- - Make subtitles dynamic
269
- - Add new switch to disable the subtitle block
270
- - Fix some minor issues
274
+ - Make subtitles dynamic
275
+ - Add new switch to disable the subtitle block
276
+ - Fix some minor issues
271
277
 
272
278
  v1.0.3
273
- - Removed controls in favour of themable overlay by `player.style`.
274
- - Updated hls library
275
- - 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,283 +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
- 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
- });
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
+ }
887
632
 
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
- }
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';
900
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
+ });
901
876
 
902
- 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();
903
888
  }
889
+ });
904
890
 
905
- observer.disconnect();
906
- } else {
907
- console.error('Button not found in Shadow DOM!');
908
- }
909
- } else {
910
- console.error('Shadow Root not found!');
891
+ controlBar.insertBefore(langDiv, fullscreenButton);
911
892
  }
912
- })
913
-
914
- // --- Start pointer-update loop ---
915
- if (!rafId) {
916
- emitPointerUpdate()
893
+
894
+ observer.disconnect();
895
+ } else {
896
+ console.error('Button not found in Shadow DOM!');
917
897
  }
918
- observer.observe(document.body, { childList: true, subtree: true });
898
+ } else {
899
+ console.error('Shadow Root not found!');
919
900
  }
920
901
  }
921
902
 
@@ -937,16 +918,6 @@ function changeSpeed(e) {
937
918
  video.value.playbackRate = video.value.playbackRate - 0.25
938
919
  }
939
920
  }
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
- });
950
921
 
951
922
  </script>
952
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.21",
4
+ "version": "1.1.23",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist"