fix(spf) subtitle change reactions (#835)

This commit is contained in:
Christian Pillsbury
2026-03-10 07:12:29 -07:00
committed by GitHub
parent dff4c72751
commit 71b5b415c5
4 changed files with 431 additions and 0 deletions
@@ -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));
};
@@ -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<SelectedTextTrackFromDomState>;
owners: WritableState<SelectedTextTrackFromDomOwners>;
}): () => 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();
};
}
@@ -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<SelectedTextTrackFromDomState>({});
const owners = createState<SelectedTextTrackFromDomOwners>({});
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<SelectedTextTrackFromDomState>({});
const owners = createState<SelectedTextTrackFromDomOwners>({ 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<SelectedTextTrackFromDomState>({});
const owners = createState<SelectedTextTrackFromDomOwners>({ 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<SelectedTextTrackFromDomState>({ selectedTextTrackId: 'track-en' });
const owners = createState<SelectedTextTrackFromDomOwners>({ 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<SelectedTextTrackFromDomState>({
selectedTextTrackId: 'track-en',
textBufferState: {
'track-en': { segments: [{ id: 'seg-0' }, { id: 'seg-1' }] },
},
});
const owners = createState<SelectedTextTrackFromDomOwners>({ 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<SelectedTextTrackFromDomState>({
selectedTextTrackId: 'track-en',
textBufferState: {
'track-en': { segments: [{ id: 'seg-0' }] },
'track-fr': { segments: [{ id: 'seg-0' }] },
},
});
const owners = createState<SelectedTextTrackFromDomOwners>({ 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<SelectedTextTrackFromDomState>({});
const owners = createState<SelectedTextTrackFromDomOwners>({ 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<SelectedTextTrackFromDomState>({ selectedTextTrackId: 'track-en' });
const owners = createState<SelectedTextTrackFromDomOwners>({ 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<SelectedTextTrackFromDomState>({});
const owners = createState<SelectedTextTrackFromDomOwners & { videoBuffer?: unknown }>({ 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<SelectedTextTrackFromDomState>({});
const owners = createState<SelectedTextTrackFromDomOwners>({});
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<SelectedTextTrackFromDomState>({});
const owners = createState<SelectedTextTrackFromDomOwners>({ 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<SelectedTextTrackFromDomState>({});
const owners = createState<SelectedTextTrackFromDomOwners>({ 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();
});
});
@@ -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 }),
];