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