Files
v10/dist/dev/playback/behaviors/dom/sync-text-tracks.js
T

124 lines
6.0 KiB
JavaScript

import { computed, peek } from "../../../core/signals/primitives.js";
import { defineBehavior } from "../../../core/composition/create-composition.js";
import { createMachineReactor } from "../../../core/reactors/create-machine-reactor.js";
import { getTracksByType } from "../../../media/utils/tracks.js";
import { syncTextTrackModes } from "../../../media/dom/text/text-track-slots.js";
import { listen } from "@videojs/utils/dom";
//#region src/playback/behaviors/dom/sync-text-tracks.ts
/**
* **Own the text-track slots on the host media element, mirroring the SPF
* model.** When a presentation is resolved and a media element is
* available, allocate one slot in `mediaElement.textTracks` per model text
* track — via creating `<track>` children, since that's the only spec
* mechanism for adding *and* removing entries to `textTracks` (no
* `removeTextTrack` API exists). Once slots are provisioned, mirror the
* resolved `selectedTextTrackId` into their `mode`s (one-way: state → DOM),
* and propagate user-initiated DOM `change` events back to
* `userTextTrackSelection` — the standing *intent* (a language-based partial,
* or `'off'`) that `switchTextTrack` resolves into `selectedTextTrackId`. So
* non-SPF consumers (host-page captions buttons, browser native UI, video.js
* store) drive selection by expressing intent, not by writing the resolved id.
*
* Single-positive-state reactor (`'preconditions-unmet'` ↔ `'sync-active'`):
* the entry allocates the slots, applies the initial selection, attaches
* the `change` listener, and opens a brief Chromium settling-window guard —
* all transition-driven, fire-once on state entry, with paired cleanup on
* state exit. A single `effects:` mirrors subsequent
* `selectedTextTrackId` changes into `mode`s; that's the only
* continuous-reactivity concern.
*
* State-exit cleanup also sends a `'clear'` message to the
* `TextTracksActor` so its cue+segment cache (keyed by trackId) is
* dropped alongside the DOM `<track>` slots. The actor itself is owned
* by `setupTextTrackActors` and bound to mediaElement, not presentation,
* so it survives source resets; clearing its context here keeps the
* cache consistent with the DOM. Without this, a subsequent
* presentation reusing a trackId would have `getSegmentsToLoad` treat
* its segments as already-buffered and skip loading them.
*
* Single-writer separation: `selectedTextTrackId` is the resolved *output*
* owned solely by `switchTextTrack`; this behavior only reads it (to mirror
* modes). The write path here is `userTextTrackSelection` — the user-intent
* *input* — so DOM action and the resolver never contend for one slot. The
* intent isn't cleared on source unload (it's a standing preference, like
* `userAudioTrackSelection`); `'off'` is written when the user disables all
* tracks via native UI.
*
* Echo guard: `selectedTextTrackId` is exactly the id this behavior last drove
* into the DOM, so a `change` event still showing it is our own echo (or a
* resolver-driven correction — e.g. the picked track's CDN failed and the
* resolver disabled it) and is ignored, never written back as a spurious user
* action. Only a showing id that *differs* from the resolved id is a real user
* pick. The settling-window guard additionally swallows Chromium's init-time
* auto-selection before the resolved selection has settled.
*/
function deriveState(presentation, mediaElement) {
if (!mediaElement || !presentation) return "preconditions-unmet";
return getTracksByType(presentation, "text").length > 0 ? "sync-active" : "preconditions-unmet";
}
/**
* Map the DOM-showing track back to standing user intent. No showing track is an
* explicit `'off'`. Otherwise prefer a language-based partial (so the pick
* persists across source changes); fall back to `{ id }` for a track without a
* language (precise within a source, just not portable).
*/
function deriveTextTrackIntent(showingId, modelTextTracks) {
if (!showingId) return "off";
const language = modelTextTracks.find((track) => track.id === showingId)?.language;
return language ? { language } : { id: showingId };
}
function syncTextTracksSetup({ state, context, config }) {
const { addSubtitlesTracksToMedia, getShowingSubtitlesTrackFromMedia, removeAllSubtitlesTracksFromMedia } = config;
const derivedStateSignal = computed(() => deriveState(state.presentation.get(), context.mediaElement.get()));
return createMachineReactor({
initial: "preconditions-unmet",
monitor: () => derivedStateSignal.get(),
states: {
"preconditions-unmet": {},
"sync-active": {
entry: () => {
const mediaElement = context.mediaElement.get();
const modelTextTracks = getTracksByType(state.presentation.get(), "text");
addSubtitlesTracksToMedia(mediaElement, modelTextTracks);
syncTextTrackModes(mediaElement.textTracks, state.selectedTextTrackId.get());
let inSettlingWindow = true;
const settlingTimeout = setTimeout(() => {
inSettlingWindow = false;
}, 0);
const onChange = () => {
if (inSettlingWindow) {
syncTextTrackModes(mediaElement.textTracks, state.selectedTextTrackId.get());
return;
}
const showingId = getShowingSubtitlesTrackFromMedia(mediaElement)?.id || void 0;
if (showingId === state.selectedTextTrackId.get()) return;
state.userTextTrackSelection.set(deriveTextTrackIntent(showingId, modelTextTracks));
};
const unlisten = listen(mediaElement.textTracks, "change", onChange);
return () => {
unlisten();
clearTimeout(settlingTimeout);
removeAllSubtitlesTracksFromMedia(mediaElement);
peek(context.textTracksActor)?.send({ type: "clear" });
};
},
effects: () => {
syncTextTrackModes(peek(context.mediaElement).textTracks, state.selectedTextTrackId.get());
}
}
}
});
}
const syncTextTracks = defineBehavior({
stateKeys: [
"presentation",
"selectedTextTrackId",
"userTextTrackSelection"
],
contextKeys: ["mediaElement", "textTracksActor"],
setup: syncTextTracksSetup
});
//#endregion
export { syncTextTracks };
//# sourceMappingURL=sync-text-tracks.js.map