diff --git a/docs/compiled/artplayer-plugin-document-pip.js b/docs/compiled/artplayer-plugin-document-pip.js index 2fdef0b83..46b687611 100644 --- a/docs/compiled/artplayer-plugin-document-pip.js +++ b/docs/compiled/artplayer-plugin-document-pip.js @@ -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,"artplayer-document-pip"),a()&&(c.events.bindGlobalEvents?.(),a()&&c.emit("document-pip",e)))},resize:()=>{a()&&c.emit("resize")},nativeResize:()=>{a()&&c.resize?.()},report(e,t){a()&&(c.notice.show=`Document Picture-in-Picture ${e} failed`,console.warn(`[artplayer-plugin-document-pip] ${e} failed:`,t))}});async function p(){if(a())return!r&&o.fallbackToVideoPiP?(c.pip=!0,void console.warn("[artplayer-plugin-document-pip] Document Picture-in-Picture is not supported, falling back to Video Picture-in-Picture")):d.open()}function f(){a()&&(d.active||d.opening?d.close():p())}let m=function(t,n,o){const i=t.constructor.utils,r=[];let c=!1;return{mount(){!c&&o()&&(r.push(()=>t.controls.remove("document-pip")),t.controls.add({name:"document-pip",position:"right",index:40,tooltip:t.i18n.get("PIP Mode"),mounted:e=>{if(c||!o())return;i.append(e,t.icons.pip);const l=()=>{o()&&n()};r.push(()=>e.removeEventListener("click",l));const s=t.proxy(e,"click",l);if(c?s():r.push(s),c||!o())return;const u=n=>{o()&&i.tooltip(e,t.i18n.get(n?"Exit PIP Mode":"PIP Mode"))};r.push(()=>t.off("document-pip",u)),t.on("document-pip",u)}}),c&&t.controls.remove("document-pip"))},destroy(){if(c)return;c=!0;const t=e(r.splice(0).reverse());if(t.length)throw t[0]}}}(t,f,a);function h(){if(s)return;s=!0;const t=c,n=m;m=null;const o=e([d.destroy,()=>n?.destroy(),()=>t?.off("destroy",h)]);c=null,l=null,o.length&&console.warn("[artplayer-plugin-document-pip] cleanup failed:",o[0])}const y={name:"artplayerPluginDocumentPip",get isSupported(){return r},get isActive(){return d.active},open:p,close:d.close,toggle:f};if(s)return c=null,l=null,y;try{t.on("destroy",h),a()?m?.mount():t.off("destroy",h)}catch(g){throw h(),g}return y}}}); diff --git a/docs/compiled/artplayer-plugin-document-pip.legacy.js b/docs/compiled/artplayer-plugin-document-pip.legacy.js index 787c05f56..177afc32b 100644 --- a/docs/compiled/artplayer-plugin-document-pip.legacy.js +++ b/docs/compiled/artplayer-plugin-document-pip.legacy.js @@ -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",e=>{p(t,c.get(e?"Exit PIP Mode":"PIP Mode"))})}}),e.on("destroy",f),{name:"artplayerPluginDocumentPip",get isSupported(){return n},get isActive(){return!!h.win},open:y,close:f,toggle:g}}}}); +!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.querySelector('meta[name="viewport"]')){const e=t.createElement("meta");e.name="viewport",e.content="width=device-width,initial-scale=1",r(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),r(n)})}catch(l){}try{e.querySelectorAll("style").forEach(e=>{const n=t.createElement("style");n.textContent=e.textContent,r(n)})}catch(l){}}(o,t,e=>s.push(e),()=>!a),!a){const n=t.adoptNode(e);a||u.appendChild(n)}a&&p()}},destroy(){a||(a=!0,p())}}}(s,e,c.placeholder),activate(e){var t,n;r&&s&&(e?d.addClass(s,"artplayer-document-pip"):d.removeClass(s,"artplayer-document-pip"),p()&&(null==(n=(t=r.events).bindGlobalEvents)||n.call(t),p()&&r.emit("document-pip",e)))},resize:()=>{p()&&r.emit("resize")},nativeResize:()=>{var e;p()&&(null==(e=r.resize)||e.call(r))},report(e,t){p()&&(r.notice.show=`Document Picture-in-Picture ${e} failed`,console.warn(`[artplayer-plugin-document-pip] ${e} failed:`,t))}});function m(){return i(this,null,function*(){if(p())return!o&&c.fallbackToVideoPiP?(r.pip=!0,void console.warn("[artplayer-plugin-document-pip] Document Picture-in-Picture is not supported, falling back to Video Picture-in-Picture")):f.open()})}function h(){p()&&(f.active||f.opening?f.close():m())}let y=function(e,t,n){const o=e.constructor.utils,r=[];let i=!1;return{mount(){!i&&n()&&(r.push(()=>e.controls.remove("document-pip")),e.controls.add({name:"document-pip",position:"right",index:40,tooltip:e.i18n.get("PIP Mode"),mounted:l=>{if(i||!n())return;o.append(l,e.icons.pip);const c=()=>{n()&&t()};r.push(()=>l.removeEventListener("click",c));const u=e.proxy(l,"click",c);if(i?u():r.push(u),i||!n())return;const s=t=>{n()&&o.tooltip(l,e.i18n.get(t?"Exit PIP Mode":"PIP Mode"))};r.push(()=>e.off("document-pip",s)),e.on("document-pip",s)}}),i&&e.controls.remove("document-pip"))},destroy(){if(i)return;i=!0;const e=l(r.splice(0).reverse());if(e.length)throw e[0]}}}(e,h,p);function v(){if(a)return;a=!0;const e=r,t=y;y=null;const n=l([f.destroy,()=>null==t?void 0:t.destroy(),()=>null==e?void 0:e.off("destroy",v)]);r=null,s=null,n.length&&console.warn("[artplayer-plugin-document-pip] cleanup failed:",n[0])}const g={name:"artplayerPluginDocumentPip",get isSupported(){return o},get isActive(){return f.active},open:m,close:f.close,toggle:h};if(a)return r=null,s=null,g;try{e.on("destroy",v),p()?null==y||y.mount():e.off("destroy",v)}catch(P){throw v(),P}return g}}}); diff --git a/docs/compiled/artplayer-plugin-document-pip.mjs b/docs/compiled/artplayer-plugin-document-pip.mjs index 451cd5a78..88245fb0d 100644 --- a/docs/compiled/artplayer-plugin-document-pip.mjs +++ b/docs/compiled/artplayer-plugin-document-pip.mjs @@ -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 }; diff --git a/package.json b/package.json index 190efc268..11d57bb93 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/artplayer-plugin-document-pip/.npmignore b/packages/artplayer-plugin-document-pip/.npmignore index 4439c6874..2b272d565 100644 --- a/packages/artplayer-plugin-document-pip/.npmignore +++ b/packages/artplayer-plugin-document-pip/.npmignore @@ -2,4 +2,5 @@ node_modules .DS_Store yarn.lock package-lock.json -src \ No newline at end of file +src +tsconfig.json diff --git a/packages/artplayer-plugin-document-pip/ARCHITECTURE.md b/packages/artplayer-plugin-document-pip/ARCHITECTURE.md new file mode 100644 index 000000000..99780764a --- /dev/null +++ b/packages/artplayer-plugin-document-pip/ARCHITECTURE.md @@ -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`, 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. diff --git a/packages/artplayer-plugin-document-pip/README.md b/packages/artplayer-plugin-document-pip/README.md index 90c763788..d51f39192 100644 --- a/packages/artplayer-plugin-document-pip/README.md +++ b/packages/artplayer-plugin-document-pip/README.md @@ -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 diff --git a/packages/artplayer-plugin-document-pip/dist/artplayer-plugin-document-pip.js b/packages/artplayer-plugin-document-pip/dist/artplayer-plugin-document-pip.js index 2fdef0b83..46b687611 100644 --- a/packages/artplayer-plugin-document-pip/dist/artplayer-plugin-document-pip.js +++ b/packages/artplayer-plugin-document-pip/dist/artplayer-plugin-document-pip.js @@ -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,"artplayer-document-pip"),a()&&(c.events.bindGlobalEvents?.(),a()&&c.emit("document-pip",e)))},resize:()=>{a()&&c.emit("resize")},nativeResize:()=>{a()&&c.resize?.()},report(e,t){a()&&(c.notice.show=`Document Picture-in-Picture ${e} failed`,console.warn(`[artplayer-plugin-document-pip] ${e} failed:`,t))}});async function p(){if(a())return!r&&o.fallbackToVideoPiP?(c.pip=!0,void console.warn("[artplayer-plugin-document-pip] Document Picture-in-Picture is not supported, falling back to Video Picture-in-Picture")):d.open()}function f(){a()&&(d.active||d.opening?d.close():p())}let m=function(t,n,o){const i=t.constructor.utils,r=[];let c=!1;return{mount(){!c&&o()&&(r.push(()=>t.controls.remove("document-pip")),t.controls.add({name:"document-pip",position:"right",index:40,tooltip:t.i18n.get("PIP Mode"),mounted:e=>{if(c||!o())return;i.append(e,t.icons.pip);const l=()=>{o()&&n()};r.push(()=>e.removeEventListener("click",l));const s=t.proxy(e,"click",l);if(c?s():r.push(s),c||!o())return;const u=n=>{o()&&i.tooltip(e,t.i18n.get(n?"Exit PIP Mode":"PIP Mode"))};r.push(()=>t.off("document-pip",u)),t.on("document-pip",u)}}),c&&t.controls.remove("document-pip"))},destroy(){if(c)return;c=!0;const t=e(r.splice(0).reverse());if(t.length)throw t[0]}}}(t,f,a);function h(){if(s)return;s=!0;const t=c,n=m;m=null;const o=e([d.destroy,()=>n?.destroy(),()=>t?.off("destroy",h)]);c=null,l=null,o.length&&console.warn("[artplayer-plugin-document-pip] cleanup failed:",o[0])}const y={name:"artplayerPluginDocumentPip",get isSupported(){return r},get isActive(){return d.active},open:p,close:d.close,toggle:f};if(s)return c=null,l=null,y;try{t.on("destroy",h),a()?m?.mount():t.off("destroy",h)}catch(g){throw h(),g}return y}}}); diff --git a/packages/artplayer-plugin-document-pip/dist/artplayer-plugin-document-pip.legacy.js b/packages/artplayer-plugin-document-pip/dist/artplayer-plugin-document-pip.legacy.js index 787c05f56..177afc32b 100644 --- a/packages/artplayer-plugin-document-pip/dist/artplayer-plugin-document-pip.legacy.js +++ b/packages/artplayer-plugin-document-pip/dist/artplayer-plugin-document-pip.legacy.js @@ -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",e=>{p(t,c.get(e?"Exit PIP Mode":"PIP Mode"))})}}),e.on("destroy",f),{name:"artplayerPluginDocumentPip",get isSupported(){return n},get isActive(){return!!h.win},open:y,close:f,toggle:g}}}}); +!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.querySelector('meta[name="viewport"]')){const e=t.createElement("meta");e.name="viewport",e.content="width=device-width,initial-scale=1",r(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),r(n)})}catch(l){}try{e.querySelectorAll("style").forEach(e=>{const n=t.createElement("style");n.textContent=e.textContent,r(n)})}catch(l){}}(o,t,e=>s.push(e),()=>!a),!a){const n=t.adoptNode(e);a||u.appendChild(n)}a&&p()}},destroy(){a||(a=!0,p())}}}(s,e,c.placeholder),activate(e){var t,n;r&&s&&(e?d.addClass(s,"artplayer-document-pip"):d.removeClass(s,"artplayer-document-pip"),p()&&(null==(n=(t=r.events).bindGlobalEvents)||n.call(t),p()&&r.emit("document-pip",e)))},resize:()=>{p()&&r.emit("resize")},nativeResize:()=>{var e;p()&&(null==(e=r.resize)||e.call(r))},report(e,t){p()&&(r.notice.show=`Document Picture-in-Picture ${e} failed`,console.warn(`[artplayer-plugin-document-pip] ${e} failed:`,t))}});function m(){return i(this,null,function*(){if(p())return!o&&c.fallbackToVideoPiP?(r.pip=!0,void console.warn("[artplayer-plugin-document-pip] Document Picture-in-Picture is not supported, falling back to Video Picture-in-Picture")):f.open()})}function h(){p()&&(f.active||f.opening?f.close():m())}let y=function(e,t,n){const o=e.constructor.utils,r=[];let i=!1;return{mount(){!i&&n()&&(r.push(()=>e.controls.remove("document-pip")),e.controls.add({name:"document-pip",position:"right",index:40,tooltip:e.i18n.get("PIP Mode"),mounted:l=>{if(i||!n())return;o.append(l,e.icons.pip);const c=()=>{n()&&t()};r.push(()=>l.removeEventListener("click",c));const u=e.proxy(l,"click",c);if(i?u():r.push(u),i||!n())return;const s=t=>{n()&&o.tooltip(l,e.i18n.get(t?"Exit PIP Mode":"PIP Mode"))};r.push(()=>e.off("document-pip",s)),e.on("document-pip",s)}}),i&&e.controls.remove("document-pip"))},destroy(){if(i)return;i=!0;const e=l(r.splice(0).reverse());if(e.length)throw e[0]}}}(e,h,p);function v(){if(a)return;a=!0;const e=r,t=y;y=null;const n=l([f.destroy,()=>null==t?void 0:t.destroy(),()=>null==e?void 0:e.off("destroy",v)]);r=null,s=null,n.length&&console.warn("[artplayer-plugin-document-pip] cleanup failed:",n[0])}const g={name:"artplayerPluginDocumentPip",get isSupported(){return o},get isActive(){return f.active},open:m,close:f.close,toggle:h};if(a)return r=null,s=null,g;try{e.on("destroy",v),p()?null==y||y.mount():e.off("destroy",v)}catch(P){throw v(),P}return g}}}); diff --git a/packages/artplayer-plugin-document-pip/dist/artplayer-plugin-document-pip.mjs b/packages/artplayer-plugin-document-pip/dist/artplayer-plugin-document-pip.mjs index 451cd5a78..88245fb0d 100644 --- a/packages/artplayer-plugin-document-pip/dist/artplayer-plugin-document-pip.mjs +++ b/packages/artplayer-plugin-document-pip/dist/artplayer-plugin-document-pip.mjs @@ -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 }; diff --git a/packages/artplayer-plugin-document-pip/src/control.ts b/packages/artplayer-plugin-document-pip/src/control.ts new file mode 100644 index 000000000..84d67028b --- /dev/null +++ b/packages/artplayer-plugin-document-pip/src/control.ts @@ -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] + }, + } +} diff --git a/packages/artplayer-plugin-document-pip/src/index.js b/packages/artplayer-plugin-document-pip/src/index.js deleted file mode 100644 index 57c5fb714..000000000 --- a/packages/artplayer-plugin-document-pip/src/index.js +++ /dev/null @@ -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 -} diff --git a/packages/artplayer-plugin-document-pip/src/index.ts b/packages/artplayer-plugin-document-pip/src/index.ts new file mode 100644 index 000000000..7c96f6170 --- /dev/null +++ b/packages/artplayer-plugin-document-pip/src/index.ts @@ -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[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 | 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) diff --git a/packages/artplayer-plugin-document-pip/src/projection.ts b/packages/artplayer-plugin-document-pip/src/projection.ts new file mode 100644 index 000000000..7cd71d4a7 --- /dev/null +++ b/packages/artplayer-plugin-document-pip/src/projection.ts @@ -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() + }, + } +} diff --git a/packages/artplayer-plugin-document-pip/src/resources.ts b/packages/artplayer-plugin-document-pip/src/resources.ts new file mode 100644 index 000000000..3a47e0ced --- /dev/null +++ b/packages/artplayer-plugin-document-pip/src/resources.ts @@ -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((resolve) => { + finish = resolve + }) + let timer: ReturnType | null = setTimeout(() => { + timer = null + finish() + }, 100) + return { + promise, + cancel() { + if (timer !== null) + clearTimeout(timer) + timer = null + finish() + }, + } +} diff --git a/packages/artplayer-plugin-document-pip/src/styles.ts b/packages/artplayer-plugin-document-pip/src/styles.ts new file mode 100644 index 000000000..8102f951b --- /dev/null +++ b/packages/artplayer-plugin-document-pip/src/styles.ts @@ -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('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. */ } +} diff --git a/packages/artplayer-plugin-document-pip/src/window-session.ts b/packages/artplayer-plugin-document-pip/src/window-session.ts new file mode 100644 index 000000000..9de365803 --- /dev/null +++ b/packages/artplayer-plugin-document-pip/src/window-session.ts @@ -0,0 +1,179 @@ +import type { Disposer } from './resources' +import { createDelay, releaseAll } from './resources' + +export interface WindowApi { + requestWindow: (option: { width?: number, height?: number }) => Promise +} + +interface Hooks { + alive: () => boolean + request: () => Promise + 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, finish: Disposer } | null = null + let delay: ReturnType | 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((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(() => {}) + }, + } +} diff --git a/packages/artplayer-plugin-document-pip/tsconfig.json b/packages/artplayer-plugin-document-pip/tsconfig.json new file mode 100644 index 000000000..c9218405d --- /dev/null +++ b/packages/artplayer-plugin-document-pip/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "allowJs": false }, + "include": ["src/**/*.ts", "types/**/*.d.ts", "../../types/assets.d.ts"] +} diff --git a/refactor/baselines/dpip-lifecycle-validation.json b/refactor/baselines/dpip-lifecycle-validation.json new file mode 100644 index 000000000..6987203bd --- /dev/null +++ b/refactor/baselines/dpip-lifecycle-validation.json @@ -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=
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=
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" + } + ] +} diff --git a/refactor/changes/2026-09-12-PKG-DPIP-03-lifecycle.md b/refactor/changes/2026-09-12-PKG-DPIP-03-lifecycle.md new file mode 100644 index 000000000..f144a8c25 --- /dev/null +++ b/refactor/changes/2026-09-12-PKG-DPIP-03-lifecycle.md @@ -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和正常事件顺序。 +未新增公开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继续。 diff --git a/refactor/dpip-validation.md b/refactor/dpip-validation.md index ea1f21d4b..2f0678776 100644 --- a/refactor/dpip-validation.md +++ b/refactor/dpip-validation.md @@ -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继续公开类型/导出/安装消费者。 diff --git a/refactor/plan.md b/refactor/plan.md index 57f9443b9..745e3da44 100644 --- a/refactor/plan.md +++ b/refactor/plan.md @@ -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
核对包契约与历史用法 | BASE-05 | width/height/placeholder/fallbackToVideoPiP、open/close/toggle 返回 | 源码/声明/README/demo/发布包差异已登记;公开形状和版本范围冻结 | H | done | | PKG-DPIP-02 | artplayer-plugin-document-pip
建立特有行为与错误测试 | PKG-DPIP-01, ENG-03, ENG-05 | 不支持/拒绝、重复打开、pagehide、核心销毁和视频 PiP fallback | 旧版本行为可重跑,成功/失败/切源/销毁有必要断言 | H | done | -| PKG-DPIP-03 | artplayer-plugin-document-pip
整理内部职责与资源 | PKG-DPIP-02, CORE-16, CORE-17 | 窗口生命周期、DOM 迁移、样式与事件 document 重绑分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | todo | +| PKG-DPIP-03 | artplayer-plugin-document-pip
整理内部职责与资源 | PKG-DPIP-02, CORE-16, CORE-17 | 窗口生命周期、DOM 迁移、样式与事件 document 重绑分离 | 结构变化和缺陷修复分开记录;原 API/事件/资源生命周期通过 | H | done | | PKG-DPIP-04 | artplayer-plugin-document-pip
迁移自有源码和公开类型 | PKG-DPIP-03, ENG-04, ENG-06, CORE-07 | Document PiP 可选能力和真实状态/返回类型 | 严格类型检查、旧消费样例通过;声明路径/导出和同步异步兼容 | H | todo | | PKG-DPIP-05 | artplayer-plugin-document-pip
验证新旧核心和组合 | PKG-DPIP-04, CORE-22, PKG-CANVAS-04, PKG-MB-04 | 原生视频及两个 proxy、键盘/焦点/全屏与关闭还原 | 最终核心与原支持范围核心分别通过;设备/SDK 缺证据不能标完成 | H | todo | | PKG-DPIP-06 | artplayer-plugin-document-pip
验证分发并同步文档 | 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) diff --git a/refactor/progress.md b/refactor/progress.md index 84792dae8..5f4a1e63a 100644 --- a/refactor/progress.md +++ b/refactor/progress.md @@ -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回滚失败和样式差异, diff --git a/refactor/risk-table.md b/refactor/risk-table.md index d80f99d69..2e17cf6b4 100644 --- a/refactor/risk-table.md +++ b/refactor/risk-table.md @@ -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 | diff --git a/refactor/risks.json b/refactor/risks.json index 642a6419a..e44677170 100644 --- a/refactor/risks.json +++ b/refactor/risks.json @@ -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", diff --git a/refactor/tasks.json b/refactor/tasks.json index 0bec1c66f..ce2993c43 100644 --- a/refactor/tasks.json +++ b/refactor/tasks.json @@ -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", diff --git a/test/browser/dpip-helpers.js b/test/browser/dpip-helpers.js new file mode 100644 index 000000000..1c9b33800 --- /dev/null +++ b/test/browser/dpip-helpers.js @@ -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 }))) }) }) +} diff --git a/test/browser/dpip-lifecycle.spec.js b/test/browser/dpip-lifecycle.spec.js new file mode 100644 index 000000000..46eb28fc7 --- /dev/null +++ b/test/browser/dpip-lifecycle.spec.js @@ -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()) + }) +} diff --git a/test/browser/dpip.spec.js b/test/browser/dpip.spec.js index e5f30fbc6..8b59e8a89 100644 --- a/test/browser/dpip.spec.js +++ b/test/browser/dpip.spec.js @@ -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) => { diff --git a/test/dpip-lifecycle.test.js b/test/dpip-lifecycle.test.js new file mode 100644 index 000000000..c45501ef9 --- /dev/null +++ b/test/dpip-lifecycle.test.js @@ -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) +}) diff --git a/test/helpers/dpip.js b/test/helpers/dpip.js index 9f570e73d..6f63ac026 100644 --- a/test/helpers/dpip.js +++ b/test/helpers/dpip.js @@ -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() {