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