refactor(core): CORE-15 type subtitles and own request and track lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 07:23:10 +08:00
1 parent 64d1939daa
commit 1b83081e8f
28 files changed
+11286 -405

No files matched your search

+259 -104
View File
@@ -361,13 +361,13 @@ class ResourceScope {
throw new ResourceCleanupError(errors);
}
}
const states$1 = /* @__PURE__ */ new WeakMap();
const states$2 = /* @__PURE__ */ new WeakMap();
const containers = /* @__PURE__ */ new WeakMap();
function beginLifecycle(owner) {
states$1.set(owner, { scope: new ResourceScope(), destroying: false });
states$2.set(owner, { scope: new ResourceScope(), destroying: false });
}
function stateOf(owner) {
const state2 = states$1.get(owner);
const state2 = states$2.get(owner);
if (!state2)
throw new Error("ArtPlayer lifecycle has not been initialized");
return state2;
@@ -1433,6 +1433,15 @@ function animationFrame(scope, callback) {
});
return release;
}
function requestController(scope) {
if (typeof AbortController === "undefined")
return void 0;
const controller = new AbortController();
scope.add(() => {
controller.abort();
});
return controller;
}
function wait(scope, delay = 0) {
if (scope.closed)
return Promise.resolve(false);
@@ -5297,17 +5306,17 @@ function beginSettingAdd(item) {
}
};
}
const states = /* @__PURE__ */ new WeakMap();
const states$1 = /* @__PURE__ */ new WeakMap();
function rememberSettingState(item, capture) {
if (capture)
states.set(item, capture);
states$1.set(item, capture);
else
states.delete(item);
states$1.delete(item);
}
function captureSettingItem(item) {
const descriptors = new Map(Reflect.ownKeys(item).map((key) => [key, Object.getOwnPropertyDescriptor(item, key)]));
const nodes = [];
const state2 = states.get(item);
const state2 = states$1.get(item);
const restoreState = state2?.();
for (const key of ["html", "icon", "tooltip"]) {
const value = descriptors.get(key)?.value;
@@ -6134,142 +6143,288 @@ class Storage {
}
}
const css = ".art-video-player {\n --art-theme: #f00;\n --art-font-color: #fff;\n --art-background-color: #000;\n --art-text-shadow-color: rgba(0, 0, 0, 0.5);\n --art-transition-duration: 0.2s;\n --art-padding: 10px;\n --art-border-radius: 3px;\n --art-progress-height: 6px;\n --art-progress-color: rgba(255, 255, 255, 0.25);\n --art-progress-top-gap: 10px;\n --art-hover-color: rgba(255, 255, 255, 0.25);\n --art-loaded-color: rgba(255, 255, 255, 0.25);\n --art-state-size: 80px;\n --art-state-opacity: 0.8;\n --art-bottom-height: 100px;\n --art-bottom-offset: 20px;\n --art-bottom-gap: 5px;\n --art-highlight-width: 8px;\n --art-highlight-color: rgba(255, 255, 255, 0.5);\n --art-control-height: 46px;\n --art-control-opacity: 0.75;\n --art-control-icon-size: 36px;\n --art-control-icon-scale: 1.1;\n --art-volume-height: 120px;\n --art-volume-handle-size: 14px;\n --art-lock-size: 36px;\n --art-indicator-scale: 0;\n --art-indicator-size: 16px;\n --art-fullscreen-web-index: 9999;\n --art-settings-icon-size: 24px;\n --art-settings-max-height: 300px;\n --art-selector-max-height: 300px;\n --art-contextmenus-min-width: 250px;\n --art-subtitle-font-size: 20px;\n --art-subtitle-gap: 5px;\n --art-subtitle-bottom: 15px;\n --art-subtitle-border: #000;\n --art-widget-background: rgba(0, 0, 0, 0.85);\n --art-tip-background: rgba(0, 0, 0, 0.7);\n --art-scrollbar-size: 4px;\n --art-scrollbar-background: rgba(255, 255, 255, 0.25);\n --art-scrollbar-background-hover: rgba(255, 255, 255, 0.5);\n --art-mini-progress-height: 2px;\n}\n.art-bg-cover {\n background-position: center center;\n background-repeat: no-repeat;\n background-size: cover;\n}\n.art-bottom-gradient {\n background-image: linear-gradient(to top, #000, rgba(0, 0, 0, 0.4), transparent);\n background-repeat: repeat-x;\n background-position: center bottom;\n}\n.art-backdrop-filter {\n -webkit-backdrop-filter: saturate(180%) blur(20px);\n backdrop-filter: saturate(180%) blur(20px);\n background-color: rgba(0, 0, 0, 0.75) !important;\n}\n.art-truncate {\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.art-video-player {\n position: relative;\n margin: 0 auto;\n width: 100%;\n height: 100%;\n outline: 0;\n zoom: 1;\n padding: 0;\n text-align: left;\n direction: ltr;\n font-size: 14px;\n line-height: 1.3;\n user-select: none;\n box-sizing: border-box;\n color: var(--art-font-color);\n background-color: var(--art-background-color);\n text-shadow: 0 0 2px var(--art-text-shadow-color);\n font-family: PingFang SC, Helvetica Neue, Microsoft YaHei, Roboto, Arial, sans-serif;\n -webkit-tap-highlight-color: rgba(0, 0, 0, 0);\n -ms-touch-action: manipulation;\n touch-action: manipulation;\n -ms-high-contrast-adjust: none;\n}\n.art-video-player *,\n.art-video-player *::before,\n.art-video-player *::after {\n box-sizing: border-box;\n}\n.art-video-player ::-webkit-scrollbar {\n width: var(--art-scrollbar-size);\n height: var(--art-scrollbar-size);\n}\n.art-video-player ::-webkit-scrollbar-thumb {\n background-color: var(--art-scrollbar-background);\n}\n.art-video-player ::-webkit-scrollbar-thumb:hover {\n background-color: var(--art-scrollbar-background-hover);\n}\n.art-video-player img {\n max-width: 100%;\n vertical-align: top;\n}\n.art-video-player svg {\n fill: var(--art-font-color);\n}\n.art-video-player a {\n color: var(--art-font-color);\n text-decoration: none;\n}\n.art-icon {\n line-height: 1;\n display: flex;\n justify-content: center;\n align-items: center;\n}\n.art-video-player.art-backdrop .art-contextmenus,\n.art-video-player.art-backdrop .art-info,\n.art-video-player.art-backdrop .art-settings,\n.art-video-player.art-backdrop .art-layer-auto-playback,\n.art-video-player.art-backdrop .art-selector-list,\n.art-video-player.art-backdrop .art-volume-inner {\n -webkit-backdrop-filter: saturate(180%) blur(20px);\n backdrop-filter: saturate(180%) blur(20px);\n background-color: rgba(0, 0, 0, 0.75) !important;\n}\n.art-video {\n position: absolute;\n inset: 0;\n z-index: 10;\n width: 100%;\n height: 100%;\n}\n.art-poster {\n position: absolute;\n inset: 0;\n z-index: 11;\n width: 100%;\n height: 100%;\n background-position: center center;\n background-repeat: no-repeat;\n background-size: cover;\n pointer-events: none;\n}\n.art-video-player .art-subtitle {\n display: none;\n justify-content: center;\n align-items: center;\n flex-direction: column;\n position: absolute;\n z-index: 20;\n width: 100%;\n padding: 0 5%;\n text-align: center;\n pointer-events: none;\n gap: var(--art-subtitle-gap);\n bottom: var(--art-subtitle-bottom);\n font-size: var(--art-subtitle-font-size);\n transition: bottom var(--art-transition-duration) ease;\n text-shadow: var(--art-subtitle-border) 1px 0 1px, var(--art-subtitle-border) 0 1px 1px, var(--art-subtitle-border) -1px 0 1px, var(--art-subtitle-border) 0 -1px 1px, var(--art-subtitle-border) 1px 1px 1px,Line truncated
class Subtitle extends Component {
function parseSubtitle(buffer, option) {
const text = new TextDecoder(option.encoding).decode(buffer);
switch (option.type || getExt(option.url)) {
case "srt":
return { vtt: option.onVttLoad(srtToVtt(text)) };
case "ass":
return { vtt: option.onVttLoad(assToVtt(text)) };
case "vtt":
return { vtt: option.onVttLoad(text) };
default:
return void 0;
}
}
class SubtitleRequest {
constructor(parent) {
this.scope = parent.child();
this.cancelled = new Promise((resolve) => {
this.scope.add(() => {
resolve(void 0);
});
});
this.signal = requestController(this.scope)?.signal;
}
get active() {
return !this.scope.closed;
}
cancel() {
this.scope.dispose();
}
async run(work) {
if (!this.active)
return void 0;
try {
return await Promise.race([work(), this.cancelled]);
} finally {
this.cancel();
}
}
}
const states = /* @__PURE__ */ new WeakMap();
function initSubtitleState(subtitle, art) {
const scope = getScope(art).child();
const state2 = { scope, revision: 0, render: 0, trackRevision: 0 };
states.set(subtitle, state2);
scope.add(() => {
releaseSubtitleURL(state2);
});
return state2;
}
function subtitleState(subtitle) {
return states.get(subtitle);
}
function beginSubtitleRequest(state2) {
const previous = state2.request;
const request = new SubtitleRequest(state2.scope);
state2.request = request;
state2.revision += 1;
previous?.cancel();
return request;
}
function releaseSubtitleURL(state2) {
const url = state2.ownedURL;
state2.ownedURL = void 0;
if (url)
URL.revokeObjectURL(url);
}
function renderSubtitle(subtitle) {
const state2 = subtitleState(subtitle);
if (state2.scope.closed)
return;
const revision = state2.revision;
const render = ++state2.render;
const active = () => !state2.scope.closed && state2.revision === revision && state2.render === render;
const { art } = subtitle;
const { $subtitle } = art.template;
$subtitle.innerHTML = "";
if (!subtitle.activeCues.length)
return;
art.emit("subtitleBeforeUpdate", subtitle.activeCues);
if (!active())
return;
const html2 = subtitle.activeCues.map((cue, index) => cue.text.split(/\r?\n/).filter((line) => line.trim()).map((line) => `<div class="art-subtitle-line" data-group="${index}">
${art.option.subtitle.escape ? escape(line) : line}
</div>`).join("")).join("");
if (!active())
return;
$subtitle.innerHTML = html2;
art.emit("subtitleAfterUpdate", subtitle.activeCues);
}
function replaceSubtitleTrack(subtitle, kind, url) {
const state2 = subtitleState(subtitle);
if (state2.scope.closed)
return;
const revision = ++state2.trackRevision;
const { art } = subtitle;
const { template } = art;
const previous = template.$track;
const parent = previous.parentNode;
const next = previous.nextSibling;
const track = document.createElement("track");
const scope = state2.scope.child();
scope.add(() => {
track.onload = null;
track.onerror = null;
});
let committed = false;
try {
track.default = true;
track.kind = kind;
track.src = url;
track.label = art.option.subtitle.name || "Artplayer";
track.track.mode = "hidden";
track.onload = () => {
if (!scope.closed && template.$track === track)
art.emit("subtitleLoad", subtitle.cues, subtitle.option);
};
track.onerror = () => {
if (!scope.closed && template.$track === track)
art.notice.show = new Error("Failed to load subtitle track");
};
if (scope.closed || state2.trackRevision !== revision) {
scope.dispose();
return;
}
previous.remove();
template.$video.appendChild(track);
template.$track = track;
const textTrack = subtitle.textTrack;
const cleanup = textTrack ? art.proxy(textTrack, "cuechange", () => {
if (!scope.closed && template.$track === track)
subtitle.update();
}) : () => {
};
scope.add(() => {
art.events.remove(cleanup);
});
if (scope.closed || state2.trackRevision !== revision) {
scope.dispose();
track.remove();
return;
}
const previousScope = state2.track;
state2.track = scope;
subtitle.destroyEvent = cleanup;
previous.onload = null;
previous.onerror = null;
committed = true;
try {
previousScope?.dispose();
} finally {
if (state2.ownedURL && state2.ownedURL !== url)
releaseSubtitleURL(state2);
}
} catch (error2) {
if (committed)
throw error2;
scope.dispose();
track.remove();
if (state2.scope.closed || state2.trackRevision !== revision)
throw error2;
template.$track = previous;
if (parent)
parent.insertBefore(previous, next?.parentNode === parent ? next : null);
throw error2;
}
}
const SubtitleBase = Component;
class Subtitle extends SubtitleBase {
constructor(art) {
super(art);
this.name = "subtitle";
this.option = null;
this.destroyEvent = () => null;
this.init(art.option.subtitle);
const state2 = initSubtitleState(this, art);
void this.init(art.option.subtitle).catch(() => {
});
let lastState = false;
art.on("video:timeupdate", () => {
if (!this.url)
const timeupdate = () => {
if (state2.scope.closed || !this.url)
return;
const state2 = this.art.template.$video.webkitDisplayingFullscreen;
if (typeof state2 !== "boolean")
return;
if (state2 !== lastState) {
lastState = state2;
this.createTrack(state2 ? "subtitles" : "metadata", this.url);
const fullscreen2 = art.template.$video.webkitDisplayingFullscreen;
if (typeof fullscreen2 === "boolean" && fullscreen2 !== lastState) {
lastState = fullscreen2;
this.createTrack(fullscreen2 ? "subtitles" : "metadata", this.url);
}
};
art.on("video:timeupdate", timeupdate);
state2.scope.add(() => {
art.off("video:timeupdate", timeupdate);
});
}
get url() {
return this.art.template.$track.src;
}
set url(url) {
this.switch(url);
void this.switch(url).catch(() => {
});
}
get textTrack() {
return this.art.template.$video?.textTracks?.[0];
}
get activeCues() {
if (!this.textTrack)
return [];
return Array.from(this.textTrack.activeCues);
return Array.from(this.textTrack?.activeCues || []);
}
get cues() {
if (!this.textTrack)
return [];
return Array.from(this.textTrack.cues);
return Array.from(this.textTrack?.cues || []);
}
style(key, value) {
const { $subtitle } = this.art.template;
if (typeof key === "object") {
return setStyles($subtitle, key);
}
return setStyle($subtitle, key, value);
return typeof key === "object" ? setStyles($subtitle, key) : setStyle($subtitle, key, value);
}
update() {
const {
option: { subtitle },
template: { $subtitle }
} = this.art;
$subtitle.innerHTML = "";
if (!this.activeCues.length)
return;
this.art.emit("subtitleBeforeUpdate", this.activeCues);
$subtitle.innerHTML = this.activeCues.map(
(cue, index) => cue.text.split(/\r?\n/).filter((line) => line.trim()).map(
(line) => `<div class="art-subtitle-line" data-group="${index}">
${subtitle.escape ? escape(line) : line}
</div>`
).join("")
).join("");
this.art.emit("subtitleAfterUpdate", this.activeCues);
renderSubtitle(this);
}
async switch(url, newOption = {}) {
const { i18n, notice, option } = this.art;
const subtitleOption = { ...option.subtitle, ...newOption, url };
const state2 = subtitleState(this);
const revision = state2.revision + 1;
const subUrl = await this.init(subtitleOption);
if (newOption.name) {
if (!state2.scope.closed && state2.revision === revision && newOption.name)
notice.show = `${i18n.get("Switch Subtitle")}: ${newOption.name}`;
}
return subUrl;
}
createTrack(kind, url) {
const { template, proxy, option } = this.art;
const { $video, $track } = template;
const $newTrack = createElement("track");
$newTrack.default = true;
$newTrack.kind = kind;
$newTrack.src = url;
$newTrack.label = option.subtitle.name || "Artplayer";
$newTrack.track.mode = "hidden";
$newTrack.onload = () => {
this.art.emit("subtitleLoad", this.cues, this.option);
};
this.art.events.remove(this.destroyEvent);
$track.onload = null;
remove($track);
append($video, $newTrack);
template.$track = $newTrack;
this.destroyEvent = proxy(this.textTrack, "cuechange", () => this.update());
replaceSubtitleTrack(this, kind, url);
}
async init(subtitleOption) {
const {
notice,
template: { $subtitle }
} = this.art;
if (!this.textTrack)
return null;
validator(subtitleOption, scheme.subtitle);
if (!subtitleOption.url)
return;
this.option = subtitleOption;
this.style(subtitleOption.style);
return fetch(subtitleOption.url).then((response) => response.arrayBuffer()).then((buffer) => {
const decoder = new TextDecoder(subtitleOption.encoding);
const text = decoder.decode(buffer);
switch (subtitleOption.type || getExt(subtitleOption.url)) {
case "srt": {
const vtt = srtToVtt(text);
const vttNew = subtitleOption.onVttLoad(vtt);
return vttToBlob(vttNew);
const state2 = subtitleState(this);
const request = beginSubtitleRequest(state2);
const { notice, template: { $subtitle } } = this.art;
return request.run(async () => {
if (!this.textTrack)
return null;
validator(subtitleOption, scheme.subtitle);
if (!subtitleOption.url)
return void 0;
this.option = subtitleOption;
this.style(subtitleOption.style);
let generated;
try {
if (!request.active)
return void 0;
const response = await fetch(subtitleOption.url, { signal: request.signal });
if (!request.active)
return void 0;
if (!response.ok)
throw new Error(`Failed to load subtitle: ${response.status} ${response.statusText}`);
const buffer = await response.arrayBuffer();
if (!request.active)
return void 0;
const vtt = parseSubtitle(buffer, subtitleOption);
if (!request.active)
return void 0;
const subUrl = vtt === void 0 ? subtitleOption.url : generated = vttToBlob(vtt.vtt);
if (!request.active)
return void 0;
$subtitle.innerHTML = "";
const trackRevision = state2.trackRevision + 1;
if (this.url !== subUrl)
this.createTrack("metadata", subUrl);
if (!request.active || state2.trackRevision > trackRevision)
return void 0;
if (generated) {
state2.ownedURL = generated;
generated = void 0;
}
case "ass": {
const vtt = assToVtt(text);
const vttNew = subtitleOption.onVttLoad(vtt);
return vttToBlob(vttNew);
}
case "vtt": {
const vttNew = subtitleOption.onVttLoad(text);
return vttToBlob(vttNew);
}
default:
return subtitleOption.url;
}
}).then((subUrl) => {
$subtitle.innerHTML = "";
if (this.url === subUrl)
return subUrl;
URL.revokeObjectURL(this.url);
this.createTrack("metadata", subUrl);
return subUrl;
}).catch((err) => {
$subtitle.innerHTML = "";
notice.show = err;
throw err;
} catch (error2) {
if (!request.active)
return void 0;
$subtitle.innerHTML = "";
notice.show = error2;
throw error2;
} finally {
if (generated) {
if (!state2.scope.closed && this.url === generated)
state2.ownedURL = generated;
else
URL.revokeObjectURL(generated);
}
}
});
}
}