mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-24 14:41:20 -08:00
refactor(core): CORE-15 type subtitles and own request and track lifetimes
This commit is contained in:
1 parent
64d1939daa
commit
1b83081e8f
28 files changed
+11286
-405
No files matched your search
+259
-104
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user