dom: expose media text track cues
This commit is contained in:
1 parent
9f33db610e
commit
3d02525f35
4 files changed
+223
No files matched your search
Vendored
+135
@@ -3424,6 +3424,103 @@ class DocumentType extends Node {
|
|||||||
|
|
||||||
const _cache = new Map();
|
const _cache = new Map();
|
||||||
|
|
||||||
|
class TextTrackCue {
|
||||||
|
constructor(startTime, endTime, text) {
|
||||||
|
this.id = "";
|
||||||
|
this.startTime = Number(startTime);
|
||||||
|
this.endTime = Number(endTime);
|
||||||
|
this.text = String(text ?? "");
|
||||||
|
this.pauseOnExit = false;
|
||||||
|
this.vertical = "";
|
||||||
|
this.snapToLines = true;
|
||||||
|
this.line = "auto";
|
||||||
|
this.lineAlign = "start";
|
||||||
|
this.position = "auto";
|
||||||
|
this.positionAlign = "auto";
|
||||||
|
this.size = 100;
|
||||||
|
this.align = "center";
|
||||||
|
this.region = null;
|
||||||
|
this.onenter = null;
|
||||||
|
this.onexit = null;
|
||||||
|
}
|
||||||
|
getCueAsHTML() {
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
fragment.appendChild(document.createTextNode(this.text));
|
||||||
|
return fragment;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class VTTCue extends TextTrackCue {}
|
||||||
|
class TextTrackCueList extends Array {
|
||||||
|
getCueById(id) {
|
||||||
|
return this.find((cue) => cue && cue.id === String(id)) || null;
|
||||||
|
}
|
||||||
|
item(index) { return this[index] || null; }
|
||||||
|
}
|
||||||
|
class TextTrack extends Node {
|
||||||
|
constructor(element, kind, label, language) {
|
||||||
|
super();
|
||||||
|
this._element = element || null;
|
||||||
|
this.kind = kind || "subtitles";
|
||||||
|
this.label = label || "";
|
||||||
|
this.language = language || "";
|
||||||
|
this.id = element?.id || "";
|
||||||
|
this.mode = element?.hasAttribute?.("default") ? "showing" : "disabled";
|
||||||
|
this.inBandMetadataTrackDispatchType = "";
|
||||||
|
this._parsedSrc = null;
|
||||||
|
this._cues = new TextTrackCueList();
|
||||||
|
this.activeCues = new TextTrackCueList();
|
||||||
|
this.oncuechange = null;
|
||||||
|
}
|
||||||
|
get cues() {
|
||||||
|
const src = this._element?.getAttribute?.("src") || "";
|
||||||
|
if (src !== this._parsedSrc) {
|
||||||
|
this._parsedSrc = src;
|
||||||
|
this._cues = _parseWebVttCues(src);
|
||||||
|
}
|
||||||
|
return this._cues;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class TextTrackList extends Array {
|
||||||
|
item(index) { return this[index] || null; }
|
||||||
|
getTrackById(id) {
|
||||||
|
return this.find((track) => track && track.id === String(id)) || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function _vttTime(value) {
|
||||||
|
const parts = String(value).trim().split(":").map(Number);
|
||||||
|
if (parts.some((part) => !Number.isFinite(part))) return 0;
|
||||||
|
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
|
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
||||||
|
return parts[0] || 0;
|
||||||
|
}
|
||||||
|
function _parseWebVttCues(src) {
|
||||||
|
const cues = new TextTrackCueList();
|
||||||
|
if (!src || !src.startsWith("data:text/vtt")) return cues;
|
||||||
|
let text = "";
|
||||||
|
try {
|
||||||
|
const comma = src.indexOf(",");
|
||||||
|
if (comma < 0) return cues;
|
||||||
|
const meta = src.slice(0, comma);
|
||||||
|
const body = src.slice(comma + 1);
|
||||||
|
text = /;base64(?:;|$)/i.test(meta) ? atob(body) : decodeURIComponent(body);
|
||||||
|
} catch (_error) {
|
||||||
|
return cues;
|
||||||
|
}
|
||||||
|
const blocks = text.replace(/\r\n?/g, "\n").split(/\n{2,}/);
|
||||||
|
for (const block of blocks) {
|
||||||
|
const lines = block.split("\n").filter((line) => line.length > 0);
|
||||||
|
if (!lines.length || lines[0].trim() === "WEBVTT" || lines[0].trim().startsWith("NOTE")) continue;
|
||||||
|
let timingIndex = lines.findIndex((line) => line.includes("-->"));
|
||||||
|
if (timingIndex < 0) continue;
|
||||||
|
const timing = lines[timingIndex].split("-->");
|
||||||
|
const endToken = (timing[1] || "").trim().split(/\s+/)[0];
|
||||||
|
const cue = new VTTCue(_vttTime(timing[0]), _vttTime(endToken), lines.slice(timingIndex + 1).join("\n"));
|
||||||
|
if (timingIndex > 0) cue.id = lines[timingIndex - 1].trim();
|
||||||
|
cues.push(cue);
|
||||||
|
}
|
||||||
|
return cues;
|
||||||
|
}
|
||||||
|
|
||||||
// Media elements need canPlayType for codec detection fingerprinting.
|
// Media elements need canPlayType for codec detection fingerprinting.
|
||||||
// Values match Chrome 145 on Linux x86_64 without proprietary codecs.
|
// Values match Chrome 145 on Linux x86_64 without proprietary codecs.
|
||||||
class HTMLMediaElement extends Element {
|
class HTMLMediaElement extends Element {
|
||||||
@@ -3452,6 +3549,14 @@ class HTMLMediaElement extends Element {
|
|||||||
set muted(v) {}
|
set muted(v) {}
|
||||||
get src() { return this.getAttribute('src') || ''; }
|
get src() { return this.getAttribute('src') || ''; }
|
||||||
set src(v) { this.setAttribute('src', v); }
|
set src(v) { this.setAttribute('src', v); }
|
||||||
|
get textTracks() {
|
||||||
|
return TextTrackList.from(
|
||||||
|
Array.from(this.querySelectorAll("track")).map((element) => element.track)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
addTextTrack(kind, label = "", language = "") {
|
||||||
|
return new TextTrack(null, String(kind), String(label), String(language));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_markNative(HTMLMediaElement.prototype.canPlayType);
|
_markNative(HTMLMediaElement.prototype.canPlayType);
|
||||||
_markNative(HTMLMediaElement.prototype.play);
|
_markNative(HTMLMediaElement.prototype.play);
|
||||||
@@ -3459,15 +3564,45 @@ _markNative(HTMLMediaElement.prototype.load);
|
|||||||
_markNative(HTMLMediaElement.prototype.pause);
|
_markNative(HTMLMediaElement.prototype.pause);
|
||||||
class HTMLVideoElement extends HTMLMediaElement {}
|
class HTMLVideoElement extends HTMLMediaElement {}
|
||||||
class HTMLAudioElement extends HTMLMediaElement {}
|
class HTMLAudioElement extends HTMLMediaElement {}
|
||||||
|
class HTMLTrackElement extends Element {
|
||||||
|
static NONE = 0;
|
||||||
|
static LOADING = 1;
|
||||||
|
static LOADED = 2;
|
||||||
|
static ERROR = 3;
|
||||||
|
get kind() { return this.getAttribute("kind") || "subtitles"; }
|
||||||
|
set kind(value) { this.setAttribute("kind", value); }
|
||||||
|
get src() { return this.getAttribute("src") || ""; }
|
||||||
|
set src(value) { this.setAttribute("src", value); }
|
||||||
|
get srclang() { return this.getAttribute("srclang") || ""; }
|
||||||
|
set srclang(value) { this.setAttribute("srclang", value); }
|
||||||
|
get label() { return this.getAttribute("label") || ""; }
|
||||||
|
set label(value) { this.setAttribute("label", value); }
|
||||||
|
get default() { return this.hasAttribute("default"); }
|
||||||
|
set default(value) { value ? this.setAttribute("default", "") : this.removeAttribute("default"); }
|
||||||
|
get readyState() { return HTMLTrackElement.LOADED; }
|
||||||
|
get track() {
|
||||||
|
if (!this._textTrack) {
|
||||||
|
this._textTrack = new TextTrack(this, this.kind, this.label, this.srclang);
|
||||||
|
}
|
||||||
|
return this._textTrack;
|
||||||
|
}
|
||||||
|
}
|
||||||
globalThis.HTMLMediaElement = HTMLMediaElement;
|
globalThis.HTMLMediaElement = HTMLMediaElement;
|
||||||
globalThis.HTMLVideoElement = HTMLVideoElement;
|
globalThis.HTMLVideoElement = HTMLVideoElement;
|
||||||
globalThis.HTMLAudioElement = HTMLAudioElement;
|
globalThis.HTMLAudioElement = HTMLAudioElement;
|
||||||
|
globalThis.HTMLTrackElement = HTMLTrackElement;
|
||||||
|
globalThis.TextTrack = TextTrack;
|
||||||
|
globalThis.TextTrackList = TextTrackList;
|
||||||
|
globalThis.TextTrackCue = TextTrackCue;
|
||||||
|
globalThis.TextTrackCueList = TextTrackCueList;
|
||||||
|
globalThis.VTTCue = VTTCue;
|
||||||
|
|
||||||
function _elementClassFor(nid) {
|
function _elementClassFor(nid) {
|
||||||
const tag = _domParse("tag_name", nid);
|
const tag = _domParse("tag_name", nid);
|
||||||
if (tag === "FORM" && globalThis.HTMLFormElement) return globalThis.HTMLFormElement;
|
if (tag === "FORM" && globalThis.HTMLFormElement) return globalThis.HTMLFormElement;
|
||||||
if (tag === "AUDIO") return HTMLAudioElement;
|
if (tag === "AUDIO") return HTMLAudioElement;
|
||||||
if (tag === "VIDEO") return HTMLVideoElement;
|
if (tag === "VIDEO") return HTMLVideoElement;
|
||||||
|
if (tag === "TRACK") return HTMLTrackElement;
|
||||||
return Element;
|
return Element;
|
||||||
}
|
}
|
||||||
function _wrap(nid) {
|
function _wrap(nid) {
|
||||||
|
|||||||
@@ -4513,4 +4513,44 @@ mod tests {
|
|||||||
])
|
])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn media_text_tracks_expose_loaded_webvtt_cues() {
|
||||||
|
let mut rt = setup_runtime(
|
||||||
|
r#"<video><track id="captions" kind="captions" srclang="en" default
|
||||||
|
src="data:text/vtt,WEBVTT%0A%0A00%3A00%3A01.000%20--%3E%2000%3A00%3A03.000%0AHello"></video>"#,
|
||||||
|
);
|
||||||
|
let result = rt
|
||||||
|
.evaluate(
|
||||||
|
r#"
|
||||||
|
const element = document.getElementById("captions");
|
||||||
|
const video = document.querySelector("video");
|
||||||
|
const cue = element.track.cues[0];
|
||||||
|
const added = video.addTextTrack("metadata", "Data", "en");
|
||||||
|
cue.line = -2;
|
||||||
|
cue.size = 80;
|
||||||
|
return [
|
||||||
|
element instanceof HTMLTrackElement,
|
||||||
|
element.readyState === HTMLTrackElement.LOADED,
|
||||||
|
element.track instanceof TextTrack,
|
||||||
|
element.track.cues.length,
|
||||||
|
cue.startTime,
|
||||||
|
cue.endTime,
|
||||||
|
cue.text,
|
||||||
|
cue.line,
|
||||||
|
cue.size,
|
||||||
|
video.textTracks.length,
|
||||||
|
video.textTracks.getTrackById("captions") === element.track,
|
||||||
|
added.kind,
|
||||||
|
];
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
serde_json::json!([
|
||||||
|
true, true, true, 1, 1, 3, "Hello", -2, 80, 1, true, "metadata"
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -167,6 +167,9 @@
|
|||||||
"max-height-replaced": [
|
"max-height-replaced": [
|
||||||
{"name": "max height clamps intrinsic aspect transfer", "color": "#1971c2", "x": 32, "y": 64, "width": 330, "height": 200}
|
{"name": "max height clamps intrinsic aspect transfer", "color": "#1971c2", "x": 32, "y": 64, "width": 330, "height": 200}
|
||||||
],
|
],
|
||||||
|
"media-text-track": [
|
||||||
|
{"name": "media text track exposes parsed cues", "color": "#18723a", "x": 0, "y": 0, "width": 420, "height": 120}
|
||||||
|
],
|
||||||
"mixed-content-block": [
|
"mixed-content-block": [
|
||||||
{"name": "full-width block child", "color": "#ccddff", "width": 400}
|
{"name": "full-width block child", "color": "#ccddff", "width": 400}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Media text track</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; }
|
||||||
|
video { display: none; }
|
||||||
|
#result {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 420px;
|
||||||
|
height: 120px;
|
||||||
|
padding: 32px;
|
||||||
|
background: #a22;
|
||||||
|
color: white;
|
||||||
|
font: 18px sans-serif;
|
||||||
|
}
|
||||||
|
#result.passed { background: #18723a; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<video>
|
||||||
|
<track id="captions" kind="captions" srclang="en" default
|
||||||
|
src="data:text/vtt,WEBVTT%0A%0A00%3A00%3A01.000%20--%3E%2000%3A00%3A03.000%0AHello">
|
||||||
|
</video>
|
||||||
|
<div id="result">text track unavailable</div>
|
||||||
|
<script>
|
||||||
|
const element = document.getElementById("captions");
|
||||||
|
function complete() {
|
||||||
|
const cue = element.track.cues && element.track.cues[0];
|
||||||
|
if (!cue) return;
|
||||||
|
cue.line = -2;
|
||||||
|
cue.size = 80;
|
||||||
|
const videoTrack = document.querySelector("video").textTracks[0];
|
||||||
|
if (videoTrack === element.track && cue.text === "Hello") {
|
||||||
|
const result = document.getElementById("result");
|
||||||
|
result.textContent = "media text track completed";
|
||||||
|
result.className = "passed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
complete();
|
||||||
|
element.addEventListener("load", complete, { once: true });
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user