refactor(canvas): [PKG-CANVAS-03] own media and frame lifecycle in strict TypeScript

This commit is contained in:
Harvey Zhao committed 2026-09-12 13:11:07 +08:00
1 parent e931520e66
commit 2b319109cd
29 files changed
+2015 -362

No files matched your search

+2 -1
View File
@@ -2,4 +2,5 @@ node_modules
.DS_Store
yarn.lock
package-lock.json
src
src
tsconfig.json
@@ -0,0 +1,73 @@
# Canvas proxy maintenance
Public entry: `src/index.ts`; public declarations remain in
`types/artplayer-proxy-canvas.d.ts`. The factory accepts the optional drawing callback,
then synchronously returns an actual HTMLCanvasElement when registered with ArtPlayer.
Public declaration/export consumer work continues in PKG-CANVAS-04.
Historical JavaScript falsy callback arguments remain ignored at runtime; the typed public
callback remains optional and function-valued.
| Module | Ownership and responsibility |
| --- | --- |
| index.ts | Host event subscriptions, deferred media forwarding, initialization rollback and terminal disposal |
| adapter.ts | Preserve native canvas methods; forward only video properties absent from canvas; guard escaped media actions after disposal |
| media.ts | Connect the backing video before playback and release its source, stream reference and DOM node |
| geometry.ts | Finite intrinsic dimensions and aspect-fit canvas/padding calculations |
| renderer.ts | Decoded-frame eligibility, bitmap acquisition/close, callback and draw/error notification order |
| scheduler.ts | One outstanding draw, coalesced requests, generations and nullable RAF ownership |
Only the entry receives ArtPlayer. The renderer has no host/DOM ownership, the scheduler
has no media knowledge, and geometry is independent of scheduling. No package imports
private core implementation modules.
## State and event order
Canvas methods keep their receiver and win over same-named video methods. Media properties
remain live enumerable/configurable descriptors. Assigning src/srcObject or invoking load
invalidates pending frames; the returned canvas and callback arguments keep their identities.
The video is connected before play with absolute positioning, transparent opacity, no focus
or pointer interaction, and intrinsic CSS sizing. Do not change it to a 1px square: tested
WebKit then reports 1x1 media dimensions and loses the source geometry.
Media forwarding remains deferred until core initialization has installed its event registry.
Native events are forwarded as `video:<type>` with the original Event. Native removal is
owned before calling the extensible art.proxy registrar, so partial registration can roll back.
Host subscriptions are also owned before art.on, including reentrant destruction during setup.
play starts one draw chain; duplicate play does not start another. resize coalesces a new
draw and invalidates an in-flight result. pause/source replacement invalidates pending work
and cancels the current RAF, including ID zero. Paused seek requests one fresh frame.
After bitmap acquisition, stale results are closed without drawing. Acquired bitmaps are
closed in finally even if drawImage throws, and before invoking the public callback. An
active callback failure emits the original error; a callback destroying the host cannot emit
a subsequent draw event or restart work. Observer failures cannot strand the scheduler busy.
readyState alone does not prove a drawable frame. Seeking or insufficient data suspends
drawing. The recorded Chromium first-frame case rejects bitmap acquisition with
InvalidStateError at currentTime 0 and zero video frames even at readyState 4; that exact
acquisition condition is deferred until another request. A zero frame counter is not a
general capability check: tested WebKit draws successfully with that counter at zero.
Errors from user callbacks or drawImage are not classified as bitmap acquisition failures.
destroy invalidates generations, clears the deferred timer, removes all host/native
subscriptions, pauses and unloads backing video, releases srcObject without stopping
consumer-owned tracks, removes the internal video and clears canvas buffers. Cleanup
attempts every resource even when one disposer throws. Captured play/load methods and
forwarded setters become inert. Native Canvas methods still operate on the same element.
## Verification and remaining work
`yarn test:canvas` runs frozen historical and candidate lifecycle tests. Candidate tests can
be run against the frozen workspace with ARTPLAYER_CANVAS_BASELINE=1. Historical inputs
come from verified archives/Git via `refactor/scripts/canvas-contract.mjs`, not current dist.
`test/browser/canvas-lifecycle.spec.js` tests actual candidate source, native pixels and
dimensions, paused seek, source replacement, callback destruction and escaped methods on
actual npm core 5.4.0 and the candidate core in three engines. `ambilight-proxy.spec.js`
uses the candidate source loader after module splitting; its palette test proves sampling
geometry and is separate from native media pixel acceptance. npm core 5.1.7 has no proxy
option and is only a native-player capability control.
Public type/import compatibility, full installed artifacts and 8082 demos remain 04/06.
Real Safari/mobile/device scope remains 05; Windows Playwright WebKit is not a device claim.
See `refactor/canvas-validation.md` and the task evidence for actual outcomes and limitations.
@@ -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.artplayerProxyCanvas=t(),define(function(){return t.artplayerProxyCanvas})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyCanvas=t()}(this,function(){"use strict";return function(e){return t=>{const{constructor:n}=t,{createElement:i}=n.utils,o=i("canvas"),a=i("video"),r=o.getContext("2d");let c=null;const d={};for(const e in o)"function"===typeof o[e]&&(d[e]=o[e].bind(o));for(const e in a)e in o||Object.defineProperty(o,e,{get(){const t=a[e];return"function"===typeof t?t.bind(a):t},set(t){a[e]=t},configurable:!0,enumerable:!0});for(const e in d)o[e]=function(...t){return e in d?d[e](...t):a[e](...t)};return function(){const{events:e}=n.config;setTimeout(()=>{e.forEach(e=>{t.proxy(a,e,e=>{t.emit(`video:${e.type}`,e)})})})}(),t.on("video:loadedmetadata",()=>{o.width=a.videoWidth,o.height=a.videoHeight}),t.on("video:play",()=>{cancelAnimationFrame(c),f()}),t.on("video:pause",()=>{cancelAnimationFrame(c)}),t.on("resize",()=>{!function(){const e=t.template?.$player;if(!e||t.option.autoSize)return;const n=a.videoWidth/a.videoHeight,i=e.clientWidth,r=e.clientHeight;let c,d;i/r>n?(d=r,c=d*n):(c=i,d=c/n),o.width=c,o.height=d;const s=(i-c)/2,f=(r-d)/2;Object.assign(o.style,{padding:`${f}px ${s}px`})}(),s()}),t.on("destroy",()=>{cancelAnimationFrame(c)}),o;async function s(){try{if("undefined"!==typeof createImageBitmap){const e=await createImageBitmap(a);r.drawImage(e,0,0,o.width,o.height),e.close()}else r.drawImage(a,0,0,o.width,o.height);e&&e(r,a),t.emit("artplayerProxyCanvas:draw",r,a)}catch(n){t.emit("artplayerProxyCanvas:error",n)}}async function f(){await s(),c=requestAnimationFrame(f)}}}});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyCanvas=t(),define(function(){return e.artplayerProxyCanvas})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyCanvas=t()}(this,function(){"use strict";function e(e){return Number.isFinite(e.videoWidth)&&Number.isFinite(e.videoHeight)&&e.videoWidth>0&&e.videoHeight>0}function t(e,t){let n=!1,i=!1,o=!1,r=!1,a=0,s=null;function l(){null!==s&&cancelAnimationFrame(s),s=null}function c(){if(!o&&!i&&t())if(r){r=!1,o=!0;const n=a,s=()=>{o=!1,c()};e(()=>!i&&t()&&a===n).then(s,s)}else n&&null===s&&(s=requestAnimationFrame(()=>{s=null,r=!0,c()}))}function u(){!i&&t()&&(a++,r=!0,l(),c())}function f(){n=!1,r=!1,a++,l()}return{request:u,start(){n||i||!t()||(n=!0,u())},stop:f,destroy(){i=!0,f()}}}return function(n){return i=>{const o=i.constructor,r=o.utils.createElement("canvas"),a=o.utils.createElement("video");let s=i,l=i.isDestroy,c=null;const u=[],f=()=>!l&&!s?.isDestroy,d=function(e,t,n){let i=!1;return{mount(){if(i||!n())return;const o=t();o&&(e.setAttribute("aria-hidden","true"),e.tabIndex=-1,e.playsInline=!0,Object.assign(e.style,{position:"absolute",width:"auto",height:"auto",opacity:"0",pointerEvents:"none",left:"0",top:"0"}),o.appendChild(e),i=!0,n()||e.remove())},destroy(){const t=[],n=()=>{e.srcObject=null};for(const i of[()=>e.pause(),()=>e.removeAttribute("src"),n,()=>e.load(),()=>e.remove()])try{i()}catch(o){t.push(o)}if(i=!1,t.length)throw t[0]}}}(a,()=>s?.template?.$player,f),p=function(t,n,i,o,r){const a=t.getContext("2d");let s=!1;return{available:()=>!!a||(s||(s=!0,r(new Error("Canvas 2D context is unavailable"))),!1),async draw(s){const l=()=>s()&&n.readyState>=2&&!n.seeking;if(!a||!l()||!e(n)||!t.width||!t.height)return;let c=!1;try{if("undefined"!==typeof createImageBitmap){c=!0;const e=await createImageBitmap(n);c=!1;try{l()&&a.drawImage(e,0,0,t.width,t.height)}finally{e.close()}}else a.drawImage(n,0,0,t.width,t.height);if(!l())return;i&&i(a,n),l()&&o(a,n)}catch(u){const e=c&&"object"===typeof u&&null!==u&&"name"in u&&"InvalidStateError"===u.name&&0===n.currentTime&&"function"===typeof n.getVideoPlaybackQuality&&0===n.getVideoPlaybackQuality().totalVideoFrames;l()&&!e&&r(u)}}}}(r,a,n,(e,t)=>s?.emit("artplayerProxyCanvas:draw",e,t),e=>s?.emit("artplayerProxyCanvas:error",e)),h=t(p.draw,f);if(function(e,t,n,i,o){const r=new Map;for(const a in e){const t=Reflect.get(e,a);"function"===typeof t&&r.set(a,(...n)=>Reflect.apply(t,e,n))}for(const a in t)a in e||Object.defineProperty(e,a,{get(){const e=Reflect.get(t,a);return"function"!==typeof e?e:(...r)=>n()?("load"===a&&i(),"play"===a&&o(),Reflect.apply(e,t,r)):"play"===a?Promise.resolve():void 0},set(e){n()&&("src"!==a&&"srcObject"!==a||i(),Reflect.set(t,a,e))},configurable:!0,enumerable:!0});for(const[a,s]of r)Reflect.set(e,a,s)}(r,a,f,h.stop,d.mount),l)return s=null,r;function y(){if(l)return;l=!0,h.destroy(),null!==c&&clearTimeout(c),c=null;const e=[];for(const n of[...u.splice(0).reverse(),d.destroy])try{n()}catch(t){e.push(t)}if(s=null,r.width=0,r.height=0,e.length)throw e[0]}function m(e,t){if(!f())return;const n=()=>{("destroy"===e||f())&&t()},o=()=>i.off(e,n);u.push(o),i.on(e,n),l&&o()}function v(){p.available()&&h.request()}try{m("destroy",y),m("video:loadedmetadata",()=>{e(a)&&(r.width=a.videoWidth,r.height=a.videoHeight)}),m("video:play",()=>{d.mount(),p.available()&&h.start()}),m("video:pause",h.stop),m("video:emptied",h.stop),m("video:seeked",v),m("resize",()=>{!function(t,n,i,o){if(!i||o||!e(n))return;const r=i.clientWidth,a=i.clientHeight;if(!Number.isFinite(r)||!Number.isFinite(a)||r<=0||a<=0)return;const s=n.videoWidth/n.videoHeight,l=r/a>s?a*s:r,c=r/a>s?a:r/s;t.width=l,t.height=c,t.style.padding=`${(a-c)/2}px ${(r-l)/2}px`}(r,a,s?.template?.$player,s?.option.autoSize),v()}),f()&&(c=setTimeout(()=>{if(c=null,f())try{d.mount();for(const e of o.config.events){if(!f())break;const t=e=>{f()&&s?.emit(`video:${e.type}`,e)},n=()=>a.removeEventListener(e,t);u.push(n);const o=i.proxy(a,e,t);f()?u.push(o):o()}}catch(e){try{y()}catch{}i.emit("artplayerProxyCanvas:error",e)}}))}catch(g){try{y()}catch{}throw g}return r}}});
@@ -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.artplayerProxyCanvas=t(),define(function(){return t.artplayerProxyCanvas})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyCanvas=t()}(this,function(){"use strict";var e=(e,t,n)=>new Promise((i,o)=>{var r=e=>{try{c(n.next(e))}catch(t){o(t)}},a=e=>{try{c(n.throw(e))}catch(t){o(t)}},c=e=>e.done?i(e.value):Promise.resolve(e.value).then(r,a);c((n=n.apply(e,t)).next())});return function(t){return n=>{const{constructor:i}=n,{createElement:o}=i.utils,r=o("canvas"),a=o("video"),c=r.getContext("2d");let d=null;const l={};for(const e in r)"function"===typeof r[e]&&(l[e]=r[e].bind(r));for(const e in a)e in r||Object.defineProperty(r,e,{get(){const t=a[e];return"function"===typeof t?t.bind(a):t},set(t){a[e]=t},configurable:!0,enumerable:!0});for(const e in l)r[e]=function(...t){return e in l?l[e](...t):a[e](...t)};return function(){const{events:e}=i.config;setTimeout(()=>{e.forEach(e=>{n.proxy(a,e,e=>{n.emit(`video:${e.type}`,e)})})})}(),n.on("video:loadedmetadata",()=>{r.width=a.videoWidth,r.height=a.videoHeight}),n.on("video:play",()=>{cancelAnimationFrame(d),u()}),n.on("video:pause",()=>{cancelAnimationFrame(d)}),n.on("resize",()=>{!function(){var e;const t=null==(e=n.template)?void 0:e.$player;if(!t||n.option.autoSize)return;const i=a.videoWidth/a.videoHeight,o=t.clientWidth,c=t.clientHeight;let d,l;o/c>i?(l=c,d=l*i):(d=o,l=d/i),r.width=d,r.height=l;const s=(o-d)/2,u=(c-l)/2;Object.assign(r.style,{padding:`${u}px ${s}px`})}(),s()}),n.on("destroy",()=>{cancelAnimationFrame(d)}),r;function s(){return e(this,null,function*(){try{if("undefined"!==typeof createImageBitmap){const e=yield createImageBitmap(a);c.drawImage(e,0,0,r.width,r.height),e.close()}else c.drawImage(a,0,0,r.width,r.height);t&&t(c,a),n.emit("artplayerProxyCanvas:draw",c,a)}catch(e){n.emit("artplayerProxyCanvas:error",e)}})}function u(){return e(this,null,function*(){yield s(),d=requestAnimationFrame(u)})}}}});
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyCanvas=t(),define(function(){return e.artplayerProxyCanvas})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyCanvas=t()}(this,function(){"use strict";function e(e){return Number.isFinite(e.videoWidth)&&Number.isFinite(e.videoHeight)&&e.videoWidth>0&&e.videoHeight>0}function t(t,n,i,o,r){const l=t.getContext("2d");let a=!1;return{available:()=>!!l||(a||(a=!0,r(new Error("Canvas 2D context is unavailable"))),!1),draw(a){return s=this,u=null,c=function*(){const s=()=>a()&&n.readyState>=2&&!n.seeking;if(!l||!s()||!e(n)||!t.width||!t.height)return;let u=!1;try{if("undefined"!==typeof createImageBitmap){u=!0;const e=yield createImageBitmap(n);u=!1;try{s()&&l.drawImage(e,0,0,t.width,t.height)}finally{e.close()}}else l.drawImage(n,0,0,t.width,t.height);if(!s())return;i&&i(l,n),s()&&o(l,n)}catch(c){const e=u&&"object"===typeof c&&null!==c&&"name"in c&&"InvalidStateError"===c.name&&0===n.currentTime&&"function"===typeof n.getVideoPlaybackQuality&&0===n.getVideoPlaybackQuality().totalVideoFrames;s()&&!e&&r(c)}},new Promise((e,t)=>{var n=e=>{try{o(c.next(e))}catch(n){t(n)}},i=e=>{try{o(c.throw(e))}catch(n){t(n)}},o=t=>t.done?e(t.value):Promise.resolve(t.value).then(n,i);o((c=c.apply(s,u)).next())});var s,u,c}}}function n(e,t){let n=!1,i=!1,o=!1,r=!1,l=0,a=null;function s(){null!==a&&cancelAnimationFrame(a),a=null}function u(){if(!o&&!i&&t())if(r){r=!1,o=!0;const n=l,a=()=>{o=!1,u()};e(()=>!i&&t()&&l===n).then(a,a)}else n&&null===a&&(a=requestAnimationFrame(()=>{a=null,r=!0,u()}))}function c(){!i&&t()&&(l++,r=!0,s(),u())}function d(){n=!1,r=!1,l++,s()}return{request:c,start(){n||i||!t()||(n=!0,c())},stop:d,destroy(){i=!0,d()}}}return function(i){return o=>{const r=o.constructor,l=r.utils.createElement("canvas"),a=r.utils.createElement("video");let s=o,u=o.isDestroy,c=null;const d=[],f=()=>!u&&!(null==s?void 0:s.isDestroy),h=function(e,t,n){let i=!1;return{mount(){if(i||!n())return;const o=t();o&&(e.setAttribute("aria-hidden","true"),e.tabIndex=-1,e.playsInline=!0,Object.assign(e.style,{position:"absolute",width:"auto",height:"auto",opacity:"0",pointerEvents:"none",left:"0",top:"0"}),o.appendChild(e),i=!0,n()||e.remove())},destroy(){const t=[],n=()=>{e.srcObject=null};for(const i of[()=>e.pause(),()=>e.removeAttribute("src"),n,()=>e.load(),()=>e.remove()])try{i()}catch(o){t.push(o)}if(i=!1,t.length)throw t[0]}}}(a,()=>{var e;return null==(e=null==s?void 0:s.template)?void 0:e.$player},f),p=t(l,a,i,(e,t)=>null==s?void 0:s.emit("artplayerProxyCanvas:draw",e,t),e=>null==s?void 0:s.emit("artplayerProxyCanvas:error",e)),y=n(p.draw,f);if(function(e,t,n,i,o){const r=new Map;for(const l in e){const t=Reflect.get(e,l);"function"===typeof t&&r.set(l,(...n)=>Reflect.apply(t,e,n))}for(const l in t)l in e||Object.defineProperty(e,l,{get(){const e=Reflect.get(t,l);return"function"!==typeof e?e:(...r)=>n()?("load"===l&&i(),"play"===l&&o(),Reflect.apply(e,t,r)):"play"===l?Promise.resolve():void 0},set(e){n()&&("src"!==l&&"srcObject"!==l||i(),Reflect.set(t,l,e))},configurable:!0,enumerable:!0});for(const[l,a]of r)Reflect.set(e,l,a)}(l,a,f,y.stop,h.mount),u)return s=null,l;function v(){if(u)return;u=!0,y.destroy(),null!==c&&clearTimeout(c),c=null;const e=[];for(const n of[...d.splice(0).reverse(),h.destroy])try{n()}catch(t){e.push(t)}if(s=null,l.width=0,l.height=0,e.length)throw e[0]}function m(e,t){if(!f())return;const n=()=>{("destroy"===e||f())&&t()},i=()=>o.off(e,n);d.push(i),o.on(e,n),u&&i()}function g(){p.available()&&y.request()}try{m("destroy",v),m("video:loadedmetadata",()=>{e(a)&&(l.width=a.videoWidth,l.height=a.videoHeight)}),m("video:play",()=>{h.mount(),p.available()&&y.start()}),m("video:pause",y.stop),m("video:emptied",y.stop),m("video:seeked",g),m("resize",()=>{var t;!function(t,n,i,o){if(!i||o||!e(n))return;const r=i.clientWidth,l=i.clientHeight;if(!Number.isFinite(r)||!Number.isFinite(l)||r<=0||l<=0)return;const a=n.videoWidth/n.videoHeight,s=r/l>a?l*a:r,u=r/l>a?l:r/a;t.width=s,t.height=u,t.style.padding=`${(l-u)/2}px ${(r-s)/2}px`}(l,a,null==(t=null==s?void 0:s.template)?void 0:t.$player,null==s?void 0:s.option.autoSize),g()}),f()&&(c=setTimeout(()=>{if(c=null,f())try{h.mount();for(const e of r.config.events){if(!f())break;const t=e=>{f()&&(null==s||s.emit(`video:${e.type}`,e))},n=()=>a.removeEventListener(e,t);d.push(n);const i=o.proxy(a,e,t);f()?d.push(i):i()}}catch(e){try{v()}catch(t){}o.emit("artplayerProxyCanvas:error",e)}}))}catch(b){try{v()}catch(w){}throw b}return l}}});
@@ -4,119 +4,322 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
function artplayerProxyCanvas(callback) {
return (art) => {
const { constructor } = art;
const { createElement } = constructor.utils;
const canvas = createElement("canvas");
const video = createElement("video");
const ctx = canvas.getContext("2d");
let animationId = null;
const originalCanvasMethods = {};
for (const prop in canvas) {
if (typeof canvas[prop] === "function") {
originalCanvasMethods[prop] = canvas[prop].bind(canvas);
}
}
for (const prop in video) {
if (!(prop in canvas)) {
Object.defineProperty(canvas, prop, {
get() {
const value = video[prop];
return typeof value === "function" ? value.bind(video) : value;
},
set(value) {
video[prop] = value;
},
configurable: true,
enumerable: true
});
}
}
for (const prop in originalCanvasMethods) {
canvas[prop] = function(...args) {
if (prop in originalCanvasMethods) {
return originalCanvasMethods[prop](...args);
}
return video[prop](...args);
function forwardMedia(canvas, video, active, invalidate, mount) {
const methods = /* @__PURE__ */ new Map();
for (const name in canvas) {
const value = Reflect.get(canvas, name);
if (typeof value === "function")
methods.set(name, (...args) => Reflect.apply(value, canvas, args));
}
for (const name in video) {
if (name in canvas)
continue;
Object.defineProperty(canvas, name, {
get() {
const value = Reflect.get(video, name);
if (typeof value !== "function")
return value;
return (...args) => {
if (!active())
return name === "play" ? Promise.resolve() : void 0;
if (name === "load")
invalidate();
if (name === "play")
mount();
return Reflect.apply(value, video, args);
};
},
set(value) {
if (!active())
return;
if (name === "src" || name === "srcObject")
invalidate();
Reflect.set(video, name, value);
},
configurable: true,
enumerable: true
});
}
for (const [name, method] of methods)
Reflect.set(canvas, name, method);
}
function hasDimensions(video) {
return Number.isFinite(video.videoWidth) && Number.isFinite(video.videoHeight) && video.videoWidth > 0 && video.videoHeight > 0;
}
function resizeCanvas(canvas, video, player, autoSize) {
if (!player || autoSize || !hasDimensions(video))
return;
const width = player.clientWidth;
const height = player.clientHeight;
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
return;
const aspect = video.videoWidth / video.videoHeight;
const canvasWidth = width / height > aspect ? height * aspect : width;
const canvasHeight = width / height > aspect ? height : width / aspect;
canvas.width = canvasWidth;
canvas.height = canvasHeight;
canvas.style.padding = `${(height - canvasHeight) / 2}px ${(width - canvasWidth) / 2}px`;
}
function ownMedia(video, parent, active) {
let mounted = false;
return {
mount() {
if (mounted || !active())
return;
const target = parent();
if (!target)
return;
video.setAttribute("aria-hidden", "true");
video.tabIndex = -1;
video.playsInline = true;
Object.assign(video.style, { position: "absolute", width: "auto", height: "auto", opacity: "0", pointerEvents: "none", left: "0", top: "0" });
target.appendChild(video);
mounted = true;
if (!active())
video.remove();
},
destroy() {
const failures = [];
const clearObject = () => {
video.srcObject = null;
};
for (const release of [() => video.pause(), () => video.removeAttribute("src"), clearObject, () => video.load(), () => video.remove()]) {
try {
release();
} catch (error) {
failures.push(error);
}
}
mounted = false;
if (failures.length)
throw failures[0];
}
setupEventListeners();
setupArtPlayerEvents();
return canvas;
function setupEventListeners() {
const { events } = constructor.config;
setTimeout(() => {
events.forEach((event) => {
art.proxy(video, event, (event2) => {
art.emit(`video:${event2.type}`, event2);
});
});
});
}
async function draw() {
};
}
function createRenderer(canvas, video, callback, notify, error) {
const context = canvas.getContext("2d");
let reported = false;
return {
available() {
if (context)
return true;
if (!reported) {
reported = true;
error(new Error("Canvas 2D context is unavailable"));
}
return false;
},
async draw(valid) {
const usable = () => valid() && video.readyState >= 2 && !video.seeking;
if (!context || !usable() || !hasDimensions(video) || !canvas.width || !canvas.height)
return;
let acquiring = false;
try {
if (typeof createImageBitmap !== "undefined") {
acquiring = true;
const bitmap = await createImageBitmap(video);
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
bitmap.close();
acquiring = false;
try {
if (usable())
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
} finally {
bitmap.close();
}
} else {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
context.drawImage(video, 0, 0, canvas.width, canvas.height);
}
callback && callback(ctx, video);
art.emit("artplayerProxyCanvas:draw", ctx, video);
} catch (error) {
art.emit("artplayerProxyCanvas:error", error);
if (!usable())
return;
if (callback)
callback(context, video);
if (usable())
notify(context, video);
} catch (failure) {
const awaitingFirstFrame = acquiring && typeof failure === "object" && failure !== null && "name" in failure && failure.name === "InvalidStateError" && video.currentTime === 0 && typeof video.getVideoPlaybackQuality === "function" && video.getVideoPlaybackQuality().totalVideoFrames === 0;
if (usable() && !awaitingFirstFrame)
error(failure);
}
}
async function animation() {
await draw();
animationId = requestAnimationFrame(animation);
};
}
function createFrameScheduler(draw, active) {
let running = false;
let closed = false;
let busy = false;
let pending = false;
let generation = 0;
let frame = null;
function cancel() {
if (frame !== null)
cancelAnimationFrame(frame);
frame = null;
}
function drain() {
if (busy || closed || !active())
return;
if (pending) {
pending = false;
busy = true;
const version = generation;
const valid = () => !closed && active() && generation === version;
const complete = () => {
busy = false;
drain();
};
void draw(valid).then(complete, complete);
} else if (running && frame === null) {
frame = requestAnimationFrame(() => {
frame = null;
pending = true;
drain();
});
}
function resize() {
const player = art.template?.$player;
if (!player || art.option.autoSize)
}
function request() {
if (closed || !active())
return;
generation++;
pending = true;
cancel();
drain();
}
function stop() {
running = false;
pending = false;
generation++;
cancel();
}
return {
request,
start() {
if (running || closed || !active())
return;
const aspectRatio = video.videoWidth / video.videoHeight;
const containerWidth = player.clientWidth;
const containerHeight = player.clientHeight;
const containerRatio = containerWidth / containerHeight;
let canvasWidth, canvasHeight;
if (containerRatio > aspectRatio) {
canvasHeight = containerHeight;
canvasWidth = canvasHeight * aspectRatio;
} else {
canvasWidth = containerWidth;
canvasHeight = canvasWidth / aspectRatio;
running = true;
request();
},
stop,
destroy() {
closed = true;
stop();
}
};
}
function artplayerProxyCanvas(callback) {
return (art) => {
const constructor = art.constructor;
const canvas = constructor.utils.createElement("canvas");
const video = constructor.utils.createElement("video");
let host = art;
let closed = art.isDestroy;
let timer = null;
const releases = [];
const active = () => !closed && !host?.isDestroy;
const media = ownMedia(video, () => host?.template?.$player, active);
const renderer = createRenderer(canvas, video, callback, (context, source) => host?.emit("artplayerProxyCanvas:draw", context, source), (error) => host?.emit("artplayerProxyCanvas:error", error));
const scheduler = createFrameScheduler(renderer.draw, active);
forwardMedia(canvas, video, active, scheduler.stop, media.mount);
if (closed) {
host = null;
return canvas;
}
function destroy() {
if (closed)
return;
closed = true;
scheduler.destroy();
if (timer !== null)
clearTimeout(timer);
timer = null;
const failures = [];
for (const release of [...releases.splice(0).reverse(), media.destroy]) {
try {
release();
} catch (error) {
failures.push(error);
}
}
canvas.width = canvasWidth;
canvas.height = canvasHeight;
const paddingLeft = (containerWidth - canvasWidth) / 2;
const paddingTop = (containerHeight - canvasHeight) / 2;
Object.assign(canvas.style, {
padding: `${paddingTop}px ${paddingLeft}px`
});
host = null;
canvas.width = 0;
canvas.height = 0;
if (failures.length)
throw failures[0];
}
function setupArtPlayerEvents() {
art.on("video:loadedmetadata", () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
});
art.on("video:play", () => {
cancelAnimationFrame(animationId);
animation();
});
art.on("video:pause", () => {
cancelAnimationFrame(animationId);
});
art.on("resize", () => {
resize();
draw();
});
art.on("destroy", () => {
cancelAnimationFrame(animationId);
});
function listen(name, handler) {
if (!active())
return;
const listener = () => {
if (name === "destroy" || active())
handler();
};
const release = () => art.off(name, listener);
releases.push(release);
art.on(name, listener);
if (closed)
release();
}
function request() {
if (renderer.available())
scheduler.request();
}
try {
listen("destroy", destroy);
listen("video:loadedmetadata", () => {
if (hasDimensions(video)) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
}
});
listen("video:play", () => {
media.mount();
if (renderer.available())
scheduler.start();
});
listen("video:pause", scheduler.stop);
listen("video:emptied", scheduler.stop);
listen("video:seeked", request);
listen("resize", () => {
resizeCanvas(canvas, video, host?.template?.$player, host?.option.autoSize);
request();
});
if (active()) {
timer = setTimeout(() => {
timer = null;
if (!active())
return;
try {
media.mount();
for (const name of constructor.config.events) {
if (!active())
break;
const forward = (event) => {
if (active())
host?.emit(`video:${event.type}`, event);
};
const remove = () => video.removeEventListener(name, forward);
releases.push(remove);
const release = art.proxy(video, name, forward);
if (active())
releases.push(release);
else
release();
}
} catch (error) {
try {
destroy();
} catch {
}
art.emit("artplayerProxyCanvas:error", error);
}
});
}
} catch (error) {
try {
destroy();
} catch {
}
throw error;
}
return canvas;
};
}
export {
@@ -0,0 +1,39 @@
export function forwardMedia(canvas: HTMLCanvasElement, video: HTMLVideoElement, active: () => boolean, invalidate: () => void, mount: () => void): void {
const methods = new Map<string, (...args: unknown[]) => unknown>()
for (const name in canvas) {
const value: unknown = Reflect.get(canvas, name)
if (typeof value === 'function')
methods.set(name, (...args) => Reflect.apply(value, canvas, args))
}
for (const name in video) {
if (name in canvas)
continue
Object.defineProperty(canvas, name, {
get() {
const value: unknown = Reflect.get(video, name)
if (typeof value !== 'function')
return value
return (...args: unknown[]) => {
if (!active())
return name === 'play' ? Promise.resolve() : undefined
if (name === 'load')
invalidate()
if (name === 'play')
mount()
return Reflect.apply(value, video, args)
}
},
set(value: unknown) {
if (!active())
return
if (name === 'src' || name === 'srcObject')
invalidate()
Reflect.set(video, name, value)
},
configurable: true,
enumerable: true,
})
}
for (const [name, method] of methods)
Reflect.set(canvas, name, method)
}
@@ -0,0 +1,18 @@
export function hasDimensions(video: HTMLVideoElement): boolean {
return Number.isFinite(video.videoWidth) && Number.isFinite(video.videoHeight) && video.videoWidth > 0 && video.videoHeight > 0
}
export function resizeCanvas(canvas: HTMLCanvasElement, video: HTMLVideoElement, player: HTMLElement | null | undefined, autoSize: boolean | undefined): void {
if (!player || autoSize || !hasDimensions(video))
return
const width = player.clientWidth
const height = player.clientHeight
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
return
const aspect = video.videoWidth / video.videoHeight
const canvasWidth = width / height > aspect ? height * aspect : width
const canvasHeight = width / height > aspect ? height : width / aspect
canvas.width = canvasWidth
canvas.height = canvasHeight
canvas.style.padding = `${(height - canvasHeight) / 2}px ${(width - canvasWidth) / 2}px`
}
@@ -1,139 +0,0 @@
export default function artplayerProxyCanvas(callback) {
return (art) => {
const { constructor } = art
const { createElement } = constructor.utils
const canvas = createElement('canvas')
const video = createElement('video')
const ctx = canvas.getContext('2d')
let animationId = null
const originalCanvasMethods = {}
for (const prop in canvas) {
if (typeof canvas[prop] === 'function') {
originalCanvasMethods[prop] = canvas[prop].bind(canvas)
}
}
for (const prop in video) {
if (!(prop in canvas)) {
Object.defineProperty(canvas, prop, {
get() {
const value = video[prop]
return typeof value === 'function' ? value.bind(video) : value
},
set(value) {
video[prop] = value
},
configurable: true,
enumerable: true,
})
}
}
for (const prop in originalCanvasMethods) {
canvas[prop] = function (...args) {
if (prop in originalCanvasMethods) {
return originalCanvasMethods[prop](...args)
}
return video[prop](...args)
}
}
setupEventListeners()
setupArtPlayerEvents()
return canvas
function setupEventListeners() {
const { events } = constructor.config
setTimeout(() => {
events.forEach((event) => {
art.proxy(video, event, (event) => {
art.emit(`video:${event.type}`, event)
})
})
})
}
async function draw() {
try {
if (typeof createImageBitmap !== 'undefined') {
const bitmap = await createImageBitmap(video)
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
bitmap.close()
}
else {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
}
callback && callback(ctx, video)
art.emit('artplayerProxyCanvas:draw', ctx, video)
}
catch (error) {
art.emit('artplayerProxyCanvas:error', error)
}
}
async function animation() {
await draw()
animationId = requestAnimationFrame(animation)
}
function resize() {
const player = art.template?.$player
if (!player || art.option.autoSize)
return
const aspectRatio = video.videoWidth / video.videoHeight
const containerWidth = player.clientWidth
const containerHeight = player.clientHeight
const containerRatio = containerWidth / containerHeight
let canvasWidth, canvasHeight
if (containerRatio > aspectRatio) {
canvasHeight = containerHeight
canvasWidth = canvasHeight * aspectRatio
}
else {
canvasWidth = containerWidth
canvasHeight = canvasWidth / aspectRatio
}
canvas.width = canvasWidth
canvas.height = canvasHeight
const paddingLeft = (containerWidth - canvasWidth) / 2
const paddingTop = (containerHeight - canvasHeight) / 2
Object.assign(canvas.style, {
padding: `${paddingTop}px ${paddingLeft}px`,
})
}
function setupArtPlayerEvents() {
art.on('video:loadedmetadata', () => {
canvas.width = video.videoWidth
canvas.height = video.videoHeight
})
art.on('video:play', () => {
cancelAnimationFrame(animationId)
animation()
})
art.on('video:pause', () => {
cancelAnimationFrame(animationId)
})
art.on('resize', () => {
resize()
draw()
})
art.on('destroy', () => {
cancelAnimationFrame(animationId)
})
}
}
}
@@ -0,0 +1,132 @@
import type Artplayer from 'artplayer'
import type { DrawCallback } from './renderer'
import { forwardMedia } from './adapter'
import { hasDimensions, resizeCanvas } from './geometry'
import { ownMedia } from './media'
import { createRenderer } from './renderer'
import { createFrameScheduler } from './scheduler'
export default function artplayerProxyCanvas(callback?: DrawCallback) {
return (art: Artplayer): HTMLCanvasElement => {
const constructor = art.constructor as typeof Artplayer
const canvas = constructor.utils.createElement('canvas')
const video = constructor.utils.createElement('video')
let host: Artplayer | null = art
let closed = art.isDestroy
let timer: ReturnType<typeof setTimeout> | null = null
const releases: Array<() => void> = []
const active = () => !closed && !host?.isDestroy
const media = ownMedia(video, () => host?.template?.$player, active)
const renderer = createRenderer(canvas, video, callback, (context, source) => host?.emit('artplayerProxyCanvas:draw', context, source), error => host?.emit('artplayerProxyCanvas:error', error))
const scheduler = createFrameScheduler(renderer.draw, active)
forwardMedia(canvas, video, active, scheduler.stop, media.mount)
if (closed) {
host = null
return canvas
}
function destroy(): void {
if (closed)
return
closed = true
scheduler.destroy()
if (timer !== null)
clearTimeout(timer)
timer = null
const failures: unknown[] = []
for (const release of [...releases.splice(0).reverse(), media.destroy]) {
try {
release()
}
catch (error) { failures.push(error) }
}
host = null
canvas.width = 0
canvas.height = 0
if (failures.length)
throw failures[0]
}
function listen(name: string, handler: () => void): void {
if (!active())
return
const listener = () => {
if (name === 'destroy' || active())
handler()
}
const release = () => art.off(name, listener)
releases.push(release)
art.on(name, listener)
if (closed)
release()
}
function request(): void {
if (renderer.available())
scheduler.request()
}
try {
listen('destroy', destroy)
listen('video:loadedmetadata', () => {
if (hasDimensions(video)) {
canvas.width = video.videoWidth
canvas.height = video.videoHeight
}
})
listen('video:play', () => {
media.mount()
if (renderer.available())
scheduler.start()
})
listen('video:pause', scheduler.stop)
listen('video:emptied', scheduler.stop)
listen('video:seeked', request)
listen('resize', () => {
resizeCanvas(canvas, video, host?.template?.$player, host?.option.autoSize)
request()
})
if (active()) {
timer = setTimeout(() => {
timer = null
if (!active())
return
try {
media.mount()
for (const name of constructor.config.events) {
if (!active())
break
const forward = (event: Event) => {
if (active())
host?.emit(`video:${event.type}`, event)
}
// Own native removal before invoking an extensible proxy registrar.
const remove = () => video.removeEventListener(name, forward)
releases.push(remove)
const release = art.proxy(video, name, forward)
if (active())
releases.push(release)
else
release()
}
}
catch (error) {
try {
destroy()
}
catch { /* Finish rollback without replacing the initialization error. */ }
art.emit('artplayerProxyCanvas:error', error)
}
})
}
}
catch (error) {
try {
destroy()
}
catch { /* Preserve the original setup error. */ }
throw error
}
return canvas
}
}
@@ -0,0 +1,36 @@
export function ownMedia(video: HTMLVideoElement, parent: () => HTMLElement | null | undefined, active: () => boolean) {
let mounted = false
return {
mount() {
if (mounted || !active())
return
const target = parent()
if (!target)
return
video.setAttribute('aria-hidden', 'true')
video.tabIndex = -1
video.playsInline = true
// Keep media connected before playback without adding another visible player.
Object.assign(video.style, { position: 'absolute', width: 'auto', height: 'auto', opacity: '0', pointerEvents: 'none', left: '0', top: '0' })
target.appendChild(video)
mounted = true
if (!active())
video.remove()
},
destroy() {
const failures: unknown[] = []
const clearObject = () => {
video.srcObject = null
}
for (const release of [() => video.pause(), () => video.removeAttribute('src'), clearObject, () => video.load(), () => video.remove()]) {
try {
release()
}
catch (error) { failures.push(error) }
}
mounted = false
if (failures.length)
throw failures[0]
},
}
}
@@ -0,0 +1,55 @@
import { hasDimensions } from './geometry'
export type DrawCallback = (context: CanvasRenderingContext2D, video: HTMLVideoElement) => void
export function createRenderer(canvas: HTMLCanvasElement, video: HTMLVideoElement, callback: DrawCallback | undefined, notify: DrawCallback, error: (failure: unknown) => void) {
const context = canvas.getContext('2d')
let reported = false
return {
available() {
if (context)
return true
if (!reported) {
reported = true
error(new Error('Canvas 2D context is unavailable'))
}
return false
},
async draw(valid: () => boolean): Promise<void> {
const usable = () => valid() && video.readyState >= 2 && !video.seeking
if (!context || !usable() || !hasDimensions(video) || !canvas.width || !canvas.height)
return
let acquiring = false
try {
if (typeof createImageBitmap !== 'undefined') {
acquiring = true
const bitmap = await createImageBitmap(video)
acquiring = false
try {
if (usable())
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
}
finally { bitmap.close() }
}
else {
context.drawImage(video, 0, 0, canvas.width, canvas.height)
}
if (!usable())
return
if (callback)
callback(context, video)
if (usable())
notify(context, video)
}
catch (failure) {
// Chromium can expose readyState 4 before its first frame is usable.
// Other engines report zero frame counts even while drawing successfully.
const awaitingFirstFrame = acquiring && typeof failure === 'object' && failure !== null && 'name' in failure
&& failure.name === 'InvalidStateError' && video.currentTime === 0
&& typeof video.getVideoPlaybackQuality === 'function' && video.getVideoPlaybackQuality().totalVideoFrames === 0
if (usable() && !awaitingFirstFrame)
error(failure)
}
},
}
}
@@ -0,0 +1,69 @@
export function createFrameScheduler(draw: (valid: () => boolean) => Promise<void>, active: () => boolean) {
let running = false
let closed = false
let busy = false
let pending = false
let generation = 0
let frame: number | null = null
function cancel(): void {
if (frame !== null)
cancelAnimationFrame(frame)
frame = null
}
function drain(): void {
if (busy || closed || !active())
return
if (pending) {
pending = false
busy = true
const version = generation
const valid = () => !closed && active() && generation === version
const complete = () => {
busy = false
drain()
}
// An observer throwing must not leave the scheduler busy or reject unhandled.
void draw(valid).then(complete, complete)
}
else if (running && frame === null) {
frame = requestAnimationFrame(() => {
frame = null
pending = true
drain()
})
}
}
function request(): void {
if (closed || !active())
return
generation++
pending = true
cancel()
drain()
}
function stop(): void {
running = false
pending = false
generation++
cancel()
}
return {
request,
start() {
if (running || closed || !active())
return
running = true
request()
},
stop,
destroy() {
closed = true
stop()
},
}
}
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "allowJs": false },
"include": ["src/**/*.ts", "types/**/*.d.ts"]
}