refactor(dpip): [PKG-DPIP-03] own windows and DOM resources in strict TypeScript

This commit is contained in:
Harvey Zhao committed 2026-09-12 14:22:09 +08:00
1 parent 202351934b
commit 80d37d66f8
31 files changed
+2335 -597

No files matched your search

@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(t.artplayerPluginDocumentPip=t(),define(function(){return t.artplayerPluginDocumentPip})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginDocumentPip=t()}(this,function(){"use strict";if("undefined"!==typeof document){const e="artplayer-plugin-document-pip";let t=document.getElementById(e);t||(t=document.createElement("style"),t.id=e,"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>{document.head.appendChild(t)}):(document.head||document.documentElement).appendChild(t)),t.textContent=".artplayer-document-pip.art-video-player{width:100%!important;height:100%!important}"}return function(e={}){const t={width:480,height:270,fallbackToVideoPiP:!0,placeholder:"Playing in Document Picture-in-Picture",...e};return e=>{const n="documentPictureInPicture"in window&&"function"===typeof window.documentPictureInPicture?.requestWindow,{proxy:i,icons:o,i18n:r,template:{$player:d},constructor:{utils:{append:l,tooltip:c,addClass:a,removeClass:p,sleep:u}}}=e,s={win:null,originalParent:null,originalNext:null,currentDoc:null,placeholder:null};async function m(){if(!n&&t.fallbackToVideoPiP)return e.pip=!0,void console.warn("[artplayer-plugin-document-pip] Document Picture-in-Picture is not supported, falling back to Video Picture-in-Picture");if(!s.win)try{const n=await window.documentPictureInPicture.requestWindow({width:t.width,height:t.height});s.win=n,s.currentDoc=n.document,s.placeholder=document.createElement("div"),s.placeholder.className="artplayer-document-pip-placeholder",s.placeholder.style.cssText="display:flex;justify-content:center;align-items:center;width:100%;height:100%;",s.placeholder.textContent=t.placeholder,s.originalParent=d.parentNode,s.originalNext=d.nextSibling,s.originalParent.insertBefore(s.placeholder,s.originalNext);const i=n.document.createElement("div");i.id="__art_dpip_root",n.document.body.appendChild(i),function(e){const t=e.createElement("style");if(t.textContent="\n html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }\n #__art_dpip_root { position:absolute; inset:0; display:flex; }\n #__art_dpip_root > * { width:100% !important; height:100% !important; }\n ",e.head.appendChild(t),!e.querySelector('meta[name="viewport"]')){const t=e.createElement("meta");t.name="viewport",t.content="width=device-width,initial-scale=1",e.head.appendChild(t)}try{document.querySelectorAll('link[rel="stylesheet"]').forEach(t=>{const n=e.createElement("link");n.rel="stylesheet",n.href=t.href,t.media&&(n.media=t.media),t.crossOrigin&&(n.crossOrigin=t.crossOrigin),t.referrerPolicy&&(n.referrerPolicy=t.referrerPolicy),e.head.appendChild(n)})}catch{}try{document.querySelectorAll("style").forEach(t=>{const n=e.createElement("style");n.textContent=t.textContent,e.head.appendChild(n)})}catch{}}(n.document);const o=n.document.adoptNode(d);i.appendChild(o),s.currentDoc=n.document;const r=()=>e.resize?.(),l=()=>h();n.addEventListener("resize",r),n.addEventListener("pagehide",l),n.addEventListener("unload",l),s.cleanup=()=>{n.removeEventListener("resize",r),n.removeEventListener("pagehide",l),n.removeEventListener("unload",l)},a(d,"artplayer-document-pip"),e.events.bindGlobalEvents?.(),e.emit("document-pip",!0),await u(100),e.emit("resize")}catch(i){e.notice.show="Document Picture-in-Picture open failed",console.warn("[artplayer-plugin-document-pip] open failed:",i)}}async function h(){if(s.win)try{s.cleanup?.(),function(){if(!s.placeholder)return;const e=s.placeholder.ownerDocument||document,t=e.adoptNode(d);s.originalParent.insertBefore(t,s.placeholder),s.placeholder.remove(),s.placeholder=null,s.currentDoc=e}(),s.win.close(),s.win=null,p(d,"artplayer-document-pip"),e.events.bindGlobalEvents?.(),e.emit("document-pip",!1),await u(100),e.emit("resize")}catch(t){e.notice.show="Document Picture-in-Picture close failed",console.warn("[artplayer-plugin-document-pip] close failed:",t)}}function f(){s.win?h():m()}return e.controls.add({name:"document-pip",position:"right",index:40,tooltip:e.i18n.get("PIP Mode"),mounted:t=>{l(t,o.pip),i(t,"click",f),e.on("document-pip",e=>{c(t,r.get(e?"Exit PIP Mode":"PIP Mode"))})}}),e.on("destroy",h),{name:"artplayerPluginDocumentPip",get isSupported(){return n},get isActive(){return!!s.win},open:m,close:h,toggle:f}}}});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginDocumentPip=t(),define(function(){return e.artplayerPluginDocumentPip})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginDocumentPip=t()}(this,function(){"use strict";function e(e){const t=[];for(const o of e)try{o()}catch(n){t.push(n)}return t}const t=".artplayer-document-pip.art-video-player{width:100%!important;height:100%!important}";function n(t){let n=!1,o=0,i=null,r=null,c=null;const l=()=>!n&&t.alive(),s=e=>e===o&&l();function u(){o++,r?.finish(),r=null,c?.cancel(),c=null}function a(t){const n=e(t.releases.splice(0));return n.push(...e([()=>t.window.close()])),n}async function d(e){if(!s(e))return;const n=function(){let e;const t=new Promise(t=>{e=t});let n=setTimeout(()=>{n=null,e()},100);return{promise:t,cancel(){null!==n&&clearTimeout(n),n=null,e()}}}();c=n,await n.promise,c===n&&(c=null),s(e)&&t.resize()}async function p(){u();const e=o,n=i;if(i=null,!n)return;const r=a(n);if(n.announced)try{t.activate(!1)}catch(c){r.push(c)}r.length&&l()&&t.report("close",r[0]),s(e)&&await d(e)}return{get active(){return null!==i},get opening(){return null!==r},open:async function(){if(!l()||i)return;if(r)return r.promise;u();const n=o;let c,f;const m=new Promise((e,t)=>{c=e,f=t}),h={promise:m,finish:c};r=h;const y=()=>{r===h&&(r=null),c()},g=o=>{if(s(n)){const n=i;i=null,n&&(a(n),n.announced&&e([()=>t.activate(!1)]));try{t.report("open",o)}catch(r){f(r)}}},v=async o=>{if(!s(n))return void(i?.window!==o&&e([()=>o.close()]));const r={window:o,releases:[],announced:!1};i=r;try{const e=t.project(o.document);if(r.releases.push(e.destroy),e.mount(),!s(n))return void a(r);for(const[i,c]of[["resize",()=>{s(n)&&t.nativeResize()}],["pagehide",()=>{p()}],["unload",()=>{p()}]])if(r.releases.unshift(()=>o.removeEventListener(i,c)),o.addEventListener(i,c),!s(n))return void a(r);r.announced=!0,t.activate(!0),await d(n)}catch(c){g(c)}};try{const e=t.request();Promise.resolve(e).then(v,g).then(y,e=>{s(n)&&f(e),y()})}catch(w){try{g(w)}finally{y()}}return m},close:p,destroy(){n||(n=!0,p().catch(()=>{}))}}}return"undefined"!==typeof document&&function(e){const n="artplayer-plugin-document-pip",o=e.getElementById(n);if(o)return void(o.textContent=t);const i=e.createElement("style");i.id=n,i.textContent=t;const r=()=>{e.removeEventListener("DOMContentLoaded",r);const o=e.getElementById(n);o?o.textContent=t:(e.head||e.documentElement).appendChild(i)};"loading"===e.readyState?e.addEventListener("DOMContentLoaded",r):r()}(document),function(t={}){const o={width:480,height:270,fallbackToVideoPiP:!0,placeholder:"Playing in Document Picture-in-Picture",...t};return t=>{const i=window,r="documentPictureInPicture"in i&&"function"===typeof i.documentPictureInPicture?.requestWindow;let c=t,l=t.template.$player,s=t.isDestroy;const u=t.constructor.utils,a=()=>!s&&!!c&&!c.isDestroy,d=n({alive:a,request:()=>i.documentPictureInPicture.requestWindow({width:o.width,height:o.height}),project:t=>function(t,n,o){const i=t.ownerDocument,r=t.parentNode,c=t.nextSibling;if(!r)throw new Error("Document Picture-in-Picture requires a mounted player");const l=i.createElement("div");l.className="artplayer-document-pip-placeholder",l.style.cssText="display:flex;justify-content:center;align-items:center;width:100%;height:100%;",l.textContent=o??"";const s=n.createElement("div");s.id="__art_dpip_root";const u=[];let a=!1;const d=()=>{const e=l.parentNode===r?l:c?.parentNode===r?c:null;if(t.parentNode!==r||t.nextSibling!==e)try{r.insertBefore(t,e)}catch(n){throw i.body.appendChild(t),n}},p=()=>{const t=e([d,()=>l.remove(),...u.splice(0).map(e=>()=>e.remove()),()=>s.remove()]);if(t.length)throw t[0]};return{mount(){if(!a){if(r.insertBefore(l,c?.parentNode===r?c:null),a||n.body.appendChild(s),a||function(e,t,n,o){const i=e=>{o()&&(n(e),t.head.appendChild(e),o()||e.remove())},r=t.createElement("style");if(r.textContent="\n html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }\n #__art_dpip_root { position:absolute; inset:0; display:flex; }\n #__art_dpip_root > * { width:100% !important; height:100% !important; }\n ",i(r),!t.querySelector('meta[name="viewport"]')){const e=t.createElement("meta");e.name="viewport",e.content="width=device-width,initial-scale=1",i(e)}try{e.querySelectorAll('link[rel="stylesheet"]').forEach(e=>{const n=t.createElement("link");n.rel="stylesheet",n.href=e.href,e.media&&(n.media=e.media),e.crossOrigin&&(n.crossOrigin=e.crossOrigin),e.referrerPolicy&&(n.referrerPolicy=e.referrerPolicy),i(n)})}catch{}try{e.querySelectorAll("style").forEach(e=>{const n=t.createElement("style");n.textContent=e.textContent,i(n)})}catch{}}(i,n,e=>u.push(e),()=>!a),!a){const e=n.adoptNode(t);a||s.appendChild(e)}a&&p()}},destroy(){a||(a=!0,p())}}}(l,t,o.placeholder),activate(e){c&&l&&(e?u.addClass(l,"artplayer-document-pip"):u.removeClass(l,"artplayerLine truncated
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(t.artplayerPluginDocumentPip=t(),define(function(){return t.artplayerPluginDocumentPip})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginDocumentPip=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,o=(t,n,i)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:i}):t[n]=i,r=(e,t,n)=>new Promise((i,o)=>{var r=e=>{try{c(n.next(e))}catch(t){o(t)}},l=e=>{try{c(n.throw(e))}catch(t){o(t)}},c=e=>e.done?i(e.value):Promise.resolve(e.value).then(r,l);c((n=n.apply(e,t)).next())});if("undefined"!==typeof document){const e="artplayer-plugin-document-pip";let t=document.getElementById(e);t||(t=document.createElement("style"),t.id=e,"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>{document.head.appendChild(t)}):(document.head||document.documentElement).appendChild(t)),t.textContent=".artplayer-document-pip.art-video-player{width:100%!important;height:100%!important}"}return function(e={}){const l=((e,r)=>{for(var l in r||(r={}))n.call(r,l)&&o(e,l,r[l]);if(t)for(var l of t(r))i.call(r,l)&&o(e,l,r[l]);return e})({width:480,height:270,fallbackToVideoPiP:!0,placeholder:"Playing in Document Picture-in-Picture"},e);return e=>{var t;const n="documentPictureInPicture"in window&&"function"===typeof(null==(t=window.documentPictureInPicture)?void 0:t.requestWindow),{proxy:i,icons:o,i18n:c,template:{$player:a},constructor:{utils:{append:d,tooltip:p,addClass:u,removeClass:s,sleep:m}}}=e,h={win:null,originalParent:null,originalNext:null,currentDoc:null,placeholder:null};function y(){return r(this,null,function*(){var t,i;if(!n&&l.fallbackToVideoPiP)return e.pip=!0,void console.warn("[artplayer-plugin-document-pip] Document Picture-in-Picture is not supported, falling back to Video Picture-in-Picture");if(!h.win)try{const n=yield window.documentPictureInPicture.requestWindow({width:l.width,height:l.height});h.win=n,h.currentDoc=n.document,h.placeholder=document.createElement("div"),h.placeholder.className="artplayer-document-pip-placeholder",h.placeholder.style.cssText="display:flex;justify-content:center;align-items:center;width:100%;height:100%;",h.placeholder.textContent=l.placeholder,h.originalParent=a.parentNode,h.originalNext=a.nextSibling,h.originalParent.insertBefore(h.placeholder,h.originalNext);const o=n.document.createElement("div");o.id="__art_dpip_root",n.document.body.appendChild(o),function(e){const t=e.createElement("style");if(t.textContent="\n html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }\n #__art_dpip_root { position:absolute; inset:0; display:flex; }\n #__art_dpip_root > * { width:100% !important; height:100% !important; }\n ",e.head.appendChild(t),!e.querySelector('meta[name="viewport"]')){const t=e.createElement("meta");t.name="viewport",t.content="width=device-width,initial-scale=1",e.head.appendChild(t)}try{document.querySelectorAll('link[rel="stylesheet"]').forEach(t=>{const n=e.createElement("link");n.rel="stylesheet",n.href=t.href,t.media&&(n.media=t.media),t.crossOrigin&&(n.crossOrigin=t.crossOrigin),t.referrerPolicy&&(n.referrerPolicy=t.referrerPolicy),e.head.appendChild(n)})}catch(n){}try{document.querySelectorAll("style").forEach(t=>{const n=e.createElement("style");n.textContent=t.textContent,e.head.appendChild(n)})}catch(n){}}(n.document);const r=n.document.adoptNode(a);o.appendChild(r),h.currentDoc=n.document;const c=()=>{var t;return null==(t=e.resize)?void 0:t.call(e)},d=()=>f();n.addEventListener("resize",c),n.addEventListener("pagehide",d),n.addEventListener("unload",d),h.cleanup=()=>{n.removeEventListener("resize",c),n.removeEventListener("pagehide",d),n.removeEventListener("unload",d)},u(a,"artplayer-document-pip"),null==(i=(t=e.events).bindGlobalEvents)||i.call(t),e.emit("document-pip",!0),yield m(100),e.emit("resize")}catch(o){e.notice.show="Document Picture-in-Picture open failed",console.warn("[artplayer-plugin-document-pip] open failed:",o)}})}function f(){return r(this,null,function*(){var t,n,i;if(h.win)try{null==(t=h.cleanup)||t.call(h),function(){if(!h.placeholder)return;const e=h.placeholder.ownerDocument||document,t=e.adoptNode(a);h.originalParent.insertBefore(t,h.placeholder),h.placeholder.remove(),h.placeholder=null,h.currentDoc=e}(),h.win.close(),h.win=null,s(a,"artplayer-document-pip"),null==(i=(n=e.events).bindGlobalEvents)||i.call(n),e.emit("document-pip",!1),yield m(100),e.emit("resize")}catch(o){e.notice.show="Document Picture-in-Picture close failed",console.warn("[artplayer-plugin-document-pip] close failed:",o)}})}function g(){h.win?f():y()}return e.controls.add({name:"document-pip",position:"right",index:40,tooltip:e.i18n.get("PIP Mode"),mounted:t=>{d(t,o.pip),i(t,"click",g),e.on("document-pip",Line truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginDocumentPip=t(),define(function(){return e.artplayerPluginDocumentPip})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginDocumentPip=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,o=Object.prototype.propertyIsEnumerable,r=(t,n,o)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[n]=o,i=(e,t,n)=>new Promise((o,r)=>{var i=e=>{try{c(n.next(e))}catch(t){r(t)}},l=e=>{try{c(n.throw(e))}catch(t){r(t)}},c=e=>e.done?o(e.value):Promise.resolve(e.value).then(i,l);c((n=n.apply(e,t)).next())});function l(e){const t=[];for(const o of e)try{o()}catch(n){t.push(n)}return t}const c=".artplayer-document-pip.art-video-player{width:100%!important;height:100%!important}";function u(e){let t=!1,n=0,o=null,r=null,c=null;const u=()=>!t&&e.alive(),s=e=>e===n&&u();function a(){n++,null==r||r.finish(),r=null,null==c||c.cancel(),c=null}function d(e){const t=l(e.releases.splice(0));return t.push(...l([()=>e.window.close()])),t}function p(t){return i(this,null,function*(){if(!s(t))return;const n=function(){let e;const t=new Promise(t=>{e=t});let n=setTimeout(()=>{n=null,e()},100);return{promise:t,cancel(){null!==n&&clearTimeout(n),n=null,e()}}}();c=n,yield n.promise,c===n&&(c=null),s(t)&&e.resize()})}function f(){return i(this,null,function*(){a();const t=n,r=o;if(o=null,!r)return;const i=d(r);if(r.announced)try{e.activate(!1)}catch(l){i.push(l)}i.length&&u()&&e.report("close",i[0]),s(t)&&(yield p(t))})}return{get active(){return null!==o},get opening(){return null!==r},open:function(){return i(this,null,function*(){if(!u()||o)return;if(r)return r.promise;a();const t=n;let c,m;const h=new Promise((e,t)=>{c=e,m=t}),y={promise:h,finish:c};r=y;const v=()=>{r===y&&(r=null),c()},g=n=>{if(s(t)){const t=o;o=null,t&&(d(t),t.announced&&l([()=>e.activate(!1)]));try{e.report("open",n)}catch(r){m(r)}}},P=n=>i(null,null,function*(){if(!s(t))return void((null==o?void 0:o.window)!==n&&l([()=>n.close()]));const r={window:n,releases:[],announced:!1};o=r;try{const o=e.project(n.document);if(r.releases.push(o.destroy),o.mount(),!s(t))return void d(r);for(const[i,l]of[["resize",()=>{s(t)&&e.nativeResize()}],["pagehide",()=>{f()}],["unload",()=>{f()}]])if(r.releases.unshift(()=>n.removeEventListener(i,l)),n.addEventListener(i,l),!s(t))return void d(r);r.announced=!0,e.activate(!0),yield p(t)}catch(i){g(i)}});try{const n=e.request();Promise.resolve(n).then(P,g).then(v,e=>{s(t)&&m(e),v()})}catch(w){try{g(w)}finally{v()}}return h})},close:f,destroy(){t||(t=!0,f().catch(()=>{}))}}}return"undefined"!==typeof document&&function(e){const t="artplayer-plugin-document-pip",n=e.getElementById(t);if(n)return void(n.textContent=c);const o=e.createElement("style");o.id=t,o.textContent=c;const r=()=>{e.removeEventListener("DOMContentLoaded",r);const n=e.getElementById(t);n?n.textContent=c:(e.head||e.documentElement).appendChild(o)};"loading"===e.readyState?e.addEventListener("DOMContentLoaded",r):r()}(document),function(e={}){const c=((e,i)=>{for(var l in i||(i={}))n.call(i,l)&&r(e,l,i[l]);if(t)for(var l of t(i))o.call(i,l)&&r(e,l,i[l]);return e})({width:480,height:270,fallbackToVideoPiP:!0,placeholder:"Playing in Document Picture-in-Picture"},e);return e=>{var t;const n=window,o="documentPictureInPicture"in n&&"function"===typeof(null==(t=n.documentPictureInPicture)?void 0:t.requestWindow);let r=e,s=e.template.$player,a=e.isDestroy;const d=e.constructor.utils,p=()=>!a&&!!r&&!r.isDestroy,f=u({alive:p,request:()=>n.documentPictureInPicture.requestWindow({width:c.width,height:c.height}),project:e=>function(e,t,n){const o=e.ownerDocument,r=e.parentNode,i=e.nextSibling;if(!r)throw new Error("Document Picture-in-Picture requires a mounted player");const c=o.createElement("div");c.className="artplayer-document-pip-placeholder",c.style.cssText="display:flex;justify-content:center;align-items:center;width:100%;height:100%;",c.textContent=null!=n?n:"";const u=t.createElement("div");u.id="__art_dpip_root";const s=[];let a=!1;const d=()=>{const t=c.parentNode===r?c:(null==i?void 0:i.parentNode)===r?i:null;if(e.parentNode!==r||e.nextSibling!==t)try{r.insertBefore(e,t)}catch(n){throw o.body.appendChild(e),n}},p=()=>{const e=l([d,()=>c.remove(),...s.splice(0).map(e=>()=>e.remove()),()=>u.remove()]);if(e.length)throw e[0]};return{mount(){if(!a){if(r.insertBefore(c,(null==i?void 0:i.parentNode)===r?i:null),a||t.body.appendChild(u),a||function(e,t,n,o){const r=e=>{o()&&(n(e),t.head.appendChild(e),o()||e.remove())},i=t.createElement("style");if(i.textContent="\n html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }\n #__art_dpip_root { position:absolute; inset:0; display:flex; }\n #__art_dpip_root > * { width:100% !important; height:100% !important; }\n ",r(i),!t.querySelectorLine truncated
+448 -162
View File
@@ -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
};
+2 -2
View File
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/dpip.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:unit": "node --test test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
@@ -74,7 +74,7 @@
"test:ambilight-types-package": "node refactor/scripts/ambilight-package-types.mjs",
"test:canvas": "node --test test/canvas.test.js test/canvas-lifecycle.test.js",
"test:canvas-types-package": "node refactor/scripts/canvas-package-types.mjs",
"test:dpip": "node --test test/dpip.test.js"
"test:dpip": "node --test test/dpip.test.js test/dpip-lifecycle.test.js"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
@@ -2,4 +2,5 @@ node_modules
.DS_Store
yarn.lock
package-lock.json
src
src
tsconfig.json
@@ -0,0 +1,72 @@
# Implementation and maintenance
The public factory still returns a synchronous plugin initializer. Its result has
`name`, readonly runtime getters `isSupported`/`isActive`, async `open`/`close`
returning `Promise<void>`, and synchronous `toggle` returning `undefined`.
The published declarations currently describe void actions and writable flags;
public declaration compatibility is tracked separately in PKG-DPIP-04.
| File | Responsibility |
| --- | --- |
| `src/index.ts` | Factory option snapshot, capability snapshot, core bridge, terminal plugin cleanup |
| `src/window-session.ts` | One pending request/active window, revisions, cancelled requests, window listeners and transition ordering |
| `src/projection.ts` | Original parent/sibling/document, placeholder, root, atomic restoration and rollback |
| `src/styles.ts` | Module style installation and best-effort copying from the player's actual document |
| `src/control.ts` | Existing control name/position/index, click and tooltip subscriptions, partial setup cleanup |
| `src/resources.ts` | Attempt-all cleanup and cancellable 100ms transition delay |
The session receives narrow callbacks and does not import Artplayer. Projection
and style modules only handle DOM ownership. Control and entry modules are the
core integration boundary. No shared runtime dependency on the new core is added.
`open()` calls the browser request synchronously to retain user activation. While
that request is pending, another open coalesces and toggle cancels it. `close()`
settles cancelled callers immediately; when the browser eventually returns its
window, the stale continuation closes it without adopting any player nodes.
An active window owns its listeners, projection and delay. Successful activation
adds the existing class, rebinds core document events and emits `document-pip(true)`;
resize follows after 100ms only if that transition is still current. Normal close
restores the same node, closes the window, removes the class, rebinds, emits false
and delays resize. Destroy is terminal and suppresses new events/timers/UI effects.
Projection snapshots the original document, parent and sibling before moving the
player. Restoration inserts directly into a validated original anchor; it does
not detach first. If the placeholder has moved, the saved sibling is used instead.
If the original parent rejects insertion, a best-effort fallback keeps the node
in the original document body and reports a close failure. This cannot guarantee
recovery if the original document also rejects insertion. Application mutation of
the placeholder does not transfer ownership of the player to another parent.
All acquired resources are registered before operations that may throw. Cleanup
attempts remaining resources after an error. Reentrant destruction during native
adoption or insertion also cleans nodes returned after the initial disposal.
The public module style stays shared for the document's lifetime; deferred
installation rechecks its existing id and removes its own loading listener.
Existing defaults, optional runtime options, names, CSS ids/classes, tooltip,
index40 and best-effort stylesheet attributes are retained. A throwing video PiP
fallback setter still rejects `open()`; normal window errors retain the existing
notice/warning behavior. Fixes to pending requests, partial rollback and stale
effects are intentional corrections to reproduced defects, not new public APIs.
Run from the repository root with the pinned Yarn toolchain:
```sh
yarn test:dpip
yarn typecheck
yarn build artplayer-plugin-document-pip
yarn test:browser test/browser/dpip.spec.js test/browser/dpip-lifecycle.spec.js
yarn dev artplayer-plugin-document-pip --no-open
```
The development example is `http://localhost:8082/?libs=./uncompiled/artplayer-plugin-document-pip/index.js&example=document.pip`.
Historical tests use actual archives and frozen Git source. Candidate lifecycle
tests can run against the frozen source with `ARTPLAYER_DPIP_BASELINE=1`; the known
failures must remain visible. `ARTPLAYER_DPIP_ARTIFACT` selects an explicit built
file for browser verification and never silently falls back to source.
The iframe matrix uses real DOM with controlled window APIs. It does not prove
native Document PiP activation, window focus/keyboard, continuous playback or
device support. Native video/Canvas/Mediabunny combinations and the observed
WebKit dimension reports remain PKG-DPIP-05. Full archive/demo/type acceptance is
PKG-DPIP-04/06. See the repository `refactor/dpip-validation.md` for exact evidence.
@@ -6,6 +6,12 @@ Document PIP plugin for ArtPlayer
[https://artplayer.org](https://artplayer.org/?libs=./uncompiled/artplayer-plugin-document-pip/index.js&example=document.pip)
## Maintenance
Source is split into strict TypeScript modules for window ownership, DOM migration,
styles and controls. See [ARCHITECTURE.md](ARCHITECTURE.md) for lifecycle behavior,
compatibility boundaries, tests and remaining native browser validation.
## License
MIT © Harvey Zhao
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(t.artplayerPluginDocumentPip=t(),define(function(){return t.artplayerPluginDocumentPip})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginDocumentPip=t()}(this,function(){"use strict";if("undefined"!==typeof document){const e="artplayer-plugin-document-pip";let t=document.getElementById(e);t||(t=document.createElement("style"),t.id=e,"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>{document.head.appendChild(t)}):(document.head||document.documentElement).appendChild(t)),t.textContent=".artplayer-document-pip.art-video-player{width:100%!important;height:100%!important}"}return function(e={}){const t={width:480,height:270,fallbackToVideoPiP:!0,placeholder:"Playing in Document Picture-in-Picture",...e};return e=>{const n="documentPictureInPicture"in window&&"function"===typeof window.documentPictureInPicture?.requestWindow,{proxy:i,icons:o,i18n:r,template:{$player:d},constructor:{utils:{append:l,tooltip:c,addClass:a,removeClass:p,sleep:u}}}=e,s={win:null,originalParent:null,originalNext:null,currentDoc:null,placeholder:null};async function m(){if(!n&&t.fallbackToVideoPiP)return e.pip=!0,void console.warn("[artplayer-plugin-document-pip] Document Picture-in-Picture is not supported, falling back to Video Picture-in-Picture");if(!s.win)try{const n=await window.documentPictureInPicture.requestWindow({width:t.width,height:t.height});s.win=n,s.currentDoc=n.document,s.placeholder=document.createElement("div"),s.placeholder.className="artplayer-document-pip-placeholder",s.placeholder.style.cssText="display:flex;justify-content:center;align-items:center;width:100%;height:100%;",s.placeholder.textContent=t.placeholder,s.originalParent=d.parentNode,s.originalNext=d.nextSibling,s.originalParent.insertBefore(s.placeholder,s.originalNext);const i=n.document.createElement("div");i.id="__art_dpip_root",n.document.body.appendChild(i),function(e){const t=e.createElement("style");if(t.textContent="\n html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }\n #__art_dpip_root { position:absolute; inset:0; display:flex; }\n #__art_dpip_root > * { width:100% !important; height:100% !important; }\n ",e.head.appendChild(t),!e.querySelector('meta[name="viewport"]')){const t=e.createElement("meta");t.name="viewport",t.content="width=device-width,initial-scale=1",e.head.appendChild(t)}try{document.querySelectorAll('link[rel="stylesheet"]').forEach(t=>{const n=e.createElement("link");n.rel="stylesheet",n.href=t.href,t.media&&(n.media=t.media),t.crossOrigin&&(n.crossOrigin=t.crossOrigin),t.referrerPolicy&&(n.referrerPolicy=t.referrerPolicy),e.head.appendChild(n)})}catch{}try{document.querySelectorAll("style").forEach(t=>{const n=e.createElement("style");n.textContent=t.textContent,e.head.appendChild(n)})}catch{}}(n.document);const o=n.document.adoptNode(d);i.appendChild(o),s.currentDoc=n.document;const r=()=>e.resize?.(),l=()=>h();n.addEventListener("resize",r),n.addEventListener("pagehide",l),n.addEventListener("unload",l),s.cleanup=()=>{n.removeEventListener("resize",r),n.removeEventListener("pagehide",l),n.removeEventListener("unload",l)},a(d,"artplayer-document-pip"),e.events.bindGlobalEvents?.(),e.emit("document-pip",!0),await u(100),e.emit("resize")}catch(i){e.notice.show="Document Picture-in-Picture open failed",console.warn("[artplayer-plugin-document-pip] open failed:",i)}}async function h(){if(s.win)try{s.cleanup?.(),function(){if(!s.placeholder)return;const e=s.placeholder.ownerDocument||document,t=e.adoptNode(d);s.originalParent.insertBefore(t,s.placeholder),s.placeholder.remove(),s.placeholder=null,s.currentDoc=e}(),s.win.close(),s.win=null,p(d,"artplayer-document-pip"),e.events.bindGlobalEvents?.(),e.emit("document-pip",!1),await u(100),e.emit("resize")}catch(t){e.notice.show="Document Picture-in-Picture close failed",console.warn("[artplayer-plugin-document-pip] close failed:",t)}}function f(){s.win?h():m()}return e.controls.add({name:"document-pip",position:"right",index:40,tooltip:e.i18n.get("PIP Mode"),mounted:t=>{l(t,o.pip),i(t,"click",f),e.on("document-pip",e=>{c(t,r.get(e?"Exit PIP Mode":"PIP Mode"))})}}),e.on("destroy",h),{name:"artplayerPluginDocumentPip",get isSupported(){return n},get isActive(){return!!s.win},open:m,close:h,toggle:f}}}});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginDocumentPip=t(),define(function(){return e.artplayerPluginDocumentPip})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginDocumentPip=t()}(this,function(){"use strict";function e(e){const t=[];for(const o of e)try{o()}catch(n){t.push(n)}return t}const t=".artplayer-document-pip.art-video-player{width:100%!important;height:100%!important}";function n(t){let n=!1,o=0,i=null,r=null,c=null;const l=()=>!n&&t.alive(),s=e=>e===o&&l();function u(){o++,r?.finish(),r=null,c?.cancel(),c=null}function a(t){const n=e(t.releases.splice(0));return n.push(...e([()=>t.window.close()])),n}async function d(e){if(!s(e))return;const n=function(){let e;const t=new Promise(t=>{e=t});let n=setTimeout(()=>{n=null,e()},100);return{promise:t,cancel(){null!==n&&clearTimeout(n),n=null,e()}}}();c=n,await n.promise,c===n&&(c=null),s(e)&&t.resize()}async function p(){u();const e=o,n=i;if(i=null,!n)return;const r=a(n);if(n.announced)try{t.activate(!1)}catch(c){r.push(c)}r.length&&l()&&t.report("close",r[0]),s(e)&&await d(e)}return{get active(){return null!==i},get opening(){return null!==r},open:async function(){if(!l()||i)return;if(r)return r.promise;u();const n=o;let c,f;const m=new Promise((e,t)=>{c=e,f=t}),h={promise:m,finish:c};r=h;const y=()=>{r===h&&(r=null),c()},g=o=>{if(s(n)){const n=i;i=null,n&&(a(n),n.announced&&e([()=>t.activate(!1)]));try{t.report("open",o)}catch(r){f(r)}}},v=async o=>{if(!s(n))return void(i?.window!==o&&e([()=>o.close()]));const r={window:o,releases:[],announced:!1};i=r;try{const e=t.project(o.document);if(r.releases.push(e.destroy),e.mount(),!s(n))return void a(r);for(const[i,c]of[["resize",()=>{s(n)&&t.nativeResize()}],["pagehide",()=>{p()}],["unload",()=>{p()}]])if(r.releases.unshift(()=>o.removeEventListener(i,c)),o.addEventListener(i,c),!s(n))return void a(r);r.announced=!0,t.activate(!0),await d(n)}catch(c){g(c)}};try{const e=t.request();Promise.resolve(e).then(v,g).then(y,e=>{s(n)&&f(e),y()})}catch(w){try{g(w)}finally{y()}}return m},close:p,destroy(){n||(n=!0,p().catch(()=>{}))}}}return"undefined"!==typeof document&&function(e){const n="artplayer-plugin-document-pip",o=e.getElementById(n);if(o)return void(o.textContent=t);const i=e.createElement("style");i.id=n,i.textContent=t;const r=()=>{e.removeEventListener("DOMContentLoaded",r);const o=e.getElementById(n);o?o.textContent=t:(e.head||e.documentElement).appendChild(i)};"loading"===e.readyState?e.addEventListener("DOMContentLoaded",r):r()}(document),function(t={}){const o={width:480,height:270,fallbackToVideoPiP:!0,placeholder:"Playing in Document Picture-in-Picture",...t};return t=>{const i=window,r="documentPictureInPicture"in i&&"function"===typeof i.documentPictureInPicture?.requestWindow;let c=t,l=t.template.$player,s=t.isDestroy;const u=t.constructor.utils,a=()=>!s&&!!c&&!c.isDestroy,d=n({alive:a,request:()=>i.documentPictureInPicture.requestWindow({width:o.width,height:o.height}),project:t=>function(t,n,o){const i=t.ownerDocument,r=t.parentNode,c=t.nextSibling;if(!r)throw new Error("Document Picture-in-Picture requires a mounted player");const l=i.createElement("div");l.className="artplayer-document-pip-placeholder",l.style.cssText="display:flex;justify-content:center;align-items:center;width:100%;height:100%;",l.textContent=o??"";const s=n.createElement("div");s.id="__art_dpip_root";const u=[];let a=!1;const d=()=>{const e=l.parentNode===r?l:c?.parentNode===r?c:null;if(t.parentNode!==r||t.nextSibling!==e)try{r.insertBefore(t,e)}catch(n){throw i.body.appendChild(t),n}},p=()=>{const t=e([d,()=>l.remove(),...u.splice(0).map(e=>()=>e.remove()),()=>s.remove()]);if(t.length)throw t[0]};return{mount(){if(!a){if(r.insertBefore(l,c?.parentNode===r?c:null),a||n.body.appendChild(s),a||function(e,t,n,o){const i=e=>{o()&&(n(e),t.head.appendChild(e),o()||e.remove())},r=t.createElement("style");if(r.textContent="\n html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }\n #__art_dpip_root { position:absolute; inset:0; display:flex; }\n #__art_dpip_root > * { width:100% !important; height:100% !important; }\n ",i(r),!t.querySelector('meta[name="viewport"]')){const e=t.createElement("meta");e.name="viewport",e.content="width=device-width,initial-scale=1",i(e)}try{e.querySelectorAll('link[rel="stylesheet"]').forEach(e=>{const n=t.createElement("link");n.rel="stylesheet",n.href=e.href,e.media&&(n.media=e.media),e.crossOrigin&&(n.crossOrigin=e.crossOrigin),e.referrerPolicy&&(n.referrerPolicy=e.referrerPolicy),i(n)})}catch{}try{e.querySelectorAll("style").forEach(e=>{const n=t.createElement("style");n.textContent=e.textContent,i(n)})}catch{}}(i,n,e=>u.push(e),()=>!a),!a){const e=n.adoptNode(t);a||s.appendChild(e)}a&&p()}},destroy(){a||(a=!0,p())}}}(l,t,o.placeholder),activate(e){c&&l&&(e?u.addClass(l,"artplayer-document-pip"):u.removeClass(l,"artplayerLine truncated
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(t.artplayerPluginDocumentPip=t(),define(function(){return t.artplayerPluginDocumentPip})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginDocumentPip=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,o=(t,n,i)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:i}):t[n]=i,r=(e,t,n)=>new Promise((i,o)=>{var r=e=>{try{c(n.next(e))}catch(t){o(t)}},l=e=>{try{c(n.throw(e))}catch(t){o(t)}},c=e=>e.done?i(e.value):Promise.resolve(e.value).then(r,l);c((n=n.apply(e,t)).next())});if("undefined"!==typeof document){const e="artplayer-plugin-document-pip";let t=document.getElementById(e);t||(t=document.createElement("style"),t.id=e,"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>{document.head.appendChild(t)}):(document.head||document.documentElement).appendChild(t)),t.textContent=".artplayer-document-pip.art-video-player{width:100%!important;height:100%!important}"}return function(e={}){const l=((e,r)=>{for(var l in r||(r={}))n.call(r,l)&&o(e,l,r[l]);if(t)for(var l of t(r))i.call(r,l)&&o(e,l,r[l]);return e})({width:480,height:270,fallbackToVideoPiP:!0,placeholder:"Playing in Document Picture-in-Picture"},e);return e=>{var t;const n="documentPictureInPicture"in window&&"function"===typeof(null==(t=window.documentPictureInPicture)?void 0:t.requestWindow),{proxy:i,icons:o,i18n:c,template:{$player:a},constructor:{utils:{append:d,tooltip:p,addClass:u,removeClass:s,sleep:m}}}=e,h={win:null,originalParent:null,originalNext:null,currentDoc:null,placeholder:null};function y(){return r(this,null,function*(){var t,i;if(!n&&l.fallbackToVideoPiP)return e.pip=!0,void console.warn("[artplayer-plugin-document-pip] Document Picture-in-Picture is not supported, falling back to Video Picture-in-Picture");if(!h.win)try{const n=yield window.documentPictureInPicture.requestWindow({width:l.width,height:l.height});h.win=n,h.currentDoc=n.document,h.placeholder=document.createElement("div"),h.placeholder.className="artplayer-document-pip-placeholder",h.placeholder.style.cssText="display:flex;justify-content:center;align-items:center;width:100%;height:100%;",h.placeholder.textContent=l.placeholder,h.originalParent=a.parentNode,h.originalNext=a.nextSibling,h.originalParent.insertBefore(h.placeholder,h.originalNext);const o=n.document.createElement("div");o.id="__art_dpip_root",n.document.body.appendChild(o),function(e){const t=e.createElement("style");if(t.textContent="\n html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }\n #__art_dpip_root { position:absolute; inset:0; display:flex; }\n #__art_dpip_root > * { width:100% !important; height:100% !important; }\n ",e.head.appendChild(t),!e.querySelector('meta[name="viewport"]')){const t=e.createElement("meta");t.name="viewport",t.content="width=device-width,initial-scale=1",e.head.appendChild(t)}try{document.querySelectorAll('link[rel="stylesheet"]').forEach(t=>{const n=e.createElement("link");n.rel="stylesheet",n.href=t.href,t.media&&(n.media=t.media),t.crossOrigin&&(n.crossOrigin=t.crossOrigin),t.referrerPolicy&&(n.referrerPolicy=t.referrerPolicy),e.head.appendChild(n)})}catch(n){}try{document.querySelectorAll("style").forEach(t=>{const n=e.createElement("style");n.textContent=t.textContent,e.head.appendChild(n)})}catch(n){}}(n.document);const r=n.document.adoptNode(a);o.appendChild(r),h.currentDoc=n.document;const c=()=>{var t;return null==(t=e.resize)?void 0:t.call(e)},d=()=>f();n.addEventListener("resize",c),n.addEventListener("pagehide",d),n.addEventListener("unload",d),h.cleanup=()=>{n.removeEventListener("resize",c),n.removeEventListener("pagehide",d),n.removeEventListener("unload",d)},u(a,"artplayer-document-pip"),null==(i=(t=e.events).bindGlobalEvents)||i.call(t),e.emit("document-pip",!0),yield m(100),e.emit("resize")}catch(o){e.notice.show="Document Picture-in-Picture open failed",console.warn("[artplayer-plugin-document-pip] open failed:",o)}})}function f(){return r(this,null,function*(){var t,n,i;if(h.win)try{null==(t=h.cleanup)||t.call(h),function(){if(!h.placeholder)return;const e=h.placeholder.ownerDocument||document,t=e.adoptNode(a);h.originalParent.insertBefore(t,h.placeholder),h.placeholder.remove(),h.placeholder=null,h.currentDoc=e}(),h.win.close(),h.win=null,s(a,"artplayer-document-pip"),null==(i=(n=e.events).bindGlobalEvents)||i.call(n),e.emit("document-pip",!1),yield m(100),e.emit("resize")}catch(o){e.notice.show="Document Picture-in-Picture close failed",console.warn("[artplayer-plugin-document-pip] close failed:",o)}})}function g(){h.win?f():y()}return e.controls.add({name:"document-pip",position:"right",index:40,tooltip:e.i18n.get("PIP Mode"),mounted:t=>{d(t,o.pip),i(t,"click",g),e.on("document-pip",Line truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginDocumentPip=t(),define(function(){return e.artplayerPluginDocumentPip})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginDocumentPip=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,o=Object.prototype.propertyIsEnumerable,r=(t,n,o)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[n]=o,i=(e,t,n)=>new Promise((o,r)=>{var i=e=>{try{c(n.next(e))}catch(t){r(t)}},l=e=>{try{c(n.throw(e))}catch(t){r(t)}},c=e=>e.done?o(e.value):Promise.resolve(e.value).then(i,l);c((n=n.apply(e,t)).next())});function l(e){const t=[];for(const o of e)try{o()}catch(n){t.push(n)}return t}const c=".artplayer-document-pip.art-video-player{width:100%!important;height:100%!important}";function u(e){let t=!1,n=0,o=null,r=null,c=null;const u=()=>!t&&e.alive(),s=e=>e===n&&u();function a(){n++,null==r||r.finish(),r=null,null==c||c.cancel(),c=null}function d(e){const t=l(e.releases.splice(0));return t.push(...l([()=>e.window.close()])),t}function p(t){return i(this,null,function*(){if(!s(t))return;const n=function(){let e;const t=new Promise(t=>{e=t});let n=setTimeout(()=>{n=null,e()},100);return{promise:t,cancel(){null!==n&&clearTimeout(n),n=null,e()}}}();c=n,yield n.promise,c===n&&(c=null),s(t)&&e.resize()})}function f(){return i(this,null,function*(){a();const t=n,r=o;if(o=null,!r)return;const i=d(r);if(r.announced)try{e.activate(!1)}catch(l){i.push(l)}i.length&&u()&&e.report("close",i[0]),s(t)&&(yield p(t))})}return{get active(){return null!==o},get opening(){return null!==r},open:function(){return i(this,null,function*(){if(!u()||o)return;if(r)return r.promise;a();const t=n;let c,m;const h=new Promise((e,t)=>{c=e,m=t}),y={promise:h,finish:c};r=y;const v=()=>{r===y&&(r=null),c()},g=n=>{if(s(t)){const t=o;o=null,t&&(d(t),t.announced&&l([()=>e.activate(!1)]));try{e.report("open",n)}catch(r){m(r)}}},P=n=>i(null,null,function*(){if(!s(t))return void((null==o?void 0:o.window)!==n&&l([()=>n.close()]));const r={window:n,releases:[],announced:!1};o=r;try{const o=e.project(n.document);if(r.releases.push(o.destroy),o.mount(),!s(t))return void d(r);for(const[i,l]of[["resize",()=>{s(t)&&e.nativeResize()}],["pagehide",()=>{f()}],["unload",()=>{f()}]])if(r.releases.unshift(()=>n.removeEventListener(i,l)),n.addEventListener(i,l),!s(t))return void d(r);r.announced=!0,e.activate(!0),yield p(t)}catch(i){g(i)}});try{const n=e.request();Promise.resolve(n).then(P,g).then(v,e=>{s(t)&&m(e),v()})}catch(w){try{g(w)}finally{v()}}return h})},close:f,destroy(){t||(t=!0,f().catch(()=>{}))}}}return"undefined"!==typeof document&&function(e){const t="artplayer-plugin-document-pip",n=e.getElementById(t);if(n)return void(n.textContent=c);const o=e.createElement("style");o.id=t,o.textContent=c;const r=()=>{e.removeEventListener("DOMContentLoaded",r);const n=e.getElementById(t);n?n.textContent=c:(e.head||e.documentElement).appendChild(o)};"loading"===e.readyState?e.addEventListener("DOMContentLoaded",r):r()}(document),function(e={}){const c=((e,i)=>{for(var l in i||(i={}))n.call(i,l)&&r(e,l,i[l]);if(t)for(var l of t(i))o.call(i,l)&&r(e,l,i[l]);return e})({width:480,height:270,fallbackToVideoPiP:!0,placeholder:"Playing in Document Picture-in-Picture"},e);return e=>{var t;const n=window,o="documentPictureInPicture"in n&&"function"===typeof(null==(t=n.documentPictureInPicture)?void 0:t.requestWindow);let r=e,s=e.template.$player,a=e.isDestroy;const d=e.constructor.utils,p=()=>!a&&!!r&&!r.isDestroy,f=u({alive:p,request:()=>n.documentPictureInPicture.requestWindow({width:c.width,height:c.height}),project:e=>function(e,t,n){const o=e.ownerDocument,r=e.parentNode,i=e.nextSibling;if(!r)throw new Error("Document Picture-in-Picture requires a mounted player");const c=o.createElement("div");c.className="artplayer-document-pip-placeholder",c.style.cssText="display:flex;justify-content:center;align-items:center;width:100%;height:100%;",c.textContent=null!=n?n:"";const u=t.createElement("div");u.id="__art_dpip_root";const s=[];let a=!1;const d=()=>{const t=c.parentNode===r?c:(null==i?void 0:i.parentNode)===r?i:null;if(e.parentNode!==r||e.nextSibling!==t)try{r.insertBefore(e,t)}catch(n){throw o.body.appendChild(e),n}},p=()=>{const e=l([d,()=>c.remove(),...s.splice(0).map(e=>()=>e.remove()),()=>u.remove()]);if(e.length)throw e[0]};return{mount(){if(!a){if(r.insertBefore(c,(null==i?void 0:i.parentNode)===r?i:null),a||t.body.appendChild(u),a||function(e,t,n,o){const r=e=>{o()&&(n(e),t.head.appendChild(e),o()||e.remove())},i=t.createElement("style");if(i.textContent="\n html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }\n #__art_dpip_root { position:absolute; inset:0; display:flex; }\n #__art_dpip_root > * { width:100% !important; height:100% !important; }\n ",r(i),!t.querySelectorLine truncated
@@ -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
};
@@ -0,0 +1,56 @@
import type Artplayer from 'artplayer'
import type { Disposer } from './resources'
import { releaseAll } from './resources'
export function createControl(art: Artplayer, toggle: Disposer, alive: () => boolean) {
const utils = (art.constructor as typeof Artplayer).utils
const releases: Disposer[] = []
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()
}
// Explicit removal also covers legacy proxy registration that throws after binding.
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: unknown) => {
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]
},
}
}
@@ -1,212 +0,0 @@
import style from './style.less?inline'
export default function artplayerPluginDocumentPip(userOptions = {}) {
const options = {
width: 480,
height: 270,
fallbackToVideoPiP: true,
placeholder: `Playing in Document Picture-in-Picture`,
...userOptions,
}
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)
}
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((style) => {
const clone = targetDoc.createElement('style')
clone.textContent = style.textContent
targetDoc.head.appendChild(clone)
})
}
catch { }
}
async function open() {
if (!isSupported && options.fallbackToVideoPiP) {
art.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)
}
}
function toggle() {
if (state.win) {
close()
}
else {
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 {
name: 'artplayerPluginDocumentPip',
get isSupported() { return isSupported },
get isActive() { return !!state.win },
open,
close,
toggle,
}
}
}
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
}
@@ -0,0 +1,115 @@
import type Artplayer from 'artplayer'
import type PublicPlugin from '../types/artplayer-plugin-document-pip'
import type { WindowApi } from './window-session'
import { createControl } from './control'
import { createProjection } from './projection'
import { releaseAll } from './resources'
import { installStyle } from './styles'
import { createWindowSession } from './window-session'
type Option = Parameters<typeof PublicPlugin>[0]
export default function artplayerPluginDocumentPip(userOptions: Option = {}) {
const options = { width: 480, height: 270, fallbackToVideoPiP: true, placeholder: 'Playing in Document Picture-in-Picture', ...userOptions }
return (art: Artplayer) => {
const browser = window as Window & { documentPictureInPicture?: WindowApi }
const isSupported = 'documentPictureInPicture' in browser && typeof browser.documentPictureInPicture?.requestWindow === 'function'
let host: (Artplayer & { resize?: () => void }) | null = art
let player: HTMLElement | null = art.template.$player
let closed = art.isDestroy
const utils = (art.constructor as typeof Artplayer).utils
const alive = () => !closed && !!host && !host.isDestroy
const session = createWindowSession({
alive,
request: () => browser.documentPictureInPicture!.requestWindow({ width: options.width, height: options.height }),
project: document => createProjection(player!, document, 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) {
host!.pip = true
console.warn('[artplayer-plugin-document-pip] Document Picture-in-Picture is not supported, falling back to Video Picture-in-Picture')
return
}
return session.open()
}
function toggle() {
if (!alive())
return
if (session.active || session.opening)
void session.close()
else
void open()
}
let control: ReturnType<typeof createControl> | null = 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' as const,
get isSupported() { return isSupported },
get isActive() { return session.active },
open,
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')
installStyle(document)
@@ -0,0 +1,61 @@
import { releaseAll } from './resources'
import { copyStyles } from './styles'
export function createProjection(player: HTMLElement, target: Document, text: string | undefined) {
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: HTMLElement[] = []
let closed = false
const restore = () => {
// Insertion adopts atomically; never detach the player before validating the anchor.
const anchor = placeholder.parentNode === parent ? placeholder : next?.parentNode === parent ? next : null
if (player.parentNode !== parent || player.nextSibling !== anchor) {
try {
parent.insertBefore(player, anchor)
}
catch (error) {
// Keep a recoverable node in the original document if its parent rejects insertion.
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()
},
}
}
@@ -0,0 +1,32 @@
export type Disposer = () => void
export function releaseAll(releases: Disposer[]): unknown[] {
const errors: unknown[] = []
for (const release of releases) {
try {
release()
}
catch (error) { errors.push(error) }
}
return errors
}
export function createDelay() {
let finish!: () => void
const promise = new Promise<void>((resolve) => {
finish = resolve
})
let timer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
timer = null
finish()
}, 100)
return {
promise,
cancel() {
if (timer !== null)
clearTimeout(timer)
timer = null
finish()
},
}
}
@@ -0,0 +1,72 @@
import style from './style.less?inline'
export function installStyle(document: Document): void {
const id = 'artplayer-plugin-document-pip'
const existing = document.getElementById(id)
if (existing) {
existing.textContent = style
return
}
const element = document.createElement('style')
element.id = id
element.textContent = style
const insert = () => {
document.removeEventListener('DOMContentLoaded', insert)
const current = document.getElementById(id)
if (current)
current.textContent = style
else
(document.head || document.documentElement).appendChild(element)
}
if (document.readyState === 'loading')
document.addEventListener('DOMContentLoaded', insert)
else
insert()
}
export function copyStyles(source: Document, target: Document, own: (node: HTMLElement) => void, alive: () => boolean): void {
const append = (node: HTMLElement) => {
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<HTMLLinkElement>('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 { /* Preserve best-effort copying when stylesheets are inaccessible. */ }
try {
source.querySelectorAll('style').forEach((element) => {
const clone = target.createElement('style')
clone.textContent = element.textContent
append(clone)
})
}
catch { /* A page may restrict access to its style nodes. */ }
}
@@ -0,0 +1,179 @@
import type { Disposer } from './resources'
import { createDelay, releaseAll } from './resources'
export interface WindowApi {
requestWindow: (option: { width?: number, height?: number }) => Promise<Window>
}
interface Hooks {
alive: () => boolean
request: () => Promise<Window>
project: (document: Document) => { mount: () => void, destroy: Disposer }
activate: (active: boolean) => void
resize: Disposer
nativeResize: Disposer
report: (action: 'open' | 'close', error: unknown) => void
}
interface Session {
window: Window
releases: Disposer[]
announced: boolean
}
export function createWindowSession(hooks: Hooks) {
let destroyed = false
let revision = 0
let session: Session | null = null
let pending: { promise: Promise<void>, finish: Disposer } | null = null
let delay: ReturnType<typeof createDelay> | null = null
const alive = () => !destroyed && hooks.alive()
const valid = (value: number) => value === revision && alive()
function invalidate() {
revision++
pending?.finish()
pending = null
delay?.cancel()
delay = null
}
function dispose(current: Session): unknown[] {
const errors = releaseAll(current.releases.splice(0))
errors.push(...releaseAll([() => current.window.close()]))
return errors
}
async function resizeLater(value: number) {
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!: Disposer
let reject!: (error: unknown) => void
const promise = new Promise<void>((resolve, fail) => {
finish = resolve
reject = fail
})
const ticket = { promise, finish }
pending = ticket
const settle = () => {
if (pending === ticket)
pending = null
finish()
}
const failed = (error: unknown) => {
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 (window: Window) => {
if (!valid(value)) {
// Only the window is retained by a stale request, never a live projection.
if (session?.window !== window)
releaseAll([() => window.close()])
return
}
const current: Session = { window, releases: [], announced: false }
session = current
try {
const projection = hooks.project(window.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()
}]] as const) {
current.releases.unshift(() => window.removeEventListener(event, callback))
window.addEventListener(event, callback)
if (!valid(value)) {
dispose(current)
return
}
}
current.announced = true
hooks.activate(true)
await resizeLater(value)
}
catch (error) { failed(error) }
}
try {
// Called directly while the caller's user activation is still available.
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
// close performs synchronous restoration; its delayed effects are now disabled.
void close().catch(() => {})
},
}
}
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "allowJs": false },
"include": ["src/**/*.ts", "types/**/*.d.ts", "../../types/assets.d.ts"]
}
@@ -0,0 +1,254 @@
{
"schemaVersion": 1,
"task": "PKG-DPIP-03",
"status": "done",
"checks": [
{
"command": "node --test test/dpip-lifecycle.test.js test/dpip.test.js refactor/scripts/dpip-contract.test.mjs",
"result": "pass",
"tests": 84,
"candidate": 20,
"historicalLifecycle": 48,
"historicalContract": 16
},
{
"command": "ARTPLAYER_DPIP_BASELINE=1 node --test test/dpip-lifecycle.test.js",
"result": "expected-historical-failures",
"passed": 3,
"failed": 17
},
{
"command": "yarn build artplayer-plugin-document-pip",
"result": "pass",
"formats": [
"main",
"legacy",
"esm"
],
"generatedFiles": 6
},
{
"command": "ARTPLAYER_DPIP_ARTIFACT=<main or legacy> node --test test/dpip-lifecycle.test.js",
"result": "pass",
"main": 20,
"legacy": 20
},
{
"command": "native Node ESM default import and initializer",
"result": "pass",
"scope": "actual ESM entry import shape; full installed consumer/type matrix remains task04"
},
{
"command": "playwright test test/browser/dpip.spec.js test/browser/dpip-lifecycle.spec.js",
"result": "pass",
"tests": 66,
"scope": "earlier source wide matrix: 48 historical plus 18 candidate; final source refinements are rechecked below with built main"
},
{
"command": "ARTPLAYER_DPIP_ARTIFACT=<main> playwright test test/browser/dpip-lifecycle.spec.js",
"result": "pass",
"tests": 18,
"skipped": 0,
"browsers": [
{
"project": "chromium",
"version": "153.0.8010.12",
"platform": "win32"
},
{
"project": "firefox",
"version": "155.0",
"platform": "win32"
},
{
"project": "webkit",
"version": "26.6",
"platform": "win32"
}
],
"scope": "final actual main artifact, two cores and three engines; native iframe DOM with controlled window APIs, not native Document PiP"
},
{
"command": "focused eslint --no-fix",
"result": "pass"
},
{
"command": "yarn ci:check",
"result": "pass",
"tests": {
"unit": 1006,
"engineering": 14,
"baseline": 116,
"mainTotal": 1136,
"repeatedContractExecutions": 44
},
"strictProductionTypeScriptFiles": 290
}
],
"limitations": [
"Public declarations and export compatibility remain task04; no inferred-type approval is borrowed from Ads.",
"The native DOM iframe matrix controls requestWindow and close; native Document PiP activation, media continuity, proxies and devices remain task05.",
"Restoration falls back to the original document body if the original parent rejects insertion, but cannot guarantee recovery if both reject insertion.",
"The full demo/archive acceptance remains task06."
],
"inputs": [
{
"file": "packages/artplayer-plugin-document-pip/src/control.ts",
"sha256LF": "9fe66b2fd59b50c05355d47a89af828b59eb9ad4e224237c72017170da7f7d65"
},
{
"file": "packages/artplayer-plugin-document-pip/src/index.ts",
"sha256LF": "cbf87010df6982f9b7ea39384f4120af380d26d3e051a96ee9db52eda8c7e1f8"
},
{
"file": "packages/artplayer-plugin-document-pip/src/projection.ts",
"sha256LF": "9a86780b54793427db448d7814d7e4a728d9f65ab3282bb6b476bb6ecedc731f"
},
{
"file": "packages/artplayer-plugin-document-pip/src/resources.ts",
"sha256LF": "6aa31086fd18104b176d6245ec297255c71993fe935db5b44ed649bedd33b8be"
},
{
"file": "packages/artplayer-plugin-document-pip/src/styles.ts",
"sha256LF": "7fed3354c81ccfd70cd70aba5afe8d1d78fa3f7022a3298c65446b5a0a0e6b96"
},
{
"file": "packages/artplayer-plugin-document-pip/src/window-session.ts",
"sha256LF": "cf6bc403caf22ecfea14da7bfa91ddb88a60ed0507d5485035abe48861e1ad17"
},
{
"file": "packages/artplayer-plugin-document-pip/src/style.less",
"sha256LF": "a75ae94a569c8fd6ce925dcc7ab6b22cfa39a4d81decb30cc447dd7108a84a22"
},
{
"file": "packages/artplayer-plugin-document-pip/tsconfig.json",
"sha256LF": "5b53a2bb158e81c5bfd3e2b50e750f7f6e20d0e9aaa6733facec036c15f28ebe"
},
{
"file": "packages/artplayer-plugin-document-pip/.npmignore",
"sha256LF": "145c5fa0341195e21cf13df3423a057282f8ea374e9a1b3065e2280e1ce1c5c1"
},
{
"file": "test/dpip-lifecycle.test.js",
"sha256LF": "bd9fb385a3bfd5d49695fbbaf5faf33d95d255cd63ef176758ccfd972e1f794c"
},
{
"file": "test/helpers/dpip.js",
"sha256LF": "36acf694e49472c927f7e8cf797fc21df27916cb5777b7d84dc280807948988e"
},
{
"file": "test/browser/dpip-helpers.js",
"sha256LF": "2ab9b30a60b809e3476462c3d89cf3eb31f74d321264a661e643321b5cf73d2a"
},
{
"file": "test/browser/dpip-lifecycle.spec.js",
"sha256LF": "813d8d3276283931b19f3046f93249116f3ff1bfe9e3782017b9d51f788166e0"
},
{
"file": "test/browser/dpip.spec.js",
"sha256LF": "fcf79924daca7b694b0265540f8e84355fc9ea81c6dd4d3d33f3749a0881e376"
},
{
"file": "package.json",
"sha256LF": "87ccb964b0247c4558f092ccb897084416f2d1a965703df7e28c1583885a134e"
}
],
"artifacts": [
{
"file": "packages/artplayer-plugin-document-pip/dist/artplayer-plugin-document-pip.js",
"sha256LF": "166931ad0c2c5b9696a4f76dbb441afb396874c1445830b8ed8a58e8e98167ab"
},
{
"file": "docs/compiled/artplayer-plugin-document-pip.js",
"sha256LF": "166931ad0c2c5b9696a4f76dbb441afb396874c1445830b8ed8a58e8e98167ab"
},
{
"file": "packages/artplayer-plugin-document-pip/dist/artplayer-plugin-document-pip.legacy.js",
"sha256LF": "d3f0cbe82ae5bccf571a8112b6803f24d2548c8b24ed839350c95a1f05dfbedb"
},
{
"file": "docs/compiled/artplayer-plugin-document-pip.legacy.js",
"sha256LF": "d3f0cbe82ae5bccf571a8112b6803f24d2548c8b24ed839350c95a1f05dfbedb"
},
{
"file": "packages/artplayer-plugin-document-pip/dist/artplayer-plugin-document-pip.mjs",
"sha256LF": "82b26dcba24d7c550c9edb5481bf2c80864cde09522c7f8b8743be63dbca4c3c"
},
{
"file": "docs/compiled/artplayer-plugin-document-pip.mjs",
"sha256LF": "82b26dcba24d7c550c9edb5481bf2c80864cde09522c7f8b8743be63dbca4c3c"
}
],
"parentCommit": "202351934b1d9511691dcd298c7882ee6fe2c9f6",
"mainSize": {
"previousBytes": 4624,
"candidateBytes": 6874,
"previousGzipBytes": 1776,
"candidateGzipBytes": 2831,
"note": "Additional request cancellation, transactional rollback and cleanup increase plugin size. This is not a full ecosystem performance acceptance."
},
"logs": [
{
"file": "refactor/.cache/dpip03-node-reentry.log",
"sha256": "92189cb71428649ab1687448a324958e002221fe350854d96c359e33c4455a40"
},
{
"file": "refactor/.cache/dpip03-notice-rejection.log",
"sha256": "af2db5c23135434c292ea8d1905aa5578bbcedd9fe5b70780594e2c6e0c69927"
},
{
"file": "refactor/.cache/dpip03-node-final.log",
"sha256": "b63a0f80492f87aa22ab82bdef8da8503fecfb6a3dd5a34bd758659f738bb14e"
},
{
"file": "refactor/.cache/dpip03-old-negative-final.log",
"sha256": "655223967f6d05d1226921923d648875924c62ca9fb2e0960e5c61d77b532cdb"
},
{
"file": "refactor/.cache/dpip03-build-final.log",
"sha256": "71e1ba8c478b29c7a020d8aa107d11bbfb69bf2f08d02a8ae96c45a685284817"
},
{
"file": "refactor/.cache/dpip03-main-lifecycle.log",
"sha256": "87424989382aabc285c18254f188d730edf30c7dc09e2e8f4b6be973cbdad1e4"
},
{
"file": "refactor/.cache/dpip03-legacy-lifecycle.log",
"sha256": "395aab100482170a1671c7652340193cecb2602ea7f0cc0f3ff9ab2b75413341"
},
{
"file": "refactor/.cache/dpip03-esm.log",
"sha256": "9df9f793dfccb511716a2d8ae3b3d9ccc616e1e8f547ebdde8245cafd4e898cb"
},
{
"file": "refactor/.cache/dpip03-lint-final.log",
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
},
{
"file": "refactor/.cache/dpip03-browser.log",
"sha256": "f51123de2b1553e56208893787bfbae15f12b26744513c0faa0529ba879e623a"
},
{
"file": "refactor/.cache/dpip03-browser-artifact-final.log",
"sha256": "a0dd85434da54fd3a10cf4c3de2e136d1da14d792dc615b66958122a440a3768"
},
{
"file": "refactor/.cache/dpip03-ci.log",
"sha256": "723c3c5014bc9fbc3f908c7973cbe6ecc44df8596e252034093ba9d601fb17dd"
},
{
"file": "refactor/.cache/dpip03-final-results/report.json",
"sha256": "c9b4822cda572b23e83ad8fef81b28302598f94eb65b367c85b2ab72f8c14722"
},
{
"file": "refactor/.cache/dpip03-wide-results/report.json",
"sha256": "92be3a027d3782b4336f541186dcc26ea9d728dc12e579df090279b2a6229b08"
}
],
"browserArtifacts": [
{
"file": "refactor/.cache/dpip03-final-results/results/.last-run.json",
"sha256": "91d1c43004802cd49950d78eb11c8fa7d05da8ffffe219a8b13b2f561bc00903"
}
]
}
@@ -0,0 +1,36 @@
# PKG-DPIP-03 严格 TypeScript 与窗口资源
原单文件迁为6个TS模块:index核心门面、window-session请求/窗口状态、projection节点
归属与还原、styles共享样式与跨文档复制、control控件监听器、resources延迟与清理。
分包严格tsconfig接入根typecheck;生产依赖和版本不变,公开声明留04独立验收。
三种构建产物通过仓库脚本重建,.npmignore排除分包tsconfig。
结构迁移保留同步注册和Result字段顺序、能力快照、工厂options浅拷贝、默认值、
右侧index40控件、CSS id/class、同步toggle、open/close Promise<void>和正常事件顺序。
未新增公开destroy方法。Window API类型为局部可选能力,不污染全局Window声明。
DOM/状态模块不依赖Artplayer,新核心仅通过既有接口集成,不提升插件最低核心依赖。
缺陷修复单独由候选测试覆盖:正在申请时coalesce、close/toggle撤销申请并提前结算
调用者、迟到窗口直接关闭、销毁终态、取消100ms定时器、事件重入/初始化失败回收、
adoption错误回滚、占位移动/删除后恢复原parent/sibling、原ownerDocument样式复制。
父节点拒绝插入时尽力恢复到原document.body并报告close失败;原document也拒绝插入
仍无法保证恢复,不声称任意DOM破坏都可以修复。所有清理逐项尝试,不因单一监听器
清理异常而跳过窗口和节点释放。销毁期间保留类清理/还原,禁止新事件/UI/延迟效果。
20项候选测试在旧冻结源码上3项正常契约通过、17项失败;候选全部通过。新加样式
插入重入测试曾发现第一版迁移残留3节点,现追加插入返回后的终态检查与二次清理。
首版记录和修正后记录都保留,不通过放宽断言隐藏失败。
浏览器宽矩阵66项(48历史+18候选)通过,随后释放control宿主引用的调整再次用
最终构建main产物运行18项候选验证。两实际核心为npm5.4.0和候选,三引擎均覆盖。
使用真实iframe DOM与受控窗口API;原生Document PiP/媒体连续性/代理设备组合仍05,
不能把这一步称为原生文档窗口验收。LIFE/DOM风险记录实现修复证据并保持原生验收
缺口开放;STYLE重复注入已有候选正反对照。类型和分发差异仍由04/06完成。
模块维护见包内ARCHITECTURE.md,测试入口与限制见[验证入口](../dpip-validation.md),
结果与哈希见[证据](../baselines/dpip-lifecycle-validation.json)。回退需整体回退src、
分包配置与同批生成产物,保留历史测试;不修改归档基线。不推送、发布或升级版本。
最终复查新增notice setter异常传播兼容用例,修复首版吞掉异常的回归;20项候选在旧源码上3正常通过/17失败。链路使用Promise.then结算,不新增Promise.finally能力要求。最后main产物浏览器与main/legacy受控生命周期重跑覆盖此修正。
最终完整CI1136项(1006单元+14工程+116基线)、44项重复契约观察与290个生产TS严格检查通过;main/legacy各20候选回归、最终main产物18项浏览器通过。main原始字节4624→6874,gzip1776→2831;资源正确性增加了体积,不宣称全项目性能门槛已经通过。本任务done并独立提交,04/05/06继续。
+11
View File
@@ -31,3 +31,14 @@ test/helpers/dpip.js是受控DOM/window边界:验证ownerDocument、节点身
当前WebKit的640宽读数与320宽fixture不同,精确保留为历史观察,待05查清尺寸和媒体
连续性;测试不会宽泛允许任意尺寸。初次错误与修正原因见[02记录](changes/2026-09-12-PKG-DPIP-02-tests.md),
验收状态见[02证据](baselines/dpip-behavior-validation.json)。03必须新增候选修复断言。
## TS实现与候选修复(03)
源码现拆为6个严格TS模块,真实文件地图见包内ARCHITECTURE.md。`yarn test:dpip`同时
执行48项历史与20项候选生命周期测试;`ARTPLAYER_DPIP_BASELINE=1`运行候选用例时,
保留旧源码17失败/3正常通过。正常契约另有16项test:baseline。
`test/browser/dpip-lifecycle.spec.js`为18项候选真实DOM验证;`dpip-helpers.js`共享受控
窗口入口,记录替换前能力,绝不冒充原生Document PiP。`ARTPLAYER_DPIP_ARTIFACT`
选择明确构建文件(不fallback);最终产物证据与宽矩阵历史证据分开保存。
本阶段保留原生窗口和媒体/代理验收门槛,详见[03记录](changes/2026-09-12-PKG-DPIP-03-lifecycle.md)
及[03证据](baselines/dpip-lifecycle-validation.json)。04继续公开类型/导出/安装消费者。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 221 项,范围 22 个包及工作区/示例。
状态:todo 120 / doing 6 / blocked 0 / done 95 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 119 / doing 6 / blocked 0 / done 96 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -295,7 +295,7 @@
| --- | --- | --- | --- | --- | --- | --- |
| PKG-DPIP-01 | artplayer-plugin-document-pip<br>核对包契约与历史用法 | BASE-05 | width/height/placeholder/fallbackToVideoPiP、open/close/toggle 返回 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done |
| PKG-DPIP-02 | artplayer-plugin-document-pip<br>建立特有行为与错误测试 | PKG-DPIP-01, ENG-03, ENG-05 | 不支持/拒绝、重复打开、pagehide、核心销毁和视频 PiP fallback | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done |
| PKG-DPIP-03 | artplayer-plugin-document-pip<br>整理内部职责与资源 | PKG-DPIP-02, CORE-16, CORE-17 | 窗口生命周期、DOM 迁移、样式与事件 document 重绑分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo |
| PKG-DPIP-03 | artplayer-plugin-document-pip<br>整理内部职责与资源 | PKG-DPIP-02, CORE-16, CORE-17 | 窗口生命周期、DOM 迁移、样式与事件 document 重绑分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | done |
| PKG-DPIP-04 | artplayer-plugin-document-pip<br>迁移自有源码和公开类型 | PKG-DPIP-03, ENG-04, ENG-06, CORE-07 | Document PiP 可选能力和真实状态/返回类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo |
| PKG-DPIP-05 | artplayer-plugin-document-pip<br>验证新旧核心和组合 | PKG-DPIP-04, CORE-22, PKG-CANVAS-04, PKG-MB-04 | 原生视频及两个 proxy、键盘/焦点/全屏与关闭还原 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo |
| PKG-DPIP-06 | artplayer-plugin-document-pip<br>验证分发并同步文档 | PKG-DPIP-05, ENG-07 | document.pip.js、浏览器能力矩阵、旧接口与恢复证据 | tarball 入口/资源、类型、8082 demo 和 README 一致,有回退记录 | H | todo |
@@ -508,6 +508,7 @@
- PKG-VAST-03: [记录](changes/2026-09-12-PKG-VAST-03-lifecycle.md) [记录](baselines/vast-lifecycle-validation.json) [记录](vast-compatibility-decision.md)
- PKG-DPIP-01: [记录](changes/2026-09-12-PKG-DPIP-01-contract.md) [记录](baselines/dpip-release.json) [记录](baselines/dpip-contract.md) [记录](baselines/dpip-contract-validation.json) [记录](dpip-validation.md)
- PKG-DPIP-02: [记录](changes/2026-09-12-PKG-DPIP-02-tests.md) [记录](baselines/dpip-behavior-validation.json) [记录](dpip-validation.md)
- PKG-DPIP-03: [记录](changes/2026-09-12-PKG-DPIP-03-lifecycle.md) [记录](baselines/dpip-lifecycle-validation.json) [记录](dpip-validation.md)
- PKG-CANVAS-01: [记录](changes/2026-09-12-PKG-CANVAS-01-contract.md) [记录](baselines/canvas-contract.md) [记录](baselines/canvas-release.json) [记录](canvas-validation.md) [记录](baselines/canvas-contract-validation.json)
- PKG-CANVAS-02: [记录](changes/2026-09-12-PKG-CANVAS-02-tests.md) [记录](baselines/canvas-behavior-validation.json) [记录](canvas-validation.md)
- PKG-CANVAS-03: [记录](changes/2026-09-12-PKG-CANVAS-03-lifecycle.md) [记录](baselines/canvas-lifecycle-validation.json) [记录](canvas-validation.md)
+11
View File
@@ -1,5 +1,16 @@
# 进度与证据
## 最新完成:Document PiP-03 严格TS与窗口资源修复
6个模块分离窗口申请/终态、DOM还原、样式、控件与延迟资源。20候选用例在旧源码
3正常通过/17失败,候选20全部通过;48历史和16正常契约继续通过。main/legacy各20
生命周期、三格式构建、原生ESM导入通过。宽矩阵66项通过后,最终main产物18项
真实DOM浏览器复验通过;受控窗口API不等于原生Document PiP,05门槛保留。
完整CI1136项通过,另44项重复契约观察,290生产TS文件严格检查。STYLE风险关闭;
LIFE/DOM记录修复,保留原生组合验收缺口。当前221项:96 done、6 doing、119 todo。
见[本步记录](changes/2026-09-12-PKG-DPIP-03-lifecycle.md)和[证据](baselines/dpip-lifecycle-validation.json)。
下一步04公开类型、两代导出与安装消费者;独立本地提交,不推送/发布。
## 最新完成:Document PiP-02 生命周期历史复现
四实现×12组共48项复现迟到/重复窗口、销毁后效果、DOM回滚失败和样式差异,
+1 -1
View File
@@ -164,5 +164,5 @@
| DPIP-TYPE-01 | open / 已复现 | Document PiP requires options and declares void actions and writable flags that differ from runtime | PKG-DPIP-04 |
| DPIP-LIFE-01 | open / 已复现 | Document PiP has no separate ownership for pending window requests and delayed post-transition callbacks | PKG-DPIP-02, PKG-DPIP-03, PKG-DPIP-05 |
| DPIP-DOM-01 | open / 已复现 | Document PiP copies global-document styles and retains original parent without transactional rollback | PKG-DPIP-02, PKG-DPIP-03, PKG-DPIP-05 |
| DPIP-STYLE-01 | open / 已复现 | Document PiP 1.1.0 and frozen source duplicate style IDs when evaluated before DOMContentLoaded | PKG-DPIP-03 |
| DPIP-STYLE-01 | resolved / 已复现 | Document PiP 1.1.0 and frozen source duplicate style IDs when evaluated before DOMContentLoaded | PKG-DPIP-03 |
| DPIP-MEDIA-01 | open / 已复现 | WebKit reports styled width640 for width320 media in the controlled Document PiP iframe matrix | PKG-DPIP-05 |
+21 -7
View File
@@ -3500,8 +3500,11 @@
"refactor/baselines/dpip-contract.md",
"refactor/changes/2026-09-12-PKG-DPIP-01-contract.md",
"refactor/changes/2026-09-12-PKG-DPIP-02-tests.md",
"refactor/baselines/dpip-behavior-validation.json"
]
"refactor/baselines/dpip-behavior-validation.json",
"refactor/changes/2026-09-12-PKG-DPIP-03-lifecycle.md",
"refactor/baselines/dpip-lifecycle-validation.json"
],
"workspaceState": "Six strict TS modules with candidate fixes for pending requests, terminal effects, partial rollback and style ownership. Candidate versus frozen tests preserve historical failures. Native Document PiP/proxy/device evidence remains task05; current iframe coverage controls the window API."
},
{
"id": "DPIP-DOM-01",
@@ -3520,8 +3523,11 @@
"refactor/baselines/dpip-contract.md",
"refactor/changes/2026-09-12-PKG-DPIP-01-contract.md",
"refactor/changes/2026-09-12-PKG-DPIP-02-tests.md",
"refactor/baselines/dpip-behavior-validation.json"
]
"refactor/baselines/dpip-behavior-validation.json",
"refactor/changes/2026-09-12-PKG-DPIP-03-lifecycle.md",
"refactor/baselines/dpip-lifecycle-validation.json"
],
"workspaceState": "Six strict TS modules with candidate fixes for pending requests, terminal effects, partial rollback and style ownership. Candidate versus frozen tests preserve historical failures. Native Document PiP/proxy/device evidence remains task05; current iframe coverage controls the window API."
},
{
"id": "DPIP-STYLE-01",
@@ -3532,11 +3538,19 @@
"compatibleResolution": "Preserve the style id and injection timing while deduplicating at insertion and releasing loading listeners.",
"closureCriteria": "Historical two-node versus Parcel cached one-node difference stays explicit; candidate has one final style node under duplicate evaluation.",
"confirmation": "reproduced",
"status": "open",
"status": "resolved",
"evidence": [
"refactor/changes/2026-09-12-PKG-DPIP-02-tests.md",
"refactor/baselines/dpip-behavior-validation.json"
]
"refactor/baselines/dpip-behavior-validation.json",
"refactor/changes/2026-09-12-PKG-DPIP-03-lifecycle.md",
"refactor/baselines/dpip-lifecycle-validation.json"
],
"workspaceState": "Six strict TS modules with candidate fixes for pending requests, terminal effects, partial rollback and style ownership. Candidate versus frozen tests preserve historical failures. Native Document PiP/proxy/device evidence remains task05; current iframe coverage controls the window API.",
"resolutionEvidence": [
"refactor/changes/2026-09-12-PKG-DPIP-03-lifecycle.md",
"refactor/baselines/dpip-lifecycle-validation.json"
],
"resolutionRationale": "Candidate and frozen-source comparison verifies one style node and zero retained DOMContentLoaded listeners after duplicate evaluation. Insertion rechecks the shared id; reentrant style insertion cleanup is covered separately. Native window acceptance is unrelated and remains task05."
},
{
"id": "DPIP-MEDIA-01",
+6 -2
View File
@@ -3031,11 +3031,15 @@
"CORE-16",
"CORE-17"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "窗口生命周期、DOM 迁移、样式与事件 document 重绑分离",
"acceptance": "结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过",
"evidence": []
"evidence": [
"changes/2026-09-12-PKG-DPIP-03-lifecycle.md",
"baselines/dpip-lifecycle-validation.json",
"dpip-validation.md"
]
},
{
"id": "PKG-DPIP-04",
+41
View File
@@ -0,0 +1,41 @@
import { hash } from '../../refactor/scripts/releases.mjs'
import { expect } from './fixtures.js'
export async function setupDpip(page, core, implementation, testInfo) {
await page.goto(`/test/player.html?core=${core}`)
await page.evaluate(() => {
window.dpipEvidence = { nativeCapability: typeof window.documentPictureInPicture?.requestWindow === 'function', scope: 'real DOM iframe with controlled requestWindow; not native Document PiP', windows: [], requests: [], events: [] }
Object.defineProperty(window, 'documentPictureInPicture', { configurable: true, value: {
requestWindow(option) {
return new Promise((resolve) => {
window.dpipEvidence.requests.push({ option, resolve })
})
},
} })
window.resolveDpip = (index) => {
const frame = document.createElement('iframe')
frame.width = '640'
frame.height = '360'
frame.style.cssText = 'display:block;border:0;width:640px;height:360px'
document.body.appendChild(frame)
const popup = frame.contentWindow
const record = { frame, popup, closed: false }
Object.defineProperty(popup, 'close', { configurable: true, value: () => {
record.closed = true
} })
window.dpipEvidence.windows.push(record)
window.dpipEvidence.requests[index].resolve(popup)
}
})
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${implementation.code}; window.dpipFactory = module.exports.default || module.exports; })();` })
await page.evaluate(() => {
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, plugins: [window.dpipFactory()] })
window.dpip = window.art.plugins.artplayerPluginDocumentPip
window.dpipPlayer = window.art.template.$player
window.dpipVideo = window.art.video
window.dpipParent = window.dpipPlayer.parentNode
window.art.on('document-pip', active => window.dpipEvidence.events.push(active))
})
await expect.poll(() => page.evaluate(() => window.art.video.readyState)).toBeGreaterThanOrEqual(2)
await testInfo.attach('dpip-input', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), core, ...(await page.evaluate(() => ({ capability: window.dpipEvidence.nativeCapability, scope: window.dpipEvidence.scope }))) }) })
}
+65
View File
@@ -0,0 +1,65 @@
import { dpipCandidate } from '../helpers/dpip.js'
import { setupDpip } from './dpip-helpers.js'
import { expect, test } from './fixtures.js'
const implementation = await dpipCandidate()
for (const core of ['published', 'candidate']) {
test(`Document PiP candidate/${core}: concurrent opens coalesce and a moved placeholder restores the native node`, async ({ page }, testInfo) => {
await setupDpip(page, core, implementation, testInfo)
await page.evaluate(() => {
window.firstDpipOpen = window.dpip.open()
window.secondDpipOpen = window.dpip.open()
})
expect(await page.evaluate(() => window.dpipEvidence.requests.length)).toBe(1)
await page.evaluate(async () => {
window.resolveDpip(0)
await Promise.all([window.firstDpipOpen, window.secondDpipOpen])
})
expect(await page.evaluate(() => ({ active: window.dpip.isActive, adopted: window.dpipPlayer.ownerDocument === window.dpipEvidence.windows[0].popup.document, sameVideo: window.art.video === window.dpipVideo }))).toEqual({ active: true, adopted: true, sameVideo: true })
await page.evaluate(async () => {
document.body.appendChild(document.querySelector('.artplayer-document-pip-placeholder'))
await window.dpip.close()
})
expect(await page.evaluate(() => ({ active: window.dpip.isActive, restored: window.dpipPlayer.parentNode === window.dpipParent && window.dpipPlayer.ownerDocument === document, closed: window.dpipEvidence.windows[0].closed, placeholder: document.querySelectorAll('.artplayer-document-pip-placeholder').length, styles: window.dpipEvidence.windows[0].popup.document.head.children.length, events: window.dpipEvidence.events }))).toEqual({ active: false, restored: true, closed: true, placeholder: 0, styles: 0, events: [true, false] })
await page.evaluate(() => window.art.destroy())
})
test(`Document PiP candidate/${core}: close and destroy revoke pending window requests before they resolve`, async ({ page }, testInfo) => {
await setupDpip(page, core, implementation, testInfo)
await page.evaluate(async () => {
const opening = window.dpip.open()
await window.dpip.close()
await opening
window.resolveDpip(0)
})
await expect.poll(() => page.evaluate(() => window.dpipEvidence.windows[0].closed)).toBe(true)
expect(await page.evaluate(() => ({ active: window.dpip.isActive, restored: window.dpipPlayer.parentNode === window.dpipParent, events: window.dpipEvidence.events }))).toEqual({ active: false, restored: true, events: [] })
await page.evaluate(async () => {
const opening = window.dpip.open()
window.art.destroy()
await opening
window.resolveDpip(1)
})
await expect.poll(() => page.evaluate(() => window.dpipEvidence.windows[1].closed)).toBe(true)
await page.evaluate(async () => {
await window.dpip.open()
window.dpip.toggle()
})
expect(await page.evaluate(() => ({ active: window.dpip.isActive, requests: window.dpipEvidence.requests.length, placeholders: document.querySelectorAll('.artplayer-document-pip-placeholder').length }))).toEqual({ active: false, requests: 2, placeholders: 0 })
})
test(`Document PiP candidate/${core}: interrupted native adoption rolls back without leaving a root or style nodes`, async ({ page }, testInfo) => {
await setupDpip(page, core, implementation, testInfo)
await page.evaluate(async () => {
const opening = window.dpip.open()
window.resolveDpip(0)
window.dpipEvidence.windows[0].popup.document.adoptNode = () => {
throw new DOMException('controlled adoption failure', 'NotSupportedError')
}
await opening
})
expect(await page.evaluate(() => ({ active: window.dpip.isActive, restored: window.dpipPlayer.parentNode === window.dpipParent, closed: window.dpipEvidence.windows[0].closed, placeholder: document.querySelectorAll('.artplayer-document-pip-placeholder').length, roots: window.dpipEvidence.windows[0].popup.document.body.children.length, styles: window.dpipEvidence.windows[0].popup.document.head.children.length }))).toEqual({ active: false, restored: true, closed: true, placeholder: 0, roots: 0, styles: 0 })
await page.evaluate(() => window.art.destroy())
})
}
+1 -40
View File
@@ -1,48 +1,9 @@
import { hash } from '../../refactor/scripts/releases.mjs'
import { dpipHistorical } from '../helpers/dpip.js'
import { setupDpip as setup } from './dpip-helpers.js'
import { expect, test } from './fixtures.js'
const implementations = await dpipHistorical()
async function setup(page, core, implementation, testInfo) {
await page.goto(`/test/player.html?core=${core}`)
await page.evaluate(() => {
window.dpipEvidence = { nativeCapability: typeof window.documentPictureInPicture?.requestWindow === 'function', scope: 'real DOM iframe with controlled requestWindow; not native Document PiP', windows: [], requests: [], events: [] }
Object.defineProperty(window, 'documentPictureInPicture', { configurable: true, value: {
requestWindow(option) {
return new Promise((resolve) => {
window.dpipEvidence.requests.push({ option, resolve })
})
},
} })
window.resolveDpip = (index) => {
const frame = document.createElement('iframe')
frame.width = '640'
frame.height = '360'
frame.style.cssText = 'display:block;border:0;width:640px;height:360px'
document.body.appendChild(frame)
const popup = frame.contentWindow
const record = { frame, popup, closed: false }
Object.defineProperty(popup, 'close', { configurable: true, value: () => {
record.closed = true
} })
window.dpipEvidence.windows.push(record)
window.dpipEvidence.requests[index].resolve(popup)
}
})
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports; ${implementation.code}; window.dpipFactory = module.exports.default || module.exports; })();` })
await page.evaluate(() => {
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, plugins: [window.dpipFactory()] })
window.dpip = window.art.plugins.artplayerPluginDocumentPip
window.dpipPlayer = window.art.template.$player
window.dpipVideo = window.art.video
window.dpipParent = window.dpipPlayer.parentNode
window.art.on('document-pip', active => window.dpipEvidence.events.push(active))
})
await expect.poll(() => page.evaluate(() => window.art.video.readyState)).toBeGreaterThanOrEqual(2)
await testInfo.attach('dpip-input', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), core, ...(await page.evaluate(() => ({ capability: window.dpipEvidence.nativeCapability, scope: window.dpipEvidence.scope }))) }) })
}
for (const implementation of implementations) {
for (const core of ['published', 'candidate']) {
test(`Document PiP ${implementation.name}/${core}: real DOM migration and pagehide restore preserve media and controls`, async ({ page }, testInfo) => {
+344
View File
@@ -0,0 +1,344 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Candidate fixes are compared to the immutable workspace via ARTPLAYER_DPIP_BASELINE.
import test from 'node:test'
import { dpipCandidate, dpipEnvironment } from './helpers/dpip.js'
const implementation = await dpipCandidate()
function deferred() {
let resolve
let reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
const setup = settings => dpipEnvironment(implementation, settings)
test('Document PiP candidate: public keys, snapshot/default options, controls and transition ordering remain stable', async () => {
const env = setup()
const options = { width: 720, placeholder: 'Active' }
const plugin = env.factory(options)
options.width = 1
const result = plugin(env.art)
assert.deepEqual(Object.keys(result), ['name', 'isSupported', 'isActive', 'open', 'close', 'toggle'])
assert.equal(Reflect.set(result, 'isActive', true), false)
assert.equal(result.name, 'artplayerPluginDocumentPip')
assert.equal(env.controls.get('document-pip').spec.index, 40)
assert.equal(await result.open(), undefined)
assert.deepEqual({ ...env.requests[0] }, { width: 720, height: 270 })
assert.deepEqual(env.emitted.map(event => [event.name, ...event.args]), [['document-pip', true], ['resize']])
assert.equal(env.parent.children[0].textContent, 'Active')
assert.equal(env.controls.get('document-pip').node.tooltip, 'Exit PIP Mode')
assert.equal(await result.close(), undefined)
assert.deepEqual(env.parent.children, [env.player, env.sibling])
assert.deepEqual(env.emitted.map(event => [event.name, ...event.args]), [['document-pip', true], ['resize'], ['document-pip', false], ['resize']])
assert.deepEqual(env.rebinds.map(item => item.document), [env.windows[0].document, env.document])
})
test('Document PiP candidate: normal rejection and unsupported fallback preserve error and return boundaries', async () => {
const error = new Error('denied')
const env = setup({ requestWindow: () => Promise.reject(error) })
const result = env.factory()(env.art)
assert.equal(await result.open(), undefined)
assert.equal(env.warnings[0][1], error)
assert.equal(result.isActive, false)
const fallback = setup({ supported: false })
const other = fallback.factory()(fallback.art)
await other.open()
assert.equal(fallback.art.pip, true)
assert.equal(other.isSupported, false)
Object.defineProperty(fallback.art, 'pip', { get() {
return false
}, set() {
throw error
} })
await assert.rejects(other.open(), caught => caught === error)
})
test('Document PiP candidate: overlapping opens issue one request and share one restored window', async () => {
const pending = deferred()
const env = setup({ requestWindow: () => pending.promise })
const result = env.factory()(env.art)
const first = result.open()
const second = result.open()
assert.equal(env.requests.length, 1)
pending.resolve(env.createWindow())
await Promise.all([first, second])
assert.equal(env.parent.children.length, 2)
await result.close()
assert.equal(env.windows[0].closed, true)
assert.deepEqual(env.parent.children, [env.player, env.sibling])
})
test('Document PiP candidate: close settles a pending open and closes the late window without adopting', async () => {
const pending = deferred()
const env = setup({ requestWindow: () => pending.promise })
const result = env.factory()(env.art)
let settled = false
const opening = result.open().then(() => {
settled = true
})
await result.close()
await env.flush()
assert.equal(settled, true)
const win = env.createWindow()
pending.resolve(win)
await env.flush()
await opening
assert.equal(win.closed, true)
assert.equal(result.isActive, false)
assert.deepEqual(env.parent.children, [env.player, env.sibling])
assert.deepEqual(env.emitted, [])
})
test('Document PiP candidate: pending toggle cancels and a fresh request survives the old resolution', async () => {
const requests = [deferred(), deferred()]
let index = 0
const env = setup({ requestWindow: () => requests[index++].promise })
const result = env.factory()(env.art)
const old = result.open()
assert.equal(result.toggle(), undefined)
const current = result.open()
const newWin = env.createWindow()
requests[1].resolve(newWin)
await current
const oldWin = env.createWindow()
requests[0].resolve(oldWin)
await old
await env.flush()
assert.equal(oldWin.closed, true)
assert.equal(newWin.closed, false)
assert.equal(env.player.ownerDocument, newWin.document)
await result.close()
})
test('Document PiP candidate: destroy cancels pending windows and makes escaped methods inert', async () => {
const pending = deferred()
const env = setup({ requestWindow: () => pending.promise })
const result = env.factory()(env.art)
const opening = result.open()
env.art.destroy()
const win = env.createWindow()
pending.resolve(win)
await opening
await env.flush()
assert.equal(win.closed, true)
await result.open()
result.toggle()
await result.close()
assert.equal(env.requests.length, 1)
assert.equal(result.isActive, false)
assert.equal(env.controls.size, 0)
assert.equal(env.subscriptions.get('destroy').size, 0)
assert.equal(env.subscriptions.get('document-pip').size, 0)
})
test('Document PiP candidate: stale rejection after destroy does not write notices', async () => {
const pending = deferred()
const env = setup({ requestWindow: () => pending.promise })
const result = env.factory()(env.art)
const opening = result.open()
env.art.destroy()
pending.reject(new Error('late rejection'))
await opening
await env.flush()
assert.equal(env.warnings.length, 0)
assert.equal(env.art.notice.show, '')
})
test('Document PiP candidate: destroy cancels owned transition timers and emits no delayed resize', async () => {
const delay = deferred()
const env = setup({ sleep: () => delay.promise })
const result = env.factory()(env.art)
const opening = result.open()
await env.flush()
env.art.destroy()
assert.equal(env.timers.size, 0)
const before = env.emitted.length
delay.resolve()
await opening
await env.flush()
assert.equal(env.emitted.length, before)
assert.equal(result.isActive, false)
assert.deepEqual(env.parent.children, [env.player, env.sibling])
})
test('Document PiP candidate: destroy from the active event releases listeners and prevents subsequent effects', async () => {
const env = setup()
const result = env.factory()(env.art)
env.art.on('document-pip', active => active && env.art.destroy())
await result.open()
assert.equal(result.isActive, false)
assert.equal(env.windows[0].closed, true)
assert.equal([...env.windows[0].listeners.values()].reduce((sum, value) => sum + value.size, 0), 0)
assert.equal(env.emitted.filter(event => event.name === 'resize').length, 0)
assert.equal(env.controls.size, 0)
})
test('Document PiP candidate: failed adoption rolls back all window and DOM resources immediately', async () => {
const env = setup({ requestWindow: (_, create) => {
const win = create()
win.document.adoptNode = () => {
throw new Error('denied adoption')
}
return Promise.resolve(win)
} })
const result = env.factory()(env.art)
await result.open()
assert.equal(result.isActive, false)
assert.equal(env.windows[0].closed, true)
assert.deepEqual(env.parent.children, [env.player, env.sibling])
assert.equal(env.windows[0].document.head.children.length, 0)
assert.equal(env.windows[0].document.body.children.length, 0)
assert.match(env.art.notice.show, /open failed/)
})
test('Document PiP candidate: moved or removed placeholders restore to the original parent and sibling', async () => {
for (const move of [true, false]) {
const env = setup()
const result = env.factory()(env.art)
await result.open()
const placeholder = env.parent.children[0]
if (move)
env.document.body.appendChild(placeholder)
else
placeholder.remove()
await result.close()
assert.deepEqual(env.parent.children, [env.player, env.sibling])
assert.equal(result.isActive, false)
assert.equal(placeholder.parentNode, null)
assert.equal(env.warnings.length, 0)
}
})
test('Document PiP candidate: cross-document styles follow the actual player owner and are released', async () => {
const env = setup()
const foreign = env.createWindow().document
foreign.body.appendChild(env.parent)
const style = foreign.createElement('style')
style.textContent = '.foreign { color:red }'
foreign.head.appendChild(style)
const result = env.factory()(env.art)
await result.open()
const target = env.windows[1].document
assert.equal(target.querySelectorAll('style').some(node => node.textContent === style.textContent), true)
await result.close()
assert.equal(env.player.ownerDocument, foreign)
assert.equal(target.head.children.length, 0)
})
test('Document PiP candidate: duplicate loading evaluation injects one style and removes loading listeners', () => {
const env = setup({ readyState: 'loading' })
env.evaluate()
env.document.dispatchEvent({ type: 'DOMContentLoaded' })
assert.equal(env.document.querySelectorAll('style').filter(node => node.id === 'artplayer-plugin-document-pip').length, 1)
assert.equal(env.document.listeners.get('DOMContentLoaded').size, 0)
})
test('Document PiP candidate: registration after destroy creates no controls or listeners', async () => {
const env = setup()
env.art.isDestroy = true
const result = env.factory()(env.art)
await result.open()
assert.equal(env.controls.size, 0)
assert.equal(env.requests.length, 0)
assert.equal(env.subscriptions.size, 0)
})
test('Document PiP candidate: partial control setup failure releases the click, control and destroy subscription', () => {
const env = setup()
const add = env.art.controls.add
env.art.controls.add = (spec) => {
add(spec)
throw new Error('control failure')
}
assert.throws(() => env.factory()(env.art), /control failure/)
assert.equal(env.controls.size, 0)
assert.equal(env.subscriptions.get('destroy').size, 0)
assert.equal(env.subscriptions.get('document-pip').size, 0)
})
test('Document PiP candidate: throwing window cleanup still restores DOM and releases remaining resources', async () => {
const env = setup()
const result = env.factory()(env.art)
await result.open()
const win = env.windows[0]
const remove = win.removeEventListener
win.removeEventListener = (...args) => {
remove(...args)
if (args[0] === 'resize')
throw new Error('listener release failed')
}
await result.close()
assert.equal(result.isActive, false)
assert.equal(win.closed, true)
assert.deepEqual(env.parent.children, [env.player, env.sibling])
assert.match(env.art.notice.show, /close failed/)
})
test('Document PiP candidate: a rejecting original parent leaves the player recoverable in its original document', async () => {
const env = setup()
const result = env.factory()(env.art)
await result.open()
env.parent.insertBefore = () => {
throw new Error('parent rejects restoration')
}
await result.close()
assert.equal(env.player.parentNode, env.document.body)
assert.equal(env.player.ownerDocument, env.document)
assert.equal(env.windows[0].closed, true)
assert.equal(result.isActive, false)
assert.match(env.art.notice.show, /close failed/)
})
test('Document PiP candidate: destruction during adoption restores the returned node without reactivating', async () => {
const env = setup({ requestWindow: (_, create) => {
const win = create()
const adopt = win.document.adoptNode
win.document.adoptNode = (node) => {
env.art.destroy()
return adopt(node)
}
return Promise.resolve(win)
} })
const result = env.factory()(env.art)
await result.open()
await env.flush()
assert.deepEqual(env.parent.children, [env.player, env.sibling])
assert.equal(env.player.ownerDocument, env.document)
assert.equal(env.windows[0].closed, true)
assert.equal(result.isActive, false)
assert.equal(env.controls.size, 0)
})
test('Document PiP candidate: destruction during style insertion releases nodes created by the interrupted mount', async () => {
const env = setup({ requestWindow: (_, create) => {
const win = create()
const append = win.document.head.appendChild
win.document.head.appendChild = function (node) {
env.art.destroy()
return append.call(this, node)
}
return Promise.resolve(win)
} })
const result = env.factory()(env.art)
await result.open()
await env.flush()
assert.equal(result.isActive, false)
assert.deepEqual(env.parent.children, [env.player, env.sibling])
assert.equal(env.windows[0].document.head.children.length, 0)
})
test('Document PiP candidate: a throwing public notice setter rejects open with the original error', async () => {
const env = setup({ requestWindow: () => Promise.reject(new Error('window rejected')) })
const noticeError = new Error('notice setter failed')
Object.defineProperty(env.art.notice, 'show', { get() {
return ''
}, set() {
throw noticeError
} })
const result = env.factory()(env.art)
await assert.rejects(result.open(), error => error === noticeError)
assert.equal(result.isActive, false)
})
+39 -2
View File
@@ -1,9 +1,32 @@
import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
import { fileURLToPath } from 'node:url'
import vm from 'node:vm'
import { transform } from 'esbuild'
import { build, transform } from 'esbuild'
import less from 'less'
import { verifyDpipContract } from '../../refactor/scripts/dpip-contract.mjs'
import { readMember } from '../../refactor/scripts/releases.mjs'
import { getEntryFile } from '../../scripts/projects.js'
export async function dpipCandidate() {
if (process.env.ARTPLAYER_DPIP_BASELINE === '1')
return (await dpipHistorical()).find(item => item.name === 'frozen-workspace')
if (process.env.ARTPLAYER_DPIP_ARTIFACT)
return { name: 'candidate-artifact', code: await fs.readFile(process.env.ARTPLAYER_DPIP_ARTIFACT, 'utf8') }
const root = fileURLToPath(new URL('../../', import.meta.url))
const output = await build({ entryPoints: [getEntryFile(path.join(root, 'packages/artplayer-plugin-document-pip'))], bundle: true, write: false, format: 'cjs', target: 'es2020', plugins: [{
name: 'dpip-less-inline',
setup(build) {
build.onLoad({ filter: /\.less$/ }, async ({ path: filename }) => {
const source = await fs.readFile(filename.replace(/\?inline$/, ''), 'utf8')
return { contents: (await less.render(source)).css, loader: 'text' }
})
},
}] })
return { name: 'candidate-source', code: output.outputFiles[0].text }
}
export async function dpipHistorical() {
const contract = await verifyDpipContract()
@@ -116,6 +139,8 @@ export function dpipEnvironment(implementation, settings = {}) {
const controls = new Map()
const proxies = []
const rebinds = []
const timers = new Map()
let timerId = 0
const window = { ...eventTarget(), document }
document.defaultView = window
function createWindow() {
@@ -186,7 +211,18 @@ export function dpipEnvironment(implementation, settings = {}) {
art.emit('destroy')
},
}
const context = vm.createContext({ window, document, console: { warn: (...args) => warnings.push(args) }, module: { exports: {} } })
const context = vm.createContext({ window, document, console: { warn: (...args) => warnings.push(args) }, module: { exports: {} }, setTimeout(callback, ms) {
const id = timerId++
timers.set(id, callback)
sleeps.push(ms)
const delay = settings.sleep ? settings.sleep(ms) : Promise.resolve()
void delay.then(() => {
const run = timers.get(id)
timers.delete(id)
run?.()
})
return id
}, clearTimeout(id) { timers.delete(id) } })
context.exports = context.module.exports
const evaluate = () => vm.runInContext(`(() => { ${implementation.code}\n })()`, context, { timeout: 5000 })
evaluate()
@@ -208,6 +244,7 @@ export function dpipEnvironment(implementation, settings = {}) {
proxies,
subscriptions,
rebinds,
timers,
evaluate,
createWindow,
async flush() {