@grfzhl/vue-hls-player 1.1.22 → 1.1.24

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,79 @@ At the moment the following attribute are supported:
207
207
  ```
208
208
 
209
209
  ### Last release:
210
+ v1.1.24
211
+ - Add user-initiated language-changed emit.
212
+ - Fix unwanted language re-sync on player init.
213
+ - Improve audio/subtitle switch stability.
214
+ v1.1.23
215
+ - Fix missing property for subtitles in vue definition
216
+ - Clean up code
217
+ - Set mutation observer to body and the video element itself - whatever loads first
210
218
  v1.1.22
211
- - Only show language selection if subtitles or audio options are > 1
219
+ - Only show language selection if subtitles or audio options are > 1
212
220
  v1.1.21
213
- - Added more tolerant processing for .txt transcripts to allow empty lines as narrators
221
+ - Added more tolerant processing for .txt transcripts to allow empty lines as narrators
214
222
  v1.1.20
215
- - Stability and UI style improvements for Language switch
223
+ - Stability and UI style improvements for Language switch
216
224
  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
225
+ - Switcher supports both single and multi-language HLS sources
226
+ - Automatically syncs subtitle language with selected audio track
227
+ - Works with videos that include or omit subtitles
228
+ - Includes native cuechange event handling for subtitle updates
229
+ - Removed the default media-captions (CC) button from the player
222
230
  v1.1.18
223
- - Added new slot `between-video-and-transcript` to `BasePlayer.vue`, `VDefaultVideoPlayer.vue` and `index.vue`
231
+ - Added new slot `between-video-and-transcript` to `BasePlayer.vue`, `VDefaultVideoPlayer.vue` and `index.vue`
224
232
  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.
233
+ - 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
234
  v1.1.17
227
- - Keep query params for transcription when getting from .vtt file to .txt
235
+ - Keep query params for transcription when getting from .vtt file to .txt
228
236
  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.
237
+ - Add the options prop to both index.vue and VpDefaultVideoPlayer.vue to pass fullscreen label settings to the BasePlayer component.
238
+ - 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
239
  v1.1.13 - v1.1.15
232
- - Update the hls.js package
233
- - Fixes
240
+ - Update the hls.js package
241
+ - Fixes
234
242
 
235
243
  v1.1.12
236
- - added component property to make it easier adding headers (like Authorization header into every hls request)
244
+ - added component property to make it easier adding headers (like Authorization header into every hls request)
237
245
 
238
246
  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)
247
+ - Fixes
248
+ - iOS specific improvements
249
+ - New Options to customize the component
250
+ - Fix problem with not updating transcript highlighting
251
+ - Extending overloaded functions for fullscreen mode (WIP)
244
252
 
245
253
  v1.0.9 - v1.0.14
246
- - Fixes
247
- - Small styling improvements
254
+ - Fixes
255
+ - Small styling improvements
248
256
 
249
257
  v1.0.9
250
- - Fix sizes in fullscreen mode for video
251
- - Hide transcript block completely when hidden
258
+ - Fix sizes in fullscreen mode for video
259
+ - Hide transcript block completely when hidden
252
260
 
253
261
  v1.0.8
254
- - Add slots to inject own elements nearby video element
255
- - Add prop for autoplay video
262
+ - Add slots to inject own elements nearby video element
263
+ - Add prop for autoplay video
256
264
 
257
265
  v1.0.7
258
- - Add function to handle own logic for fullscreen
266
+ - Add function to handle own logic for fullscreen
259
267
 
260
268
  v1.0.6
261
- - Small fixes
262
- - Remove debug log
269
+ - Small fixes
270
+ - Remove debug log
263
271
 
264
272
  v1.0.5
265
- - Load transcriptions additionally to subtitles
266
- - Add styled transcription block for better readability
267
- - Improve interaction and dynamic params
273
+ - Load transcriptions additionally to subtitles
274
+ - Add styled transcription block for better readability
275
+ - Improve interaction and dynamic params
268
276
 
269
277
  v1.0.4
270
- - Make subtitles dynamic
271
- - Add new switch to disable the subtitle block
272
- - Fix some minor issues
278
+ - Make subtitles dynamic
279
+ - Add new switch to disable the subtitle block
280
+ - Fix some minor issues
273
281
 
274
282
  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.
283
+ - Removed controls in favour of themable overlay by `player.style`.
284
+ - Updated hls library
285
+ - Added styled caption overlays. Added separate container to show all captions.
@@ -71,7 +71,7 @@
71
71
  <slot name="between-video-and-transcript"></slot>
72
72
  <slot name="before-transcripts"></slot>
73
73
  <SubtitleBlock
74
- :key="currentLang"
74
+ :key="`${currentLang}-${currentSubtitleLang}`"
75
75
  ref="transcriptRef"
76
76
  :subtitle="currentSubtitle"
77
77
  :cursor="videoCursor"
@@ -167,7 +167,7 @@ const props = defineProps({
167
167
  }
168
168
  })
169
169
 
170
- const emit = defineEmits(['pause', 'video-ended', 'video-fullscreen-change', 'pointer-update'])
170
+ const emit = defineEmits(['pause', 'video-ended', 'video-fullscreen-change', 'pointer-update', 'language-changed'])
171
171
  const video = ref(null)
172
172
  const subtitlesContainer = ref(null)
173
173
  const currentSubtitleLang = ref(null)
@@ -179,12 +179,37 @@ 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
185
186
  let buttonElement = null
186
187
  // --- lang switcher ---
187
188
  const currentLang = ref(props.defaultLang || 'en')
189
+ const isUserInitiatedLangChange = ref(false)
190
+
191
+ let initialLoad = true;
192
+
193
+ watch(
194
+ () => props.defaultLang,
195
+ (newLang, oldLang) => {
196
+ if (initialLoad) {
197
+ initialLoad = false;
198
+ return;
199
+ }
200
+
201
+ if (newLang && newLang !== oldLang && newLang !== currentLang.value) {
202
+ const hasSub = props.subtitles?.find(s => s.lang === newLang);
203
+ if (hasSub) {
204
+ currentSubtitleLang.value = newLang;
205
+ Array.from(video.value?.textTracks || []).forEach(track => {
206
+ const tLang = (track.language || track.srclang || '').toLowerCase();
207
+ track.mode = tLang === newLang.toLowerCase() ? 'showing' : 'disabled';
208
+ });
209
+ }
210
+ }
211
+ }
212
+ )
188
213
  // --- Remember and restore last subtitle language ---
189
214
  async function selectLang(lang) {
190
215
  if (!video.value || !hls) return;
@@ -224,14 +249,19 @@ async function selectLang(lang) {
224
249
  // Matching subtitles for matchLang
225
250
  Array.from(video.value?.textTracks || []).forEach(track => {
226
251
  const tLang = (track.language || track.srclang || '').toLowerCase();
227
- console.log('[LangSwitch] Track', tLang, 'current mode:', track.mode);
228
252
  track.mode = tLang === matchLang ? 'showing' : 'disabled';
229
- console.log('[LangSwitch] Track', tLang, '→ new mode:', track.mode);
230
253
  });
231
254
 
232
255
  currentSubtitleLang.value = lang;
233
- console.log('[LangSwitch] currentSubtitleLang set to', currentSubtitleLang.value);
256
+ // Emit ONLY if user initiated the change
257
+ setTimeout(() => {
258
+ if (isUserInitiatedLangChange.value) {
259
+ emit('language-changed', lang);
260
+ isUserInitiatedLangChange.value = false; // Reset flag
261
+ }
262
+ }, 100);
234
263
  });
264
+ // Attach HLS
235
265
  await hls.loadSource(newSource.file_url);
236
266
  video.value.muted = true;
237
267
  } catch (err) {
@@ -254,7 +284,6 @@ function updateLangMenuState() {
254
284
  const langElement = li.querySelector('span[data-lang]')
255
285
  const liLang = langElement?.dataset?.lang;
256
286
  // const liLang = li.textContent.trim().toLowerCase();
257
- console.log("-- audio li lang", currentLang.value, liLang)
258
287
  li.classList.toggle('active', liLang === currentLang.value.toLowerCase());
259
288
  });
260
289
 
@@ -272,26 +301,6 @@ function updateLangMenuState() {
272
301
 
273
302
  watch(currentSubtitleLang, () => updateLangMenuState());
274
303
 
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
304
  // --- Frame Pointer Loop ---
296
305
  let rafId = null
297
306
  const FPS = 30
@@ -344,18 +353,20 @@ const mutedAttr = computed(() => {
344
353
  return (props.autoplay || props.isMuted);
345
354
  })
346
355
 
356
+
347
357
  const currentSubtitle = computed(() => {
348
- if(props.subtitles) {
349
- const current = props.subtitles.filter((subt) => {
350
- if(currentSubtitleLang.value) {
351
- return subt.lang === currentSubtitleLang.value
352
- } else {
353
- return subt.lang === "en"
354
- }
355
- })
356
- return current.length ? current[0] : null
358
+ if (!props.subtitles?.length) return null;
359
+
360
+ if (currentSubtitleLang.value) {
361
+ const match = props.subtitles.find(s => s.lang === currentSubtitleLang.value);
362
+ if (match) return match;
357
363
  }
358
- return null
364
+
365
+ // Fallback
366
+ const defaultMatch = props.subtitles.find(s => s.lang === props.defaultLang);
367
+ if (defaultMatch) return defaultMatch;
368
+
369
+ return props.subtitles[0];
359
370
  })
360
371
 
361
372
  watch(() => props.autoplay, (a) => {
@@ -537,7 +548,6 @@ function prepareVideoPlayer(link) {
537
548
 
538
549
  // Preparing video player with link: ${link}
539
550
  hls = new Hls(hlsConfig);
540
-
541
551
  // Attach HLS
542
552
  hls.loadSource(link);
543
553
  hls.attachMedia(video.value);
@@ -580,14 +590,16 @@ function prepareVideoPlayer(link) {
580
590
  // Initialize subtitles
581
591
  if (props.subtitles?.length > 0) {
582
592
  const defaultSub = props.subtitles.find(s => s.lang === props.defaultLang);
583
- currentSubtitleLang.value = defaultSub ? defaultSub.lang : props.subtitles[0].lang;
593
+ currentSubtitleLang.value = defaultSub ? props.defaultLang : props.subtitles[0].lang;
584
594
  Array.from(video.value?.textTracks || []).forEach(track => {
585
595
  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';
596
+ const shouldShow = tLang === currentSubtitleLang.value.toLowerCase();
597
+ track.mode = shouldShow ? 'showing' : 'disabled';
598
+ // console.log('[SubtitleInit] Track found:', tLang, '->', shouldShow ? 'showing' : 'disabled');
588
599
  });
589
600
  }
590
601
 
602
+ selectLang(props.defaultLang);
591
603
  // HLS attached to <video>
592
604
  hls.recoverMediaError();
593
605
 
@@ -639,284 +651,284 @@ function initVideo() {
639
651
  * overwrite player.style video fullscreen button
640
652
  * to inject own fullscreen logic
641
653
  */
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
- });
654
+ const observer = new MutationObserver(mutationObserver)
655
+ // --- Start pointer-update loop ---
656
+ if (!rafId) {
657
+ emitPointerUpdate()
658
+ }
659
+ observer.observe(document.querySelector('.video-player-theme-container'), { childList: true, subtree: true });
660
+ observer.observe(document.body, { childList: true, subtree: true });
661
+ }
662
+ }
888
663
 
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
- }
664
+ const mutationObserver = (mutationsList, observer) => {
665
+ const mediaTheme = document.querySelector('.video-player-theme-container');
666
+ if (mediaTheme && mediaTheme.shadowRoot) {
667
+ // controllbar
668
+ const controlBar = mediaTheme.shadowRoot.querySelector('media-control-bar');
669
+ const fullscreenButton = mediaTheme.shadowRoot.querySelector('media-fullscreen-button');
670
+ buttonElement = fullscreenButton
671
+ const playbackRateButton = mediaTheme.shadowRoot.querySelector('media-playback-rate-menu');
672
+ playbackRateButton.setAttribute('rates', '0.25 0.5 0.75 1 1.5 2 3');
673
+ if (fullscreenButton) {
674
+ fullscreenButton.handleClick = async (event) => {
675
+ event.preventDefault();
676
+ event.stopPropagation();
677
+ event.stopImmediatePropagation();
678
+ startFullscreen();
679
+ }
680
+ // --- Remove default CC button ---
681
+ const ccButton = mediaTheme.shadowRoot.querySelector('media-captions-button');
682
+ if (ccButton) {
683
+ ccButton.remove();
684
+ }
685
+ // --- Amazon Prime Style Language Switcher ---
686
+ if (controlBar && !controlBar.querySelector('.lang-switcher')
687
+ && (props.multiLangSources.length > 1 || props.subtitles.length > 1)) {
688
+ const langDiv = document.createElement('div');
689
+ langDiv.className = 'lang-switcher';
690
+ langDiv.innerHTML = `
691
+ <style>
692
+ .lang-switcher {
693
+ position: relative;
694
+ display: flex;
695
+ align-items: center;
696
+ justify-content: center;
697
+ margin-left: var(--media-control-spacing, 6px);
698
+ }
699
+
700
+ .lang-switcher button {
701
+ all: unset;
702
+ display: flex;
703
+ align-items: center;
704
+ justify-content: center;
705
+ cursor: pointer;
706
+ height: var(--media-button-height, 32px);
707
+ border-radius: 25%;
708
+ min-width: var(--media-button-height, 32px);
709
+ padding: var(--media-button-padding, 0 5px);
710
+ background: transparent;
711
+ transition: background 0.15s ease, transform 0.1s ease;
712
+ }
713
+
714
+ .lang-switcher button svg {
715
+ width: var(--media-icon-size, 24px);
716
+ height: var(--media-icon-size, 24px);
717
+ color: var(--media-icon-color, white);
718
+ stroke: currentColor;
719
+ opacity: var(--media-icon-opacity, 0.9);
720
+ pointer-events: none;
721
+ transition: opacity 0.15s ease;
722
+ }
723
+
724
+ .lang-switcher button:hover svg {
725
+ transform: scale(1.1);
726
+ }
727
+
728
+ .lang-switcher button:hover {
729
+ opacity: var(--media-icon-opacity-hover, 1);
730
+ background: var(--media-control-hover-background, rgba(50 50 70 / .7));
731
+ transition: backdrop-filter 0.3s, -webkit-backdrop-filter 0.3s;
732
+ box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 5px;
733
+ backdrop-filter: blur(10px) invert(15%) brightness(80%) opacity(1);
734
+ padding: 5px;
735
+ color: var(--media-text-color, var(--media-primary-color, rgb(238 238 238)));
736
+ }
737
+
738
+ .lang-btn[title]:hover::after {
739
+ content: attr(title);
740
+ position: fixed;
741
+ bottom: 122%;
742
+ right: -100%;
743
+ white-space: nowrap;
744
+ background: var(--media-tooltip-background, rgba(0,0,0,0.2));
745
+ color: var(--media-tooltip-color, #fff);
746
+ border-radius: 4px;
747
+ padding: 4px 15px;
748
+ font-size: var(--media-font-size, 12px);
749
+ box-shadow: 0 2px 8px rgba(0,0,0,0.4);
750
+ line-height: calc(1.2 * var(--base));
751
+ }
752
+
753
+ .lang-menu {
754
+ position: absolute;
755
+ bottom: calc(var(--media-button-height, 32px) * 1.7);
756
+ right: -48px;
757
+ background: var(--media-control-bar-background, rgba(20,20,20,0.4));
758
+ backdrop-filter: blur(12px);
759
+ border-radius: 12px;
760
+ box-shadow: rgba(0, 0, 0, 0.3) 0px 0px 5px;
761
+ padding: 10px;
762
+ min-width: 240px;
763
+ display: none;
764
+ animation: fadeUp 0.15s ease-out;
765
+ color: var(--media-text-color, #fff);
766
+ font-family: var(--media-font-family, system-ui, sans-serif);
767
+ font-size: var(--media-font-size, 13px);
768
+ z-index: 9999;
769
+ }
770
+
771
+ @keyframes fadeUp {
772
+ from { opacity: 0; transform: translateY(8px); }
773
+ to { opacity: 1; transform: translateY(0); }
774
+ }
775
+
776
+ .lang-columns {
777
+ display: flex;
778
+ justify-content: space-between;
779
+ gap: 1rem;
780
+ }
781
+
782
+ .lang-col {
783
+ flex: 1;
784
+ display: flex;
785
+ flex-direction: column;
786
+ margin: 0;
787
+ padding: 0;
788
+ }
789
+
790
+ .lang-col .title {
791
+ line-height: calc(1.2 * var(--base));
792
+ font-weight: 600;
793
+ font-size: calc(var(--media-font-size, 13px) - 1px);
794
+ margin-bottom: 4px;
795
+ border-bottom: 1px solid rgba(255,255,255,0.1);
796
+ padding-bottom: 2px;
797
+ }
798
+
799
+ .lang-col ul {
800
+ list-style: none;
801
+ margin: 0;
802
+ padding: 0;
803
+ }
804
+
805
+ .lang-col li {
806
+ display: flex;
807
+ align-items: center;
808
+ justify-content: space-between;
809
+ cursor: pointer;
810
+ border-radius: 4px;
811
+ padding: 2px 12px;
812
+ transition: background 0.15s ease, color 0.15s ease;
813
+ }
814
+
815
+ .lang-col li span {
816
+ line-height: calc(1.2 * var(--base));
817
+ }
818
+
819
+ .lang-col li:hover {
820
+ background: var(--media-control-hover-background, rgba(255,255,255,0.15));
821
+ }
822
+
823
+ .lang-col li.active {
824
+ font-weight: 500;
825
+ color: var(--media-accent-color, #fff);
826
+ }
827
+
828
+ .lang-col li .icon {
829
+ opacity: 0;
830
+ transition: opacity 0.15s ease, transform 0.15s ease;
831
+ transform: translateX(2px);
832
+ }
833
+
834
+ .lang-col li.active .icon {
835
+ opacity: 1;
836
+ transform: translateX(0);
837
+ }
838
+ </style>
839
+
840
+ <button title="Audio & Subtitles" class="lang-btn">
841
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 26 26" fill="none" stroke="currentColor" stroke-width="1.5"
842
+ stroke-linecap="round" stroke-linejoin="round">
843
+ <path d="m5 8 6 6"/>
844
+ <path d="m4 14 6-6 2-3"/>
845
+ <path d="M2 5h12"/>
846
+ <path d="M7 2h1"/>
847
+ <path d="m22 22-5-10-5 10"/>
848
+ <path d="M14 18h6"/>
849
+ </svg>
850
+ </button>
851
+
852
+ <div class="lang-menu">
853
+ <div class="lang-columns">
854
+ <ul class="lang-col audio-col"><li class="title">Audio</li></ul>
855
+ <ul class="lang-col sub-col"><li class="title">Subtitles</li></ul>
856
+ </div>
857
+ </div>
858
+ `;
859
+ const menu = langDiv.querySelector('.lang-menu');
860
+ const audioCol = menu.querySelector('.audio-col');
861
+ const subCol = menu.querySelector('.sub-col');
862
+ const button = langDiv.querySelector('button');
863
+ menu.addEventListener('click', e => e.stopPropagation());
864
+ // Chrome Media Player style check icon
865
+ const renderIcon = () => `
866
+ <svg aria-hidden="true" viewBox="0 1 24 24" width="16" height="16" part="checked-indicator indicator">
867
+ <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>
868
+ </svg>
869
+ `;
870
+
871
+ // Audio options
872
+ props.multiLangSources.forEach(src => {
873
+ const li = document.createElement('li');
874
+ li.innerHTML = `
875
+ <span data-lang="${ src.lang }">${src.label || src.lang.toUpperCase()}</span>
876
+ <span class="icon">${renderIcon()}</span>
877
+ `;
878
+ if (src.lang === currentLang.value) li.classList.add('active');
879
+ li.addEventListener('click', () => {
880
+ audioCol.querySelectorAll('li').forEach(el => el.classList.remove('active'));
881
+ li.classList.add('active');
882
+ isUserInitiatedLangChange.value = true;
883
+ selectLang(src.lang);
884
+ menu.style.display = 'none';
885
+ });
886
+ audioCol.appendChild(li);
887
+ });
888
+ props.subtitles.forEach(sub => {
889
+ const li = document.createElement('li');
890
+ li.innerHTML = `
891
+ <span data-lang="${ sub.lang }">${sub.label || sub.lang.toUpperCase()}</span>
892
+ <span class="icon">${renderIcon()}</span>
893
+ `;
894
+
895
+ if (sub.lang === currentSubtitleLang.value) li.classList.add('active');
896
+ li.addEventListener('click', () => {
897
+ Array.from(video.value?.textTracks || []).forEach(track => {
898
+ const tLang = (track.language || track.srclang || '').toLowerCase();
899
+ track.mode = tLang === sub.lang.toLowerCase() ? 'showing' : 'disabled';
901
900
  });
901
+ subCol.querySelectorAll('li').forEach(el => el.classList.remove('active'));
902
+ li.classList.add('active');
903
+ menu.style.display = 'none';
904
+ currentSubtitleLang.value = sub.lang;
905
+ });
906
+ subCol.appendChild(li);
907
+ });
902
908
 
903
- controlBar.insertBefore(langDiv, fullscreenButton);
909
+ button.addEventListener('click', e => {
910
+ e.stopPropagation();
911
+ const isOpen = menu.style.display === 'block';
912
+ menu.style.display = isOpen ? 'none' : 'block';
913
+ updateLangMenuState();
914
+ });
915
+ document.addEventListener('click', e => {
916
+ if (!menu.contains(e.target) && !button.contains(e.target)) {
917
+ menu.style.display = 'none';
918
+ button.setAttribute('aria-expanded', 'false');
919
+ updateLangMenuState();
904
920
  }
921
+ });
905
922
 
906
- observer.disconnect();
907
- } else {
908
- console.error('Button not found in Shadow DOM!');
909
- }
910
- } else {
911
- console.error('Shadow Root not found!');
923
+ controlBar.insertBefore(langDiv, fullscreenButton);
912
924
  }
913
- })
914
-
915
- // --- Start pointer-update loop ---
916
- if (!rafId) {
917
- emitPointerUpdate()
925
+
926
+ observer.disconnect();
927
+ } else {
928
+ console.error('Button not found in Shadow DOM!');
918
929
  }
919
- observer.observe(document.body, { childList: true, subtree: true });
930
+ } else {
931
+ console.error('Shadow Root not found!');
920
932
  }
921
933
  }
922
934
 
@@ -938,16 +950,6 @@ function changeSpeed(e) {
938
950
  video.value.playbackRate = video.value.playbackRate - 0.25
939
951
  }
940
952
  }
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
953
 
952
954
  </script>
953
955
  <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"
@@ -16,6 +17,7 @@
16
17
  @pause="pause"
17
18
  @video-ended="onVideoEnd"
18
19
  @video-fullscreen-change="onFullscreenChange"
20
+ @language-changed="onLanguageChanged"
19
21
  v-model="videoElement"
20
22
  ref="childRef"
21
23
  >
@@ -31,7 +33,7 @@
31
33
  import BasePlayer from './BasePlayer.vue'
32
34
  import { ref, toRef } from 'vue'
33
35
 
34
- const emit = defineEmits(['pause', 'video-ended', 'video-fullscreen-change'])
36
+ const emit = defineEmits(['pause', 'video-ended', 'video-fullscreen-change', 'language-changed'])
35
37
 
36
38
  const videoElement = ref(null);
37
39
  const childRef = ref(null)
@@ -88,6 +90,10 @@ const props = defineProps({
88
90
  additionHeaders: {
89
91
  type: Object,
90
92
  default: {}
93
+ },
94
+ subtitles: {
95
+ type: Array,
96
+ default: []
91
97
  }
92
98
  })
93
99
 
@@ -109,6 +115,9 @@ function onFullscreenChange(data) {
109
115
  emit('video-fullscreen-change', data);
110
116
  }
111
117
 
118
+ function onLanguageChanged(data) {
119
+ emit('language-changed', data);
120
+ }
112
121
  function startFullscreen() {
113
122
  childRef.value.startFullscreen();
114
123
  }
@@ -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.24",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "dist"