mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-23 05:41:20 -08:00
refactor(dpip): [PKG-DPIP-03] own windows and DOM resources in strict TypeScript
This commit is contained in:
1 parent
202351934b
commit
80d37d66f8
31 files changed
+2335
-597
No files matched your search
@@ -4,191 +4,477 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
const style = ".artplayer-document-pip.art-video-player {\n width: 100% !important;\n height: 100% !important;\n}\n";
|
||||
function artplayerPluginDocumentPip(userOptions = {}) {
|
||||
const options = {
|
||||
width: 480,
|
||||
height: 270,
|
||||
fallbackToVideoPiP: true,
|
||||
placeholder: `Playing in Document Picture-in-Picture`,
|
||||
...userOptions
|
||||
function releaseAll(releases) {
|
||||
const errors = [];
|
||||
for (const release of releases) {
|
||||
try {
|
||||
release();
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
function createDelay() {
|
||||
let finish;
|
||||
const promise = new Promise((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
let timer = setTimeout(() => {
|
||||
timer = null;
|
||||
finish();
|
||||
}, 100);
|
||||
return {
|
||||
promise,
|
||||
cancel() {
|
||||
if (timer !== null)
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
finish();
|
||||
}
|
||||
};
|
||||
return (art) => {
|
||||
const isSupported = "documentPictureInPicture" in window && typeof window.documentPictureInPicture?.requestWindow === "function";
|
||||
const { proxy, icons, i18n, template: { $player }, constructor: { utils: { append, tooltip, addClass, removeClass, sleep } } } = art;
|
||||
const state = {
|
||||
win: null,
|
||||
originalParent: null,
|
||||
originalNext: null,
|
||||
currentDoc: null,
|
||||
placeholder: null
|
||||
};
|
||||
function copyStylesTo(targetDoc) {
|
||||
const base = targetDoc.createElement("style");
|
||||
base.textContent = `
|
||||
html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
|
||||
#__art_dpip_root { position:absolute; inset:0; display:flex; }
|
||||
#__art_dpip_root > * { width:100% !important; height:100% !important; }
|
||||
`;
|
||||
targetDoc.head.appendChild(base);
|
||||
if (!targetDoc.querySelector('meta[name="viewport"]')) {
|
||||
const meta = targetDoc.createElement("meta");
|
||||
meta.name = "viewport";
|
||||
meta.content = "width=device-width,initial-scale=1";
|
||||
targetDoc.head.appendChild(meta);
|
||||
}
|
||||
}
|
||||
function createControl(art, toggle, alive) {
|
||||
const utils = art.constructor.utils;
|
||||
const releases = [];
|
||||
let closed = false;
|
||||
return {
|
||||
mount() {
|
||||
if (closed || !alive())
|
||||
return;
|
||||
releases.push(() => art.controls.remove("document-pip"));
|
||||
art.controls.add({
|
||||
name: "document-pip",
|
||||
position: "right",
|
||||
index: 40,
|
||||
tooltip: art.i18n.get("PIP Mode"),
|
||||
mounted: (control) => {
|
||||
if (closed || !alive())
|
||||
return;
|
||||
utils.append(control, art.icons.pip);
|
||||
const click = () => {
|
||||
if (alive())
|
||||
toggle();
|
||||
};
|
||||
releases.push(() => control.removeEventListener("click", click));
|
||||
const unbind = art.proxy(control, "click", click);
|
||||
if (closed)
|
||||
unbind();
|
||||
else
|
||||
releases.push(unbind);
|
||||
if (closed || !alive())
|
||||
return;
|
||||
const update = (active) => {
|
||||
if (alive())
|
||||
utils.tooltip(control, art.i18n.get(active ? "Exit PIP Mode" : "PIP Mode"));
|
||||
};
|
||||
releases.push(() => art.off("document-pip", update));
|
||||
art.on("document-pip", update);
|
||||
}
|
||||
});
|
||||
if (closed)
|
||||
art.controls.remove("document-pip");
|
||||
},
|
||||
destroy() {
|
||||
if (closed)
|
||||
return;
|
||||
closed = true;
|
||||
const errors = releaseAll(releases.splice(0).reverse());
|
||||
if (errors.length)
|
||||
throw errors[0];
|
||||
}
|
||||
};
|
||||
}
|
||||
const style = ".artplayer-document-pip.art-video-player {\n width: 100% !important;\n height: 100% !important;\n}\n";
|
||||
function installStyle(document2) {
|
||||
const id = "artplayer-plugin-document-pip";
|
||||
const existing = document2.getElementById(id);
|
||||
if (existing) {
|
||||
existing.textContent = style;
|
||||
return;
|
||||
}
|
||||
const element = document2.createElement("style");
|
||||
element.id = id;
|
||||
element.textContent = style;
|
||||
const insert = () => {
|
||||
document2.removeEventListener("DOMContentLoaded", insert);
|
||||
const current = document2.getElementById(id);
|
||||
if (current)
|
||||
current.textContent = style;
|
||||
else
|
||||
(document2.head || document2.documentElement).appendChild(element);
|
||||
};
|
||||
if (document2.readyState === "loading")
|
||||
document2.addEventListener("DOMContentLoaded", insert);
|
||||
else
|
||||
insert();
|
||||
}
|
||||
function copyStyles(source, target, own, alive) {
|
||||
const append = (node) => {
|
||||
if (!alive())
|
||||
return;
|
||||
own(node);
|
||||
target.head.appendChild(node);
|
||||
if (!alive())
|
||||
node.remove();
|
||||
};
|
||||
const base = target.createElement("style");
|
||||
base.textContent = `
|
||||
html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
|
||||
#__art_dpip_root { position:absolute; inset:0; display:flex; }
|
||||
#__art_dpip_root > * { width:100% !important; height:100% !important; }
|
||||
`;
|
||||
append(base);
|
||||
if (!target.querySelector('meta[name="viewport"]')) {
|
||||
const meta = target.createElement("meta");
|
||||
meta.name = "viewport";
|
||||
meta.content = "width=device-width,initial-scale=1";
|
||||
append(meta);
|
||||
}
|
||||
try {
|
||||
source.querySelectorAll('link[rel="stylesheet"]').forEach((link) => {
|
||||
const clone = target.createElement("link");
|
||||
clone.rel = "stylesheet";
|
||||
clone.href = link.href;
|
||||
if (link.media)
|
||||
clone.media = link.media;
|
||||
if (link.crossOrigin)
|
||||
clone.crossOrigin = link.crossOrigin;
|
||||
if (link.referrerPolicy)
|
||||
clone.referrerPolicy = link.referrerPolicy;
|
||||
append(clone);
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
try {
|
||||
source.querySelectorAll("style").forEach((element) => {
|
||||
const clone = target.createElement("style");
|
||||
clone.textContent = element.textContent;
|
||||
append(clone);
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
function createProjection(player, target, text) {
|
||||
const source = player.ownerDocument;
|
||||
const parent = player.parentNode;
|
||||
const next = player.nextSibling;
|
||||
if (!parent)
|
||||
throw new Error("Document Picture-in-Picture requires a mounted player");
|
||||
const placeholder = source.createElement("div");
|
||||
placeholder.className = "artplayer-document-pip-placeholder";
|
||||
placeholder.style.cssText = "display:flex;justify-content:center;align-items:center;width:100%;height:100%;";
|
||||
placeholder.textContent = text ?? "";
|
||||
const root = target.createElement("div");
|
||||
root.id = "__art_dpip_root";
|
||||
const nodes = [];
|
||||
let closed = false;
|
||||
const restore = () => {
|
||||
const anchor = placeholder.parentNode === parent ? placeholder : next?.parentNode === parent ? next : null;
|
||||
if (player.parentNode !== parent || player.nextSibling !== anchor) {
|
||||
try {
|
||||
document.querySelectorAll('link[rel="stylesheet"]').forEach((link) => {
|
||||
const clone = targetDoc.createElement("link");
|
||||
clone.rel = "stylesheet";
|
||||
clone.href = link.href;
|
||||
if (link.media)
|
||||
clone.media = link.media;
|
||||
if (link.crossOrigin)
|
||||
clone.crossOrigin = link.crossOrigin;
|
||||
if (link.referrerPolicy)
|
||||
clone.referrerPolicy = link.referrerPolicy;
|
||||
targetDoc.head.appendChild(clone);
|
||||
});
|
||||
} catch {
|
||||
}
|
||||
try {
|
||||
document.querySelectorAll("style").forEach((style2) => {
|
||||
const clone = targetDoc.createElement("style");
|
||||
clone.textContent = style2.textContent;
|
||||
targetDoc.head.appendChild(clone);
|
||||
});
|
||||
} catch {
|
||||
parent.insertBefore(player, anchor);
|
||||
} catch (error) {
|
||||
source.body.appendChild(player);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
const cleanup = () => {
|
||||
const errors = releaseAll([restore, () => placeholder.remove(), ...nodes.splice(0).map((node) => () => node.remove()), () => root.remove()]);
|
||||
if (errors.length)
|
||||
throw errors[0];
|
||||
};
|
||||
return {
|
||||
mount() {
|
||||
if (closed)
|
||||
return;
|
||||
parent.insertBefore(placeholder, next?.parentNode === parent ? next : null);
|
||||
if (!closed)
|
||||
target.body.appendChild(root);
|
||||
if (!closed)
|
||||
copyStyles(source, target, (node) => nodes.push(node), () => !closed);
|
||||
if (!closed) {
|
||||
const adopted = target.adoptNode(player);
|
||||
if (!closed)
|
||||
root.appendChild(adopted);
|
||||
}
|
||||
if (closed)
|
||||
cleanup();
|
||||
},
|
||||
destroy() {
|
||||
if (closed)
|
||||
return;
|
||||
closed = true;
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}
|
||||
function createWindowSession(hooks) {
|
||||
let destroyed = false;
|
||||
let revision = 0;
|
||||
let session = null;
|
||||
let pending = null;
|
||||
let delay = null;
|
||||
const alive = () => !destroyed && hooks.alive();
|
||||
const valid = (value) => value === revision && alive();
|
||||
function invalidate() {
|
||||
revision++;
|
||||
pending?.finish();
|
||||
pending = null;
|
||||
delay?.cancel();
|
||||
delay = null;
|
||||
}
|
||||
function dispose(current) {
|
||||
const errors = releaseAll(current.releases.splice(0));
|
||||
errors.push(...releaseAll([() => current.window.close()]));
|
||||
return errors;
|
||||
}
|
||||
async function resizeLater(value) {
|
||||
if (!valid(value))
|
||||
return;
|
||||
const current = createDelay();
|
||||
delay = current;
|
||||
await current.promise;
|
||||
if (delay === current)
|
||||
delay = null;
|
||||
if (valid(value))
|
||||
hooks.resize();
|
||||
}
|
||||
async function close() {
|
||||
invalidate();
|
||||
const value = revision;
|
||||
const current = session;
|
||||
session = null;
|
||||
if (!current)
|
||||
return;
|
||||
const errors = dispose(current);
|
||||
if (current.announced) {
|
||||
try {
|
||||
hooks.activate(false);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
if (errors.length && alive())
|
||||
hooks.report("close", errors[0]);
|
||||
if (valid(value))
|
||||
await resizeLater(value);
|
||||
}
|
||||
async function open() {
|
||||
if (!alive() || session)
|
||||
return;
|
||||
if (pending)
|
||||
return pending.promise;
|
||||
invalidate();
|
||||
const value = revision;
|
||||
let finish;
|
||||
let reject;
|
||||
const promise = new Promise((resolve, fail) => {
|
||||
finish = resolve;
|
||||
reject = fail;
|
||||
});
|
||||
const ticket = { promise, finish };
|
||||
pending = ticket;
|
||||
const settle = () => {
|
||||
if (pending === ticket)
|
||||
pending = null;
|
||||
finish();
|
||||
};
|
||||
const failed = (error) => {
|
||||
if (valid(value)) {
|
||||
const current = session;
|
||||
session = null;
|
||||
if (current) {
|
||||
dispose(current);
|
||||
if (current.announced)
|
||||
releaseAll([() => hooks.activate(false)]);
|
||||
}
|
||||
try {
|
||||
hooks.report("open", error);
|
||||
} catch (noticeError) {
|
||||
reject(noticeError);
|
||||
}
|
||||
}
|
||||
};
|
||||
const receive = async (window2) => {
|
||||
if (!valid(value)) {
|
||||
if (session?.window !== window2)
|
||||
releaseAll([() => window2.close()]);
|
||||
return;
|
||||
}
|
||||
const current = { window: window2, releases: [], announced: false };
|
||||
session = current;
|
||||
try {
|
||||
const projection = hooks.project(window2.document);
|
||||
current.releases.push(projection.destroy);
|
||||
projection.mount();
|
||||
if (!valid(value)) {
|
||||
dispose(current);
|
||||
return;
|
||||
}
|
||||
for (const [event, callback] of [["resize", () => {
|
||||
if (valid(value))
|
||||
hooks.nativeResize();
|
||||
}], ["pagehide", () => {
|
||||
void close();
|
||||
}], ["unload", () => {
|
||||
void close();
|
||||
}]]) {
|
||||
current.releases.unshift(() => window2.removeEventListener(event, callback));
|
||||
window2.addEventListener(event, callback);
|
||||
if (!valid(value)) {
|
||||
dispose(current);
|
||||
return;
|
||||
}
|
||||
}
|
||||
current.announced = true;
|
||||
hooks.activate(true);
|
||||
await resizeLater(value);
|
||||
} catch (error) {
|
||||
failed(error);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const request = hooks.request();
|
||||
void Promise.resolve(request).then(receive, failed).then(settle, (error) => {
|
||||
if (valid(value))
|
||||
reject(error);
|
||||
settle();
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
failed(error);
|
||||
} finally {
|
||||
settle();
|
||||
}
|
||||
}
|
||||
return promise;
|
||||
}
|
||||
return {
|
||||
get active() {
|
||||
return session !== null;
|
||||
},
|
||||
get opening() {
|
||||
return pending !== null;
|
||||
},
|
||||
open,
|
||||
close,
|
||||
destroy() {
|
||||
if (destroyed)
|
||||
return;
|
||||
destroyed = true;
|
||||
void close().catch(() => {
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
function artplayerPluginDocumentPip(userOptions = {}) {
|
||||
const options = { width: 480, height: 270, fallbackToVideoPiP: true, placeholder: "Playing in Document Picture-in-Picture", ...userOptions };
|
||||
return (art) => {
|
||||
const browser = window;
|
||||
const isSupported = "documentPictureInPicture" in browser && typeof browser.documentPictureInPicture?.requestWindow === "function";
|
||||
let host = art;
|
||||
let player = art.template.$player;
|
||||
let closed = art.isDestroy;
|
||||
const utils = art.constructor.utils;
|
||||
const alive = () => !closed && !!host && !host.isDestroy;
|
||||
const session = createWindowSession({
|
||||
alive,
|
||||
request: () => browser.documentPictureInPicture.requestWindow({ width: options.width, height: options.height }),
|
||||
project: (document2) => createProjection(player, document2, options.placeholder),
|
||||
activate(active) {
|
||||
if (!host || !player)
|
||||
return;
|
||||
if (active)
|
||||
utils.addClass(player, "artplayer-document-pip");
|
||||
else
|
||||
utils.removeClass(player, "artplayer-document-pip");
|
||||
if (!alive())
|
||||
return;
|
||||
host.events.bindGlobalEvents?.();
|
||||
if (alive())
|
||||
host.emit("document-pip", active);
|
||||
},
|
||||
resize: () => {
|
||||
if (alive())
|
||||
host.emit("resize");
|
||||
},
|
||||
nativeResize: () => {
|
||||
if (alive())
|
||||
host.resize?.();
|
||||
},
|
||||
report(action, error) {
|
||||
if (!alive())
|
||||
return;
|
||||
host.notice.show = `Document Picture-in-Picture ${action} failed`;
|
||||
console.warn(`[artplayer-plugin-document-pip] ${action} failed:`, error);
|
||||
}
|
||||
});
|
||||
async function open() {
|
||||
if (!alive())
|
||||
return;
|
||||
if (!isSupported && options.fallbackToVideoPiP) {
|
||||
art.pip = true;
|
||||
host.pip = true;
|
||||
console.warn("[artplayer-plugin-document-pip] Document Picture-in-Picture is not supported, falling back to Video Picture-in-Picture");
|
||||
return;
|
||||
}
|
||||
if (state.win)
|
||||
return;
|
||||
try {
|
||||
const pipWin = await window.documentPictureInPicture.requestWindow({
|
||||
width: options.width,
|
||||
height: options.height
|
||||
});
|
||||
state.win = pipWin;
|
||||
state.currentDoc = pipWin.document;
|
||||
state.placeholder = document.createElement("div");
|
||||
state.placeholder.className = "artplayer-document-pip-placeholder";
|
||||
state.placeholder.style.cssText = `display:flex;justify-content:center;align-items:center;width:100%;height:100%;`;
|
||||
state.placeholder.textContent = options.placeholder;
|
||||
state.originalParent = $player.parentNode;
|
||||
state.originalNext = $player.nextSibling;
|
||||
state.originalParent.insertBefore(state.placeholder, state.originalNext);
|
||||
const root = pipWin.document.createElement("div");
|
||||
root.id = "__art_dpip_root";
|
||||
pipWin.document.body.appendChild(root);
|
||||
copyStylesTo(pipWin.document);
|
||||
const adopted = pipWin.document.adoptNode($player);
|
||||
root.appendChild(adopted);
|
||||
state.currentDoc = pipWin.document;
|
||||
const onResize = () => art.resize?.();
|
||||
const onClose = () => close();
|
||||
pipWin.addEventListener("resize", onResize);
|
||||
pipWin.addEventListener("pagehide", onClose);
|
||||
pipWin.addEventListener("unload", onClose);
|
||||
state.cleanup = () => {
|
||||
pipWin.removeEventListener("resize", onResize);
|
||||
pipWin.removeEventListener("pagehide", onClose);
|
||||
pipWin.removeEventListener("unload", onClose);
|
||||
};
|
||||
addClass($player, "artplayer-document-pip");
|
||||
art.events.bindGlobalEvents?.();
|
||||
art.emit("document-pip", true);
|
||||
await sleep(100);
|
||||
art.emit("resize");
|
||||
} catch (err) {
|
||||
art.notice.show = "Document Picture-in-Picture open failed";
|
||||
console.warn("[artplayer-plugin-document-pip] open failed:", err);
|
||||
}
|
||||
}
|
||||
function restoreToOriginalDocument() {
|
||||
if (!state.placeholder)
|
||||
return;
|
||||
const backDoc = state.placeholder.ownerDocument || document;
|
||||
const adoptedBack = backDoc.adoptNode($player);
|
||||
state.originalParent.insertBefore(adoptedBack, state.placeholder);
|
||||
state.placeholder.remove();
|
||||
state.placeholder = null;
|
||||
state.currentDoc = backDoc;
|
||||
}
|
||||
async function close() {
|
||||
if (!state.win)
|
||||
return;
|
||||
try {
|
||||
state.cleanup?.();
|
||||
restoreToOriginalDocument();
|
||||
state.win.close();
|
||||
state.win = null;
|
||||
removeClass($player, "artplayer-document-pip");
|
||||
art.events.bindGlobalEvents?.();
|
||||
art.emit("document-pip", false);
|
||||
await sleep(100);
|
||||
art.emit("resize");
|
||||
} catch (err) {
|
||||
art.notice.show = "Document Picture-in-Picture close failed";
|
||||
console.warn("[artplayer-plugin-document-pip] close failed:", err);
|
||||
}
|
||||
return session.open();
|
||||
}
|
||||
function toggle() {
|
||||
if (state.win) {
|
||||
close();
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
if (!alive())
|
||||
return;
|
||||
if (session.active || session.opening)
|
||||
void session.close();
|
||||
else
|
||||
void open();
|
||||
}
|
||||
art.controls.add({
|
||||
name: "document-pip",
|
||||
position: "right",
|
||||
index: 40,
|
||||
tooltip: art.i18n.get("PIP Mode"),
|
||||
mounted: ($control) => {
|
||||
append($control, icons.pip);
|
||||
proxy($control, "click", toggle);
|
||||
art.on("document-pip", (value) => {
|
||||
tooltip($control, i18n.get(value ? "Exit PIP Mode" : "PIP Mode"));
|
||||
});
|
||||
}
|
||||
});
|
||||
art.on("destroy", close);
|
||||
return {
|
||||
let control = createControl(art, toggle, alive);
|
||||
function destroy() {
|
||||
if (closed)
|
||||
return;
|
||||
closed = true;
|
||||
const current = host;
|
||||
const previousControl = control;
|
||||
control = null;
|
||||
const errors = releaseAll([session.destroy, () => previousControl?.destroy(), () => current?.off("destroy", destroy)]);
|
||||
host = null;
|
||||
player = null;
|
||||
if (errors.length)
|
||||
console.warn("[artplayer-plugin-document-pip] cleanup failed:", errors[0]);
|
||||
}
|
||||
const result = {
|
||||
name: "artplayerPluginDocumentPip",
|
||||
get isSupported() {
|
||||
return isSupported;
|
||||
},
|
||||
get isActive() {
|
||||
return !!state.win;
|
||||
return session.active;
|
||||
},
|
||||
open,
|
||||
close,
|
||||
close: session.close,
|
||||
toggle
|
||||
};
|
||||
if (closed) {
|
||||
host = null;
|
||||
player = null;
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
art.on("destroy", destroy);
|
||||
if (alive())
|
||||
control?.mount();
|
||||
else
|
||||
art.off("destroy", destroy);
|
||||
} catch (error) {
|
||||
destroy();
|
||||
throw error;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
if (typeof document !== "undefined") {
|
||||
const id = "artplayer-plugin-document-pip";
|
||||
let $style = document.getElementById(id);
|
||||
if (!$style) {
|
||||
$style = document.createElement("style");
|
||||
$style.id = id;
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.head.appendChild($style);
|
||||
});
|
||||
} else {
|
||||
(document.head || document.documentElement).appendChild($style);
|
||||
}
|
||||
}
|
||||
$style.textContent = style;
|
||||
}
|
||||
if (typeof document !== "undefined")
|
||||
installStyle(document);
|
||||
export {
|
||||
artplayerPluginDocumentPip as default
|
||||
};
|
||||
Reference in new issue
Block a user