mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +00:00
fix(spf) subtitle change reactions (#835)
This commit is contained in:
@@ -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 }),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user