From 71b5b415c52a8819491b11d17a5cf81b8ccab827 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Tue, 10 Mar 2026 07:12:29 -0700 Subject: [PATCH] fix(spf) subtitle change reactions (#835) --- .../src/dom/features/load-text-track-cues.ts | 10 + .../sync-selected-text-track-from-dom.ts | 102 ++++++ .../sync-selected-text-track-from-dom.test.ts | 312 ++++++++++++++++++ .../spf/src/dom/playback-engine/engine.ts | 7 + 4 files changed, 431 insertions(+) create mode 100644 packages/spf/src/dom/features/sync-selected-text-track-from-dom.ts create mode 100644 packages/spf/src/dom/features/tests/sync-selected-text-track-from-dom.test.ts diff --git a/packages/spf/src/dom/features/load-text-track-cues.ts b/packages/spf/src/dom/features/load-text-track-cues.ts index 6dd9605c..6aac326a 100644 --- a/packages/spf/src/dom/features/load-text-track-cues.ts +++ b/packages/spf/src/dom/features/load-text-track-cues.ts @@ -87,6 +87,16 @@ const loadTextTrackCuesTask = async ( } } + // Chrome bug: after a track goes through mode='disabled' (which clears cues) and back + // to 'showing', cues added to the track aren't activated. Re-adding all cues forces + // Chrome to re-process them. Safe no-op in other browsers. + // Mirrors the workaround in HlsMediaTextTracksMixin (packages/core/src/dom/media/hls/text-tracks.ts). + if (context.textTrack.mode === 'showing' && context.textTrack.cues) { + Array.from(context.textTrack.cues).forEach((cue) => { + context.textTrack.addCue(cue); + }); + } + // Wait a frame before completing to allow state updates to flush await new Promise((resolve) => requestAnimationFrame(resolve)); }; diff --git a/packages/spf/src/dom/features/sync-selected-text-track-from-dom.ts b/packages/spf/src/dom/features/sync-selected-text-track-from-dom.ts new file mode 100644 index 00000000..9df3e7c2 --- /dev/null +++ b/packages/spf/src/dom/features/sync-selected-text-track-from-dom.ts @@ -0,0 +1,102 @@ +import { listen } from '@videojs/utils/dom'; +import type { WritableState } from '../../core/state/create-state'; +import type { TextTrackBufferState } from './load-text-track-cues'; + +/** + * State shape for DOM-driven text track selection. + */ +export interface SelectedTextTrackFromDomState { + selectedTextTrackId?: string | undefined; + textBufferState?: TextTrackBufferState | undefined; +} + +/** + * Owners shape for DOM-driven text track selection. + */ +export interface SelectedTextTrackFromDomOwners { + mediaElement?: HTMLMediaElement | undefined; +} + +/** + * Sync selectedTextTrackId from DOM text track mode changes. + * + * Listens to the `change` event on `media.textTracks` and updates + * `selectedTextTrackId` when external code (e.g. the captions button via + * `toggleSubtitles()`) changes a subtitle/caption track mode to 'showing'. + * + * This bridges the core store's `toggleSubtitles()` with SPF's reactive text + * track pipeline (`syncTextTrackModes`, `loadTextTrackCues`). Without this + * bridge, direct DOM mode changes would be immediately overridden by + * `syncTextTrackModes` on the next SPF state update. + * + * When a subtitle/caption track's mode is 'showing', its DOM `id` — which + * matches the SPF track ID set by `setupTextTracks` — is written to + * `selectedTextTrackId`. When no subtitle/caption track is 'showing', + * `selectedTextTrackId` is cleared along with the deselected track's + * `textBufferState` entry — setting mode to 'disabled' clears native cues from + * the track element, so the buffer must be reset to re-fetch cues on re-enable. + * + * @example + * const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + */ +export function syncSelectedTextTrackFromDom({ + state, + owners, +}: { + state: WritableState; + owners: WritableState; +}): () => void { + let lastMediaElement: HTMLMediaElement | undefined; + let removeListener: (() => void) | null = null; + + const unsubscribe = owners.subscribe((currentOwners) => { + const { mediaElement } = currentOwners; + + if (mediaElement === lastMediaElement) return; + + removeListener?.(); + removeListener = null; + lastMediaElement = mediaElement; + + if (!mediaElement) return; + + const sync = () => { + const showingTrack = Array.from(mediaElement.textTracks).find( + (t) => t.mode === 'showing' && (t.kind === 'subtitles' || t.kind === 'captions') + ); + + // showingTrack.id is set from the SPF presentation track ID by setupTextTracks. + // Fall back to undefined for empty-string IDs (non-SPF-managed tracks). + const newId = showingTrack?.id || undefined; + const current = state.current; + + // Guard against redundant patches — e.g. syncTextTrackModes confirming the + // current selection, which would otherwise create a feedback loop. + if (current.selectedTextTrackId === newId) return; + + if (newId) { + state.patch({ selectedTextTrackId: newId }); + } else { + // When deselecting, clear the textBufferState entry for the previous track. + // Setting mode to 'disabled' (as toggleSubtitles() does) clears native cues + // from the track element, so the buffer must be reset to allow re-fetching + // on re-enable. + const prevId = current.selectedTextTrackId; + if (prevId && current.textBufferState?.[prevId]) { + const next = { ...current.textBufferState }; + delete next[prevId]; + state.patch({ selectedTextTrackId: undefined, textBufferState: next }); + } else { + state.patch({ selectedTextTrackId: undefined }); + } + } + }; + + removeListener = listen(mediaElement.textTracks, 'change', sync); + }); + + return () => { + removeListener?.(); + unsubscribe(); + }; +} diff --git a/packages/spf/src/dom/features/tests/sync-selected-text-track-from-dom.test.ts b/packages/spf/src/dom/features/tests/sync-selected-text-track-from-dom.test.ts new file mode 100644 index 00000000..3ac255ac --- /dev/null +++ b/packages/spf/src/dom/features/tests/sync-selected-text-track-from-dom.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createState } from '../../../core/state/create-state'; +import { + type SelectedTextTrackFromDomOwners, + type SelectedTextTrackFromDomState, + syncSelectedTextTrackFromDom, +} from '../sync-selected-text-track-from-dom'; + +function createSubtitleTrack(mediaElement: HTMLMediaElement, id: string): HTMLTrackElement { + const trackEl = document.createElement('track'); + trackEl.kind = 'subtitles'; + trackEl.label = id; + trackEl.id = id; + trackEl.src = 'data:text/vtt,'; + mediaElement.appendChild(trackEl); + return trackEl; +} + +describe('syncSelectedTextTrackFromDom', () => { + it('does nothing when no mediaElement', async () => { + const state = createState({}); + const owners = createState({}); + + const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(state.current.selectedTextTrackId).toBeUndefined(); + + cleanup(); + }); + + it('patches selectedTextTrackId when a subtitle track mode changes to "showing"', async () => { + const mediaElement = document.createElement('video'); + const trackEl = createSubtitleTrack(mediaElement, 'track-en'); + + // Wait for track to initialize + await new Promise((resolve) => setTimeout(resolve, 50)); + + const state = createState({}); + const owners = createState({ mediaElement }); + + const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + + // Simulate toggleSubtitles() setting the track to 'showing' + trackEl.track.mode = 'showing'; + mediaElement.textTracks.dispatchEvent(new Event('change')); + + await vi.waitFor(() => { + expect(state.current.selectedTextTrackId).toBe('track-en'); + }); + + cleanup(); + }); + + it('patches selectedTextTrackId when a captions track mode changes to "showing"', async () => { + const mediaElement = document.createElement('video'); + const trackEl = document.createElement('track'); + trackEl.kind = 'captions'; + trackEl.id = 'track-cc'; + trackEl.src = 'data:text/vtt,'; + mediaElement.appendChild(trackEl); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const state = createState({}); + const owners = createState({ mediaElement }); + + const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + + trackEl.track.mode = 'showing'; + mediaElement.textTracks.dispatchEvent(new Event('change')); + + await vi.waitFor(() => { + expect(state.current.selectedTextTrackId).toBe('track-cc'); + }); + + cleanup(); + }); + + it('clears selectedTextTrackId when no subtitle/caption track is showing', async () => { + const mediaElement = document.createElement('video'); + const trackEl = createSubtitleTrack(mediaElement, 'track-en'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const state = createState({ selectedTextTrackId: 'track-en' }); + const owners = createState({ mediaElement }); + + const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + + // Simulate toggleSubtitles() disabling captions + trackEl.track.mode = 'disabled'; + mediaElement.textTracks.dispatchEvent(new Event('change')); + + await vi.waitFor(() => { + expect(state.current.selectedTextTrackId).toBeUndefined(); + }); + + cleanup(); + }); + + it('clears textBufferState for the deselected track when disabling', async () => { + // When mode='disabled' clears native cues, textBufferState must be reset so + // segments are re-fetched if the user re-enables captions. + const mediaElement = document.createElement('video'); + const trackEl = createSubtitleTrack(mediaElement, 'track-en'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const state = createState({ + selectedTextTrackId: 'track-en', + textBufferState: { + 'track-en': { segments: [{ id: 'seg-0' }, { id: 'seg-1' }] }, + }, + }); + const owners = createState({ mediaElement }); + + const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + + trackEl.track.mode = 'disabled'; + mediaElement.textTracks.dispatchEvent(new Event('change')); + + await vi.waitFor(() => { + expect(state.current.selectedTextTrackId).toBeUndefined(); + expect(state.current.textBufferState?.['track-en']).toBeUndefined(); + }); + + cleanup(); + }); + + it('does not modify textBufferState for other tracks when disabling one', async () => { + const mediaElement = document.createElement('video'); + const trackEl = createSubtitleTrack(mediaElement, 'track-en'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const state = createState({ + selectedTextTrackId: 'track-en', + textBufferState: { + 'track-en': { segments: [{ id: 'seg-0' }] }, + 'track-fr': { segments: [{ id: 'seg-0' }] }, + }, + }); + const owners = createState({ mediaElement }); + + const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + + trackEl.track.mode = 'disabled'; + mediaElement.textTracks.dispatchEvent(new Event('change')); + + await vi.waitFor(() => { + expect(state.current.textBufferState?.['track-en']).toBeUndefined(); + }); + + expect(state.current.textBufferState?.['track-fr']).toEqual({ segments: [{ id: 'seg-0' }] }); + + cleanup(); + }); + + it('ignores non-subtitle/caption track kinds', async () => { + const mediaElement = document.createElement('video'); + const chapterEl = document.createElement('track'); + chapterEl.kind = 'chapters'; + chapterEl.id = 'chapters-track'; + chapterEl.src = 'data:text/vtt,'; + mediaElement.appendChild(chapterEl); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const state = createState({}); + const owners = createState({ mediaElement }); + + const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + + // Chapters track going 'showing' should not affect selectedTextTrackId + chapterEl.track.mode = 'showing'; + mediaElement.textTracks.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(state.current.selectedTextTrackId).toBeUndefined(); + + cleanup(); + }); + + it('does not patch when selectedTextTrackId already matches the showing track', async () => { + const mediaElement = document.createElement('video'); + const trackEl = createSubtitleTrack(mediaElement, 'track-en'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const state = createState({ selectedTextTrackId: 'track-en' }); + const owners = createState({ mediaElement }); + + const patchSpy = vi.spyOn(state, 'patch'); + + const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + + // Simulate syncTextTrackModes confirming the track as 'showing' + trackEl.track.mode = 'showing'; + mediaElement.textTracks.dispatchEvent(new Event('change')); + + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(patchSpy).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('does not re-register listener when owners updates but mediaElement is unchanged', async () => { + const mediaElement = document.createElement('video'); + + const state = createState({}); + const owners = createState({ mediaElement }); + + const addEventListenerSpy = vi.spyOn(mediaElement.textTracks, 'addEventListener'); + + const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + const callsBefore = addEventListenerSpy.mock.calls.length; + + // Patch an unrelated owner + owners.patch({ videoBuffer: {} }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(addEventListenerSpy.mock.calls.length).toBe(callsBefore); + + cleanup(); + }); + + it('starts listening when mediaElement is added later', async () => { + const state = createState({}); + const owners = createState({}); + + const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(state.current.selectedTextTrackId).toBeUndefined(); + + const mediaElement = document.createElement('video'); + const trackEl = createSubtitleTrack(mediaElement, 'track-en'); + await new Promise((resolve) => setTimeout(resolve, 50)); + + owners.patch({ mediaElement }); + + trackEl.track.mode = 'showing'; + mediaElement.textTracks.dispatchEvent(new Event('change')); + + await vi.waitFor(() => { + expect(state.current.selectedTextTrackId).toBe('track-en'); + }); + + cleanup(); + }); + + it('stops listening to old mediaElement when replaced', async () => { + const element1 = document.createElement('video'); + const trackEl1 = createSubtitleTrack(element1, 'track-en'); + + const element2 = document.createElement('video'); + const trackEl2 = createSubtitleTrack(element2, 'track-fr'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const state = createState({}); + const owners = createState({ mediaElement: element1 }); + + const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + + // Switch to element2 + owners.patch({ mediaElement: element2 }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Events on old element should no longer affect state + trackEl1.track.mode = 'showing'; + element1.textTracks.dispatchEvent(new Event('change')); + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(state.current.selectedTextTrackId).toBeUndefined(); + + // Events on new element should work + trackEl2.track.mode = 'showing'; + element2.textTracks.dispatchEvent(new Event('change')); + + await vi.waitFor(() => { + expect(state.current.selectedTextTrackId).toBe('track-fr'); + }); + + cleanup(); + }); + + it('removes listener on cleanup', async () => { + const mediaElement = document.createElement('video'); + const trackEl = createSubtitleTrack(mediaElement, 'track-en'); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + const state = createState({}); + const owners = createState({ mediaElement }); + + const cleanup = syncSelectedTextTrackFromDom({ state, owners }); + cleanup(); + + trackEl.track.mode = 'showing'; + mediaElement.textTracks.dispatchEvent(new Event('change')); + await new Promise((resolve) => setTimeout(resolve, 30)); + + expect(state.current.selectedTextTrackId).toBeUndefined(); + }); +}); diff --git a/packages/spf/src/dom/playback-engine/engine.ts b/packages/spf/src/dom/playback-engine/engine.ts index e8d35b41..6f1ccf64 100644 --- a/packages/spf/src/dom/playback-engine/engine.ts +++ b/packages/spf/src/dom/playback-engine/engine.ts @@ -22,6 +22,7 @@ import { loadTextTrackCues } from '../features/load-text-track-cues'; import { setupMediaSource } from '../features/setup-mediasource'; import { setupSourceBuffer } from '../features/setup-sourcebuffer'; import { setupTextTracks } from '../features/setup-text-tracks'; +import { syncSelectedTextTrackFromDom } from '../features/sync-selected-text-track-from-dom'; import { syncTextTrackModes } from '../features/sync-text-track-modes'; import { trackCurrentTime } from '../features/track-current-time'; import { trackPlaybackInitiated } from '../features/track-playback-initiated'; @@ -311,6 +312,12 @@ export function createPlaybackEngine(config: PlaybackEngineConfig = {}): Playbac // 8. Sync text track modes (when track selected and track elements created) syncTextTrackModes({ state, owners }), + // 8.5. Bridge DOM text track mode changes → selectedTextTrackId + // Detects when external code (e.g. captions button via toggleSubtitles()) + // sets a subtitle/caption track to 'showing' and reflects that into SPF + // state, which in turn drives loadTextTrackCues. + syncSelectedTextTrackFromDom({ state, owners }), + // 9. Load text track cues (when track resolved and mode set) loadTextTrackCues({ state, owners }), ];