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

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

No files matched your search

@@ -2,4 +2,5 @@ node_modules
.DS_Store
yarn.lock
package-lock.json
src
src
tsconfig.json
@@ -0,0 +1,72 @@
# Implementation and maintenance
The public factory still returns a synchronous plugin initializer. Its result has
`name`, readonly runtime getters `isSupported`/`isActive`, async `open`/`close`
returning `Promise<void>`, and synchronous `toggle` returning `undefined`.
The published declarations currently describe void actions and writable flags;
public declaration compatibility is tracked separately in PKG-DPIP-04.
| File | Responsibility |
| --- | --- |
| `src/index.ts` | Factory option snapshot, capability snapshot, core bridge, terminal plugin cleanup |
| `src/window-session.ts` | One pending request/active window, revisions, cancelled requests, window listeners and transition ordering |
| `src/projection.ts` | Original parent/sibling/document, placeholder, root, atomic restoration and rollback |
| `src/styles.ts` | Module style installation and best-effort copying from the player's actual document |
| `src/control.ts` | Existing control name/position/index, click and tooltip subscriptions, partial setup cleanup |
| `src/resources.ts` | Attempt-all cleanup and cancellable 100ms transition delay |
The session receives narrow callbacks and does not import Artplayer. Projection
and style modules only handle DOM ownership. Control and entry modules are the
core integration boundary. No shared runtime dependency on the new core is added.
`open()` calls the browser request synchronously to retain user activation. While
that request is pending, another open coalesces and toggle cancels it. `close()`
settles cancelled callers immediately; when the browser eventually returns its
window, the stale continuation closes it without adopting any player nodes.
An active window owns its listeners, projection and delay. Successful activation
adds the existing class, rebinds core document events and emits `document-pip(true)`;
resize follows after 100ms only if that transition is still current. Normal close
restores the same node, closes the window, removes the class, rebinds, emits false
and delays resize. Destroy is terminal and suppresses new events/timers/UI effects.
Projection snapshots the original document, parent and sibling before moving the
player. Restoration inserts directly into a validated original anchor; it does
not detach first. If the placeholder has moved, the saved sibling is used instead.
If the original parent rejects insertion, a best-effort fallback keeps the node
in the original document body and reports a close failure. This cannot guarantee
recovery if the original document also rejects insertion. Application mutation of
the placeholder does not transfer ownership of the player to another parent.
All acquired resources are registered before operations that may throw. Cleanup
attempts remaining resources after an error. Reentrant destruction during native
adoption or insertion also cleans nodes returned after the initial disposal.
The public module style stays shared for the document's lifetime; deferred
installation rechecks its existing id and removes its own loading listener.
Existing defaults, optional runtime options, names, CSS ids/classes, tooltip,
index40 and best-effort stylesheet attributes are retained. A throwing video PiP
fallback setter still rejects `open()`; normal window errors retain the existing
notice/warning behavior. Fixes to pending requests, partial rollback and stale
effects are intentional corrections to reproduced defects, not new public APIs.
Run from the repository root with the pinned Yarn toolchain:
```sh
yarn test:dpip
yarn typecheck
yarn build artplayer-plugin-document-pip
yarn test:browser test/browser/dpip.spec.js test/browser/dpip-lifecycle.spec.js
yarn dev artplayer-plugin-document-pip --no-open
```
The development example is `http://localhost:8082/?libs=./uncompiled/artplayer-plugin-document-pip/index.js&example=document.pip`.
Historical tests use actual archives and frozen Git source. Candidate lifecycle
tests can run against the frozen source with `ARTPLAYER_DPIP_BASELINE=1`; the known
failures must remain visible. `ARTPLAYER_DPIP_ARTIFACT` selects an explicit built
file for browser verification and never silently falls back to source.
The iframe matrix uses real DOM with controlled window APIs. It does not prove
native Document PiP activation, window focus/keyboard, continuous playback or
device support. Native video/Canvas/Mediabunny combinations and the observed
WebKit dimension reports remain PKG-DPIP-05. Full archive/demo/type acceptance is
PKG-DPIP-04/06. See the repository `refactor/dpip-validation.md` for exact evidence.
@@ -6,6 +6,12 @@ Document PIP plugin for ArtPlayer
[https://artplayer.org](https://artplayer.org/?libs=./uncompiled/artplayer-plugin-document-pip/index.js&example=document.pip)
## Maintenance
Source is split into strict TypeScript modules for window ownership, DOM migration,
styles and controls. See [ARCHITECTURE.md](ARCHITECTURE.md) for lifecycle behavior,
compatibility boundaries, tests and remaining native browser validation.
## License
MIT © Harvey Zhao
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(t.artplayerPluginDocumentPip=t(),define(function(){return t.artplayerPluginDocumentPip})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginDocumentPip=t()}(this,function(){"use strict";if("undefined"!==typeof document){const e="artplayer-plugin-document-pip";let t=document.getElementById(e);t||(t=document.createElement("style"),t.id=e,"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>{document.head.appendChild(t)}):(document.head||document.documentElement).appendChild(t)),t.textContent=".artplayer-document-pip.art-video-player{width:100%!important;height:100%!important}"}return function(e={}){const t={width:480,height:270,fallbackToVideoPiP:!0,placeholder:"Playing in Document Picture-in-Picture",...e};return e=>{const n="documentPictureInPicture"in window&&"function"===typeof window.documentPictureInPicture?.requestWindow,{proxy:i,icons:o,i18n:r,template:{$player:d},constructor:{utils:{append:l,tooltip:c,addClass:a,removeClass:p,sleep:u}}}=e,s={win:null,originalParent:null,originalNext:null,currentDoc:null,placeholder:null};async function m(){if(!n&&t.fallbackToVideoPiP)return e.pip=!0,void console.warn("[artplayer-plugin-document-pip] Document Picture-in-Picture is not supported, falling back to Video Picture-in-Picture");if(!s.win)try{const n=await window.documentPictureInPicture.requestWindow({width:t.width,height:t.height});s.win=n,s.currentDoc=n.document,s.placeholder=document.createElement("div"),s.placeholder.className="artplayer-document-pip-placeholder",s.placeholder.style.cssText="display:flex;justify-content:center;align-items:center;width:100%;height:100%;",s.placeholder.textContent=t.placeholder,s.originalParent=d.parentNode,s.originalNext=d.nextSibling,s.originalParent.insertBefore(s.placeholder,s.originalNext);const i=n.document.createElement("div");i.id="__art_dpip_root",n.document.body.appendChild(i),function(e){const t=e.createElement("style");if(t.textContent="\n html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }\n #__art_dpip_root { position:absolute; inset:0; display:flex; }\n #__art_dpip_root > * { width:100% !important; height:100% !important; }\n ",e.head.appendChild(t),!e.querySelector('meta[name="viewport"]')){const t=e.createElement("meta");t.name="viewport",t.content="width=device-width,initial-scale=1",e.head.appendChild(t)}try{document.querySelectorAll('link[rel="stylesheet"]').forEach(t=>{const n=e.createElement("link");n.rel="stylesheet",n.href=t.href,t.media&&(n.media=t.media),t.crossOrigin&&(n.crossOrigin=t.crossOrigin),t.referrerPolicy&&(n.referrerPolicy=t.referrerPolicy),e.head.appendChild(n)})}catch{}try{document.querySelectorAll("style").forEach(t=>{const n=e.createElement("style");n.textContent=t.textContent,e.head.appendChild(n)})}catch{}}(n.document);const o=n.document.adoptNode(d);i.appendChild(o),s.currentDoc=n.document;const r=()=>e.resize?.(),l=()=>h();n.addEventListener("resize",r),n.addEventListener("pagehide",l),n.addEventListener("unload",l),s.cleanup=()=>{n.removeEventListener("resize",r),n.removeEventListener("pagehide",l),n.removeEventListener("unload",l)},a(d,"artplayer-document-pip"),e.events.bindGlobalEvents?.(),e.emit("document-pip",!0),await u(100),e.emit("resize")}catch(i){e.notice.show="Document Picture-in-Picture open failed",console.warn("[artplayer-plugin-document-pip] open failed:",i)}}async function h(){if(s.win)try{s.cleanup?.(),function(){if(!s.placeholder)return;const e=s.placeholder.ownerDocument||document,t=e.adoptNode(d);s.originalParent.insertBefore(t,s.placeholder),s.placeholder.remove(),s.placeholder=null,s.currentDoc=e}(),s.win.close(),s.win=null,p(d,"artplayer-document-pip"),e.events.bindGlobalEvents?.(),e.emit("document-pip",!1),await u(100),e.emit("resize")}catch(t){e.notice.show="Document Picture-in-Picture close failed",console.warn("[artplayer-plugin-document-pip] close failed:",t)}}function f(){s.win?h():m()}return e.controls.add({name:"document-pip",position:"right",index:40,tooltip:e.i18n.get("PIP Mode"),mounted:t=>{l(t,o.pip),i(t,"click",f),e.on("document-pip",e=>{c(t,r.get(e?"Exit PIP Mode":"PIP Mode"))})}}),e.on("destroy",h),{name:"artplayerPluginDocumentPip",get isSupported(){return n},get isActive(){return!!s.win},open:m,close:h,toggle:f}}}});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginDocumentPip=t(),define(function(){return e.artplayerPluginDocumentPip})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginDocumentPip=t()}(this,function(){"use strict";function e(e){const t=[];for(const o of e)try{o()}catch(n){t.push(n)}return t}const t=".artplayer-document-pip.art-video-player{width:100%!important;height:100%!important}";function n(t){let n=!1,o=0,i=null,r=null,c=null;const l=()=>!n&&t.alive(),s=e=>e===o&&l();function u(){o++,r?.finish(),r=null,c?.cancel(),c=null}function a(t){const n=e(t.releases.splice(0));return n.push(...e([()=>t.window.close()])),n}async function d(e){if(!s(e))return;const n=function(){let e;const t=new Promise(t=>{e=t});let n=setTimeout(()=>{n=null,e()},100);return{promise:t,cancel(){null!==n&&clearTimeout(n),n=null,e()}}}();c=n,await n.promise,c===n&&(c=null),s(e)&&t.resize()}async function p(){u();const e=o,n=i;if(i=null,!n)return;const r=a(n);if(n.announced)try{t.activate(!1)}catch(c){r.push(c)}r.length&&l()&&t.report("close",r[0]),s(e)&&await d(e)}return{get active(){return null!==i},get opening(){return null!==r},open:async function(){if(!l()||i)return;if(r)return r.promise;u();const n=o;let c,f;const m=new Promise((e,t)=>{c=e,f=t}),h={promise:m,finish:c};r=h;const y=()=>{r===h&&(r=null),c()},g=o=>{if(s(n)){const n=i;i=null,n&&(a(n),n.announced&&e([()=>t.activate(!1)]));try{t.report("open",o)}catch(r){f(r)}}},v=async o=>{if(!s(n))return void(i?.window!==o&&e([()=>o.close()]));const r={window:o,releases:[],announced:!1};i=r;try{const e=t.project(o.document);if(r.releases.push(e.destroy),e.mount(),!s(n))return void a(r);for(const[i,c]of[["resize",()=>{s(n)&&t.nativeResize()}],["pagehide",()=>{p()}],["unload",()=>{p()}]])if(r.releases.unshift(()=>o.removeEventListener(i,c)),o.addEventListener(i,c),!s(n))return void a(r);r.announced=!0,t.activate(!0),await d(n)}catch(c){g(c)}};try{const e=t.request();Promise.resolve(e).then(v,g).then(y,e=>{s(n)&&f(e),y()})}catch(w){try{g(w)}finally{y()}}return m},close:p,destroy(){n||(n=!0,p().catch(()=>{}))}}}return"undefined"!==typeof document&&function(e){const n="artplayer-plugin-document-pip",o=e.getElementById(n);if(o)return void(o.textContent=t);const i=e.createElement("style");i.id=n,i.textContent=t;const r=()=>{e.removeEventListener("DOMContentLoaded",r);const o=e.getElementById(n);o?o.textContent=t:(e.head||e.documentElement).appendChild(i)};"loading"===e.readyState?e.addEventListener("DOMContentLoaded",r):r()}(document),function(t={}){const o={width:480,height:270,fallbackToVideoPiP:!0,placeholder:"Playing in Document Picture-in-Picture",...t};return t=>{const i=window,r="documentPictureInPicture"in i&&"function"===typeof i.documentPictureInPicture?.requestWindow;let c=t,l=t.template.$player,s=t.isDestroy;const u=t.constructor.utils,a=()=>!s&&!!c&&!c.isDestroy,d=n({alive:a,request:()=>i.documentPictureInPicture.requestWindow({width:o.width,height:o.height}),project:t=>function(t,n,o){const i=t.ownerDocument,r=t.parentNode,c=t.nextSibling;if(!r)throw new Error("Document Picture-in-Picture requires a mounted player");const l=i.createElement("div");l.className="artplayer-document-pip-placeholder",l.style.cssText="display:flex;justify-content:center;align-items:center;width:100%;height:100%;",l.textContent=o??"";const s=n.createElement("div");s.id="__art_dpip_root";const u=[];let a=!1;const d=()=>{const e=l.parentNode===r?l:c?.parentNode===r?c:null;if(t.parentNode!==r||t.nextSibling!==e)try{r.insertBefore(t,e)}catch(n){throw i.body.appendChild(t),n}},p=()=>{const t=e([d,()=>l.remove(),...u.splice(0).map(e=>()=>e.remove()),()=>s.remove()]);if(t.length)throw t[0]};return{mount(){if(!a){if(r.insertBefore(l,c?.parentNode===r?c:null),a||n.body.appendChild(s),a||function(e,t,n,o){const i=e=>{o()&&(n(e),t.head.appendChild(e),o()||e.remove())},r=t.createElement("style");if(r.textContent="\n html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }\n #__art_dpip_root { position:absolute; inset:0; display:flex; }\n #__art_dpip_root > * { width:100% !important; height:100% !important; }\n ",i(r),!t.querySelector('meta[name="viewport"]')){const e=t.createElement("meta");e.name="viewport",e.content="width=device-width,initial-scale=1",i(e)}try{e.querySelectorAll('link[rel="stylesheet"]').forEach(e=>{const n=t.createElement("link");n.rel="stylesheet",n.href=e.href,e.media&&(n.media=e.media),e.crossOrigin&&(n.crossOrigin=e.crossOrigin),e.referrerPolicy&&(n.referrerPolicy=e.referrerPolicy),i(n)})}catch{}try{e.querySelectorAll("style").forEach(e=>{const n=t.createElement("style");n.textContent=e.textContent,i(n)})}catch{}}(i,n,e=>u.push(e),()=>!a),!a){const e=n.adoptNode(t);a||s.appendChild(e)}a&&p()}},destroy(){a||(a=!0,p())}}}(l,t,o.placeholder),activate(e){c&&l&&(e?u.addClass(l,"artplayer-document-pip"):u.removeClass(l,"artplayerLine truncated
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(t.artplayerPluginDocumentPip=t(),define(function(){return t.artplayerPluginDocumentPip})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginDocumentPip=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,o=(t,n,i)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:i}):t[n]=i,r=(e,t,n)=>new Promise((i,o)=>{var r=e=>{try{c(n.next(e))}catch(t){o(t)}},l=e=>{try{c(n.throw(e))}catch(t){o(t)}},c=e=>e.done?i(e.value):Promise.resolve(e.value).then(r,l);c((n=n.apply(e,t)).next())});if("undefined"!==typeof document){const e="artplayer-plugin-document-pip";let t=document.getElementById(e);t||(t=document.createElement("style"),t.id=e,"loading"===document.readyState?document.addEventListener("DOMContentLoaded",()=>{document.head.appendChild(t)}):(document.head||document.documentElement).appendChild(t)),t.textContent=".artplayer-document-pip.art-video-player{width:100%!important;height:100%!important}"}return function(e={}){const l=((e,r)=>{for(var l in r||(r={}))n.call(r,l)&&o(e,l,r[l]);if(t)for(var l of t(r))i.call(r,l)&&o(e,l,r[l]);return e})({width:480,height:270,fallbackToVideoPiP:!0,placeholder:"Playing in Document Picture-in-Picture"},e);return e=>{var t;const n="documentPictureInPicture"in window&&"function"===typeof(null==(t=window.documentPictureInPicture)?void 0:t.requestWindow),{proxy:i,icons:o,i18n:c,template:{$player:a},constructor:{utils:{append:d,tooltip:p,addClass:u,removeClass:s,sleep:m}}}=e,h={win:null,originalParent:null,originalNext:null,currentDoc:null,placeholder:null};function y(){return r(this,null,function*(){var t,i;if(!n&&l.fallbackToVideoPiP)return e.pip=!0,void console.warn("[artplayer-plugin-document-pip] Document Picture-in-Picture is not supported, falling back to Video Picture-in-Picture");if(!h.win)try{const n=yield window.documentPictureInPicture.requestWindow({width:l.width,height:l.height});h.win=n,h.currentDoc=n.document,h.placeholder=document.createElement("div"),h.placeholder.className="artplayer-document-pip-placeholder",h.placeholder.style.cssText="display:flex;justify-content:center;align-items:center;width:100%;height:100%;",h.placeholder.textContent=l.placeholder,h.originalParent=a.parentNode,h.originalNext=a.nextSibling,h.originalParent.insertBefore(h.placeholder,h.originalNext);const o=n.document.createElement("div");o.id="__art_dpip_root",n.document.body.appendChild(o),function(e){const t=e.createElement("style");if(t.textContent="\n html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }\n #__art_dpip_root { position:absolute; inset:0; display:flex; }\n #__art_dpip_root > * { width:100% !important; height:100% !important; }\n ",e.head.appendChild(t),!e.querySelector('meta[name="viewport"]')){const t=e.createElement("meta");t.name="viewport",t.content="width=device-width,initial-scale=1",e.head.appendChild(t)}try{document.querySelectorAll('link[rel="stylesheet"]').forEach(t=>{const n=e.createElement("link");n.rel="stylesheet",n.href=t.href,t.media&&(n.media=t.media),t.crossOrigin&&(n.crossOrigin=t.crossOrigin),t.referrerPolicy&&(n.referrerPolicy=t.referrerPolicy),e.head.appendChild(n)})}catch(n){}try{document.querySelectorAll("style").forEach(t=>{const n=e.createElement("style");n.textContent=t.textContent,e.head.appendChild(n)})}catch(n){}}(n.document);const r=n.document.adoptNode(a);o.appendChild(r),h.currentDoc=n.document;const c=()=>{var t;return null==(t=e.resize)?void 0:t.call(e)},d=()=>f();n.addEventListener("resize",c),n.addEventListener("pagehide",d),n.addEventListener("unload",d),h.cleanup=()=>{n.removeEventListener("resize",c),n.removeEventListener("pagehide",d),n.removeEventListener("unload",d)},u(a,"artplayer-document-pip"),null==(i=(t=e.events).bindGlobalEvents)||i.call(t),e.emit("document-pip",!0),yield m(100),e.emit("resize")}catch(o){e.notice.show="Document Picture-in-Picture open failed",console.warn("[artplayer-plugin-document-pip] open failed:",o)}})}function f(){return r(this,null,function*(){var t,n,i;if(h.win)try{null==(t=h.cleanup)||t.call(h),function(){if(!h.placeholder)return;const e=h.placeholder.ownerDocument||document,t=e.adoptNode(a);h.originalParent.insertBefore(t,h.placeholder),h.placeholder.remove(),h.placeholder=null,h.currentDoc=e}(),h.win.close(),h.win=null,s(a,"artplayer-document-pip"),null==(i=(n=e.events).bindGlobalEvents)||i.call(n),e.emit("document-pip",!1),yield m(100),e.emit("resize")}catch(o){e.notice.show="Document Picture-in-Picture close failed",console.warn("[artplayer-plugin-document-pip] close failed:",o)}})}function g(){h.win?f():y()}return e.controls.add({name:"document-pip",position:"right",index:40,tooltip:e.i18n.get("PIP Mode"),mounted:t=>{d(t,o.pip),i(t,"click",g),e.on("document-pip",Line truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerPluginDocumentPip=t(),define(function(){return e.artplayerPluginDocumentPip})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginDocumentPip=t()}(this,function(){"use strict";var e=Object.defineProperty,t=Object.getOwnPropertySymbols,n=Object.prototype.hasOwnProperty,o=Object.prototype.propertyIsEnumerable,r=(t,n,o)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:o}):t[n]=o,i=(e,t,n)=>new Promise((o,r)=>{var i=e=>{try{c(n.next(e))}catch(t){r(t)}},l=e=>{try{c(n.throw(e))}catch(t){r(t)}},c=e=>e.done?o(e.value):Promise.resolve(e.value).then(i,l);c((n=n.apply(e,t)).next())});function l(e){const t=[];for(const o of e)try{o()}catch(n){t.push(n)}return t}const c=".artplayer-document-pip.art-video-player{width:100%!important;height:100%!important}";function u(e){let t=!1,n=0,o=null,r=null,c=null;const u=()=>!t&&e.alive(),s=e=>e===n&&u();function a(){n++,null==r||r.finish(),r=null,null==c||c.cancel(),c=null}function d(e){const t=l(e.releases.splice(0));return t.push(...l([()=>e.window.close()])),t}function p(t){return i(this,null,function*(){if(!s(t))return;const n=function(){let e;const t=new Promise(t=>{e=t});let n=setTimeout(()=>{n=null,e()},100);return{promise:t,cancel(){null!==n&&clearTimeout(n),n=null,e()}}}();c=n,yield n.promise,c===n&&(c=null),s(t)&&e.resize()})}function f(){return i(this,null,function*(){a();const t=n,r=o;if(o=null,!r)return;const i=d(r);if(r.announced)try{e.activate(!1)}catch(l){i.push(l)}i.length&&u()&&e.report("close",i[0]),s(t)&&(yield p(t))})}return{get active(){return null!==o},get opening(){return null!==r},open:function(){return i(this,null,function*(){if(!u()||o)return;if(r)return r.promise;a();const t=n;let c,m;const h=new Promise((e,t)=>{c=e,m=t}),y={promise:h,finish:c};r=y;const v=()=>{r===y&&(r=null),c()},g=n=>{if(s(t)){const t=o;o=null,t&&(d(t),t.announced&&l([()=>e.activate(!1)]));try{e.report("open",n)}catch(r){m(r)}}},P=n=>i(null,null,function*(){if(!s(t))return void((null==o?void 0:o.window)!==n&&l([()=>n.close()]));const r={window:n,releases:[],announced:!1};o=r;try{const o=e.project(n.document);if(r.releases.push(o.destroy),o.mount(),!s(t))return void d(r);for(const[i,l]of[["resize",()=>{s(t)&&e.nativeResize()}],["pagehide",()=>{f()}],["unload",()=>{f()}]])if(r.releases.unshift(()=>n.removeEventListener(i,l)),n.addEventListener(i,l),!s(t))return void d(r);r.announced=!0,e.activate(!0),yield p(t)}catch(i){g(i)}});try{const n=e.request();Promise.resolve(n).then(P,g).then(v,e=>{s(t)&&m(e),v()})}catch(w){try{g(w)}finally{v()}}return h})},close:f,destroy(){t||(t=!0,f().catch(()=>{}))}}}return"undefined"!==typeof document&&function(e){const t="artplayer-plugin-document-pip",n=e.getElementById(t);if(n)return void(n.textContent=c);const o=e.createElement("style");o.id=t,o.textContent=c;const r=()=>{e.removeEventListener("DOMContentLoaded",r);const n=e.getElementById(t);n?n.textContent=c:(e.head||e.documentElement).appendChild(o)};"loading"===e.readyState?e.addEventListener("DOMContentLoaded",r):r()}(document),function(e={}){const c=((e,i)=>{for(var l in i||(i={}))n.call(i,l)&&r(e,l,i[l]);if(t)for(var l of t(i))o.call(i,l)&&r(e,l,i[l]);return e})({width:480,height:270,fallbackToVideoPiP:!0,placeholder:"Playing in Document Picture-in-Picture"},e);return e=>{var t;const n=window,o="documentPictureInPicture"in n&&"function"===typeof(null==(t=n.documentPictureInPicture)?void 0:t.requestWindow);let r=e,s=e.template.$player,a=e.isDestroy;const d=e.constructor.utils,p=()=>!a&&!!r&&!r.isDestroy,f=u({alive:p,request:()=>n.documentPictureInPicture.requestWindow({width:c.width,height:c.height}),project:e=>function(e,t,n){const o=e.ownerDocument,r=e.parentNode,i=e.nextSibling;if(!r)throw new Error("Document Picture-in-Picture requires a mounted player");const c=o.createElement("div");c.className="artplayer-document-pip-placeholder",c.style.cssText="display:flex;justify-content:center;align-items:center;width:100%;height:100%;",c.textContent=null!=n?n:"";const u=t.createElement("div");u.id="__art_dpip_root";const s=[];let a=!1;const d=()=>{const t=c.parentNode===r?c:(null==i?void 0:i.parentNode)===r?i:null;if(e.parentNode!==r||e.nextSibling!==t)try{r.insertBefore(e,t)}catch(n){throw o.body.appendChild(e),n}},p=()=>{const e=l([d,()=>c.remove(),...s.splice(0).map(e=>()=>e.remove()),()=>u.remove()]);if(e.length)throw e[0]};return{mount(){if(!a){if(r.insertBefore(c,(null==i?void 0:i.parentNode)===r?i:null),a||t.body.appendChild(u),a||function(e,t,n,o){const r=e=>{o()&&(n(e),t.head.appendChild(e),o()||e.remove())},i=t.createElement("style");if(i.textContent="\n html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }\n #__art_dpip_root { position:absolute; inset:0; display:flex; }\n #__art_dpip_root > * { width:100% !important; height:100% !important; }\n ",r(i),!t.querySelectorLine truncated
@@ -4,191 +4,477 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
const style = ".artplayer-document-pip.art-video-player {\n width: 100% !important;\n height: 100% !important;\n}\n";
function artplayerPluginDocumentPip(userOptions = {}) {
const options = {
width: 480,
height: 270,
fallbackToVideoPiP: true,
placeholder: `Playing in Document Picture-in-Picture`,
...userOptions
function releaseAll(releases) {
const errors = [];
for (const release of releases) {
try {
release();
} catch (error) {
errors.push(error);
}
}
return errors;
}
function createDelay() {
let finish;
const promise = new Promise((resolve) => {
finish = resolve;
});
let timer = setTimeout(() => {
timer = null;
finish();
}, 100);
return {
promise,
cancel() {
if (timer !== null)
clearTimeout(timer);
timer = null;
finish();
}
};
return (art) => {
const isSupported = "documentPictureInPicture" in window && typeof window.documentPictureInPicture?.requestWindow === "function";
const { proxy, icons, i18n, template: { $player }, constructor: { utils: { append, tooltip, addClass, removeClass, sleep } } } = art;
const state = {
win: null,
originalParent: null,
originalNext: null,
currentDoc: null,
placeholder: null
};
function copyStylesTo(targetDoc) {
const base = targetDoc.createElement("style");
base.textContent = `
html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
#__art_dpip_root { position:absolute; inset:0; display:flex; }
#__art_dpip_root > * { width:100% !important; height:100% !important; }
`;
targetDoc.head.appendChild(base);
if (!targetDoc.querySelector('meta[name="viewport"]')) {
const meta = targetDoc.createElement("meta");
meta.name = "viewport";
meta.content = "width=device-width,initial-scale=1";
targetDoc.head.appendChild(meta);
}
}
function createControl(art, toggle, alive) {
const utils = art.constructor.utils;
const releases = [];
let closed = false;
return {
mount() {
if (closed || !alive())
return;
releases.push(() => art.controls.remove("document-pip"));
art.controls.add({
name: "document-pip",
position: "right",
index: 40,
tooltip: art.i18n.get("PIP Mode"),
mounted: (control) => {
if (closed || !alive())
return;
utils.append(control, art.icons.pip);
const click = () => {
if (alive())
toggle();
};
releases.push(() => control.removeEventListener("click", click));
const unbind = art.proxy(control, "click", click);
if (closed)
unbind();
else
releases.push(unbind);
if (closed || !alive())
return;
const update = (active) => {
if (alive())
utils.tooltip(control, art.i18n.get(active ? "Exit PIP Mode" : "PIP Mode"));
};
releases.push(() => art.off("document-pip", update));
art.on("document-pip", update);
}
});
if (closed)
art.controls.remove("document-pip");
},
destroy() {
if (closed)
return;
closed = true;
const errors = releaseAll(releases.splice(0).reverse());
if (errors.length)
throw errors[0];
}
};
}
const style = ".artplayer-document-pip.art-video-player {\n width: 100% !important;\n height: 100% !important;\n}\n";
function installStyle(document2) {
const id = "artplayer-plugin-document-pip";
const existing = document2.getElementById(id);
if (existing) {
existing.textContent = style;
return;
}
const element = document2.createElement("style");
element.id = id;
element.textContent = style;
const insert = () => {
document2.removeEventListener("DOMContentLoaded", insert);
const current = document2.getElementById(id);
if (current)
current.textContent = style;
else
(document2.head || document2.documentElement).appendChild(element);
};
if (document2.readyState === "loading")
document2.addEventListener("DOMContentLoaded", insert);
else
insert();
}
function copyStyles(source, target, own, alive) {
const append = (node) => {
if (!alive())
return;
own(node);
target.head.appendChild(node);
if (!alive())
node.remove();
};
const base = target.createElement("style");
base.textContent = `
html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
#__art_dpip_root { position:absolute; inset:0; display:flex; }
#__art_dpip_root > * { width:100% !important; height:100% !important; }
`;
append(base);
if (!target.querySelector('meta[name="viewport"]')) {
const meta = target.createElement("meta");
meta.name = "viewport";
meta.content = "width=device-width,initial-scale=1";
append(meta);
}
try {
source.querySelectorAll('link[rel="stylesheet"]').forEach((link) => {
const clone = target.createElement("link");
clone.rel = "stylesheet";
clone.href = link.href;
if (link.media)
clone.media = link.media;
if (link.crossOrigin)
clone.crossOrigin = link.crossOrigin;
if (link.referrerPolicy)
clone.referrerPolicy = link.referrerPolicy;
append(clone);
});
} catch {
}
try {
source.querySelectorAll("style").forEach((element) => {
const clone = target.createElement("style");
clone.textContent = element.textContent;
append(clone);
});
} catch {
}
}
function createProjection(player, target, text) {
const source = player.ownerDocument;
const parent = player.parentNode;
const next = player.nextSibling;
if (!parent)
throw new Error("Document Picture-in-Picture requires a mounted player");
const placeholder = source.createElement("div");
placeholder.className = "artplayer-document-pip-placeholder";
placeholder.style.cssText = "display:flex;justify-content:center;align-items:center;width:100%;height:100%;";
placeholder.textContent = text ?? "";
const root = target.createElement("div");
root.id = "__art_dpip_root";
const nodes = [];
let closed = false;
const restore = () => {
const anchor = placeholder.parentNode === parent ? placeholder : next?.parentNode === parent ? next : null;
if (player.parentNode !== parent || player.nextSibling !== anchor) {
try {
document.querySelectorAll('link[rel="stylesheet"]').forEach((link) => {
const clone = targetDoc.createElement("link");
clone.rel = "stylesheet";
clone.href = link.href;
if (link.media)
clone.media = link.media;
if (link.crossOrigin)
clone.crossOrigin = link.crossOrigin;
if (link.referrerPolicy)
clone.referrerPolicy = link.referrerPolicy;
targetDoc.head.appendChild(clone);
});
} catch {
}
try {
document.querySelectorAll("style").forEach((style2) => {
const clone = targetDoc.createElement("style");
clone.textContent = style2.textContent;
targetDoc.head.appendChild(clone);
});
} catch {
parent.insertBefore(player, anchor);
} catch (error) {
source.body.appendChild(player);
throw error;
}
}
};
const cleanup = () => {
const errors = releaseAll([restore, () => placeholder.remove(), ...nodes.splice(0).map((node) => () => node.remove()), () => root.remove()]);
if (errors.length)
throw errors[0];
};
return {
mount() {
if (closed)
return;
parent.insertBefore(placeholder, next?.parentNode === parent ? next : null);
if (!closed)
target.body.appendChild(root);
if (!closed)
copyStyles(source, target, (node) => nodes.push(node), () => !closed);
if (!closed) {
const adopted = target.adoptNode(player);
if (!closed)
root.appendChild(adopted);
}
if (closed)
cleanup();
},
destroy() {
if (closed)
return;
closed = true;
cleanup();
}
};
}
function createWindowSession(hooks) {
let destroyed = false;
let revision = 0;
let session = null;
let pending = null;
let delay = null;
const alive = () => !destroyed && hooks.alive();
const valid = (value) => value === revision && alive();
function invalidate() {
revision++;
pending?.finish();
pending = null;
delay?.cancel();
delay = null;
}
function dispose(current) {
const errors = releaseAll(current.releases.splice(0));
errors.push(...releaseAll([() => current.window.close()]));
return errors;
}
async function resizeLater(value) {
if (!valid(value))
return;
const current = createDelay();
delay = current;
await current.promise;
if (delay === current)
delay = null;
if (valid(value))
hooks.resize();
}
async function close() {
invalidate();
const value = revision;
const current = session;
session = null;
if (!current)
return;
const errors = dispose(current);
if (current.announced) {
try {
hooks.activate(false);
} catch (error) {
errors.push(error);
}
}
if (errors.length && alive())
hooks.report("close", errors[0]);
if (valid(value))
await resizeLater(value);
}
async function open() {
if (!alive() || session)
return;
if (pending)
return pending.promise;
invalidate();
const value = revision;
let finish;
let reject;
const promise = new Promise((resolve, fail) => {
finish = resolve;
reject = fail;
});
const ticket = { promise, finish };
pending = ticket;
const settle = () => {
if (pending === ticket)
pending = null;
finish();
};
const failed = (error) => {
if (valid(value)) {
const current = session;
session = null;
if (current) {
dispose(current);
if (current.announced)
releaseAll([() => hooks.activate(false)]);
}
try {
hooks.report("open", error);
} catch (noticeError) {
reject(noticeError);
}
}
};
const receive = async (window2) => {
if (!valid(value)) {
if (session?.window !== window2)
releaseAll([() => window2.close()]);
return;
}
const current = { window: window2, releases: [], announced: false };
session = current;
try {
const projection = hooks.project(window2.document);
current.releases.push(projection.destroy);
projection.mount();
if (!valid(value)) {
dispose(current);
return;
}
for (const [event, callback] of [["resize", () => {
if (valid(value))
hooks.nativeResize();
}], ["pagehide", () => {
void close();
}], ["unload", () => {
void close();
}]]) {
current.releases.unshift(() => window2.removeEventListener(event, callback));
window2.addEventListener(event, callback);
if (!valid(value)) {
dispose(current);
return;
}
}
current.announced = true;
hooks.activate(true);
await resizeLater(value);
} catch (error) {
failed(error);
}
};
try {
const request = hooks.request();
void Promise.resolve(request).then(receive, failed).then(settle, (error) => {
if (valid(value))
reject(error);
settle();
});
} catch (error) {
try {
failed(error);
} finally {
settle();
}
}
return promise;
}
return {
get active() {
return session !== null;
},
get opening() {
return pending !== null;
},
open,
close,
destroy() {
if (destroyed)
return;
destroyed = true;
void close().catch(() => {
});
}
};
}
function artplayerPluginDocumentPip(userOptions = {}) {
const options = { width: 480, height: 270, fallbackToVideoPiP: true, placeholder: "Playing in Document Picture-in-Picture", ...userOptions };
return (art) => {
const browser = window;
const isSupported = "documentPictureInPicture" in browser && typeof browser.documentPictureInPicture?.requestWindow === "function";
let host = art;
let player = art.template.$player;
let closed = art.isDestroy;
const utils = art.constructor.utils;
const alive = () => !closed && !!host && !host.isDestroy;
const session = createWindowSession({
alive,
request: () => browser.documentPictureInPicture.requestWindow({ width: options.width, height: options.height }),
project: (document2) => createProjection(player, document2, options.placeholder),
activate(active) {
if (!host || !player)
return;
if (active)
utils.addClass(player, "artplayer-document-pip");
else
utils.removeClass(player, "artplayer-document-pip");
if (!alive())
return;
host.events.bindGlobalEvents?.();
if (alive())
host.emit("document-pip", active);
},
resize: () => {
if (alive())
host.emit("resize");
},
nativeResize: () => {
if (alive())
host.resize?.();
},
report(action, error) {
if (!alive())
return;
host.notice.show = `Document Picture-in-Picture ${action} failed`;
console.warn(`[artplayer-plugin-document-pip] ${action} failed:`, error);
}
});
async function open() {
if (!alive())
return;
if (!isSupported && options.fallbackToVideoPiP) {
art.pip = true;
host.pip = true;
console.warn("[artplayer-plugin-document-pip] Document Picture-in-Picture is not supported, falling back to Video Picture-in-Picture");
return;
}
if (state.win)
return;
try {
const pipWin = await window.documentPictureInPicture.requestWindow({
width: options.width,
height: options.height
});
state.win = pipWin;
state.currentDoc = pipWin.document;
state.placeholder = document.createElement("div");
state.placeholder.className = "artplayer-document-pip-placeholder";
state.placeholder.style.cssText = `display:flex;justify-content:center;align-items:center;width:100%;height:100%;`;
state.placeholder.textContent = options.placeholder;
state.originalParent = $player.parentNode;
state.originalNext = $player.nextSibling;
state.originalParent.insertBefore(state.placeholder, state.originalNext);
const root = pipWin.document.createElement("div");
root.id = "__art_dpip_root";
pipWin.document.body.appendChild(root);
copyStylesTo(pipWin.document);
const adopted = pipWin.document.adoptNode($player);
root.appendChild(adopted);
state.currentDoc = pipWin.document;
const onResize = () => art.resize?.();
const onClose = () => close();
pipWin.addEventListener("resize", onResize);
pipWin.addEventListener("pagehide", onClose);
pipWin.addEventListener("unload", onClose);
state.cleanup = () => {
pipWin.removeEventListener("resize", onResize);
pipWin.removeEventListener("pagehide", onClose);
pipWin.removeEventListener("unload", onClose);
};
addClass($player, "artplayer-document-pip");
art.events.bindGlobalEvents?.();
art.emit("document-pip", true);
await sleep(100);
art.emit("resize");
} catch (err) {
art.notice.show = "Document Picture-in-Picture open failed";
console.warn("[artplayer-plugin-document-pip] open failed:", err);
}
}
function restoreToOriginalDocument() {
if (!state.placeholder)
return;
const backDoc = state.placeholder.ownerDocument || document;
const adoptedBack = backDoc.adoptNode($player);
state.originalParent.insertBefore(adoptedBack, state.placeholder);
state.placeholder.remove();
state.placeholder = null;
state.currentDoc = backDoc;
}
async function close() {
if (!state.win)
return;
try {
state.cleanup?.();
restoreToOriginalDocument();
state.win.close();
state.win = null;
removeClass($player, "artplayer-document-pip");
art.events.bindGlobalEvents?.();
art.emit("document-pip", false);
await sleep(100);
art.emit("resize");
} catch (err) {
art.notice.show = "Document Picture-in-Picture close failed";
console.warn("[artplayer-plugin-document-pip] close failed:", err);
}
return session.open();
}
function toggle() {
if (state.win) {
close();
} else {
open();
}
if (!alive())
return;
if (session.active || session.opening)
void session.close();
else
void open();
}
art.controls.add({
name: "document-pip",
position: "right",
index: 40,
tooltip: art.i18n.get("PIP Mode"),
mounted: ($control) => {
append($control, icons.pip);
proxy($control, "click", toggle);
art.on("document-pip", (value) => {
tooltip($control, i18n.get(value ? "Exit PIP Mode" : "PIP Mode"));
});
}
});
art.on("destroy", close);
return {
let control = createControl(art, toggle, alive);
function destroy() {
if (closed)
return;
closed = true;
const current = host;
const previousControl = control;
control = null;
const errors = releaseAll([session.destroy, () => previousControl?.destroy(), () => current?.off("destroy", destroy)]);
host = null;
player = null;
if (errors.length)
console.warn("[artplayer-plugin-document-pip] cleanup failed:", errors[0]);
}
const result = {
name: "artplayerPluginDocumentPip",
get isSupported() {
return isSupported;
},
get isActive() {
return !!state.win;
return session.active;
},
open,
close,
close: session.close,
toggle
};
if (closed) {
host = null;
player = null;
return result;
}
try {
art.on("destroy", destroy);
if (alive())
control?.mount();
else
art.off("destroy", destroy);
} catch (error) {
destroy();
throw error;
}
return result;
};
}
if (typeof document !== "undefined") {
const id = "artplayer-plugin-document-pip";
let $style = document.getElementById(id);
if (!$style) {
$style = document.createElement("style");
$style.id = id;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => {
document.head.appendChild($style);
});
} else {
(document.head || document.documentElement).appendChild($style);
}
}
$style.textContent = style;
}
if (typeof document !== "undefined")
installStyle(document);
export {
artplayerPluginDocumentPip as default
};
@@ -0,0 +1,56 @@
import type Artplayer from 'artplayer'
import type { Disposer } from './resources'
import { releaseAll } from './resources'
export function createControl(art: Artplayer, toggle: Disposer, alive: () => boolean) {
const utils = (art.constructor as typeof Artplayer).utils
const releases: Disposer[] = []
let closed = false
return {
mount() {
if (closed || !alive())
return
releases.push(() => art.controls.remove('document-pip'))
art.controls.add({
name: 'document-pip',
position: 'right',
index: 40,
tooltip: art.i18n.get('PIP Mode'),
mounted: (control) => {
if (closed || !alive())
return
utils.append(control, art.icons.pip)
const click = () => {
if (alive())
toggle()
}
// Explicit removal also covers legacy proxy registration that throws after binding.
releases.push(() => control.removeEventListener('click', click))
const unbind = art.proxy(control, 'click', click)
if (closed)
unbind()
else
releases.push(unbind)
if (closed || !alive())
return
const update = (active: unknown) => {
if (alive())
utils.tooltip(control, art.i18n.get(active ? 'Exit PIP Mode' : 'PIP Mode'))
}
releases.push(() => art.off('document-pip', update))
art.on('document-pip', update)
},
})
if (closed)
art.controls.remove('document-pip')
},
destroy() {
if (closed)
return
closed = true
const errors = releaseAll(releases.splice(0).reverse())
if (errors.length)
throw errors[0]
},
}
}
@@ -1,212 +0,0 @@
import style from './style.less?inline'
export default function artplayerPluginDocumentPip(userOptions = {}) {
const options = {
width: 480,
height: 270,
fallbackToVideoPiP: true,
placeholder: `Playing in Document Picture-in-Picture`,
...userOptions,
}
return (art) => {
const isSupported = 'documentPictureInPicture' in window
&& typeof window.documentPictureInPicture?.requestWindow === 'function'
const { proxy, icons, i18n, template: { $player }, constructor: { utils: { append, tooltip, addClass, removeClass, sleep } } } = art
const state = {
win: null,
originalParent: null,
originalNext: null,
currentDoc: null,
placeholder: null,
}
function copyStylesTo(targetDoc) {
const base = targetDoc.createElement('style')
base.textContent = `
html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
#__art_dpip_root { position:absolute; inset:0; display:flex; }
#__art_dpip_root > * { width:100% !important; height:100% !important; }
`
targetDoc.head.appendChild(base)
if (!targetDoc.querySelector('meta[name="viewport"]')) {
const meta = targetDoc.createElement('meta')
meta.name = 'viewport'
meta.content = 'width=device-width,initial-scale=1'
targetDoc.head.appendChild(meta)
}
try {
document.querySelectorAll('link[rel="stylesheet"]').forEach((link) => {
const clone = targetDoc.createElement('link')
clone.rel = 'stylesheet'
clone.href = link.href
if (link.media)
clone.media = link.media
if (link.crossOrigin)
clone.crossOrigin = link.crossOrigin
if (link.referrerPolicy)
clone.referrerPolicy = link.referrerPolicy
targetDoc.head.appendChild(clone)
})
}
catch { }
try {
document.querySelectorAll('style').forEach((style) => {
const clone = targetDoc.createElement('style')
clone.textContent = style.textContent
targetDoc.head.appendChild(clone)
})
}
catch { }
}
async function open() {
if (!isSupported && options.fallbackToVideoPiP) {
art.pip = true
console.warn('[artplayer-plugin-document-pip] Document Picture-in-Picture is not supported, falling back to Video Picture-in-Picture')
return
}
if (state.win)
return
try {
const pipWin = await window.documentPictureInPicture.requestWindow({
width: options.width,
height: options.height,
})
state.win = pipWin
state.currentDoc = pipWin.document
state.placeholder = document.createElement('div')
state.placeholder.className = 'artplayer-document-pip-placeholder'
state.placeholder.style.cssText = `display:flex;justify-content:center;align-items:center;width:100%;height:100%;`
state.placeholder.textContent = options.placeholder
state.originalParent = $player.parentNode
state.originalNext = $player.nextSibling
state.originalParent.insertBefore(state.placeholder, state.originalNext)
const root = pipWin.document.createElement('div')
root.id = '__art_dpip_root'
pipWin.document.body.appendChild(root)
copyStylesTo(pipWin.document)
const adopted = pipWin.document.adoptNode($player)
root.appendChild(adopted)
state.currentDoc = pipWin.document
const onResize = () => art.resize?.()
const onClose = () => close()
pipWin.addEventListener('resize', onResize)
pipWin.addEventListener('pagehide', onClose)
pipWin.addEventListener('unload', onClose)
state.cleanup = () => {
pipWin.removeEventListener('resize', onResize)
pipWin.removeEventListener('pagehide', onClose)
pipWin.removeEventListener('unload', onClose)
}
addClass($player, 'artplayer-document-pip')
art.events.bindGlobalEvents?.()
art.emit('document-pip', true)
await sleep(100)
art.emit('resize')
}
catch (err) {
art.notice.show = 'Document Picture-in-Picture open failed'
console.warn('[artplayer-plugin-document-pip] open failed:', err)
}
}
function restoreToOriginalDocument() {
if (!state.placeholder)
return
const backDoc = state.placeholder.ownerDocument || document
const adoptedBack = backDoc.adoptNode($player)
state.originalParent.insertBefore(adoptedBack, state.placeholder)
state.placeholder.remove()
state.placeholder = null
state.currentDoc = backDoc
}
async function close() {
if (!state.win)
return
try {
state.cleanup?.()
restoreToOriginalDocument()
state.win.close()
state.win = null
removeClass($player, 'artplayer-document-pip')
art.events.bindGlobalEvents?.()
art.emit('document-pip', false)
await sleep(100)
art.emit('resize')
}
catch (err) {
art.notice.show = 'Document Picture-in-Picture close failed'
console.warn('[artplayer-plugin-document-pip] close failed:', err)
}
}
function toggle() {
if (state.win) {
close()
}
else {
open()
}
}
art.controls.add({
name: 'document-pip',
position: 'right',
index: 40,
tooltip: art.i18n.get('PIP Mode'),
mounted: ($control) => {
append($control, icons.pip)
proxy($control, 'click', toggle)
art.on('document-pip', (value) => {
tooltip($control, i18n.get(value ? 'Exit PIP Mode' : 'PIP Mode'))
})
},
})
art.on('destroy', close)
return {
name: 'artplayerPluginDocumentPip',
get isSupported() { return isSupported },
get isActive() { return !!state.win },
open,
close,
toggle,
}
}
}
if (typeof document !== 'undefined') {
const id = 'artplayer-plugin-document-pip'
let $style = document.getElementById(id)
if (!$style) {
$style = document.createElement('style')
$style.id = id
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
document.head.appendChild($style)
})
}
else {
(document.head || document.documentElement).appendChild($style)
}
}
$style.textContent = style
}
@@ -0,0 +1,115 @@
import type Artplayer from 'artplayer'
import type PublicPlugin from '../types/artplayer-plugin-document-pip'
import type { WindowApi } from './window-session'
import { createControl } from './control'
import { createProjection } from './projection'
import { releaseAll } from './resources'
import { installStyle } from './styles'
import { createWindowSession } from './window-session'
type Option = Parameters<typeof PublicPlugin>[0]
export default function artplayerPluginDocumentPip(userOptions: Option = {}) {
const options = { width: 480, height: 270, fallbackToVideoPiP: true, placeholder: 'Playing in Document Picture-in-Picture', ...userOptions }
return (art: Artplayer) => {
const browser = window as Window & { documentPictureInPicture?: WindowApi }
const isSupported = 'documentPictureInPicture' in browser && typeof browser.documentPictureInPicture?.requestWindow === 'function'
let host: (Artplayer & { resize?: () => void }) | null = art
let player: HTMLElement | null = art.template.$player
let closed = art.isDestroy
const utils = (art.constructor as typeof Artplayer).utils
const alive = () => !closed && !!host && !host.isDestroy
const session = createWindowSession({
alive,
request: () => browser.documentPictureInPicture!.requestWindow({ width: options.width, height: options.height }),
project: document => createProjection(player!, document, options.placeholder),
activate(active) {
if (!host || !player)
return
if (active)
utils.addClass(player, 'artplayer-document-pip')
else
utils.removeClass(player, 'artplayer-document-pip')
if (!alive())
return
host.events.bindGlobalEvents?.()
if (alive())
host.emit('document-pip', active)
},
resize: () => {
if (alive())
host!.emit('resize')
},
nativeResize: () => {
if (alive())
host!.resize?.()
},
report(action, error) {
if (!alive())
return
host!.notice.show = `Document Picture-in-Picture ${action} failed`
console.warn(`[artplayer-plugin-document-pip] ${action} failed:`, error)
},
})
async function open() {
if (!alive())
return
if (!isSupported && options.fallbackToVideoPiP) {
host!.pip = true
console.warn('[artplayer-plugin-document-pip] Document Picture-in-Picture is not supported, falling back to Video Picture-in-Picture')
return
}
return session.open()
}
function toggle() {
if (!alive())
return
if (session.active || session.opening)
void session.close()
else
void open()
}
let control: ReturnType<typeof createControl> | null = createControl(art, toggle, alive)
function destroy() {
if (closed)
return
closed = true
const current = host
const previousControl = control
control = null
const errors = releaseAll([session.destroy, () => previousControl?.destroy(), () => current?.off('destroy', destroy)])
host = null
player = null
if (errors.length)
console.warn('[artplayer-plugin-document-pip] cleanup failed:', errors[0])
}
const result = {
name: 'artplayerPluginDocumentPip' as const,
get isSupported() { return isSupported },
get isActive() { return session.active },
open,
close: session.close,
toggle,
}
if (closed) {
host = null
player = null
return result
}
try {
art.on('destroy', destroy)
if (alive())
control?.mount()
else
art.off('destroy', destroy)
}
catch (error) {
destroy()
throw error
}
return result
}
}
if (typeof document !== 'undefined')
installStyle(document)
@@ -0,0 +1,61 @@
import { releaseAll } from './resources'
import { copyStyles } from './styles'
export function createProjection(player: HTMLElement, target: Document, text: string | undefined) {
const source = player.ownerDocument
const parent = player.parentNode
const next = player.nextSibling
if (!parent)
throw new Error('Document Picture-in-Picture requires a mounted player')
const placeholder = source.createElement('div')
placeholder.className = 'artplayer-document-pip-placeholder'
placeholder.style.cssText = 'display:flex;justify-content:center;align-items:center;width:100%;height:100%;'
placeholder.textContent = text ?? ''
const root = target.createElement('div')
root.id = '__art_dpip_root'
const nodes: HTMLElement[] = []
let closed = false
const restore = () => {
// Insertion adopts atomically; never detach the player before validating the anchor.
const anchor = placeholder.parentNode === parent ? placeholder : next?.parentNode === parent ? next : null
if (player.parentNode !== parent || player.nextSibling !== anchor) {
try {
parent.insertBefore(player, anchor)
}
catch (error) {
// Keep a recoverable node in the original document if its parent rejects insertion.
source.body.appendChild(player)
throw error
}
}
}
const cleanup = () => {
const errors = releaseAll([restore, () => placeholder.remove(), ...nodes.splice(0).map(node => () => node.remove()), () => root.remove()])
if (errors.length)
throw errors[0]
}
return {
mount() {
if (closed)
return
parent.insertBefore(placeholder, next?.parentNode === parent ? next : null)
if (!closed)
target.body.appendChild(root)
if (!closed)
copyStyles(source, target, node => nodes.push(node), () => !closed)
if (!closed) {
const adopted = target.adoptNode(player)
if (!closed)
root.appendChild(adopted)
}
if (closed)
cleanup()
},
destroy() {
if (closed)
return
closed = true
cleanup()
},
}
}
@@ -0,0 +1,32 @@
export type Disposer = () => void
export function releaseAll(releases: Disposer[]): unknown[] {
const errors: unknown[] = []
for (const release of releases) {
try {
release()
}
catch (error) { errors.push(error) }
}
return errors
}
export function createDelay() {
let finish!: () => void
const promise = new Promise<void>((resolve) => {
finish = resolve
})
let timer: ReturnType<typeof setTimeout> | null = setTimeout(() => {
timer = null
finish()
}, 100)
return {
promise,
cancel() {
if (timer !== null)
clearTimeout(timer)
timer = null
finish()
},
}
}
@@ -0,0 +1,72 @@
import style from './style.less?inline'
export function installStyle(document: Document): void {
const id = 'artplayer-plugin-document-pip'
const existing = document.getElementById(id)
if (existing) {
existing.textContent = style
return
}
const element = document.createElement('style')
element.id = id
element.textContent = style
const insert = () => {
document.removeEventListener('DOMContentLoaded', insert)
const current = document.getElementById(id)
if (current)
current.textContent = style
else
(document.head || document.documentElement).appendChild(element)
}
if (document.readyState === 'loading')
document.addEventListener('DOMContentLoaded', insert)
else
insert()
}
export function copyStyles(source: Document, target: Document, own: (node: HTMLElement) => void, alive: () => boolean): void {
const append = (node: HTMLElement) => {
if (!alive())
return
own(node)
target.head.appendChild(node)
if (!alive())
node.remove()
}
const base = target.createElement('style')
base.textContent = `
html, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }
#__art_dpip_root { position:absolute; inset:0; display:flex; }
#__art_dpip_root > * { width:100% !important; height:100% !important; }
`
append(base)
if (!target.querySelector('meta[name="viewport"]')) {
const meta = target.createElement('meta')
meta.name = 'viewport'
meta.content = 'width=device-width,initial-scale=1'
append(meta)
}
try {
source.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]').forEach((link) => {
const clone = target.createElement('link')
clone.rel = 'stylesheet'
clone.href = link.href
if (link.media)
clone.media = link.media
if (link.crossOrigin)
clone.crossOrigin = link.crossOrigin
if (link.referrerPolicy)
clone.referrerPolicy = link.referrerPolicy
append(clone)
})
}
catch { /* Preserve best-effort copying when stylesheets are inaccessible. */ }
try {
source.querySelectorAll('style').forEach((element) => {
const clone = target.createElement('style')
clone.textContent = element.textContent
append(clone)
})
}
catch { /* A page may restrict access to its style nodes. */ }
}
@@ -0,0 +1,179 @@
import type { Disposer } from './resources'
import { createDelay, releaseAll } from './resources'
export interface WindowApi {
requestWindow: (option: { width?: number, height?: number }) => Promise<Window>
}
interface Hooks {
alive: () => boolean
request: () => Promise<Window>
project: (document: Document) => { mount: () => void, destroy: Disposer }
activate: (active: boolean) => void
resize: Disposer
nativeResize: Disposer
report: (action: 'open' | 'close', error: unknown) => void
}
interface Session {
window: Window
releases: Disposer[]
announced: boolean
}
export function createWindowSession(hooks: Hooks) {
let destroyed = false
let revision = 0
let session: Session | null = null
let pending: { promise: Promise<void>, finish: Disposer } | null = null
let delay: ReturnType<typeof createDelay> | null = null
const alive = () => !destroyed && hooks.alive()
const valid = (value: number) => value === revision && alive()
function invalidate() {
revision++
pending?.finish()
pending = null
delay?.cancel()
delay = null
}
function dispose(current: Session): unknown[] {
const errors = releaseAll(current.releases.splice(0))
errors.push(...releaseAll([() => current.window.close()]))
return errors
}
async function resizeLater(value: number) {
if (!valid(value))
return
const current = createDelay()
delay = current
await current.promise
if (delay === current)
delay = null
if (valid(value))
hooks.resize()
}
async function close() {
invalidate()
const value = revision
const current = session
session = null
if (!current)
return
const errors = dispose(current)
if (current.announced) {
try {
hooks.activate(false)
}
catch (error) { errors.push(error) }
}
if (errors.length && alive())
hooks.report('close', errors[0])
if (valid(value))
await resizeLater(value)
}
async function open() {
if (!alive() || session)
return
if (pending)
return pending.promise
invalidate()
const value = revision
let finish!: Disposer
let reject!: (error: unknown) => void
const promise = new Promise<void>((resolve, fail) => {
finish = resolve
reject = fail
})
const ticket = { promise, finish }
pending = ticket
const settle = () => {
if (pending === ticket)
pending = null
finish()
}
const failed = (error: unknown) => {
if (valid(value)) {
const current = session
session = null
if (current) {
dispose(current)
if (current.announced)
releaseAll([() => hooks.activate(false)])
}
try {
hooks.report('open', error)
}
catch (noticeError) { reject(noticeError) }
}
}
const receive = async (window: Window) => {
if (!valid(value)) {
// Only the window is retained by a stale request, never a live projection.
if (session?.window !== window)
releaseAll([() => window.close()])
return
}
const current: Session = { window, releases: [], announced: false }
session = current
try {
const projection = hooks.project(window.document)
current.releases.push(projection.destroy)
projection.mount()
if (!valid(value)) {
dispose(current)
return
}
for (const [event, callback] of [['resize', () => {
if (valid(value))
hooks.nativeResize()
}], ['pagehide', () => {
void close()
}], ['unload', () => {
void close()
}]] as const) {
current.releases.unshift(() => window.removeEventListener(event, callback))
window.addEventListener(event, callback)
if (!valid(value)) {
dispose(current)
return
}
}
current.announced = true
hooks.activate(true)
await resizeLater(value)
}
catch (error) { failed(error) }
}
try {
// Called directly while the caller's user activation is still available.
const request = hooks.request()
void Promise.resolve(request).then(receive, failed).then(settle, (error) => {
if (valid(value))
reject(error)
settle()
})
}
catch (error) {
try {
failed(error)
}
finally {
settle()
}
}
return promise
}
return {
get active() { return session !== null },
get opening() { return pending !== null },
open,
close,
destroy() {
if (destroyed)
return
destroyed = true
// close performs synchronous restoration; its delayed effects are now disabled.
void close().catch(() => {})
},
}
}
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "allowJs": false },
"include": ["src/**/*.ts", "types/**/*.d.ts", "../../types/assets.d.ts"]
}