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();
|
||||
|
||||
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.
|
||||
// Values match Chrome 145 on Linux x86_64 without proprietary codecs.
|
||||
class HTMLMediaElement extends Element {
|
||||
@@ -3452,6 +3549,14 @@ class HTMLMediaElement extends Element {
|
||||
set muted(v) {}
|
||||
get src() { return this.getAttribute('src') || ''; }
|
||||
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.play);
|
||||
@@ -3459,15 +3564,45 @@ _markNative(HTMLMediaElement.prototype.load);
|
||||
_markNative(HTMLMediaElement.prototype.pause);
|
||||
class HTMLVideoElement 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.HTMLVideoElement = HTMLVideoElement;
|
||||
globalThis.HTMLAudioElement = HTMLAudioElement;
|
||||
globalThis.HTMLTrackElement = HTMLTrackElement;
|
||||
globalThis.TextTrack = TextTrack;
|
||||
globalThis.TextTrackList = TextTrackList;
|
||||
globalThis.TextTrackCue = TextTrackCue;
|
||||
globalThis.TextTrackCueList = TextTrackCueList;
|
||||
globalThis.VTTCue = VTTCue;
|
||||
|
||||
function _elementClassFor(nid) {
|
||||
const tag = _domParse("tag_name", nid);
|
||||
if (tag === "FORM" && globalThis.HTMLFormElement) return globalThis.HTMLFormElement;
|
||||
if (tag === "AUDIO") return HTMLAudioElement;
|
||||
if (tag === "VIDEO") return HTMLVideoElement;
|
||||
if (tag === "TRACK") return HTMLTrackElement;
|
||||
return Element;
|
||||
}
|
||||
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": [
|
||||
{"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": [
|
||||
{"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