mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
124 lines
6.0 KiB
JavaScript
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
|