diff --git a/ui/app/components/VideoPlayer.vue b/ui/app/components/VideoPlayer.vue index 4ae7a26b..03ac412d 100644 --- a/ui/app/components/VideoPlayer.vue +++ b/ui/app/components/VideoPlayer.vue @@ -94,6 +94,7 @@ :label="nativeSubtitleTrack.name || 'Subtitles'" default :src="uri(nativeSubtitleTrack.url)" + @load="handleNativeTrackLoad" /> Your browser does not support the video tag. @@ -170,16 +171,30 @@ @click="forceSwitchToHls" /> - + :aria-label="subtitleButtonLabel" + > + + { return `${currentLabel} / ${durationLabel}`; }); const { + subtitleTracks, subtitleLoading, subtitleLoadError, subtitleEnabled, + selectedSubtitleTrack, + selectedSubtitleTrackId, nativeSubtitleTrack, usesAssSubtitleTrack, hasSubtitles, @@ -445,6 +463,40 @@ const { overlay: assOverlayElement, }); +const SUBTITLE_OFF_VALUE = '__off__'; +const subtitleSelectContent = { align: 'end' as const } as const; +const subtitleSelectUi = { + base: 'size-8 min-h-8 min-w-8 justify-center rounded-md px-0', + value: 'flex items-center justify-center px-0', + trailing: 'hidden', + leading: 'hidden', + content: 'z-40 min-w-56 w-auto max-w-[min(24rem,calc(100vw-2rem))]', +} as const; +const subtitleSelectItems = computed(() => [ + { label: 'Off', value: SUBTITLE_OFF_VALUE }, + ...subtitleTracks.value.map((track) => ({ label: track.name, value: track.url })), +]); +const subtitleButtonLabel = computed(() => { + if (!subtitleEnabled.value || !selectedSubtitleTrack.value) { + return 'Subtitles off'; + } + return selectedSubtitleTrack.value.name || 'Subtitles'; +}); +const subtitleSelectValue = computed({ + get: () => + subtitleEnabled.value + ? (selectedSubtitleTrackId.value ?? SUBTITLE_OFF_VALUE) + : SUBTITLE_OFF_VALUE, + set: (value: string) => { + if (SUBTITLE_OFF_VALUE === value) { + subtitleEnabled.value = false; + return; + } + selectedSubtitleTrackId.value = value; + subtitleEnabled.value = true; + }, +}); + useHead(() => (title.value ? { title: formatPageTitle(`Playing: ${title.value}`) } : {})); watch( @@ -960,6 +1012,10 @@ function updateMediaSessionPosition(target: EventTarget | null) { } async function restoreDefaultTextTrack() { + if (true === destroyed.value) { + return; + } + const el = videoElement.value; if (!el) { return; @@ -971,36 +1027,6 @@ async function restoreDefaultTextTrack() { return; } - const firstTrack = tracksList[0] as TextTrack | undefined; - const needsReload = firstTrack && (!firstTrack.cues || firstTrack.cues.length === 0); - - if (needsReload) { - const trackElements = el.querySelectorAll('track'); - - trackElements.forEach((trackEl, idx) => { - const parent = trackEl.parentNode; - const clone = trackEl.cloneNode(true) as HTMLTrackElement; - trackEl.remove(); - setTimeout(() => { - if (parent) { - parent.appendChild(clone); - clone.addEventListener( - 'load', - () => { - const trackObj = clone.track; - if (trackObj) { - trackObj.mode = idx === 0 && subtitleEnabled.value ? 'showing' : 'disabled'; - } - }, - { once: true }, - ); - } - }, idx * 10); - }); - - return; - } - for (let i = 0; i < tracksList.length; i += 1) { const track = tracksList[i] as TextTrack | undefined; if (track) { @@ -1010,18 +1036,34 @@ async function restoreDefaultTextTrack() { await new Promise((resolve) => setTimeout(resolve, 50)); - for (let i = 0; i < tracksList.length; i += 1) { - const track = tracksList[i] as TextTrack | undefined; + if (el !== videoElement.value) { + return; + } + + if (false === subtitleEnabled.value || null === nativeSubtitleTrack.value) { + return; + } + + const activeTracksList = el.textTracks; + for (let i = 0; i < activeTracksList.length; i += 1) { + const track = activeTracksList[i] as TextTrack | undefined; if (!track) { continue; } - track.mode = i === 0 && subtitleEnabled.value ? 'showing' : 'disabled'; + track.mode = i === 0 ? 'showing' : 'disabled'; } } catch (error) { console.warn('Failed to restore subtitle track state', error); } } +function handleNativeTrackLoad() { + if (true === destroyed.value) { + return; + } + void restoreDefaultTextTrack(); +} + function applyMediaSessionMetadata() { if (false === 'mediaSession' in navigator) { return; @@ -1242,6 +1284,10 @@ watch(subtitleEnabled, () => { void nextTick(() => restoreDefaultTextTrack()); }); +watch(selectedSubtitleTrack, () => { + void nextTick(() => restoreDefaultTextTrack()); +}); + onMounted(async () => { disableOpacity(); isTouchDevice.value = window.matchMedia('(pointer: coarse)').matches; diff --git a/ui/app/composables/usePlayerSubtitles.ts b/ui/app/composables/usePlayerSubtitles.ts index 355bf24a..3e3ba885 100644 --- a/ui/app/composables/usePlayerSubtitles.ts +++ b/ui/app/composables/usePlayerSubtitles.ts @@ -54,7 +54,10 @@ export function usePlayerSubtitles(options: UsePlayerSubtitlesOptions) { const subtitleLoading = ref(false); const subtitleLoadError = ref(''); const subtitleEnabled = ref(true); - const selectedTrack = computed(() => tracks.value[0] || null); + const selectedTrackId = ref(null); + const selectedTrack = computed( + () => tracks.value.find((track) => track.url === selectedTrackId.value) || null, + ); const nativeSubtitleTrack = computed(() => { const track = selectedTrack.value; return subtitleEnabled.value && track?.renderer === 'native' ? track : null; @@ -82,6 +85,7 @@ export function usePlayerSubtitles(options: UsePlayerSubtitlesOptions) { assRequestId += 1; destroyAssRenderer(); tracks.value = []; + selectedTrackId.value = null; subtitleLoadError.value = ''; if (!manifestUrl || !isVideo || !canPlay) { @@ -104,6 +108,7 @@ export function usePlayerSubtitles(options: UsePlayerSubtitlesOptions) { tracks.value = (payload as SubtitleManifestResponse).subtitles || []; if (tracks.value.length > 0) { + selectedTrackId.value = tracks.value[0]?.url || null; subtitleEnabled.value = true; } } catch { @@ -212,6 +217,7 @@ export function usePlayerSubtitles(options: UsePlayerSubtitlesOptions) { subtitleLoadError, subtitleEnabled, selectedSubtitleTrack: selectedTrack, + selectedSubtitleTrackId: selectedTrackId, nativeSubtitleTrack, usesAssSubtitleTrack: usesAssTrack, hasSubtitles,