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"
+ >
+
+ {{ 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,