refactor(mediabunny): [PKG-MB-04] isolate typed playback and readiness coordination

This commit is contained in:
Harvey Zhao committed 2026-09-12 19:00:21 +08:00
1 parent 9af253a5a9
commit 5d0b128483
29 files changed
+2841 -726

No files matched your search

+1 -1
View File
@@ -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?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t=e=>{throw TypeError(e)},i=(e,i,r)=>i.has(e)||t("Cannot "+r),r=(e,t,r)=>(i(e,t,"read from private field"),r?r.call(e):t.get(e));function a(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class s{constructor(){var i,r,a;i=this,a=!1,(r=e).has(i)?t("Cannot add the same private member more than once"):r instanceof WeakSet?r.add(i):r.set(i,a),this.listeners=new Map}addEventListener(t,i){r(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),this.listeners.get(t)?.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){if(r(this,e))return;const a=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{r(this,e)||t(a)})}destroy(){var t,r,a,s;a=!0,i(t=this,r=e,"write to private field"),s?s.call(t,a):r.set(t,a),this.listeners.clear()}}function n(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const o=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},c=e=>e&&e[e.length-1],l=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},u=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-2**r)},d=e=>{const t=l(e);return 0===(1&t)?-(t>>1):t+1>>1},h=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),f=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),m=new TextDecoder,p=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),g={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},k=p(g),y={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},b=p(y),w={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},T=p(w),S=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,v={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},P=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class I{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}async acquire(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,await i,e}}const x=/^[0-9a-fA-F]+$/,C=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),B=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,E=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+a>>1,o=i(e[n]);o===t?(s=n,a=n-1):o<t?r=n+1:a=n-1}return s},_=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+(a-r+1)/2|0;i(e[n])<=t?(s=n,r=n+1):a=n-1}return s},R=(e,t,i)=>{const r=_(e,i(t),i);e.splice(r+1,0,t)},F=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},A=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},D=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},M=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},N=e=>{throw new Error(`Unexpected value: ${e}`)},O=(e,t,i)=>{const r=e.getUint8(t),a=e.getUint8(t+1),s=e.getUint8(t+2);return i?r|a<<8|s<<16:r<<16|a<<8|s},L=(e,t,i,r)=>{i>>>=0,i&=16777215,e.setUint8(t,i>>>16&255),e.setUint8(t+1,i>>>8&255),e.setUint8(t+2,255&i)},z=(e,t)=>({async next(){const i=await e.next();return i.done?{value:void 0,done:!0}:{value:t(i.value),done:!1}},return:()=>e.return(),throw:t=>e.throw(t),[Symbol.asyncIterator](){return this}}),U=(e,t,i)=>Math.max(t,Math.min(i,e)),q="und",W=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},H=(e,t)=>Math.round(e/t)*t,V=(e,t)=>Math.round(e*t)/t,j=(e,t)=>Math.floor(e/t)*t,K=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},X=/^[a-z]{3}$/,$=e=>X.test(e),G=1e6*(1+Number.EPSILON),Q=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?Y(e.headers):{},a=t.headers?Y(t.headers):{},s={...r};Object.entries(a).forEach(([e,t])=>{const i=Object.keys(s).find(t=>t.toLowerCase()===e.toLowerCase());i&&delete s[i],s[e]=t}),i.headers=s}return i},Y=e=>{if(e instanceof Headers){const t={};return e.forEach((e,i)=>{t[i]=e}),t}if(Array.isArray(e)){const t={};return e.forEach(([e,i])=>{t[e]=i}),t}return e},J=async(e,t,i,r,a)=>{let s=0;for(;;)try{return await e(t,i)}catch(n){if(a())throw n;s++;const e=r(s,n,t);if(null===e)throw n;if(ve._error("Retrying failed fetch. Error:",n),!Number.isFinite(e)||e<0)throw new TypeError("Retry delay must be aLine truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i=e=>{throw TypeError(e)},r=(e,t,r)=>t.has(e)||i("Cannot "+r),a=(e,t,i)=>(r(e,t,"read from private field"),i?i.call(e):t.get(e)),s=(e,t,r)=>t.has(e)?i("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,r),n=(e,t,i,a)=>(r(e,t,"write to private field"),a?a.call(e,i):t.set(e,i),i);function o(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class c{constructor(){s(this,e,!1),this.listeners=new Map}addEventListener(t,i){a(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),this.listeners.get(t)?.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){if(a(this,e))return;const r=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{a(this,e)||t(r)})}destroy(){n(this,e,!0),this.listeners.clear()}}function l(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const u=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},d=e=>e&&e[e.length-1],h=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},f=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-2**r)},m=e=>{const t=h(e);return 0===(1&t)?-(t>>1):t+1>>1},p=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),g=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),k=new TextDecoder,y=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),b={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},w=y(b),T={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},S=y(T),v={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},P=y(v),I=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,x={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},C=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class B{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}async acquire(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,await i,e}}const E=/^[0-9a-fA-F]+$/,_=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),R=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,F=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+a>>1,o=i(e[n]);o===t?(s=n,a=n-1):o<t?r=n+1:a=n-1}return s},A=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+(a-r+1)/2|0;i(e[n])<=t?(s=n,r=n+1):a=n-1}return s},D=(e,t,i)=>{const r=A(e,i(t),i);e.splice(r+1,0,t)},M=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},N=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},O=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},L=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},z=e=>{throw new Error(`Unexpected value: ${e}`)},U=(e,t,i)=>{const r=e.getUint8(t),a=e.getUint8(t+1),s=e.getUint8(t+2);return i?r|a<<8|s<<16:r<<16|a<<8|s},q=(e,t,i,r)=>{i>>>=0,i&=16777215,e.setUint8(t,i>>>16&255),e.setUint8(t+1,i>>>8&255),e.setUint8(t+2,255&i)},W=(e,t)=>({async next(){const i=await e.next();return i.done?{value:void 0,done:!0}:{value:t(i.value),done:!1}},return:()=>e.return(),throw:t=>e.throw(t),[Symbol.asyncIterator](){return this}}),H=(e,t,i)=>Math.max(t,Math.min(i,e)),V="und",j=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},K=(e,t)=>Math.round(e/t)*t,X=(e,t)=>Math.round(e*t)/t,$=(e,t)=>Math.floor(e/t)*t,G=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},Q=/^[a-z]{3}$/,Y=e=>Q.test(e),J=1e6*(1+Number.EPSILON),Z=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?ee(e.headers):{},a=t.headers?ee(t.headers):{},s={...r};Object.entries(a).forEach(([e,t])=>{const i=Object.keys(s).find(t=>t.toLowerCase()===e.toLowerCase());i&&delete s[i],s[e]=t}),i.headers=s}return i},ee=e=>{if(e instanceof Headers){const t={};return e.forEach((e,i)=>{t[i]=e}),t}if(Array.isArray(e)){const t={};return e.forEach(([e,i])=>{t[e]=i}),t}return e},te=async(e,t,i,r,a)=>{let s=0;for(;;)try{return await e(t,i)}catch(n){if(a())throw n;s++;const e=r(s,n,t);if(null===e)throw n;if(xe._error("Retrying failed fetch. Error:",n),!Number.isFinite(e)||e<0)throw new TypeError("Retry delayLine 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?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t=Object.defineProperty,i=Object.defineProperties,r=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,s=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,o=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),l=e=>{throw TypeError(e)},c=Math.pow,u=(e,i,r)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[i]=r,d=(e,t)=>{for(var i in t||(t={}))s.call(t,i)&&u(e,i,t[i]);if(n)for(var i of n(t))a.call(t,i)&&u(e,i,t[i]);return e},h=(e,t)=>i(e,r(t)),f=(e,t,i)=>t.has(e)||l("Cannot "+i),m=(e,t,i)=>(f(e,t,"read from private field"),i?i.call(e):t.get(e)),p=(e,t,i)=>new Promise((r,n)=>{var s=e=>{try{o(i.next(e))}catch(t){n(t)}},a=e=>{try{o(i.throw(e))}catch(t){n(t)}},o=e=>e.done?r(e.value):Promise.resolve(e.value).then(s,a);o((i=i.apply(e,t)).next())}),g=function(e,t){this[0]=e,this[1]=t},k=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof g,l=a.done;Promise.resolve(o?t[0]:t).then(i=>o?n("return"===e?e:"next",t[1]?{done:i.done,value:i.value}:i,r,s):r({value:i,done:l})).catch(e=>n("throw",e,r,s))}catch(c){s(c)}},s=(e,t,i,s)=>a[e]=a=>(t=new Promise((t,i,s)=>(s=()=>n(e,a,t,i),r?r.then(s):s())),s=()=>r===i&&(r=0),r=i=t.then(s,s),t),a={};return i=i.apply(e,t),a[o("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},y=e=>{var t,i=e[o("asyncIterator")],r=!1,n={};return null==i?(i=e[o("iterator")](),t=e=>n[e]=t=>i[e](t)):(i=i.call(e),t=e=>n[e]=t=>{if(r){if(r=!1,"throw"===e)throw t;return t}return r=!0,{done:!1,value:new g(new Promise(r=>{var n=i[e](t);n instanceof Object||l("Object expected"),r(n)}),1)}}),n[o("iterator")]=()=>n,t("next"),"throw"in i?t("throw"):n.throw=e=>{throw e},"return"in i&&t("return"),n},b=(e,t,i)=>(t=e[o("asyncIterator")])?t.call(e):(e=e[o("iterator")](),t={},(i=(i,r)=>(r=e[i])&&(t[i]=t=>new Promise((i,n,s)=>(t=r.call(e,t),s=t.done,Promise.resolve(t.value).then(e=>i({value:e,done:s}),n)))))("next"),i("return"),t);function T(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class v{constructor(){var t,i,r;t=this,r=!1,(i=e).has(t)?l("Cannot add the same private member more than once"):i instanceof WeakSet?i.add(t):i.set(t,r),this.listeners=new Map}addEventListener(t,i){var r;m(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),null==(r=this.listeners.get(t))||r.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){var r;if(m(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{m(this,e)||t(n)})}destroy(){var t,i,r,n;r=!0,f(t=this,i=e,"write to private field"),n?n.call(t,r):i.set(t,r),this.listeners.clear()}}function S(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const w=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},P=e=>e&&e[e.length-1],I=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},x=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-c(2,r))},C=e=>{const t=I(e);return 0===(1&t)?-(t>>1):t+1>>1},B=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),E=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),_=new TextDecoder,R=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),F={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},A=R(F),D={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},M=R(D),N={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},O=R(N),L=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,z={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},U=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class q{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}acquire(){return p(this,null,function*(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,yield i,e})}}const W=/^[0-9a-fA-F]+$/,H=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),V=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,j=(e,t,i)=>{let r=0,n=e.length-1,s=-1;for(;r<=n;){const a=r+n>>1,o=i(e[a]);o===t?(s=a,n=a-1):o<t?r=a+1:n=a-1}return s},Line truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,s=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,o=Object.prototype.propertyIsEnumerable,l=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),c=e=>{throw TypeError(e)},u=Math.pow,d=(e,t,r)=>t in e?i(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,h=(e,t)=>{for(var i in t||(t={}))a.call(t,i)&&d(e,i,t[i]);if(s)for(var i of s(t))o.call(t,i)&&d(e,i,t[i]);return e},f=(e,t)=>r(e,n(t)),m=(e,t,i)=>t.has(e)||c("Cannot "+i),p=(e,t,i)=>(m(e,t,"read from private field"),i?i.call(e):t.get(e)),g=(e,t,i)=>t.has(e)?c("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),k=(e,t,i,r)=>(m(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),y=(e,t,i)=>new Promise((r,n)=>{var s=e=>{try{o(i.next(e))}catch(t){n(t)}},a=e=>{try{o(i.throw(e))}catch(t){n(t)}},o=e=>e.done?r(e.value):Promise.resolve(e.value).then(s,a);o((i=i.apply(e,t)).next())}),b=function(e,t){this[0]=e,this[1]=t},T=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof b,l=a.done;Promise.resolve(o?t[0]:t).then(i=>o?n("return"===e?e:"next",t[1]?{done:i.done,value:i.value}:i,r,s):r({value:i,done:l})).catch(e=>n("throw",e,r,s))}catch(c){s(c)}},s=(e,t,i,s)=>a[e]=a=>(t=new Promise((t,i,s)=>(s=()=>n(e,a,t,i),r?r.then(s):s())),s=()=>r===i&&(r=0),r=i=t.then(s,s),t),a={};return i=i.apply(e,t),a[l("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},v=e=>{var t,i=e[l("asyncIterator")],r=!1,n={};return null==i?(i=e[l("iterator")](),t=e=>n[e]=t=>i[e](t)):(i=i.call(e),t=e=>n[e]=t=>{if(r){if(r=!1,"throw"===e)throw t;return t}return r=!0,{done:!1,value:new b(new Promise(r=>{var n=i[e](t);n instanceof Object||c("Object expected"),r(n)}),1)}}),n[l("iterator")]=()=>n,t("next"),"throw"in i?t("throw"):n.throw=e=>{throw e},"return"in i&&t("return"),n},S=(e,t,i)=>(t=e[l("asyncIterator")])?t.call(e):(e=e[l("iterator")](),t={},(i=(i,r)=>(r=e[i])&&(t[i]=t=>new Promise((i,n,s)=>(t=r.call(e,t),s=t.done,Promise.resolve(t.value).then(e=>i({value:e,done:s}),n)))))("next"),i("return"),t);function w(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class P{constructor(){g(this,e,!1),this.listeners=new Map}addEventListener(t,i){var r;p(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),null==(r=this.listeners.get(t))||r.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){var r;if(p(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{p(this,e)||t(n)})}destroy(){k(this,e,!0),this.listeners.clear()}}function I(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const x=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},C=e=>e&&e[e.length-1],B=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},E=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-u(2,r))},_=e=>{const t=B(e);return 0===(1&t)?-(t>>1):t+1>>1},R=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),F=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),A=new TextDecoder,D=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),M={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},N=D(M),O={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},L=D(O),z={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},U=D(z),q=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,W={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},H=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class V{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}acquire(){return y(this,null,function*(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,yield i,e})}}const j=/^[0-9a-fA-F]+$/,K=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),X=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,$=(e,t,i)=>{let r=0,n=e.length-1,s=-1;for(;r<=n;){const a=r+n>>1,o=i(e[a]);o===t?(s=a,n=a-1):o<t?r=a+1:n=a-1}retuLine truncated
+365 -160
View File
@@ -11,7 +11,7 @@ var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
var _closed;
var _closed, _playback;
const $audio = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M256 80C149.9 80 62.4 159.4 49.6 262c9.4-3.8 19.6-6 30.4-6c26.5 0 48 21.5 48 48l0 128c0 26.5-21.5 48-48 48c-44.2 0-80-35.8-80-80l0-16 0-48 0-48C0 146.6 114.6 32 256 32s256 114.6 256 256l0 48 0 48 0 16c0 44.2-35.8 80-80 80c-26.5 0-48-21.5-48-48l0-128c0-26.5 21.5-48 48-48c10.8 0 21 2.1 30.4 6C449.6 159.4 362.1 80 256 80z"/></svg>';
const $quality = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM323.8 202.5c-4.5-6.6-11.9-10.5-19.8-10.5s-15.4 3.9-19.8 10.5l-87 127.6L170.7 297c-4.6-5.7-11.5-9-18.7-9s-14.2 3.3-18.7 9l-64 80c-5.8 7.2-6.9 17.1-2.9 25.4s12.4 13.6 21.6 13.6l96 0 32 0 208 0c8.9 0 17.1-4.9 21.2-12.8s3.6-17.4-1.4-24.7l-120-176zM112 192a48 48 0 1 0 0-96 48 48 0 1 0 0 96z"/></svg>';
function uniqBy(array, property) {
@@ -24271,6 +24271,62 @@ class AudioEngine {
this.audioSink = null;
}
}
async function selectQuality(host, value) {
const { input, media, loadSeq } = host;
if (!media?.isHls || !input || host.destroyed)
return;
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media;
let selection;
try {
const tracks = await input.getVideoTracks();
if (!current())
return;
const videoTrack = value === "auto" ? await input.getPrimaryVideoTrack() : tracks.find((track) => track.id === value) ?? media.videoTrack;
if (!current() || !videoTrack)
return;
let audioTrack = media.audioTrack;
let audioMode = media.audioMode;
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack();
audioMode = "auto";
}
selection = { videoTrack, audioTrack, videoMode: value === "auto" ? "auto" : "manual", audioMode };
} catch (error) {
if (!current())
return;
throw error;
}
if (current())
await host.replaceTracks(selection);
}
async function selectAudio(host, value) {
const { input, media, loadSeq } = host;
if (!media?.isHls || !input || host.destroyed)
return;
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media;
let selection;
try {
const tracks = await input.getAudioTracks();
if (!current())
return;
const audioTrack = value === "auto" ? media.videoTrack ? await media.videoTrack.getPrimaryPairableAudioTrack() : await input.getPrimaryAudioTrack() : tracks.find((track) => track.id === value) ?? media.audioTrack;
if (!current() || !audioTrack)
return;
let videoTrack = media.videoTrack;
let videoMode = media.videoMode;
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack();
videoMode = "auto";
}
selection = { videoTrack, audioTrack, videoMode, audioMode: value === "auto" ? "auto" : "manual" };
} catch (error) {
if (!current())
return;
throw error;
}
if (current())
await host.replaceTracks(selection);
}
async function getTrackBitrate(track) {
return await track.getAverageBitrate() ?? await track.getBitrate() ?? 0;
}
@@ -24404,6 +24460,30 @@ class LoadSession {
this.disposeInput();
}
}
function publish(events, current, names) {
for (const name of names) {
if (!current())
return;
events.emit(name);
}
}
function metadataBarrier(ready, current) {
let video = false;
let audio = false;
let published = false;
function complete(part) {
if (!current() || published)
return;
if (part === "video")
video = true;
else audio = true;
if (video && audio) {
published = true;
ready();
}
}
return { video: () => complete("video"), audio: () => complete("audio") };
}
async function resolveDuration({ input, videoTrack, audioTrack }) {
const tracks = [videoTrack, audioTrack].filter((track) => track !== null);
const referenceTrack = videoTrack || audioTrack;
@@ -24433,6 +24513,212 @@ async function selectPlaybackTracks(input, src) {
audioMode: isHls ? "auto" : "manual"
};
}
function completion() {
let resolve;
let reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
class Playback {
constructor(host) {
this.host = host;
this.wanted = false;
this.playing = null;
this.seeking = null;
this.audioStart = null;
}
operation() {
return { ...completion(), source: this.host.loadSeq, complete: completion() };
}
current(operation, kind) {
return !this.host.destroyed && operation.source === this.host.loadSeq && this[kind] === operation;
}
cancelPlay() {
const previous = this.playing;
this.playing = null;
previous?.resolve();
}
invalidate() {
this.cancelPlay();
this.wanted = false;
const previous = this.seeking;
this.seeking = null;
previous?.complete.resolve();
previous?.resolve();
this.host.seeking = false;
}
ended() {
this.wanted = false;
this.cancelPlay();
}
stop() {
if (this.host.paused)
return;
this.host.paused = true;
this.stopEngines();
this.host.events.emit("pause");
}
stopEngines() {
const failures = [];
for (const stop of [() => this.host.audio.pause(), () => this.host.video.stop()]) {
try {
stop();
} catch (error) {
failures.push(error);
}
}
if (failures.length)
throw failures[0];
}
pause() {
this.wanted = false;
this.cancelPlay();
this.stop();
}
play() {
if (this.host.destroyed)
return Promise.resolve();
this.wanted = true;
if (this.playing)
return this.playing.promise;
if (this.host.ended)
return this.seek(0, true);
if (!this.host.paused && !this.seeking)
return Promise.resolve();
const operation = this.operation();
this.playing = operation;
this.runPlay(operation).then(operation.resolve, operation.reject);
return operation.promise;
}
async startAudio() {
if (!this.audioStart)
this.audioStart = this.host.audio.play();
const pending = this.audioStart;
try {
await pending;
} finally {
if (this.audioStart === pending)
this.audioStart = null;
}
}
async runPlay(operation) {
const current = () => this.current(operation, "playing") && this.wanted;
try {
while (this.seeking) {
await this.seeking.complete.promise;
if (!current())
return;
}
if (!current())
return;
this.host.paused = false;
await this.startAudio();
if (!current()) {
if (!this.wanted || this.host.destroyed)
this.host.audio.pause();
return;
}
this.host.video.start(this.host.audio);
publish(this.host.events, current, ["play", "playing"]);
} catch (error) {
if (!current())
return;
this.wanted = false;
this.host.paused = true;
try {
this.stopEngines();
} catch (failure) {
console.warn("MediaBunny play cleanup error:", failure);
}
throw error;
} finally {
if (this.playing === operation)
this.playing = null;
}
}
seek(time, resume) {
return this.position(async (current) => {
const audio = this.host.audio.seek(time);
const video = current() ? this.host.video.seek(time) : Promise.resolve();
await Promise.all([audio, video]);
}, ["seeked"], false, resume);
}
replace(media, time) {
return this.position(async (current) => {
media.duration = await resolveDuration(media);
if (!current())
return;
this.host.media = media;
await Promise.all([this.host.video.load(media), this.host.audio.load(media)]);
if (!current())
return;
await Promise.all([this.host.video.seek(time), this.host.audio.seek(time)]);
if (current()) {
this.host.readyState = 4;
this.host.networkState = 1;
}
}, ["loadedmetadata", "durationchange", "progress", "loadeddata", "canplay", "canplaythrough", "seeked"], true);
}
position(work, events, pauseFirst, resume) {
if (this.host.destroyed)
return Promise.resolve();
this.wanted = resume ?? (this.seeking ? this.wanted : !this.host.paused);
this.cancelPlay();
const previous = this.seeking;
const operation = this.operation();
this.seeking = operation;
previous?.complete.resolve();
previous?.resolve();
this.runPosition(operation, work, events, pauseFirst).then(operation.resolve, operation.reject);
return operation.promise;
}
async runPosition(operation, work, events, pauseFirst) {
const current = () => this.current(operation, "seeking");
let resume = false;
try {
if (pauseFirst)
this.stop();
if (!current())
return;
this.host.ended = false;
this.host.seeking = true;
publish(this.host.events, current, ["seeking", "waiting"]);
if (!current())
return;
if (!pauseFirst)
this.stop();
if (!current())
return;
await work(current);
if (!current())
return;
this.host.seeking = false;
operation.complete.resolve();
publish(this.host.events, current, events);
if (!current())
return;
this.seeking = null;
resume = this.wanted;
} catch (error) {
if (!current())
return;
this.host.seeking = false;
this.wanted = false;
this.cancelPlay();
this.host.reportError(error);
throw error;
} finally {
operation.complete.resolve();
if (this.seeking === operation)
this.seeking = null;
}
if (resume)
await this.play();
}
}
async function preflightRange(url, enabled, events, signal, isCurrent = () => true) {
if (signal?.aborted || !isCurrent())
return false;
@@ -24661,6 +24947,7 @@ class VideoEngine {
}
class MediaBunnyEngine {
constructor({ canvas, ctx, events, option = {} }) {
__privateAdd(this, _playback);
this.events = events;
this.option = option;
this.audio = new AudioEngine(events);
@@ -24685,15 +24972,20 @@ class MediaBunnyEngine {
this.media = null;
this.loadSession = null;
this.destroyed = false;
events.addEventListener?.("ended", () => {
__privateSet(this, _playback, new Playback(this));
events.addEventListener("ended", () => {
if (this.destroyed)
return;
this.ended = true;
this.paused = true;
__privateGet(this, _playback).ended();
});
}
async load(src) {
if (this.destroyed)
return;
const id = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
this.loadSession?.cancel();
if (id !== this.loadSeq || this.destroyed)
return;
@@ -24709,75 +25001,62 @@ class MediaBunnyEngine {
this.readyState = 0;
session.defer(() => this.events.emit("waiting"));
session.defer(() => this.events.emit("loadstart"));
const loadTimeout = Number.isFinite(this.option.loadTimeout) ? this.option.loadTimeout : 0;
const timeout = Number.isFinite(this.option.loadTimeout) ? this.option.loadTimeout ?? 0 : 0;
try {
await session.wait(this.performLoad(src, id, session), loadTimeout);
} catch (err) {
await session.wait(this.performLoad(src, id, session), timeout);
} catch (error) {
if (id !== this.loadSeq)
return;
const failed = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
session.cancel();
if (failed !== this.loadSeq || this.destroyed)
return;
this.disposeInput();
this.error = { code: 4, message: err.message };
this.networkState = 3;
this.events.emit("error");
this.reportError(error);
}
}
async performLoad(src, id, session = this.loadSession) {
if (!await this.video.preflight(src, session?.signal, () => id === this.loadSeq) || id !== this.loadSeq)
const current = () => !this.destroyed && id === this.loadSeq;
if (!session || !await this.video.preflight(src, session.signal, current) || !current())
return;
const input = createInput(src);
if (!input) {
this.video.handleNoVideoTrack();
this.audio.handleNoAudioTrack();
if (current())
this.audio.handleNoAudioTrack();
return;
}
session.own(input);
const media = await selectPlaybackTracks(input, src);
if (id !== this.loadSeq)
if (!current())
return;
if (!media.videoTrack && !media.audioTrack) {
this.video.handleNoVideoTrack();
this.video.duration = Number.NaN;
this.audio.duration = Number.NaN;
throw new Error("Input has no audio or video tracks.");
}
this.input = input;
this.media = media;
let videoMetadataLoaded = !media.videoTrack;
let audioMetadataLoaded = !media.audioTrack;
const checkMetadata = () => {
if (videoMetadataLoaded && audioMetadataLoaded) {
this.readyState = 1;
this.events.emit("loadedmetadata");
this.events.emit("durationchange");
this.events.emit("progress");
}
};
const metadata = metadataBarrier(() => {
this.readyState = 1;
publish(this.events, current, ["loadedmetadata", "durationchange", "progress"]);
}, current);
try {
await Promise.all([
this.video.load(media, () => {
if (id !== this.loadSeq)
return;
videoMetadataLoaded = true;
checkMetadata();
}),
this.audio.load(media, () => {
if (id !== this.loadSeq)
return;
audioMetadataLoaded = true;
checkMetadata();
})
]);
if (id !== this.loadSeq)
const video = this.video.load(media, metadata.video);
const audio = current() ? this.audio.load(media, metadata.audio) : Promise.resolve();
await Promise.all([video, audio]);
if (!current())
return;
this.readyState = 4;
this.networkState = 1;
this.events.emit("loadeddata");
this.events.emit("canplay");
this.events.emit("canplaythrough");
this.events.emit("progress");
} catch (err) {
if (id !== this.loadSeq)
publish(this.events, current, ["loadeddata", "canplay", "canplaythrough", "progress"]);
} catch (error) {
if (!current())
return;
console.error("MediaBunny load error:", err);
throw err;
console.error("MediaBunny load error:", error);
throw error;
}
}
createTimeout(ms) {
@@ -24790,127 +25069,38 @@ class MediaBunnyEngine {
this.input = null;
this.media = null;
}
async replaceTracks({ videoTrack, audioTrack, videoMode, audioMode }) {
if (!this.media)
async replaceTracks(tracks) {
if (!this.media || this.destroyed)
return;
const nextMedia = {
...this.media,
videoTrack,
audioTrack,
videoMode,
audioMode
};
const shouldResume = !this.paused;
const currentTime = this.currentTime;
this.pause();
this.ended = false;
this.seeking = true;
this.events.emit("seeking");
this.events.emit("waiting");
nextMedia.duration = await resolveDuration(nextMedia);
this.media = nextMedia;
await Promise.all([
this.video.load(nextMedia),
this.audio.load(nextMedia)
]);
await Promise.all([
this.video.seek(currentTime),
this.audio.seek(currentTime)
]);
this.readyState = 4;
this.networkState = 1;
this.seeking = false;
this.events.emit("loadedmetadata");
this.events.emit("durationchange");
this.events.emit("progress");
this.events.emit("loadeddata");
this.events.emit("canplay");
this.events.emit("canplaythrough");
this.events.emit("seeked");
if (shouldResume) {
await this.play();
}
return __privateGet(this, _playback).replace({ ...this.media, ...tracks }, this.currentTime);
}
async selectHlsQuality(value) {
if (!this.media?.isHls || !this.input)
return;
const videoTracks = await this.input.getVideoTracks();
const videoTrack = value === "auto" ? await this.input.getPrimaryVideoTrack() : videoTracks.find((track) => track.id === value) ?? this.media.videoTrack;
if (!videoTrack)
return;
let audioTrack = this.media.audioTrack;
let audioMode = this.media.audioMode;
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack();
audioMode = "auto";
}
await this.replaceTracks({
videoTrack,
audioTrack,
videoMode: value === "auto" ? "auto" : "manual",
audioMode
});
return selectQuality(this, value);
}
async selectHlsAudio(value) {
if (!this.media?.isHls || !this.input)
return;
const audioTracks = await this.input.getAudioTracks();
const audioTrack = value === "auto" ? this.media.videoTrack ? await this.media.videoTrack.getPrimaryPairableAudioTrack() : await this.input.getPrimaryAudioTrack() : audioTracks.find((track) => track.id === value) ?? this.media.audioTrack;
if (!audioTrack)
return;
let videoTrack = this.media.videoTrack;
let videoMode = this.media.videoMode;
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack();
videoMode = "auto";
}
await this.replaceTracks({
videoTrack,
audioTrack,
videoMode,
audioMode: value === "auto" ? "auto" : "manual"
});
return selectAudio(this, value);
}
getHlsState() {
return getHlsState(this.media);
async getHlsState() {
const media = this.media;
if (this.destroyed)
return null;
try {
const state2 = await getHlsState(media);
return !this.destroyed && this.media === media ? state2 : null;
} catch (error) {
if (this.destroyed || this.media !== media)
return null;
throw error;
}
}
async play() {
if (!this.paused)
return;
if (this.ended) {
this.ended = false;
await this.seek(0);
}
this.paused = false;
await this.audio.play();
this.video.start(this.audio);
this.events.emit("play");
this.events.emit("playing");
return __privateGet(this, _playback).play();
}
pause() {
if (this.paused)
return;
this.paused = true;
this.audio.pause();
this.video.stop();
this.events.emit("pause");
__privateGet(this, _playback).pause();
}
async seek(time) {
const shouldResume = !this.paused;
this.ended = false;
this.seeking = true;
this.events.emit("seeking");
this.events.emit("waiting");
this.pause();
await Promise.all([
this.audio.seek(time),
this.video.seek(time)
]);
this.seeking = false;
this.events.emit("seeked");
if (shouldResume && !this.ended) {
await this.play();
}
return __privateGet(this, _playback).seek(time);
}
setVolume(volume, muted) {
this.audio.setVolume(volume, muted);
@@ -24919,18 +25109,31 @@ class MediaBunnyEngine {
this.audio.setPlaybackRate(rate);
this.video.setPlaybackRate(rate);
}
reportError(error) {
if (this.destroyed)
return;
const message = error && typeof error === "object" ? Reflect.get(error, "message") : void 0;
this.error = { code: 4, message: typeof message === "string" ? message : String(error) };
this.networkState = 3;
this.events.emit("error");
}
destroy() {
if (this.destroyed)
return;
this.destroyed = true;
this.loadSeq++;
this.loadSession?.cancel();
this.pause();
this.disposeInput();
this.audio.destroy();
this.video.destroy();
__privateGet(this, _playback).invalidate();
const failures = [];
for (const release of [() => this.loadSession?.cancel(), () => this.pause(), () => this.disposeInput(), () => this.audio.destroy(), () => this.video.destroy()]) {
try {
release();
} catch (error) {
failures.push(error);
}
}
if (failures.length)
throw failures[0];
}
// Getters
get currentTime() {
return this.audio.currentTime;
}
@@ -24944,6 +25147,7 @@ class MediaBunnyEngine {
return this.video.height;
}
}
_playback = new WeakMap();
const states = /* @__PURE__ */ new WeakMap();
function state(owner) {
let value = states.get(owner);
@@ -25049,7 +25253,8 @@ class VideoShim {
return this.engine.currentTime;
}
set currentTime(t) {
this.engine.seek(Number(t) || 0);
void Promise.resolve(this.engine.seek(Number(t) || 0)).catch(() => {
});
}
get duration() {
return this.engine.duration;
+2 -2
View File
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:unit": "node --test test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js test/dpip.test.js test/dpip-lifecycle.test.js test/canvas.test.js test/canvas-lifecycle.test.js test/ambilight.test.js test/ambilight-lifecycle.test.js test/ambilight-proxy.test.js test/vast.test.js test/vast-lifecycle.test.js test/ads.test.js test/ads-lifecycle.test.js test/playback.test.js test/dash-control.test.js test/dash-contract.test.js test/dash-lifecycle.test.js test/dash-events.test.js test/hls-control.test.js test/audio-track.test.js test/public-behavior.test.js test/helpers.test.js test/chapter.test.js test/utils.test.js test/resource-scope.test.js test/instance-lifecycle.test.js test/options.test.js test/media-hosts.test.js test/plugins.test.js test/source.test.js test/playback-properties.test.js test/media-events.test.js test/template-resources.test.js test/core-vendor.test.js test/component-resources.test.js test/setting-model.test.js test/setting-layout.test.js test/setting-resources.test.js test/subtitle.test.js test/display-native.test.js test/display-video-fullscreen.test.js test/display-pip.test.js test/display-mini.test.js test/display-sizing.test.js test/display-orientation.test.js test/hotkey.test.js test/listener-registry.test.js test/global-events.test.js test/pointer-events.test.js test/gesture.test.js test/event-scheduling.test.js test/notice.test.js test/fast-forward.test.js test/auto-playback.test.js test/builtin-layers.test.js test/prompt-components.test.js test/screenshot.test.js test/thumbnails.test.js test/progress.test.js test/environment.test.js test/storage.test.js test/facade-properties.test.js test/dom-boundaries.test.js test/initialization.test.js test/entry.test.js test/accessibility-button.test.js test/accessibility-focus.test.js test/accessibility-slider.test.js",
"test:coverage": "node --test test/coverage.test.js && node scripts/coverage.mjs",
"test": "yarn test:node && yarn test:baseline",
"test:browser": "playwright test",
@@ -76,7 +76,7 @@
"test:canvas-types-package": "node refactor/scripts/canvas-package-types.mjs",
"test:dpip": "node --test test/dpip.test.js test/dpip-lifecycle.test.js",
"test:dpip-types-package": "node refactor/scripts/dpip-package-types.mjs",
"test:mediabunny": "node --test test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js"
"test:mediabunny": "node --test test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-coordination.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
@@ -15,19 +15,22 @@ declarations, the factory signature, canvas forwarding, or package entrypoints.
| `hls-state.ts` | HLS track snapshots, bitrate/name/language values and actual selected track identity |
| `preflight.ts` | Optional HEAD Range check, historical warning/error policy, cancellation and stale-result guard |
| `load-session.ts` | Ownership of the pending/active SDK Input, HEAD AbortController, deferred load events and deadline |
| `MediaBunnyEngine.js` | Current coordinator; connects session cancellation to load, source replacement, errors and destroy |
| `MediaBunnyEngine.ts` | Load/readiness coordinator, source identity, errors and terminal teardown |
| `playback.ts` | Playback intent, cancellable play/seek/replacement operations and shared pending audio resume |
| `readiness.ts` | One metadata publication after both participants, guarded event sequences |
| `hls-selection.ts` | Source-bound track queries, pairing and active replacement errors |
| `VideoEngine.js` / `AudioEngine.js` | Current native decoding, rendering, seeking, Web Audio clock and scheduling |
| `VideoShim.ts` / `EventTarget.ts` | Video-like descriptors, synchronous/Promise forwarding and owned event lifecycle |
| `shim-values.ts` | Existing volume coercion and synthetic TimeRanges values |
| `shim-frames.ts` | Synthetic frame callbacks, per-instance RAF ownership and terminal cleanup |
| `engine-types.ts` / `MediaBunnyEngine.d.ts` | Explicit temporary coordinator boundary while its JavaScript implementation is migrated in MB-04 |
| `engine-types.ts` / `engine-ports.ts` | Internal shim, coordinator and decoder interfaces |
| `AudioEngine.d.ts` / `VideoEngine.d.ts` | Temporary boundaries for JS decoders, removed in MB-06/05 |
| `m3u8.js` | Current ArtPlayer control/setting integration |
The eleven TypeScript modules use strict checking with `skipLibCheck: false` and no
ambient Node types. The adjacent coordinator declaration is a transitional boundary,
not proof that the JavaScript coordinator has been checked. Replace it with the actual
strict implementation before completing MB-04. Remaining JavaScript is explicitly
scheduled in PKG-MB-04/05/06/07/08.
The sixteen production TypeScript modules use strict checking with `skipLibCheck: false`
and no ambient Node types. The coordinator is an actual checked implementation. The two
adjacent decoder declarations are temporary boundaries, not checking of their JavaScript
implementations. Remaining JavaScript is scheduled in PKG-MB-05/06/07/08.
Do not describe the whole package as migrated yet.
The shim keeps its existing own-property order and direct prototype surface because
@@ -40,7 +43,8 @@ EventTarget retains duplicate listeners, first-match removal, live-array `forEac
mutation semantics, detail identity and listener exceptions during normal dispatch.
Its terminal state stops callbacks later in a dispatch when a listener destroys the
shim. Shim teardown closes events in `finally`, even if engine cleanup throws, and
subsequent teardown is inert. Engine playback/readiness race fixes remain in progress.
subsequent teardown is inert. Engine teardown attempts input, audio and video cleanup
even when an earlier release throws, then preserves the first failure.
## Input and cancellation flow
@@ -69,6 +73,32 @@ HLS matching preserves the old case-insensitive `.m3u8` suffix/query/hash rule.
One explicit InputOptions assertion leaves unsupported values to SDK validation, preserving
the old error owner rather than silently converting arbitrary inputs.
## Playback and readiness ownership
Playback intent is separate from the temporary paused state during seek. Explicit pause
cancels pending play and prevents automatic resume; a newer seek settles the old operation
without allowing its completion or rejection to overwrite current state. Pending play waits
for the seek completion signal, resolved before optional resume to avoid a cycle.
A pending AudioContext resume is shared across play-pause-play; only the current operation
starts video and emits play/playing. Source change and destroy invalidate both operations.
Cancellation settles the public operation while observing the underlying late result;
actual late decoder work still belongs to MB-05/06.
Normal play, pause, seek/resume and HLS replacement event order is retained. Before each
event the coordinator rechecks ownership, since a listener may synchronously pause, seek,
load another source or destroy. Active play errors reject their Promise and restore paused
state; active seek errors additionally publish one media error. The synchronous currentTime
setter observes its otherwise unreturnable rejection; direct engine.seek still rejects.
Active replacement errors remain visible even after the media object changes. Stale HLS query
errors are ignored only when their captured source no longer owns the result.
Metadata publishes once after both audio/video participants report; repeated or obsolete
callbacks cannot ready the current source. A parsed container with neither track fails with
code 4 and message `Input has no audio or video tracks.` before decoder/AudioContext setup.
This corrects the old false-ready state; normal valid input event order is preserved.
Existing-track unsupported-codec handling remains a decoder capability task, not evidence
that every capability failure has been fixed.
## Types and dependencies
The locked runtime SDK remains MediaBunny 1.56.1. Its pinned WebCodecs declaration
@@ -101,7 +131,7 @@ The source of historical truth is `refactor/baselines/mb-release.json` and its G
inputs, not a rebuilt dist. Package-specific evidence and remaining tasks are in
`refactor/mb-validation.md`. The full refactor plan is at the repository root.
PKG-MB-04 still owns duplicate/trackless readiness, reentrant event sequences and
PKG-MB-04 covers duplicate/trackless readiness, reentrant event sequences and
pending play/seek coordination. PKG-MB-05/06 own late frame and audio callbacks,
including operations already inside decoders at cancellation; releasing Input alone
does not prove those resources are fully handled. PKG-MB-07 owns HLS UI/selection
@@ -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?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t=e=>{throw TypeError(e)},i=(e,i,r)=>i.has(e)||t("Cannot "+r),r=(e,t,r)=>(i(e,t,"read from private field"),r?r.call(e):t.get(e));function a(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class s{constructor(){var i,r,a;i=this,a=!1,(r=e).has(i)?t("Cannot add the same private member more than once"):r instanceof WeakSet?r.add(i):r.set(i,a),this.listeners=new Map}addEventListener(t,i){r(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),this.listeners.get(t)?.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){if(r(this,e))return;const a=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{r(this,e)||t(a)})}destroy(){var t,r,a,s;a=!0,i(t=this,r=e,"write to private field"),s?s.call(t,a):r.set(t,a),this.listeners.clear()}}function n(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const o=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},c=e=>e&&e[e.length-1],l=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},u=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-2**r)},d=e=>{const t=l(e);return 0===(1&t)?-(t>>1):t+1>>1},h=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),f=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),m=new TextDecoder,p=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),g={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},k=p(g),y={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},b=p(y),w={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},T=p(w),S=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,v={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},P=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class I{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}async acquire(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,await i,e}}const x=/^[0-9a-fA-F]+$/,C=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),B=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,E=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+a>>1,o=i(e[n]);o===t?(s=n,a=n-1):o<t?r=n+1:a=n-1}return s},_=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+(a-r+1)/2|0;i(e[n])<=t?(s=n,r=n+1):a=n-1}return s},R=(e,t,i)=>{const r=_(e,i(t),i);e.splice(r+1,0,t)},F=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},A=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},D=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},M=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},N=e=>{throw new Error(`Unexpected value: ${e}`)},O=(e,t,i)=>{const r=e.getUint8(t),a=e.getUint8(t+1),s=e.getUint8(t+2);return i?r|a<<8|s<<16:r<<16|a<<8|s},L=(e,t,i,r)=>{i>>>=0,i&=16777215,e.setUint8(t,i>>>16&255),e.setUint8(t+1,i>>>8&255),e.setUint8(t+2,255&i)},z=(e,t)=>({async next(){const i=await e.next();return i.done?{value:void 0,done:!0}:{value:t(i.value),done:!1}},return:()=>e.return(),throw:t=>e.throw(t),[Symbol.asyncIterator](){return this}}),U=(e,t,i)=>Math.max(t,Math.min(i,e)),q="und",W=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},H=(e,t)=>Math.round(e/t)*t,V=(e,t)=>Math.round(e*t)/t,j=(e,t)=>Math.floor(e/t)*t,K=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},X=/^[a-z]{3}$/,$=e=>X.test(e),G=1e6*(1+Number.EPSILON),Q=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?Y(e.headers):{},a=t.headers?Y(t.headers):{},s={...r};Object.entries(a).forEach(([e,t])=>{const i=Object.keys(s).find(t=>t.toLowerCase()===e.toLowerCase());i&&delete s[i],s[e]=t}),i.headers=s}return i},Y=e=>{if(e instanceof Headers){const t={};return e.forEach((e,i)=>{t[i]=e}),t}if(Array.isArray(e)){const t={};return e.forEach(([e,i])=>{t[e]=i}),t}return e},J=async(e,t,i,r,a)=>{let s=0;for(;;)try{return await e(t,i)}catch(n){if(a())throw n;s++;const e=r(s,n,t);if(null===e)throw n;if(ve._error("Retrying failed fetch. Error:",n),!Number.isFinite(e)||e<0)throw new TypeError("Retry delay must be aLine truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i=e=>{throw TypeError(e)},r=(e,t,r)=>t.has(e)||i("Cannot "+r),a=(e,t,i)=>(r(e,t,"read from private field"),i?i.call(e):t.get(e)),s=(e,t,r)=>t.has(e)?i("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,r),n=(e,t,i,a)=>(r(e,t,"write to private field"),a?a.call(e,i):t.set(e,i),i);function o(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class c{constructor(){s(this,e,!1),this.listeners=new Map}addEventListener(t,i){a(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),this.listeners.get(t)?.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){if(a(this,e))return;const r=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{a(this,e)||t(r)})}destroy(){n(this,e,!0),this.listeners.clear()}}function l(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const u=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},d=e=>e&&e[e.length-1],h=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},f=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-2**r)},m=e=>{const t=h(e);return 0===(1&t)?-(t>>1):t+1>>1},p=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),g=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),k=new TextDecoder,y=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),b={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},w=y(b),T={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},S=y(T),v={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},P=y(v),I=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,x={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},C=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class B{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}async acquire(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,await i,e}}const E=/^[0-9a-fA-F]+$/,_=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),R=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,F=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+a>>1,o=i(e[n]);o===t?(s=n,a=n-1):o<t?r=n+1:a=n-1}return s},A=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+(a-r+1)/2|0;i(e[n])<=t?(s=n,r=n+1):a=n-1}return s},D=(e,t,i)=>{const r=A(e,i(t),i);e.splice(r+1,0,t)},M=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},N=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},O=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},L=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},z=e=>{throw new Error(`Unexpected value: ${e}`)},U=(e,t,i)=>{const r=e.getUint8(t),a=e.getUint8(t+1),s=e.getUint8(t+2);return i?r|a<<8|s<<16:r<<16|a<<8|s},q=(e,t,i,r)=>{i>>>=0,i&=16777215,e.setUint8(t,i>>>16&255),e.setUint8(t+1,i>>>8&255),e.setUint8(t+2,255&i)},W=(e,t)=>({async next(){const i=await e.next();return i.done?{value:void 0,done:!0}:{value:t(i.value),done:!1}},return:()=>e.return(),throw:t=>e.throw(t),[Symbol.asyncIterator](){return this}}),H=(e,t,i)=>Math.max(t,Math.min(i,e)),V="und",j=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},K=(e,t)=>Math.round(e/t)*t,X=(e,t)=>Math.round(e*t)/t,$=(e,t)=>Math.floor(e/t)*t,G=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},Q=/^[a-z]{3}$/,Y=e=>Q.test(e),J=1e6*(1+Number.EPSILON),Z=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?ee(e.headers):{},a=t.headers?ee(t.headers):{},s={...r};Object.entries(a).forEach(([e,t])=>{const i=Object.keys(s).find(t=>t.toLowerCase()===e.toLowerCase());i&&delete s[i],s[e]=t}),i.headers=s}return i},ee=e=>{if(e instanceof Headers){const t={};return e.forEach((e,i)=>{t[i]=e}),t}if(Array.isArray(e)){const t={};return e.forEach(([e,i])=>{t[e]=i}),t}return e},te=async(e,t,i,r,a)=>{let s=0;for(;;)try{return await e(t,i)}catch(n){if(a())throw n;s++;const e=r(s,n,t);if(null===e)throw n;if(xe._error("Retrying failed fetch. Error:",n),!Number.isFinite(e)||e<0)throw new TypeError("Retry delayLine 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?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t=Object.defineProperty,i=Object.defineProperties,r=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,s=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,o=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),l=e=>{throw TypeError(e)},c=Math.pow,u=(e,i,r)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[i]=r,d=(e,t)=>{for(var i in t||(t={}))s.call(t,i)&&u(e,i,t[i]);if(n)for(var i of n(t))a.call(t,i)&&u(e,i,t[i]);return e},h=(e,t)=>i(e,r(t)),f=(e,t,i)=>t.has(e)||l("Cannot "+i),m=(e,t,i)=>(f(e,t,"read from private field"),i?i.call(e):t.get(e)),p=(e,t,i)=>new Promise((r,n)=>{var s=e=>{try{o(i.next(e))}catch(t){n(t)}},a=e=>{try{o(i.throw(e))}catch(t){n(t)}},o=e=>e.done?r(e.value):Promise.resolve(e.value).then(s,a);o((i=i.apply(e,t)).next())}),g=function(e,t){this[0]=e,this[1]=t},k=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof g,l=a.done;Promise.resolve(o?t[0]:t).then(i=>o?n("return"===e?e:"next",t[1]?{done:i.done,value:i.value}:i,r,s):r({value:i,done:l})).catch(e=>n("throw",e,r,s))}catch(c){s(c)}},s=(e,t,i,s)=>a[e]=a=>(t=new Promise((t,i,s)=>(s=()=>n(e,a,t,i),r?r.then(s):s())),s=()=>r===i&&(r=0),r=i=t.then(s,s),t),a={};return i=i.apply(e,t),a[o("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},y=e=>{var t,i=e[o("asyncIterator")],r=!1,n={};return null==i?(i=e[o("iterator")](),t=e=>n[e]=t=>i[e](t)):(i=i.call(e),t=e=>n[e]=t=>{if(r){if(r=!1,"throw"===e)throw t;return t}return r=!0,{done:!1,value:new g(new Promise(r=>{var n=i[e](t);n instanceof Object||l("Object expected"),r(n)}),1)}}),n[o("iterator")]=()=>n,t("next"),"throw"in i?t("throw"):n.throw=e=>{throw e},"return"in i&&t("return"),n},b=(e,t,i)=>(t=e[o("asyncIterator")])?t.call(e):(e=e[o("iterator")](),t={},(i=(i,r)=>(r=e[i])&&(t[i]=t=>new Promise((i,n,s)=>(t=r.call(e,t),s=t.done,Promise.resolve(t.value).then(e=>i({value:e,done:s}),n)))))("next"),i("return"),t);function T(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class v{constructor(){var t,i,r;t=this,r=!1,(i=e).has(t)?l("Cannot add the same private member more than once"):i instanceof WeakSet?i.add(t):i.set(t,r),this.listeners=new Map}addEventListener(t,i){var r;m(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),null==(r=this.listeners.get(t))||r.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){var r;if(m(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{m(this,e)||t(n)})}destroy(){var t,i,r,n;r=!0,f(t=this,i=e,"write to private field"),n?n.call(t,r):i.set(t,r),this.listeners.clear()}}function S(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const w=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},P=e=>e&&e[e.length-1],I=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},x=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-c(2,r))},C=e=>{const t=I(e);return 0===(1&t)?-(t>>1):t+1>>1},B=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),E=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),_=new TextDecoder,R=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),F={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},A=R(F),D={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},M=R(D),N={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},O=R(N),L=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,z={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},U=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class q{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}acquire(){return p(this,null,function*(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,yield i,e})}}const W=/^[0-9a-fA-F]+$/,H=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),V=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,j=(e,t,i)=>{let r=0,n=e.length-1,s=-1;for(;r<=n;){const a=r+n>>1,o=i(e[a]);o===t?(s=a,n=a-1):o<t?r=a+1:n=a-1}return s},Line truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,s=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,o=Object.prototype.propertyIsEnumerable,l=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),c=e=>{throw TypeError(e)},u=Math.pow,d=(e,t,r)=>t in e?i(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,h=(e,t)=>{for(var i in t||(t={}))a.call(t,i)&&d(e,i,t[i]);if(s)for(var i of s(t))o.call(t,i)&&d(e,i,t[i]);return e},f=(e,t)=>r(e,n(t)),m=(e,t,i)=>t.has(e)||c("Cannot "+i),p=(e,t,i)=>(m(e,t,"read from private field"),i?i.call(e):t.get(e)),g=(e,t,i)=>t.has(e)?c("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),k=(e,t,i,r)=>(m(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),y=(e,t,i)=>new Promise((r,n)=>{var s=e=>{try{o(i.next(e))}catch(t){n(t)}},a=e=>{try{o(i.throw(e))}catch(t){n(t)}},o=e=>e.done?r(e.value):Promise.resolve(e.value).then(s,a);o((i=i.apply(e,t)).next())}),b=function(e,t){this[0]=e,this[1]=t},T=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof b,l=a.done;Promise.resolve(o?t[0]:t).then(i=>o?n("return"===e?e:"next",t[1]?{done:i.done,value:i.value}:i,r,s):r({value:i,done:l})).catch(e=>n("throw",e,r,s))}catch(c){s(c)}},s=(e,t,i,s)=>a[e]=a=>(t=new Promise((t,i,s)=>(s=()=>n(e,a,t,i),r?r.then(s):s())),s=()=>r===i&&(r=0),r=i=t.then(s,s),t),a={};return i=i.apply(e,t),a[l("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},v=e=>{var t,i=e[l("asyncIterator")],r=!1,n={};return null==i?(i=e[l("iterator")](),t=e=>n[e]=t=>i[e](t)):(i=i.call(e),t=e=>n[e]=t=>{if(r){if(r=!1,"throw"===e)throw t;return t}return r=!0,{done:!1,value:new b(new Promise(r=>{var n=i[e](t);n instanceof Object||c("Object expected"),r(n)}),1)}}),n[l("iterator")]=()=>n,t("next"),"throw"in i?t("throw"):n.throw=e=>{throw e},"return"in i&&t("return"),n},S=(e,t,i)=>(t=e[l("asyncIterator")])?t.call(e):(e=e[l("iterator")](),t={},(i=(i,r)=>(r=e[i])&&(t[i]=t=>new Promise((i,n,s)=>(t=r.call(e,t),s=t.done,Promise.resolve(t.value).then(e=>i({value:e,done:s}),n)))))("next"),i("return"),t);function w(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class P{constructor(){g(this,e,!1),this.listeners=new Map}addEventListener(t,i){var r;p(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),null==(r=this.listeners.get(t))||r.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){var r;if(p(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{p(this,e)||t(n)})}destroy(){k(this,e,!0),this.listeners.clear()}}function I(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const x=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},C=e=>e&&e[e.length-1],B=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},E=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-u(2,r))},_=e=>{const t=B(e);return 0===(1&t)?-(t>>1):t+1>>1},R=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),F=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),A=new TextDecoder,D=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),M={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},N=D(M),O={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},L=D(O),z={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},U=D(z),q=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,W={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},H=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class V{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}acquire(){return y(this,null,function*(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,yield i,e})}}const j=/^[0-9a-fA-F]+$/,K=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),X=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,$=(e,t,i)=>{let r=0,n=e.length-1,s=-1;for(;r<=n;){const a=r+n>>1,o=i(e[a]);o===t?(s=a,n=a-1):o<t?r=a+1:n=a-1}retuLine truncated
@@ -11,7 +11,7 @@ var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
var _closed;
var _closed, _playback;
const $audio = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M256 80C149.9 80 62.4 159.4 49.6 262c9.4-3.8 19.6-6 30.4-6c26.5 0 48 21.5 48 48l0 128c0 26.5-21.5 48-48 48c-44.2 0-80-35.8-80-80l0-16 0-48 0-48C0 146.6 114.6 32 256 32s256 114.6 256 256l0 48 0 48 0 16c0 44.2-35.8 80-80 80c-26.5 0-48-21.5-48-48l0-128c0-26.5 21.5-48 48-48c10.8 0 21 2.1 30.4 6C449.6 159.4 362.1 80 256 80z"/></svg>';
const $quality = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM323.8 202.5c-4.5-6.6-11.9-10.5-19.8-10.5s-15.4 3.9-19.8 10.5l-87 127.6L170.7 297c-4.6-5.7-11.5-9-18.7-9s-14.2 3.3-18.7 9l-64 80c-5.8 7.2-6.9 17.1-2.9 25.4s12.4 13.6 21.6 13.6l96 0 32 0 208 0c8.9 0 17.1-4.9 21.2-12.8s3.6-17.4-1.4-24.7l-120-176zM112 192a48 48 0 1 0 0-96 48 48 0 1 0 0 96z"/></svg>';
function uniqBy(array, property) {
@@ -24271,6 +24271,62 @@ class AudioEngine {
this.audioSink = null;
}
}
async function selectQuality(host, value) {
const { input, media, loadSeq } = host;
if (!media?.isHls || !input || host.destroyed)
return;
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media;
let selection;
try {
const tracks = await input.getVideoTracks();
if (!current())
return;
const videoTrack = value === "auto" ? await input.getPrimaryVideoTrack() : tracks.find((track) => track.id === value) ?? media.videoTrack;
if (!current() || !videoTrack)
return;
let audioTrack = media.audioTrack;
let audioMode = media.audioMode;
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack();
audioMode = "auto";
}
selection = { videoTrack, audioTrack, videoMode: value === "auto" ? "auto" : "manual", audioMode };
} catch (error) {
if (!current())
return;
throw error;
}
if (current())
await host.replaceTracks(selection);
}
async function selectAudio(host, value) {
const { input, media, loadSeq } = host;
if (!media?.isHls || !input || host.destroyed)
return;
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media;
let selection;
try {
const tracks = await input.getAudioTracks();
if (!current())
return;
const audioTrack = value === "auto" ? media.videoTrack ? await media.videoTrack.getPrimaryPairableAudioTrack() : await input.getPrimaryAudioTrack() : tracks.find((track) => track.id === value) ?? media.audioTrack;
if (!current() || !audioTrack)
return;
let videoTrack = media.videoTrack;
let videoMode = media.videoMode;
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack();
videoMode = "auto";
}
selection = { videoTrack, audioTrack, videoMode, audioMode: value === "auto" ? "auto" : "manual" };
} catch (error) {
if (!current())
return;
throw error;
}
if (current())
await host.replaceTracks(selection);
}
async function getTrackBitrate(track) {
return await track.getAverageBitrate() ?? await track.getBitrate() ?? 0;
}
@@ -24404,6 +24460,30 @@ class LoadSession {
this.disposeInput();
}
}
function publish(events, current, names) {
for (const name of names) {
if (!current())
return;
events.emit(name);
}
}
function metadataBarrier(ready, current) {
let video = false;
let audio = false;
let published = false;
function complete(part) {
if (!current() || published)
return;
if (part === "video")
video = true;
else audio = true;
if (video && audio) {
published = true;
ready();
}
}
return { video: () => complete("video"), audio: () => complete("audio") };
}
async function resolveDuration({ input, videoTrack, audioTrack }) {
const tracks = [videoTrack, audioTrack].filter((track) => track !== null);
const referenceTrack = videoTrack || audioTrack;
@@ -24433,6 +24513,212 @@ async function selectPlaybackTracks(input, src) {
audioMode: isHls ? "auto" : "manual"
};
}
function completion() {
let resolve;
let reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
class Playback {
constructor(host) {
this.host = host;
this.wanted = false;
this.playing = null;
this.seeking = null;
this.audioStart = null;
}
operation() {
return { ...completion(), source: this.host.loadSeq, complete: completion() };
}
current(operation, kind) {
return !this.host.destroyed && operation.source === this.host.loadSeq && this[kind] === operation;
}
cancelPlay() {
const previous = this.playing;
this.playing = null;
previous?.resolve();
}
invalidate() {
this.cancelPlay();
this.wanted = false;
const previous = this.seeking;
this.seeking = null;
previous?.complete.resolve();
previous?.resolve();
this.host.seeking = false;
}
ended() {
this.wanted = false;
this.cancelPlay();
}
stop() {
if (this.host.paused)
return;
this.host.paused = true;
this.stopEngines();
this.host.events.emit("pause");
}
stopEngines() {
const failures = [];
for (const stop of [() => this.host.audio.pause(), () => this.host.video.stop()]) {
try {
stop();
} catch (error) {
failures.push(error);
}
}
if (failures.length)
throw failures[0];
}
pause() {
this.wanted = false;
this.cancelPlay();
this.stop();
}
play() {
if (this.host.destroyed)
return Promise.resolve();
this.wanted = true;
if (this.playing)
return this.playing.promise;
if (this.host.ended)
return this.seek(0, true);
if (!this.host.paused && !this.seeking)
return Promise.resolve();
const operation = this.operation();
this.playing = operation;
this.runPlay(operation).then(operation.resolve, operation.reject);
return operation.promise;
}
async startAudio() {
if (!this.audioStart)
this.audioStart = this.host.audio.play();
const pending = this.audioStart;
try {
await pending;
} finally {
if (this.audioStart === pending)
this.audioStart = null;
}
}
async runPlay(operation) {
const current = () => this.current(operation, "playing") && this.wanted;
try {
while (this.seeking) {
await this.seeking.complete.promise;
if (!current())
return;
}
if (!current())
return;
this.host.paused = false;
await this.startAudio();
if (!current()) {
if (!this.wanted || this.host.destroyed)
this.host.audio.pause();
return;
}
this.host.video.start(this.host.audio);
publish(this.host.events, current, ["play", "playing"]);
} catch (error) {
if (!current())
return;
this.wanted = false;
this.host.paused = true;
try {
this.stopEngines();
} catch (failure) {
console.warn("MediaBunny play cleanup error:", failure);
}
throw error;
} finally {
if (this.playing === operation)
this.playing = null;
}
}
seek(time, resume) {
return this.position(async (current) => {
const audio = this.host.audio.seek(time);
const video = current() ? this.host.video.seek(time) : Promise.resolve();
await Promise.all([audio, video]);
}, ["seeked"], false, resume);
}
replace(media, time) {
return this.position(async (current) => {
media.duration = await resolveDuration(media);
if (!current())
return;
this.host.media = media;
await Promise.all([this.host.video.load(media), this.host.audio.load(media)]);
if (!current())
return;
await Promise.all([this.host.video.seek(time), this.host.audio.seek(time)]);
if (current()) {
this.host.readyState = 4;
this.host.networkState = 1;
}
}, ["loadedmetadata", "durationchange", "progress", "loadeddata", "canplay", "canplaythrough", "seeked"], true);
}
position(work, events, pauseFirst, resume) {
if (this.host.destroyed)
return Promise.resolve();
this.wanted = resume ?? (this.seeking ? this.wanted : !this.host.paused);
this.cancelPlay();
const previous = this.seeking;
const operation = this.operation();
this.seeking = operation;
previous?.complete.resolve();
previous?.resolve();
this.runPosition(operation, work, events, pauseFirst).then(operation.resolve, operation.reject);
return operation.promise;
}
async runPosition(operation, work, events, pauseFirst) {
const current = () => this.current(operation, "seeking");
let resume = false;
try {
if (pauseFirst)
this.stop();
if (!current())
return;
this.host.ended = false;
this.host.seeking = true;
publish(this.host.events, current, ["seeking", "waiting"]);
if (!current())
return;
if (!pauseFirst)
this.stop();
if (!current())
return;
await work(current);
if (!current())
return;
this.host.seeking = false;
operation.complete.resolve();
publish(this.host.events, current, events);
if (!current())
return;
this.seeking = null;
resume = this.wanted;
} catch (error) {
if (!current())
return;
this.host.seeking = false;
this.wanted = false;
this.cancelPlay();
this.host.reportError(error);
throw error;
} finally {
operation.complete.resolve();
if (this.seeking === operation)
this.seeking = null;
}
if (resume)
await this.play();
}
}
async function preflightRange(url, enabled, events, signal, isCurrent = () => true) {
if (signal?.aborted || !isCurrent())
return false;
@@ -24661,6 +24947,7 @@ class VideoEngine {
}
class MediaBunnyEngine {
constructor({ canvas, ctx, events, option = {} }) {
__privateAdd(this, _playback);
this.events = events;
this.option = option;
this.audio = new AudioEngine(events);
@@ -24685,15 +24972,20 @@ class MediaBunnyEngine {
this.media = null;
this.loadSession = null;
this.destroyed = false;
events.addEventListener?.("ended", () => {
__privateSet(this, _playback, new Playback(this));
events.addEventListener("ended", () => {
if (this.destroyed)
return;
this.ended = true;
this.paused = true;
__privateGet(this, _playback).ended();
});
}
async load(src) {
if (this.destroyed)
return;
const id = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
this.loadSession?.cancel();
if (id !== this.loadSeq || this.destroyed)
return;
@@ -24709,75 +25001,62 @@ class MediaBunnyEngine {
this.readyState = 0;
session.defer(() => this.events.emit("waiting"));
session.defer(() => this.events.emit("loadstart"));
const loadTimeout = Number.isFinite(this.option.loadTimeout) ? this.option.loadTimeout : 0;
const timeout = Number.isFinite(this.option.loadTimeout) ? this.option.loadTimeout ?? 0 : 0;
try {
await session.wait(this.performLoad(src, id, session), loadTimeout);
} catch (err) {
await session.wait(this.performLoad(src, id, session), timeout);
} catch (error) {
if (id !== this.loadSeq)
return;
const failed = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
session.cancel();
if (failed !== this.loadSeq || this.destroyed)
return;
this.disposeInput();
this.error = { code: 4, message: err.message };
this.networkState = 3;
this.events.emit("error");
this.reportError(error);
}
}
async performLoad(src, id, session = this.loadSession) {
if (!await this.video.preflight(src, session?.signal, () => id === this.loadSeq) || id !== this.loadSeq)
const current = () => !this.destroyed && id === this.loadSeq;
if (!session || !await this.video.preflight(src, session.signal, current) || !current())
return;
const input = createInput(src);
if (!input) {
this.video.handleNoVideoTrack();
this.audio.handleNoAudioTrack();
if (current())
this.audio.handleNoAudioTrack();
return;
}
session.own(input);
const media = await selectPlaybackTracks(input, src);
if (id !== this.loadSeq)
if (!current())
return;
if (!media.videoTrack && !media.audioTrack) {
this.video.handleNoVideoTrack();
this.video.duration = Number.NaN;
this.audio.duration = Number.NaN;
throw new Error("Input has no audio or video tracks.");
}
this.input = input;
this.media = media;
let videoMetadataLoaded = !media.videoTrack;
let audioMetadataLoaded = !media.audioTrack;
const checkMetadata = () => {
if (videoMetadataLoaded && audioMetadataLoaded) {
this.readyState = 1;
this.events.emit("loadedmetadata");
this.events.emit("durationchange");
this.events.emit("progress");
}
};
const metadata = metadataBarrier(() => {
this.readyState = 1;
publish(this.events, current, ["loadedmetadata", "durationchange", "progress"]);
}, current);
try {
await Promise.all([
this.video.load(media, () => {
if (id !== this.loadSeq)
return;
videoMetadataLoaded = true;
checkMetadata();
}),
this.audio.load(media, () => {
if (id !== this.loadSeq)
return;
audioMetadataLoaded = true;
checkMetadata();
})
]);
if (id !== this.loadSeq)
const video = this.video.load(media, metadata.video);
const audio = current() ? this.audio.load(media, metadata.audio) : Promise.resolve();
await Promise.all([video, audio]);
if (!current())
return;
this.readyState = 4;
this.networkState = 1;
this.events.emit("loadeddata");
this.events.emit("canplay");
this.events.emit("canplaythrough");
this.events.emit("progress");
} catch (err) {
if (id !== this.loadSeq)
publish(this.events, current, ["loadeddata", "canplay", "canplaythrough", "progress"]);
} catch (error) {
if (!current())
return;
console.error("MediaBunny load error:", err);
throw err;
console.error("MediaBunny load error:", error);
throw error;
}
}
createTimeout(ms) {
@@ -24790,127 +25069,38 @@ class MediaBunnyEngine {
this.input = null;
this.media = null;
}
async replaceTracks({ videoTrack, audioTrack, videoMode, audioMode }) {
if (!this.media)
async replaceTracks(tracks) {
if (!this.media || this.destroyed)
return;
const nextMedia = {
...this.media,
videoTrack,
audioTrack,
videoMode,
audioMode
};
const shouldResume = !this.paused;
const currentTime = this.currentTime;
this.pause();
this.ended = false;
this.seeking = true;
this.events.emit("seeking");
this.events.emit("waiting");
nextMedia.duration = await resolveDuration(nextMedia);
this.media = nextMedia;
await Promise.all([
this.video.load(nextMedia),
this.audio.load(nextMedia)
]);
await Promise.all([
this.video.seek(currentTime),
this.audio.seek(currentTime)
]);
this.readyState = 4;
this.networkState = 1;
this.seeking = false;
this.events.emit("loadedmetadata");
this.events.emit("durationchange");
this.events.emit("progress");
this.events.emit("loadeddata");
this.events.emit("canplay");
this.events.emit("canplaythrough");
this.events.emit("seeked");
if (shouldResume) {
await this.play();
}
return __privateGet(this, _playback).replace({ ...this.media, ...tracks }, this.currentTime);
}
async selectHlsQuality(value) {
if (!this.media?.isHls || !this.input)
return;
const videoTracks = await this.input.getVideoTracks();
const videoTrack = value === "auto" ? await this.input.getPrimaryVideoTrack() : videoTracks.find((track) => track.id === value) ?? this.media.videoTrack;
if (!videoTrack)
return;
let audioTrack = this.media.audioTrack;
let audioMode = this.media.audioMode;
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack();
audioMode = "auto";
}
await this.replaceTracks({
videoTrack,
audioTrack,
videoMode: value === "auto" ? "auto" : "manual",
audioMode
});
return selectQuality(this, value);
}
async selectHlsAudio(value) {
if (!this.media?.isHls || !this.input)
return;
const audioTracks = await this.input.getAudioTracks();
const audioTrack = value === "auto" ? this.media.videoTrack ? await this.media.videoTrack.getPrimaryPairableAudioTrack() : await this.input.getPrimaryAudioTrack() : audioTracks.find((track) => track.id === value) ?? this.media.audioTrack;
if (!audioTrack)
return;
let videoTrack = this.media.videoTrack;
let videoMode = this.media.videoMode;
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack();
videoMode = "auto";
}
await this.replaceTracks({
videoTrack,
audioTrack,
videoMode,
audioMode: value === "auto" ? "auto" : "manual"
});
return selectAudio(this, value);
}
getHlsState() {
return getHlsState(this.media);
async getHlsState() {
const media = this.media;
if (this.destroyed)
return null;
try {
const state2 = await getHlsState(media);
return !this.destroyed && this.media === media ? state2 : null;
} catch (error) {
if (this.destroyed || this.media !== media)
return null;
throw error;
}
}
async play() {
if (!this.paused)
return;
if (this.ended) {
this.ended = false;
await this.seek(0);
}
this.paused = false;
await this.audio.play();
this.video.start(this.audio);
this.events.emit("play");
this.events.emit("playing");
return __privateGet(this, _playback).play();
}
pause() {
if (this.paused)
return;
this.paused = true;
this.audio.pause();
this.video.stop();
this.events.emit("pause");
__privateGet(this, _playback).pause();
}
async seek(time) {
const shouldResume = !this.paused;
this.ended = false;
this.seeking = true;
this.events.emit("seeking");
this.events.emit("waiting");
this.pause();
await Promise.all([
this.audio.seek(time),
this.video.seek(time)
]);
this.seeking = false;
this.events.emit("seeked");
if (shouldResume && !this.ended) {
await this.play();
}
return __privateGet(this, _playback).seek(time);
}
setVolume(volume, muted) {
this.audio.setVolume(volume, muted);
@@ -24919,18 +25109,31 @@ class MediaBunnyEngine {
this.audio.setPlaybackRate(rate);
this.video.setPlaybackRate(rate);
}
reportError(error) {
if (this.destroyed)
return;
const message = error && typeof error === "object" ? Reflect.get(error, "message") : void 0;
this.error = { code: 4, message: typeof message === "string" ? message : String(error) };
this.networkState = 3;
this.events.emit("error");
}
destroy() {
if (this.destroyed)
return;
this.destroyed = true;
this.loadSeq++;
this.loadSession?.cancel();
this.pause();
this.disposeInput();
this.audio.destroy();
this.video.destroy();
__privateGet(this, _playback).invalidate();
const failures = [];
for (const release of [() => this.loadSession?.cancel(), () => this.pause(), () => this.disposeInput(), () => this.audio.destroy(), () => this.video.destroy()]) {
try {
release();
} catch (error) {
failures.push(error);
}
}
if (failures.length)
throw failures[0];
}
// Getters
get currentTime() {
return this.audio.currentTime;
}
@@ -24944,6 +25147,7 @@ class MediaBunnyEngine {
return this.video.height;
}
}
_playback = new WeakMap();
const states = /* @__PURE__ */ new WeakMap();
function state(owner) {
let value = states.get(owner);
@@ -25049,7 +25253,8 @@ class VideoShim {
return this.engine.currentTime;
}
set currentTime(t) {
this.engine.seek(Number(t) || 0);
void Promise.resolve(this.engine.seek(Number(t) || 0)).catch(() => {
});
}
get duration() {
return this.engine.duration;
@@ -0,0 +1,6 @@
import type { AudioPort } from './engine-ports'
import type EventTarget from './EventTarget'
// Exact coordinator-facing port; implementation migration and decoder ownership are MB-06.
declare const AudioEngine: new (events: EventTarget) => AudioPort
export default AudioEngine
@@ -1,4 +0,0 @@
import type { EngineOptions, EnginePort } from './engine-types'
declare const MediaBunnyEngine: new (options: EngineOptions) => EnginePort
export default MediaBunnyEngine
@@ -1,372 +0,0 @@
/**
* Main MediaBunny Engine
* Coordinates audio and video playback
*/
import AudioEngine from './AudioEngine.js'
import { getHlsState } from './hls-state'
import { createInput } from './input'
import LoadSession from './load-session'
import { resolveDuration, selectPlaybackTracks } from './tracks'
import VideoEngine from './VideoEngine.js'
export default class MediaBunnyEngine {
constructor({ canvas, ctx, events, option = {} }) {
this.events = events
this.option = option
// Create audio and video engines
this.audio = new AudioEngine(events)
this.video = new VideoEngine({
canvas,
ctx,
events,
timeupdateInterval: option.timeupdateInterval ?? 250,
avSyncTolerance: option.avSyncTolerance ?? 0.12,
dropLateFrames: option.dropLateFrames ?? false,
poster: option.poster ?? '',
preflightRange: option.preflightRange ?? false,
})
// Playback state
this.paused = true
this.ended = false
this.readyState = 0
this.networkState = 0
this.error = null
this.seeking = false
this.loadSeq = 0
this.input = null
this.media = null
this.loadSession = null
this.destroyed = false
// Listen to ended event
events.addEventListener?.('ended', () => {
this.ended = true
this.paused = true
})
}
async load(src) {
if (this.destroyed)
return
const id = ++this.loadSeq
this.loadSession?.cancel()
if (id !== this.loadSeq || this.destroyed)
return
this.pause()
if (id !== this.loadSeq || this.destroyed)
return
this.disposeInput()
const session = new LoadSession()
this.loadSession = session
this.ended = false
this.error = null
this.networkState = 2 // NETWORK_LOADING
this.readyState = 0 // HAVE_NOTHING
session.defer(() => this.events.emit('waiting'))
session.defer(() => this.events.emit('loadstart'))
const loadTimeout = Number.isFinite(this.option.loadTimeout)
? this.option.loadTimeout
: 0
try {
await session.wait(this.performLoad(src, id, session), loadTimeout)
}
catch (err) {
if (id !== this.loadSeq)
return
const failed = ++this.loadSeq
session.cancel()
if (failed !== this.loadSeq || this.destroyed)
return
this.disposeInput()
this.error = { code: 4, message: err.message }
this.networkState = 3 // NETWORK_NO_SOURCE
this.events.emit('error')
}
}
async performLoad(src, id, session = this.loadSession) {
if (!(await this.video.preflight(src, session?.signal, () => id === this.loadSeq)) || id !== this.loadSeq)
return
const input = createInput(src)
if (!input) {
this.video.handleNoVideoTrack()
this.audio.handleNoAudioTrack()
return
}
session.own(input)
const media = await selectPlaybackTracks(input, src)
if (id !== this.loadSeq)
return
this.input = input
this.media = media
let videoMetadataLoaded = !media.videoTrack
let audioMetadataLoaded = !media.audioTrack
const checkMetadata = () => {
if (videoMetadataLoaded && audioMetadataLoaded) {
this.readyState = 1 // HAVE_METADATA
this.events.emit('loadedmetadata')
this.events.emit('durationchange')
this.events.emit('progress')
}
}
try {
await Promise.all([
this.video.load(media, () => {
if (id !== this.loadSeq)
return
videoMetadataLoaded = true
checkMetadata()
}),
this.audio.load(media, () => {
if (id !== this.loadSeq)
return
audioMetadataLoaded = true
checkMetadata()
}),
])
if (id !== this.loadSeq)
return
this.readyState = 4 // HAVE_ENOUGH_DATA
this.networkState = 1 // NETWORK_IDLE
this.events.emit('loadeddata')
this.events.emit('canplay')
this.events.emit('canplaythrough')
this.events.emit('progress')
}
catch (err) {
if (id !== this.loadSeq)
return
console.error('MediaBunny load error:', err)
throw err
}
}
createTimeout(ms) {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error('Load timeout')), ms)
})
}
disposeInput() {
this.loadSession?.disposeInput()
this.input = null
this.media = null
}
async replaceTracks({ videoTrack, audioTrack, videoMode, audioMode }) {
if (!this.media)
return
const nextMedia = {
...this.media,
videoTrack,
audioTrack,
videoMode,
audioMode,
}
const shouldResume = !this.paused
const currentTime = this.currentTime
this.pause()
this.ended = false
this.seeking = true
this.events.emit('seeking')
this.events.emit('waiting')
nextMedia.duration = await resolveDuration(nextMedia)
this.media = nextMedia
await Promise.all([
this.video.load(nextMedia),
this.audio.load(nextMedia),
])
await Promise.all([
this.video.seek(currentTime),
this.audio.seek(currentTime),
])
this.readyState = 4
this.networkState = 1
this.seeking = false
this.events.emit('loadedmetadata')
this.events.emit('durationchange')
this.events.emit('progress')
this.events.emit('loadeddata')
this.events.emit('canplay')
this.events.emit('canplaythrough')
this.events.emit('seeked')
if (shouldResume) {
await this.play()
}
}
async selectHlsQuality(value) {
if (!this.media?.isHls || !this.input)
return
const videoTracks = await this.input.getVideoTracks()
const videoTrack = value === 'auto'
? await this.input.getPrimaryVideoTrack()
: videoTracks.find(track => track.id === value) ?? this.media.videoTrack
if (!videoTrack)
return
let audioTrack = this.media.audioTrack
let audioMode = this.media.audioMode
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack()
audioMode = 'auto'
}
await this.replaceTracks({
videoTrack,
audioTrack,
videoMode: value === 'auto' ? 'auto' : 'manual',
audioMode,
})
}
async selectHlsAudio(value) {
if (!this.media?.isHls || !this.input)
return
const audioTracks = await this.input.getAudioTracks()
const audioTrack = value === 'auto'
? this.media.videoTrack
? await this.media.videoTrack.getPrimaryPairableAudioTrack()
: await this.input.getPrimaryAudioTrack()
: audioTracks.find(track => track.id === value) ?? this.media.audioTrack
if (!audioTrack)
return
let videoTrack = this.media.videoTrack
let videoMode = this.media.videoMode
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack()
videoMode = 'auto'
}
await this.replaceTracks({
videoTrack,
audioTrack,
videoMode,
audioMode: value === 'auto' ? 'auto' : 'manual',
})
}
getHlsState() {
return getHlsState(this.media)
}
async play() {
if (!this.paused)
return
if (this.ended) {
this.ended = false
await this.seek(0)
}
this.paused = false
await this.audio.play()
this.video.start(this.audio)
this.events.emit('play')
this.events.emit('playing')
}
pause() {
if (this.paused)
return
this.paused = true
this.audio.pause()
this.video.stop()
this.events.emit('pause')
}
async seek(time) {
const shouldResume = !this.paused
this.ended = false
this.seeking = true
this.events.emit('seeking')
this.events.emit('waiting')
this.pause()
await Promise.all([
this.audio.seek(time),
this.video.seek(time),
])
this.seeking = false
this.events.emit('seeked')
if (shouldResume && !this.ended) {
await this.play()
}
}
setVolume(volume, muted) {
this.audio.setVolume(volume, muted)
}
setPlaybackRate(rate) {
this.audio.setPlaybackRate(rate)
this.video.setPlaybackRate(rate)
}
destroy() {
if (this.destroyed)
return
this.destroyed = true
this.loadSeq++
this.loadSession?.cancel()
this.pause()
this.disposeInput()
this.audio.destroy()
this.video.destroy()
}
// Getters
get currentTime() {
return this.audio.currentTime
}
get duration() {
return this.media?.duration ?? this.audio.duration ?? this.video.duration
}
get videoWidth() {
return this.video.width
}
get videoHeight() {
return this.video.height
}
}
@@ -0,0 +1,255 @@
import type { Input } from 'mediabunny'
import type { AudioPort, VideoPort } from './engine-ports'
import type { EngineOptions, EnginePort, ProxyOptions } from './engine-types'
import type EventTarget from './EventTarget'
import type { SelectedTracks } from './hls-selection'
import type { PlaybackMedia } from './media'
import AudioEngine from './AudioEngine.js'
import { selectAudio, selectQuality } from './hls-selection'
import { getHlsState } from './hls-state'
import { createInput } from './input'
import LoadSession from './load-session'
import Playback from './playback'
import { metadataBarrier, publish } from './readiness'
import { selectPlaybackTracks } from './tracks'
import VideoEngine from './VideoEngine.js'
export default class MediaBunnyEngine implements EnginePort {
declare events: EventTarget
declare option: ProxyOptions
declare audio: AudioPort
declare video: VideoPort
declare paused: boolean
declare ended: boolean
declare readyState: number
declare networkState: number
declare error: { code: number, message: string } | null
declare seeking: boolean
declare loadSeq: number
declare input: Input | null
declare media: PlaybackMedia | null
declare loadSession: LoadSession | null
declare destroyed: boolean
#playback: Playback
constructor({ canvas, ctx, events, option = {} }: EngineOptions) {
this.events = events
this.option = option
this.audio = new AudioEngine(events)
this.video = new VideoEngine({
canvas,
ctx,
events,
timeupdateInterval: option.timeupdateInterval ?? 250,
avSyncTolerance: option.avSyncTolerance ?? 0.12,
dropLateFrames: option.dropLateFrames ?? false,
poster: option.poster ?? '',
preflightRange: option.preflightRange ?? false,
})
this.paused = true
this.ended = false
this.readyState = 0
this.networkState = 0
this.error = null
this.seeking = false
this.loadSeq = 0
this.input = null
this.media = null
this.loadSession = null
this.destroyed = false
this.#playback = new Playback(this)
events.addEventListener('ended', () => {
if (this.destroyed)
return
this.ended = true
this.paused = true
this.#playback.ended()
})
}
async load(src: unknown): Promise<void> {
if (this.destroyed)
return
const id = ++this.loadSeq
this.#playback.invalidate()
this.loadSession?.cancel()
if (id !== this.loadSeq || this.destroyed)
return
this.pause()
if (id !== this.loadSeq || this.destroyed)
return
this.disposeInput()
const session = new LoadSession()
this.loadSession = session
this.ended = false
this.error = null
this.networkState = 2
this.readyState = 0
session.defer(() => this.events.emit('waiting'))
session.defer(() => this.events.emit('loadstart'))
const timeout = Number.isFinite(this.option.loadTimeout) ? this.option.loadTimeout ?? 0 : 0
try {
await session.wait(this.performLoad(src, id, session), timeout)
}
catch (error) {
if (id !== this.loadSeq)
return
const failed = ++this.loadSeq
this.#playback.invalidate()
session.cancel()
if (failed !== this.loadSeq || this.destroyed)
return
this.disposeInput()
this.reportError(error)
}
}
async performLoad(src: unknown, id: number, session = this.loadSession): Promise<void> {
const current = () => !this.destroyed && id === this.loadSeq
if (!session || !(await this.video.preflight(src, session.signal, current)) || !current())
return
const input = createInput(src)
if (!input) {
this.video.handleNoVideoTrack()
if (current())
this.audio.handleNoAudioTrack()
return
}
session.own(input)
const media = await selectPlaybackTracks(input, src)
if (!current())
return
if (!media.videoTrack && !media.audioTrack) {
this.video.handleNoVideoTrack()
this.video.duration = Number.NaN
this.audio.duration = Number.NaN
throw new Error('Input has no audio or video tracks.')
}
this.input = input
this.media = media
const metadata = metadataBarrier(() => {
this.readyState = 1
publish(this.events, current, ['loadedmetadata', 'durationchange', 'progress'])
}, current)
try {
const video = this.video.load(media, metadata.video)
const audio = current() ? this.audio.load(media, metadata.audio) : Promise.resolve()
await Promise.all([video, audio])
if (!current())
return
this.readyState = 4
this.networkState = 1
publish(this.events, current, ['loadeddata', 'canplay', 'canplaythrough', 'progress'])
}
catch (error) {
if (!current())
return
console.error('MediaBunny load error:', error)
throw error
}
}
createTimeout(ms: number): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error('Load timeout')), ms)
})
}
disposeInput(): void {
this.loadSession?.disposeInput()
this.input = null
this.media = null
}
async replaceTracks(tracks: SelectedTracks): Promise<void> {
if (!this.media || this.destroyed)
return
return this.#playback.replace({ ...this.media, ...tracks }, this.currentTime)
}
async selectHlsQuality(value: unknown): Promise<void> {
return selectQuality(this, value)
}
async selectHlsAudio(value: unknown): Promise<void> {
return selectAudio(this, value)
}
async getHlsState() {
const media = this.media
if (this.destroyed)
return null
try {
const state = await getHlsState(media)
return !this.destroyed && this.media === media ? state : null
}
catch (error) {
if (this.destroyed || this.media !== media)
return null
throw error
}
}
async play(): Promise<void> {
return this.#playback.play()
}
pause(): void {
this.#playback.pause()
}
async seek(time: number): Promise<void> {
return this.#playback.seek(time)
}
setVolume(volume: number, muted: boolean): void {
this.audio.setVolume(volume, muted)
}
setPlaybackRate(rate: number): void {
this.audio.setPlaybackRate(rate)
this.video.setPlaybackRate(rate)
}
reportError(error: unknown): void {
if (this.destroyed)
return
const message: unknown = error && typeof error === 'object' ? Reflect.get(error, 'message') : undefined
this.error = { code: 4, message: typeof message === 'string' ? message : String(error) }
this.networkState = 3
this.events.emit('error')
}
destroy(): void {
if (this.destroyed)
return
this.destroyed = true
this.loadSeq++
this.#playback.invalidate()
const failures: unknown[] = []
for (const release of [() => this.loadSession?.cancel(), () => this.pause(), () => this.disposeInput(), () => this.audio.destroy(), () => this.video.destroy()]) {
try {
release()
}
catch (error) { failures.push(error) }
}
if (failures.length)
throw failures[0]
}
get currentTime(): number {
return this.audio.currentTime
}
get duration(): number {
return this.media?.duration ?? this.audio.duration ?? this.video.duration
}
get videoWidth(): number {
return this.video.width
}
get videoHeight(): number {
return this.video.height
}
}
@@ -0,0 +1,5 @@
import type { VideoOptions, VideoPort } from './engine-ports'
// Exact coordinator-facing port; implementation migration and frame ownership are MB-05.
declare const VideoEngine: new (options: VideoOptions) => VideoPort
export default VideoEngine
@@ -87,7 +87,8 @@ export default class VideoShim {
}
set currentTime(t: unknown) {
this.engine.seek(Number(t) || 0)
// The coordinator reports active failures; the synchronous setter must observe rejection.
void Promise.resolve(this.engine.seek(Number(t) || 0)).catch(() => {})
}
get duration() {
@@ -0,0 +1,60 @@
import type { Input } from 'mediabunny'
import type { ProxyOptions } from './engine-types'
import type EventTarget from './EventTarget'
import type { PlaybackMedia } from './media'
export interface AudioPort {
readonly currentTime: number
duration: number
paused: boolean
load: (media: PlaybackMedia, metadata?: () => void) => Promise<void>
play: () => Promise<void>
pause: () => void
seek: (time: number) => Promise<void>
setVolume: (volume: number, muted: boolean) => void
setPlaybackRate: (rate: number) => void
handleNoAudioTrack: () => void
destroy: () => void
}
export interface VideoPort {
width: number
height: number
duration: number
preflight: (source: unknown, signal?: AbortSignal, current?: () => boolean) => Promise<boolean>
load: (media: PlaybackMedia, metadata?: () => void) => Promise<void>
start: (clock: AudioPort) => void
stop: () => void
seek: (time: number) => Promise<void>
setPlaybackRate: (rate: number) => void
handleNoVideoTrack: () => void
destroy: () => void
}
export interface VideoOptions {
canvas: HTMLCanvasElement
ctx: CanvasRenderingContext2D | null
events: EventTarget
timeupdateInterval: number
avSyncTolerance: number
dropLateFrames: boolean
poster: string
preflightRange: boolean
}
export interface PlaybackHost {
audio: AudioPort
video: VideoPort
events: EventTarget
readonly destroyed: boolean
readonly loadSeq: number
paused: boolean
ended: boolean
seeking: boolean
readyState: number
networkState: number
media: PlaybackMedia | null
input: Input | null
option: ProxyOptions
reportError: (error: unknown) => void
}
@@ -19,7 +19,7 @@ export interface EngineOptions {
option?: ProxyOptions
}
// Transitional boundary for the coordinator; replaced by its strict implementation in MB-04.
// Stable coordinator-facing surface used by the public shim.
export interface EnginePort {
readonly currentTime: number
readonly duration: number
@@ -0,0 +1,74 @@
import type { Input } from 'mediabunny'
import type { PlaybackMedia } from './media'
export type SelectedTracks = Pick<PlaybackMedia, 'videoTrack' | 'audioTrack' | 'videoMode' | 'audioMode'>
interface SelectionHost {
input: Input | null
media: PlaybackMedia | null
destroyed: boolean
loadSeq: number
replaceTracks: (tracks: SelectedTracks) => Promise<void>
}
export async function selectQuality(host: SelectionHost, value: unknown): Promise<void> {
const { input, media, loadSeq } = host
if (!media?.isHls || !input || host.destroyed)
return
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media
let selection: SelectedTracks
try {
const tracks = await input.getVideoTracks()
if (!current())
return
const videoTrack = value === 'auto' ? await input.getPrimaryVideoTrack() : tracks.find(track => track.id === value) ?? media.videoTrack
if (!current() || !videoTrack)
return
let audioTrack = media.audioTrack
let audioMode = media.audioMode
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack()
audioMode = 'auto'
}
selection = { videoTrack, audioTrack, videoMode: value === 'auto' ? 'auto' : 'manual', audioMode }
}
catch (error) {
if (!current())
return
throw error
}
if (current())
await host.replaceTracks(selection)
}
export async function selectAudio(host: SelectionHost, value: unknown): Promise<void> {
const { input, media, loadSeq } = host
if (!media?.isHls || !input || host.destroyed)
return
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media
let selection: SelectedTracks
try {
const tracks = await input.getAudioTracks()
if (!current())
return
const audioTrack = value === 'auto'
? media.videoTrack ? await media.videoTrack.getPrimaryPairableAudioTrack() : await input.getPrimaryAudioTrack()
: tracks.find(track => track.id === value) ?? media.audioTrack
if (!current() || !audioTrack)
return
let videoTrack = media.videoTrack
let videoMode = media.videoMode
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack()
videoMode = 'auto'
}
selection = { videoTrack, audioTrack, videoMode, audioMode: value === 'auto' ? 'auto' : 'manual' }
}
catch (error) {
if (!current())
return
throw error
}
if (current())
await host.replaceTracks(selection)
}
@@ -0,0 +1,240 @@
import type { PlaybackHost } from './engine-ports'
import type { PlaybackMedia } from './media'
import { publish } from './readiness'
import { resolveDuration } from './tracks'
interface Completion {
promise: Promise<void>
resolve: () => void
reject: (error: unknown) => void
}
interface Operation extends Completion {
source: number
complete: Completion
}
function completion(): Completion {
let resolve!: () => void
let reject!: (error: unknown) => void
const promise = new Promise<void>((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
export default class Playback {
private wanted = false
private playing: Operation | null = null
private seeking: Operation | null = null
private audioStart: Promise<void> | null = null
constructor(private host: PlaybackHost) {}
private operation(): Operation {
return { ...completion(), source: this.host.loadSeq, complete: completion() }
}
private current(operation: Operation, kind: 'playing' | 'seeking'): boolean {
return !this.host.destroyed && operation.source === this.host.loadSeq && this[kind] === operation
}
private cancelPlay(): void {
const previous = this.playing
this.playing = null
previous?.resolve()
}
invalidate(): void {
this.cancelPlay()
this.wanted = false
const previous = this.seeking
this.seeking = null
previous?.complete.resolve()
previous?.resolve()
this.host.seeking = false
}
ended(): void {
this.wanted = false
this.cancelPlay()
}
private stop(): void {
if (this.host.paused)
return
this.host.paused = true
this.stopEngines()
this.host.events.emit('pause')
}
private stopEngines(): void {
const failures: unknown[] = []
for (const stop of [() => this.host.audio.pause(), () => this.host.video.stop()]) {
try {
stop()
}
catch (error) { failures.push(error) }
}
if (failures.length)
throw failures[0]
}
pause(): void {
this.wanted = false
this.cancelPlay()
this.stop()
}
play(): Promise<void> {
if (this.host.destroyed)
return Promise.resolve()
this.wanted = true
if (this.playing)
return this.playing.promise
if (this.host.ended)
return this.seek(0, true)
if (!this.host.paused && !this.seeking)
return Promise.resolve()
const operation = this.operation()
this.playing = operation
this.runPlay(operation).then(operation.resolve, operation.reject)
return operation.promise
}
private async startAudio(): Promise<void> {
if (!this.audioStart)
this.audioStart = this.host.audio.play()
const pending = this.audioStart
try {
await pending
}
finally {
if (this.audioStart === pending)
this.audioStart = null
}
}
private async runPlay(operation: Operation): Promise<void> {
const current = () => this.current(operation, 'playing') && this.wanted
try {
while (this.seeking) {
await this.seeking.complete.promise
if (!current())
return
}
if (!current())
return
this.host.paused = false
await this.startAudio()
if (!current()) {
if (!this.wanted || this.host.destroyed)
this.host.audio.pause()
return
}
this.host.video.start(this.host.audio)
publish(this.host.events, current, ['play', 'playing'])
}
catch (error) {
if (!current())
return
this.wanted = false
this.host.paused = true
try {
this.stopEngines()
}
catch (failure) { console.warn('MediaBunny play cleanup error:', failure) }
throw error
}
finally {
if (this.playing === operation)
this.playing = null
}
}
seek(time: number, resume?: boolean): Promise<void> {
return this.position(async (current) => {
const audio = this.host.audio.seek(time)
const video = current() ? this.host.video.seek(time) : Promise.resolve()
await Promise.all([audio, video])
}, ['seeked'], false, resume)
}
replace(media: PlaybackMedia, time: number): Promise<void> {
return this.position(async (current) => {
media.duration = await resolveDuration(media)
if (!current())
return
this.host.media = media
await Promise.all([this.host.video.load(media), this.host.audio.load(media)])
if (!current())
return
await Promise.all([this.host.video.seek(time), this.host.audio.seek(time)])
if (current()) {
this.host.readyState = 4
this.host.networkState = 1
}
}, ['loadedmetadata', 'durationchange', 'progress', 'loadeddata', 'canplay', 'canplaythrough', 'seeked'], true)
}
private position(work: (current: () => boolean) => Promise<void>, events: readonly string[], pauseFirst: boolean, resume?: boolean): Promise<void> {
if (this.host.destroyed)
return Promise.resolve()
this.wanted = resume ?? (this.seeking ? this.wanted : !this.host.paused)
this.cancelPlay()
const previous = this.seeking
const operation = this.operation()
this.seeking = operation
previous?.complete.resolve()
previous?.resolve()
this.runPosition(operation, work, events, pauseFirst).then(operation.resolve, operation.reject)
return operation.promise
}
private async runPosition(operation: Operation, work: (current: () => boolean) => Promise<void>, events: readonly string[], pauseFirst: boolean): Promise<void> {
const current = () => this.current(operation, 'seeking')
let resume = false
try {
if (pauseFirst)
this.stop()
if (!current())
return
this.host.ended = false
this.host.seeking = true
publish(this.host.events, current, ['seeking', 'waiting'])
if (!current())
return
if (!pauseFirst)
this.stop()
if (!current())
return
await work(current)
if (!current())
return
this.host.seeking = false
operation.complete.resolve()
publish(this.host.events, current, events)
if (!current())
return
this.seeking = null
resume = this.wanted
}
catch (error) {
if (!current())
return
this.host.seeking = false
this.wanted = false
this.cancelPlay()
this.host.reportError(error)
throw error
}
finally {
operation.complete.resolve()
if (this.seeking === operation)
this.seeking = null
}
if (resume)
await this.play()
}
}
@@ -0,0 +1,27 @@
import type EventTarget from './EventTarget'
export function publish(events: EventTarget, current: () => boolean, names: readonly string[]): void {
for (const name of names) {
if (!current())
return
events.emit(name)
}
}
export function metadataBarrier(ready: () => void, current: () => boolean) {
let video = false
let audio = false
let published = false
function complete(part: 'audio' | 'video'): void {
if (!current() || published)
return
if (part === 'video')
video = true
else audio = true
if (video && audio) {
published = true
ready()
}
}
return { video: () => complete('video'), audio: () => complete('audio') }
}
@@ -0,0 +1,728 @@
{
"task": "PKG-MB-04",
"status": "done",
"parentCommit": "9af253a5a9db9c10062f7552f4e084582d5f6e94",
"sourceSha256LF": {
"packages/artplayer-proxy-mediabunny/src/AudioEngine.d.ts": "2d59216f234173253b87e2b3ab8710efd25666f6cadbb2d1052b5c1cb829635a",
"packages/artplayer-proxy-mediabunny/src/AudioEngine.js": "910d6831372e4895d55dec78010f281a199bc88f99735630a5a3b621f192ac35",
"packages/artplayer-proxy-mediabunny/src/engine-ports.ts": "43a5b9277f2b52a537c522f1cf2a2c92ce4cfb1d2fb6c91e73519371a92940a2",
"packages/artplayer-proxy-mediabunny/src/engine-types.ts": "857a61f1fa3531bd737626658981eaab1b83eb369266fb45528676b9f57a293a",
"packages/artplayer-proxy-mediabunny/src/EventTarget.ts": "4cea57e59b0a1118744a55b0a87c1df28cb0d69c26c50f9275039fe7540c9720",
"packages/artplayer-proxy-mediabunny/src/hls-selection.ts": "a8632c0ea7d0c59434de005eee81bbf8d1dea8ae73c690673b603e5d6fa3cfd6",
"packages/artplayer-proxy-mediabunny/src/hls-state.ts": "ff9ffd742f9763349df1f50eac6b6bd488a80d05aaab8ca198f387f0d17d5e25",
"packages/artplayer-proxy-mediabunny/src/index.js": "4e1be8937908ff6e6ef8a4e863e22b4468312cf31d66ac299322e475757773bb",
"packages/artplayer-proxy-mediabunny/src/input.ts": "d25b1c4ee4aee358c86fd8c554b8b36bfc23fdbd3c27fa21d2cd7e812c98bf9c",
"packages/artplayer-proxy-mediabunny/src/load-session.ts": "560fbd5935fc787bde5f413fcaa41b8829506179c5328ce3cb51e734bbba8cec",
"packages/artplayer-proxy-mediabunny/src/m3u8.js": "4639f09e0c743dc2b24078f48ca1e58eaaedaa3f5e6b863583cbd4bc4a7d3142",
"packages/artplayer-proxy-mediabunny/src/media.ts": "e745cd8e750e05d0fcfa4bc588f7eb5a5576f3be20f5e67b65b704f57d539ae9",
"packages/artplayer-proxy-mediabunny/src/MediaBunnyEngine.ts": "d13bdd700399b57a4ff90a0fa9bdedf10206609e994ff07326a37c6838be135c",
"packages/artplayer-proxy-mediabunny/src/playback.ts": "0512f6a1b253d754b6644266327e368e0e18cce1cf9a3af62b6747ed55d691f7",
"packages/artplayer-proxy-mediabunny/src/preflight.ts": "d296234312f23b97a1e0a054f1d7dbbc988d5240c5fd5089c36df1677c7b7348",
"packages/artplayer-proxy-mediabunny/src/readiness.ts": "9aa24b5e994006b45bda9eafad602785c3e0e334cef54ff45a4939e386c4aceb",
"packages/artplayer-proxy-mediabunny/src/shim-frames.ts": "172b222f997a711c2cc7741f189f05127a4ac5099c1cbe2729e903b36b4ce24a",
"packages/artplayer-proxy-mediabunny/src/shim-values.ts": "a93ee17e87cc7db24f4b02d866af8af206d3007d01def446906fb769c20de17d",
"packages/artplayer-proxy-mediabunny/src/tracks.ts": "6da68530c1fc7105ab17ed742d87d93e84447e822e84386591f9c3e6d0bec733",
"packages/artplayer-proxy-mediabunny/src/VideoEngine.d.ts": "172fd4d30fa98a3eaa540ac8e5b653ae321c4ad8f376f3954550e2298bff5a9d",
"packages/artplayer-proxy-mediabunny/src/VideoEngine.js": "5ac862b0fd9c196dc6656f8908a214c1b3a0a53a7e85ff845d9068888831986b",
"packages/artplayer-proxy-mediabunny/src/VideoShim.ts": "1fef3028a3937182c31503129655efdb88118ea0f6d3be96e9be99dcbcc5fdc7",
"packages/artplayer-proxy-mediabunny/dist/artplayer-proxy-mediabunny.js": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"packages/artplayer-proxy-mediabunny/dist/artplayer-proxy-mediabunny.legacy.js": "7c39173fdc56ce9089542191f8e0d731d825967244828e4a358d951421889cfc",
"packages/artplayer-proxy-mediabunny/dist/artplayer-proxy-mediabunny.mjs": "aed179b85002f5ddab3f26a02b20ad5e0fae203bc865cb0a241a003aa22c8a9e",
"packages/artplayer-proxy-mediabunny/ARCHITECTURE.md": "9bd639cab075ac3e5c333b509c016614ad64e14fd58b3f308e0656e325662a43",
"package.json": "f91ece4140c07c5bcec06556d088cd98b0d368471df7b129999e3239d5960334",
"test/mediabunny-coordination.test.js": "1ce97e02492c0b85e041f3bf33b75ec9915af160d7fd7550bef84408e034cba9",
"test/browser/mediabunny-inputs.spec.js": "561fbb9316691210c239831aa9f87ec5f2b6adaa5a7cde7b5830b721bd9d56de",
"test/browser/mediabunny.spec.js": "59491aa2007e1816fb8561f3feaf4d149c74667994ed2632ea3d37cf10891364"
},
"node": {
"total": 108,
"coordinator": 40,
"sameCoordinatorAssertionsOnPreviousMain": {
"passed": 8,
"failed": 32
},
"main": 72,
"legacy": 72,
"artifactScope": "Each 72-case command includes 68 candidate assertions and four frozen normal-event controls."
},
"browser": {
"total": 39,
"outcomes": {
"native-input-playback-and-seek": 10,
"trackless-rejected-before-decoder-setup": 3,
"unsupported-capability-control": 5,
"native-pending-stream-cancellation": 9,
"native-synthetic-raf": 9,
"native-mp4-playback-observed": 2,
"verified-unsupported-capability-control": 1
},
"records": [
{
"project": "chromium",
"browser": "153.0.8010.12",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "webm",
"outcome": "native-input-playback-and-seek",
"cleanup": {
"audioContext": "closed",
"pendingFrames": 0,
"streamCancelled": false
},
"reentrantPlay": {
"paused": true,
"events": [
"video:play",
"video:pause"
]
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "chromium",
"browser": "153.0.8010.12",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "blob",
"outcome": "native-input-playback-and-seek",
"cleanup": {
"audioContext": "closed",
"pendingFrames": 0,
"streamCancelled": false
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "chromium",
"browser": "153.0.8010.12",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "stream",
"outcome": "native-input-playback-and-seek",
"cleanup": {
"audioContext": "closed",
"pendingFrames": 0,
"streamCancelled": true
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "chromium",
"browser": "153.0.8010.12",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "audio",
"outcome": "native-input-playback-and-seek",
"cleanup": {
"audioContext": "closed",
"pendingFrames": 0,
"streamCancelled": false
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "chromium",
"browser": "153.0.8010.12",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "hls",
"outcome": "native-input-playback-and-seek",
"cleanup": {
"audioContext": "closed",
"pendingFrames": 0,
"streamCancelled": false
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "chromium",
"browser": "153.0.8010.12",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "trackless",
"outcome": "trackless-rejected-before-decoder-setup",
"cleanup": {
"audioContext": "not-created",
"pendingFrames": 0,
"streamCancelled": false
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "firefox",
"browser": "155.0",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "webm",
"outcome": "native-input-playback-and-seek",
"cleanup": {
"audioContext": "closed",
"pendingFrames": 0,
"streamCancelled": false
},
"reentrantPlay": {
"paused": true,
"events": [
"video:play",
"video:pause"
]
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "firefox",
"browser": "155.0",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "blob",
"outcome": "native-input-playback-and-seek",
"cleanup": {
"audioContext": "closed",
"pendingFrames": 0,
"streamCancelled": false
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "firefox",
"browser": "155.0",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "stream",
"outcome": "native-input-playback-and-seek",
"cleanup": {
"audioContext": "closed",
"pendingFrames": 0,
"streamCancelled": true
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "firefox",
"browser": "155.0",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "audio",
"outcome": "native-input-playback-and-seek",
"cleanup": {
"audioContext": "closed",
"pendingFrames": 0,
"streamCancelled": false
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "firefox",
"browser": "155.0",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "hls",
"outcome": "native-input-playback-and-seek",
"cleanup": {
"audioContext": "closed",
"pendingFrames": 0,
"streamCancelled": false
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "firefox",
"browser": "155.0",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "trackless",
"outcome": "trackless-rejected-before-decoder-setup",
"cleanup": {
"audioContext": "not-created",
"pendingFrames": 0,
"streamCancelled": false
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "webkit",
"browser": "26.6",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "webm",
"outcome": "unsupported-capability-control",
"cleanup": {
"audioContext": "not-created",
"pendingFrames": 0,
"streamCancelled": false
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "webkit",
"browser": "26.6",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "blob",
"outcome": "unsupported-capability-control",
"cleanup": {
"audioContext": "not-created",
"pendingFrames": 0,
"streamCancelled": false
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "webkit",
"browser": "26.6",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "stream",
"outcome": "unsupported-capability-control",
"cleanup": {
"audioContext": "not-created",
"pendingFrames": 0,
"streamCancelled": true
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "webkit",
"browser": "26.6",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "audio",
"outcome": "unsupported-capability-control",
"cleanup": {
"audioContext": "not-created",
"pendingFrames": 0,
"streamCancelled": false
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "webkit",
"browser": "26.6",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "hls",
"outcome": "unsupported-capability-control",
"cleanup": {
"audioContext": "not-created",
"pendingFrames": 0,
"streamCancelled": false
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "webkit",
"browser": "26.6",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"input": "trackless",
"outcome": "trackless-rejected-before-decoder-setup",
"cleanup": {
"audioContext": "not-created",
"pendingFrames": 0,
"streamCancelled": false
},
"scope": "Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance."
},
{
"project": "chromium",
"browser": "153.0.8010.12",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"ending": "destroy",
"outcome": "native-pending-stream-cancellation",
"cancellations": [
"old"
],
"scope": "Real browser ReadableStream and actual SDK Input disposal before first media bytes; real timeout; controlled ArtPlayer host; does not assert decoder, late frame or audio-node cleanup."
},
{
"project": "chromium",
"browser": "153.0.8010.12",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"ending": "source",
"outcome": "native-pending-stream-cancellation",
"cancellations": [
"old",
"new"
],
"scope": "Real browser ReadableStream and actual SDK Input disposal before first media bytes; real timeout; controlled ArtPlayer host; does not assert decoder, late frame or audio-node cleanup."
},
{
"project": "chromium",
"browser": "153.0.8010.12",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"ending": "timeout",
"outcome": "native-pending-stream-cancellation",
"cancellations": [
"old"
],
"scope": "Real browser ReadableStream and actual SDK Input disposal before first media bytes; real timeout; controlled ArtPlayer host; does not assert decoder, late frame or audio-node cleanup."
},
{
"project": "firefox",
"browser": "155.0",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"ending": "destroy",
"outcome": "native-pending-stream-cancellation",
"cancellations": [
"old"
],
"scope": "Real browser ReadableStream and actual SDK Input disposal before first media bytes; real timeout; controlled ArtPlayer host; does not assert decoder, late frame or audio-node cleanup."
},
{
"project": "firefox",
"browser": "155.0",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"ending": "source",
"outcome": "native-pending-stream-cancellation",
"cancellations": [
"old",
"new"
],
"scope": "Real browser ReadableStream and actual SDK Input disposal before first media bytes; real timeout; controlled ArtPlayer host; does not assert decoder, late frame or audio-node cleanup."
},
{
"project": "firefox",
"browser": "155.0",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"ending": "timeout",
"outcome": "native-pending-stream-cancellation",
"cancellations": [
"old"
],
"scope": "Real browser ReadableStream and actual SDK Input disposal before first media bytes; real timeout; controlled ArtPlayer host; does not assert decoder, late frame or audio-node cleanup."
},
{
"project": "webkit",
"browser": "26.6",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"ending": "destroy",
"outcome": "native-pending-stream-cancellation",
"cancellations": [
"old"
],
"scope": "Real browser ReadableStream and actual SDK Input disposal before first media bytes; real timeout; controlled ArtPlayer host; does not assert decoder, late frame or audio-node cleanup."
},
{
"project": "webkit",
"browser": "26.6",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"ending": "source",
"outcome": "native-pending-stream-cancellation",
"cancellations": [
"old",
"new"
],
"scope": "Real browser ReadableStream and actual SDK Input disposal before first media bytes; real timeout; controlled ArtPlayer host; does not assert decoder, late frame or audio-node cleanup."
},
{
"project": "webkit",
"browser": "26.6",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"ending": "timeout",
"outcome": "native-pending-stream-cancellation",
"cancellations": [
"old"
],
"scope": "Real browser ReadableStream and actual SDK Input disposal before first media bytes; real timeout; controlled ArtPlayer host; does not assert decoder, late frame or audio-node cleanup."
},
{
"project": "chromium",
"browser": "153.0.8010.12",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"scenario": "callback",
"outcome": "native-synthetic-raf",
"result": {
"idType": "number",
"calls": [
{
"owner": "first",
"time": "number",
"mediaTime": 0,
"presentedFrames": 0
}
],
"listeners": 0
},
"scope": "Real browser requestAnimationFrame and actual proxy instances; synthetic metadata is preserved, no decoding or video frame accuracy is claimed."
},
{
"project": "chromium",
"browser": "153.0.8010.12",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"scenario": "destroy",
"outcome": "native-synthetic-raf",
"result": {
"idType": "number",
"calls": [],
"listeners": 0
},
"scope": "Real browser requestAnimationFrame and actual proxy instances; synthetic metadata is preserved, no decoding or video frame accuracy is claimed."
},
{
"project": "chromium",
"browser": "153.0.8010.12",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"scenario": "independent",
"outcome": "native-synthetic-raf",
"result": {
"idType": "number",
"calls": [
{
"owner": "second"
}
],
"listeners": 0
},
"scope": "Real browser requestAnimationFrame and actual proxy instances; synthetic metadata is preserved, no decoding or video frame accuracy is claimed."
},
{
"project": "firefox",
"browser": "155.0",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"scenario": "callback",
"outcome": "native-synthetic-raf",
"result": {
"idType": "number",
"calls": [
{
"owner": "first",
"time": "number",
"mediaTime": 0,
"presentedFrames": 0
}
],
"listeners": 0
},
"scope": "Real browser requestAnimationFrame and actual proxy instances; synthetic metadata is preserved, no decoding or video frame accuracy is claimed."
},
{
"project": "firefox",
"browser": "155.0",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"scenario": "destroy",
"outcome": "native-synthetic-raf",
"result": {
"idType": "number",
"calls": [],
"listeners": 0
},
"scope": "Real browser requestAnimationFrame and actual proxy instances; synthetic metadata is preserved, no decoding or video frame accuracy is claimed."
},
{
"project": "firefox",
"browser": "155.0",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"scenario": "independent",
"outcome": "native-synthetic-raf",
"result": {
"idType": "number",
"calls": [
{
"owner": "second"
}
],
"listeners": 0
},
"scope": "Real browser requestAnimationFrame and actual proxy instances; synthetic metadata is preserved, no decoding or video frame accuracy is claimed."
},
{
"project": "webkit",
"browser": "26.6",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"scenario": "callback",
"outcome": "native-synthetic-raf",
"result": {
"idType": "number",
"calls": [
{
"owner": "first",
"time": "number",
"mediaTime": 0,
"presentedFrames": 0
}
],
"listeners": 0
},
"scope": "Real browser requestAnimationFrame and actual proxy instances; synthetic metadata is preserved, no decoding or video frame accuracy is claimed."
},
{
"project": "webkit",
"browser": "26.6",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"scenario": "destroy",
"outcome": "native-synthetic-raf",
"result": {
"idType": "number",
"calls": [],
"listeners": 0
},
"scope": "Real browser requestAnimationFrame and actual proxy instances; synthetic metadata is preserved, no decoding or video frame accuracy is claimed."
},
{
"project": "webkit",
"browser": "26.6",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"scenario": "independent",
"outcome": "native-synthetic-raf",
"result": {
"idType": "number",
"calls": [
{
"owner": "second"
}
],
"listeners": 0
},
"scope": "Real browser requestAnimationFrame and actual proxy instances; synthetic metadata is preserved, no decoding or video frame accuracy is claimed."
},
{
"project": "chromium",
"browser": "153.0.8010.12",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"outcome": "native-mp4-playback-observed",
"scope": "Identified published or candidate proxy with actual core5.4.0; native MP4 pixels/clock or explicitly unsupported Windows WebKit APIs. No HLS or long-run AV-sync acceptance."
},
{
"project": "firefox",
"browser": "155.0",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"outcome": "native-mp4-playback-observed",
"scope": "Identified published or candidate proxy with actual core5.4.0; native MP4 pixels/clock or explicitly unsupported Windows WebKit APIs. No HLS or long-run AV-sync acceptance."
},
{
"project": "webkit",
"browser": "26.6",
"platform": "win32",
"implementation": "candidate-artifact",
"sha256": "01b88c915804e1c42b90c8c37aa948029472c7f47f6eb3a266629ed945be9fb1",
"outcome": "verified-unsupported-capability-control",
"scope": "Identified published or candidate proxy with actual core5.4.0; native MP4 pixels/clock or explicitly unsupported Windows WebKit APIs. No HLS or long-run AV-sync acceptance."
}
],
"report": "refactor/.cache/mb04-coordination-results/report.json",
"reportSha256": "17dbd77680e414f0b27e94e278b24d46cd6cde443ed1bb96dcc2f3d285a6557f",
"results": "refactor/.cache/mb04-coordination-results/results"
},
"checks": [
{
"command": "yarn test:mediabunny",
"result": "pass",
"tests": 108
},
{
"command": "main/legacy shim, load and coordinator tests",
"result": "pass",
"eachRun": 72
},
{
"command": "same 40 coordinator assertions on adba8a3d main",
"result": "expected historical failures",
"passed": 8,
"failed": 32
},
{
"command": "package strict tsc and focused eslint --no-fix",
"result": "pass",
"productionModules": 16,
"temporaryDecoderDeclarationBridges": 2
},
{
"command": "yarn build artplayer-proxy-mediabunny",
"result": "pass",
"formats": [
"main",
"legacy",
"esm"
]
},
{
"command": "four browser specs with built main",
"result": "pass",
"tests": 39
},
{
"command": "yarn ci:check",
"result": "pass",
"tests": {
"unit": 1114,
"engineering": 14,
"baseline": 141,
"mainTotal": 1269,
"repeatedContractExecutions": 44
},
"strictProductionTypeScriptFiles": 306,
"log": "refactor/.cache/mb04-coordination-ci.log"
},
{
"command": "built ESM default export and source function name",
"result": "pass",
"log": "refactor/.cache/mb04-coordination-esm.log"
}
],
"limitations": [
"AudioEngine and VideoEngine remain JS behind explicit temporary declarations; their late internal work is MB-05/06.",
"Six Windows WebKit capability controls are not successful playback.",
"Trackless false readiness is corrected; existing-track unsupported codec behavior remains a capability/decoder task.",
"Synthetic RAF is not decoded frame accuracy; native sampled frames and short clocks are not long-run AV sync.",
"HLS query source ownership is guarded; full concurrent selection/UI topology remains MB-07.",
"No npm release or next-major approval is implied."
]
}
@@ -0,0 +1,56 @@
# PKG-MB-04:VideoShim、事件与主协调器
任务通过完整CI后标记done,并立即以专用本地提交交付。前置shim检查点为9af253a5;本轮将
MediaBunnyEngine.js实际迁移为严格TS,删除其临时声明桥。包内现在16个生产TS模块,
AudioEngine/VideoEngine两个显式临时声明桥不计作实现迁移,分别留给MB-06/05。
文件职责、状态归属、错误传播和继续维护入口见包内ARCHITECTURE.md。
## 行为修复与兼容性
- playback.ts独立管理播放意图和操作完成信号;pause能够阻止pending play或seek恢复,
新操作使旧结果失效。取消可先结束公开Promise,底层晚结果仍被观察。
- seek完成信号先于可选恢复播放,避免play等待seek而seek又等待play的死锁。
play-pause-play共享尚未结束的audio resume,只有当前操作启动video和发送play/playing。
- 每次就绪/播放事件前重新检查归属,允许用户在回调中同步切源、销毁或改变播放状态。
正常play/pause、seek/resume和HLS replacement事件顺序由对照测试保持。
- 两个metadata参与者均完成才发布一次metadata;无轨道容器提前报code 4,信息为
`Input has no audio or video tracks.`,不再错误地readyState 4。有效媒体行为保持。
- 活动play失败保留原始Promise拒绝并恢复paused;活动seek失败报告一次error并拒绝。
currentTime同步setter观察无法返回的拒绝,直接调用engine.seek仍能捕获错误。
- HLS旧source查询失败不污染新source,当前查询失败继续拒绝;replacement改变media对象
后的活动错误也不能被误认为旧查询并吞掉。
- destroy尽力执行所有input/audio/video释放步骤,之后保留首个异常;shim仍以finally
清理事件和synthetic RAF。事件安静不等于底层解码器晚操作已完全停止。
工厂签名、公开声明、canvas描述符和同步/Promise接口保持。正常事件顺序、方法转发及
描述符对照继续纳入验证。非正常行为差异是以上明确的竞态/错误修复,不宣称逐字节行为不变。
旧createTimeout帮助方法保持原样,受管理的load路径不使用它;不要称所有手动调用的内部
帮助方法也都具备自动取消。
## 验证
新增40项协调器测试,使用实际构建代理、受控异步音视频操作和实际SDK解析输入。
同一断言对adba8a3d冻结main为8通过/32失败,候选全部通过。包专项108项通过。
main与legacy各72项通过,其中68候选产物断言、4冻结正常事件对照。三格式构建通过。
初轮main产物测试有一条断言把新source合法waiting/loadstart误当作旧replacement事件。
已缩小到旧seek/readiness结果并保留完整禁止列表,重新运行main/legacy均72通过;未修改
生产代码来消除新来源合法事件。历史同断言仍32失败,修复回归证据没有被放宽掉。
浏览器报告及整个results归档后记录到mb-coordination-validation.json。39项候选检查区分
12真实播放、9原生Stream取消、9synthetic RAF、6Windows WebKit能力对照及3无轨道拒绝。
WebM在Chromium/Firefox另验证真实播放的play回调内pause后不会再发playing。
此断言附属于现有播放用例,不增加测试数量。长播放、听觉输出和AV同步仍为MB-09。
## 后续边界
MB-05/06继续实际视频帧、解码器迭代器和Web Audio调度迁移;两处声明桥必须移除。
MB-07继续HLS控件拓扑与并发选轨;MB-08完整公开类型/工厂兼容矩阵;MB-09/10完成组合、
长播放、demo、真实安装包与许可通知。MB-LIFE-01/MB-READY-01保持open并更新已修复范围,
既有轨道但不支持codec的路径仍需要解码器验证。此任务不构成npm发布验收。
最终完整CI1269项(1114单元+14工程+141基线)通过,另44重复契约;306生产TS。
所有记录的源码/测试/产物及报告哈希已核验,三格式docs/compiled与dist一致。ESM默认工厂
及实际源码名称artplayerProxyMediabunny保持;首次临时检查误写大小写,已按实际入口修正。
回退本任务可revert专用提交;旧shim检查点9af253a5与更早adba8a3d基线仍可对照。
未推送、打tag或发布;下一项PKG-MB-05处理视频迭代器、晚帧与seek释放。
+9
View File
@@ -45,3 +45,12 @@ MB-03实施将input分离为六个strict TS模块,接入加载session、Range
Chrome连接不可用时可使用内置浏览器;CLI自动化也按现有Playwright三引擎流程执行。
保留report.json及整个results目录。受控输入不能替代真实解码,iframe不能替代原生Document PiP。
VAST脚本VPN豁免不适用于本包。
## MB-04 coordinator implementation
VideoShim/EventTarget与主协调器已拆为严格TS模块,详情见
[协调器变更](changes/2026-09-12-PKG-MB-04-coordination.md)及
[验证记录](baselines/mb-coordination-validation.json)。包专项命令已包含新的协调器回归用例。
无轨道容器由历史readyState 4改为code 4错误;有效媒体的正常顺序保持,重复就绪和
失效操作结果被阻止。不要把synthetic RAF、受控竞态或Windows WebKit能力对照计为真实播放。
音视频解码器仍通过两个显式声明桥连接,MB-05/06继续实际实现迁移和底层晚操作清理。
+3 -3
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 222 项,范围 22 个包及工作区/示例。
状态:todo 114 / doing 8 / blocked 0 / done 100 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 114 / doing 7 / blocked 0 / done 101 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -332,7 +332,7 @@
| PKG-MB-01 | artplayer-proxy-mediabunny<br>核对 shim 与媒体契约 | BASE-05 | 全部 option、art.mediabunny、canvas/shim 属性及事件表 | 公开暴露成员和实际发布依赖/资源范围明确;核实 third-party.json 所记录依赖 MPL-2.0 元数据与实际 bundle 的许可/通知范围,不从根 MIT 推断 | H | done |
| PKG-MB-02 | artplayer-proxy-mediabunny<br>建立真实媒体与事件基线 | PKG-MB-01, ENG-05, ENG-08 | MP4/WebM/HLS/Blob/Stream、seek/错误/轨道切换样本 | ready 事件顺序、音画同步、超时和释放可重跑 | H | done |
| PKG-MB-03 | artplayer-proxy-mediabunny<br>整理 input 与加载取消 | PKG-MB-02, CORE-09 | input.js 源检测、输入资源、Range 与取消 | HLS 检测在 input 边界,旧来源类型和超时保持 | H | done |
| PKG-MB-04 | artplayer-proxy-mediabunny<br>整理 VideoShim/EventTarget/Engine 协调 | PKG-MB-03, CORE-06, CORE-11 | shim 描述符、事件桥、加载状态和操作隔离 | 公开属性/同步异步返回与事件重入顺序保持 | H | doing |
| PKG-MB-04 | artplayer-proxy-mediabunny<br>整理 VideoShim/EventTarget/Engine 协调 | PKG-MB-03, CORE-06, CORE-11 | shim 描述符、事件桥、加载状态和操作隔离 | 公开属性/同步异步返回与事件重入顺序保持 | H | done |
| PKG-MB-05 | artplayer-proxy-mediabunny<br>整理视频解码、seek 和帧释放 | PKG-MB-04 | VideoEngine 调度、队列、晚帧处理和资源释放 | 暂停/seek/切源无旧帧覆盖,默认丢帧策略不变 | H | todo |
| PKG-MB-06 | artplayer-proxy-mediabunny<br>整理音频解码、时钟与同步 | PKG-MB-04 | AudioEngine 时钟、缓冲和 AudioContext 归属 | AV sync、倍速、无音轨、静音/音量和暂停恢复通过 | H | todo |
| PKG-MB-07 | artplayer-proxy-mediabunny<br>整理 HLS 配对轨道与 selector | PKG-MB-05, PKG-MB-06, CORE-14 | m3u8 配对、质量/音频选择及拓扑清理 | 实际选择高亮、切到无轨道来源清理、无重复 readiness | H | todo |
@@ -523,5 +523,5 @@
- PKG-MB-01: [记录](baselines/mb-release.json) [记录](baselines/mb-surface.json) [记录](baselines/mb-contract.md) [记录](baselines/mb-contract-validation.json) [记录](mb-validation.md) [记录](changes/2026-09-12-PKG-MB-01-contract.md)
- PKG-MB-02: [记录](changes/2026-09-12-PKG-MB-02-checkpoint.md) [记录](baselines/mb-media-checkpoint.json) [记录](mb-validation.md) [记录](changes/2026-09-12-PKG-MB-02-baseline.md) [记录](baselines/mb-behavior-validation.json)
- PKG-MB-03: [记录](changes/2026-09-12-PKG-MB-03-input.md) [记录](baselines/mb-input-validation.json)
- PKG-MB-04: [记录](baselines/mb-coordination-source.json) [记录](changes/2026-09-12-PKG-MB-04-shim-checkpoint.md) [记录](baselines/mb-shim-checkpoint.json)
- PKG-MB-04: [记录](baselines/mb-coordination-source.json) [记录](changes/2026-09-12-PKG-MB-04-shim-checkpoint.md) [记录](baselines/mb-shim-checkpoint.json) [记录](changes/2026-09-12-PKG-MB-04-coordination.md) [记录](baselines/mb-coordination-validation.json)
- PKG-FACTORY-01: [记录](baselines/factory-assignment-gaps.json) [记录](baselines/factory-compatibility-proposals.json) [记录](factory-compatibility-decision.md) [记录](changes/2026-09-12-PKG-FACTORY-01-decision.md)
+12
View File
@@ -1,5 +1,17 @@
# 进度与证据
## PKG-MB-04完成:主协调器TS与操作隔离
MediaBunnyEngine实际迁移TS,并拆分playback/readiness/HLS查询职责;正常接口和事件顺序
保持,修复旧play/seek覆盖、错误吞掉、重复就绪及无轨道假就绪。新增40项测试在旧main上
8通过/32失败,候选全通过;包专项108通过、main/legacy各72通过。最终39项浏览器
包括12真实播放、9Stream取消、9synthetic RAF、6能力对照及3无轨道拒绝;另在已有
Chromium/Firefox播放用例内验证play回调同步pause后无playing。完整CI1269通过,
另44重复契约,306生产TS;包内16生产TS和2个未迁移解码器声明桥明确区分。
见[变更](changes/2026-09-12-PKG-MB-04-coordination.md)及[证据](baselines/mb-coordination-validation.json)。
222项:101 done、7 doing、114 todo;立即专用本地提交,下一项MB-05视频帧资源。
MB-LIFE-01/MB-READY-01剩余解码器/能力与组合范围仍open;不构成npm发布验收。
## 当前检查点:PKG-MB-04 VideoShim、事件与RAF资源
新增五个生产TS模块,保持shim/canvas布局和正常事件/Promise/属性契约;修复销毁后的RAF、
+8 -4
View File
@@ -3648,7 +3648,7 @@
"PKG-MB-07",
"PKG-MB-09"
],
"compatibleResolution": "Reproduce pending operations and destroy/source-switch races before migrating each owner; preserve public event and promise behavior while preventing stale resource use. MB-02 now reproduces late load rejection and uncancelled waiting/loadstart timers after destroy in all three historical implementations using a controlled host. Other interleavings and full-core native behavior are not yet verified. Expanded MB-02 baseline reproduces play-after-pause/destroy, rejected-play false state, seek-after-destroy and retained successful-load timeout timers in all three actual historical implementations; Range and active-timeout normal controls remain separate. MB-03 candidate owns pending Input before track selection and cancels load/HEAD/timers on source replacement, timeout and destroy; source/main/legacy regressions pass. Pending play/seek, late decoder work and full readiness coordination remain MB-04/05/06, so this risk stays open. MB-04 shim checkpoint fixes synthetic RAF retention and terminal event-listener cleanup, including engine cleanup exceptions. Closing the event bridge does not prove pending engine operations stopped; coordination remains in progress.",
"compatibleResolution": "Reproduce pending operations and destroy/source-switch races before migrating each owner; preserve public event and promise behavior while preventing stale resource use. MB-02 now reproduces late load rejection and uncancelled waiting/loadstart timers after destroy in all three historical implementations using a controlled host. Other interleavings and full-core native behavior are not yet verified. Expanded MB-02 baseline reproduces play-after-pause/destroy, rejected-play false state, seek-after-destroy and retained successful-load timeout timers in all three actual historical implementations; Range and active-timeout normal controls remain separate. MB-03 candidate owns pending Input before track selection and cancels load/HEAD/timers on source replacement, timeout and destroy; source/main/legacy regressions pass. Pending play/seek, late decoder work and full readiness coordination remain MB-04/05/06, so this risk stays open. MB-04 shim checkpoint fixes synthetic RAF retention and terminal event-listener cleanup, including engine cleanup exceptions. Closing the event bridge does not prove pending engine operations stopped; coordination remains in progress. MB-04 now guards play/seek/replacement completion, preserves explicit pause, observes stale failures and isolates reentrant event sequences. Decoder-internal late frames/audio and HLS UI races remain MB-05/06/07; risk stays open.",
"closureCriteria": "Historical failing interleavings and candidate cancellation/cleanup pass with real media and event evidence, including long playback and track topology.",
"status": "open",
"evidence": [
@@ -3660,7 +3660,9 @@
"refactor/changes/2026-09-12-PKG-MB-02-baseline.md",
"refactor/baselines/mb-behavior-validation.json",
"refactor/changes/2026-09-12-PKG-MB-03-input.md",
"refactor/changes/2026-09-12-PKG-MB-04-shim-checkpoint.md"
"refactor/changes/2026-09-12-PKG-MB-04-shim-checkpoint.md",
"refactor/changes/2026-09-12-PKG-MB-04-coordination.md",
"refactor/baselines/mb-coordination-validation.json"
]
},
{
@@ -3707,11 +3709,13 @@
"PKG-MB-04",
"PKG-MB-09"
],
"compatibleResolution": "Use explicit coordinated readiness and failure state, preserving valid event order while preventing duplicate or stale callbacks; evaluate trackless behavior separately from normal media.",
"compatibleResolution": "Use explicit coordinated readiness and failure state, preserving valid event order while preventing duplicate or stale callbacks; evaluate trackless behavior separately from normal media. MB-04 coordinates metadata once, suppresses stale readiness and rejects trackless input before decoder setup. Existing-track unsupported-codec behavior and full native combinations remain MB-05/06/09; risk stays open.",
"closureCriteria": "Candidate metadata/data/canplay ordering and counts pass normal, absent-track and failure interleavings in native browsers and controlled race tests.",
"evidence": [
"refactor/changes/2026-09-12-PKG-MB-02-baseline.md",
"refactor/baselines/mb-behavior-validation.json"
"refactor/baselines/mb-behavior-validation.json",
"refactor/changes/2026-09-12-PKG-MB-04-coordination.md",
"refactor/baselines/mb-coordination-validation.json"
]
}
]
+4 -2
View File
@@ -3460,14 +3460,16 @@
"CORE-06",
"CORE-11"
],
"status": "doing",
"status": "done",
"risk": "H",
"deliverable": "shim 描述符、事件桥、加载状态和操作隔离",
"acceptance": "公开属性/同步异步返回与事件重入顺序保持",
"evidence": [
"baselines/mb-coordination-source.json",
"changes/2026-09-12-PKG-MB-04-shim-checkpoint.md",
"baselines/mb-shim-checkpoint.json"
"baselines/mb-shim-checkpoint.json",
"changes/2026-09-12-PKG-MB-04-coordination.md",
"baselines/mb-coordination-validation.json"
]
},
{
+25 -3
View File
@@ -95,6 +95,13 @@ for (const implementation of implementations) {
expect(loaded.dimensions).toEqual([0, 0])
outcome = input === 'hls' ? 'historical-unsupported-hls-control' : 'historical-stream-lock-control'
}
else if (implementation.name.startsWith('candidate') && input === 'trackless') {
expect(loaded.error).toEqual({ code: 4, message: 'Input has no audio or video tracks.' })
expect(loaded.readyState).toBe(0)
expect(loaded.dimensions).toEqual([0, 0])
expect(await page.evaluate(() => window.mbEvents.filter(item => ['video:loadedmetadata', 'video:loadeddata', 'video:canplay'].includes(item.name)))).toEqual([])
outcome = 'trackless-rejected-before-decoder-setup'
}
else if (capabilities.AudioContext === 'undefined' && capabilities.webkitAudioContext === 'undefined') {
expect(browserName).toBe('webkit')
expect(capabilities).toEqual({ VideoDecoder: 'undefined', AudioDecoder: 'undefined', AudioContext: 'undefined', webkitAudioContext: 'undefined' })
@@ -115,7 +122,7 @@ for (const implementation of implementations) {
expect(loaded.dimensions).toEqual([0, 0])
expect(await page.evaluate(() => window.mbEvents.filter(item => item.name === 'video:loadedmetadata').length)).toBe(implementation.name === 'published-1.0.0' ? 1 : 2)
expect(await page.evaluate(() => window.mbEvents.filter(item => item.name === 'video:canplay').length)).toBe(1)
outcome = implementation.name.startsWith('candidate') ? 'candidate-trackless-readiness-gap' : 'historical-trackless-readiness-control'
outcome = 'historical-trackless-readiness-control'
return
}
const readiness = await page.evaluate(() => window.mbEvents.map(item => item.name).filter(name => ['video:loadedmetadata', 'video:loadeddata', 'video:canplay', 'video:canplaythrough'].includes(name)))
@@ -177,6 +184,21 @@ for (const implementation of implementations) {
await expect.poll(() => page.evaluate(() => window.mbFrameClockSamples.filter(sample => !sample.paused).length)).toBeGreaterThan(2)
await page.evaluate(() => window.mbCanvas.pause())
}
if (implementation.name.startsWith('candidate') && input === 'webm') {
await page.evaluate(() => {
const canvas = window.mbCanvas
window.mbReentrantStart = window.mbEvents.length
const pause = () => {
canvas.events.removeEventListener('play', pause)
canvas.pause()
}
canvas.events.addEventListener('play', pause)
})
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.mbEvents.slice(window.mbReentrantStart).filter(item => ['video:play', 'video:pause', 'video:playing'].includes(item.name)).map(item => item.name))).toEqual(['video:play', 'video:pause'])
expect(await page.evaluate(() => window.mbCanvas.paused)).toBe(true)
await page.evaluate(() => window.mbReentrantPlay = { paused: window.mbCanvas.paused, events: window.mbEvents.slice(window.mbReentrantStart).filter(item => ['video:play', 'video:pause', 'video:playing'].includes(item.name)).map(item => item.name) })
}
if (input === 'hls') {
const tracks = await page.evaluate(async () => {
const canvas = window.mbCanvas
@@ -205,7 +227,7 @@ for (const implementation of implementations) {
if (!canvas)
return null
const hls = canvas.getM3u8State ? await canvas.getM3u8State().catch(error => ({ error: error.message })) : null
return { events: window.mbEvents, actions: window.mbActions, dimensions: [canvas.width, canvas.height, canvas.videoWidth, canvas.videoHeight], currentTime: canvas.currentTime, duration: canvas.duration, error: canvas.error, readyState: canvas.readyState, audioState: canvas.engine.audio.audioContext?.state, frameClockSamples: window.mbFrameClockSamples, tracks: window.mbTrackTransitions, hls: hls && { levels: hls.levels?.map(({ id, height }) => ({ id, height })), audios: hls.audios?.map(({ id, lang }) => ({ id, lang })), error: hls.error }, inputBytes: window.mbInputBytes }
return { events: window.mbEvents, actions: window.mbActions, dimensions: [canvas.width, canvas.height, canvas.videoWidth, canvas.videoHeight], currentTime: canvas.currentTime, duration: canvas.duration, error: canvas.error, readyState: canvas.readyState, audioState: canvas.engine.audio.audioContext?.state, frameClockSamples: window.mbFrameClockSamples, tracks: window.mbTrackTransitions, reentrantPlay: window.mbReentrantPlay, hls: hls && { levels: hls.levels?.map(({ id, height }) => ({ id, height })), audios: hls.audios?.map(({ id, lang }) => ({ id, lang })), error: hls.error }, inputBytes: window.mbInputBytes }
}).catch(error => ({ error: error.message }))
await page.evaluate(() => {
window.mbContext = window.mbCanvas?.engine.audio.audioContext
@@ -214,7 +236,7 @@ for (const implementation of implementations) {
await expect.poll(() => page.evaluate(() => window.mbContext?.state || 'not-created')).toMatch(/^(?:closed|not-created)$/)
expect(await page.evaluate(() => window.mbPendingFrames.size)).toBe(0)
const cleanup = await page.evaluate(() => ({ audioContext: window.mbContext?.state || 'not-created', pendingFrames: window.mbPendingFrames.size, streamCancelled: window.mbStreamCancelled || false }))
await testInfo.attach('mediabunny-native-input', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), input, capabilities, outcome, state, cleanup, hlsManifest: input === 'hls' ? manifest : null, tracklessFixture: input === 'trackless' ? { parentSha256: hash(pattern), sha256: hash(trackless) } : null, scope: 'Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless readiness remains MB-READY-01, not release acceptance.' }) })
await testInfo.attach('mediabunny-native-input', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), input, capabilities, outcome, state, cleanup, hlsManifest: input === 'hls' ? manifest : null, tracklessFixture: input === 'trackless' ? { parentSha256: hash(pattern), sha256: hash(trackless) } : null, scope: 'Identified proxy with native browser media and controlled ArtPlayer host using actual core utilities/config; frame timestamps sampled against the SDK audio clock, not acoustic/long-run AV-sync acceptance. Cleanup checks settled playback, not pending-operation races. Candidate trackless rejection is checked separately before decoder setup; this is not full release acceptance.' }) })
}
})
}
+1 -1
View File
@@ -65,7 +65,7 @@ for (const implementation of implementations) {
const canvas = window.art.video
return { events: window.mbEvents, actions: window.mbActions, ready: window.art.isReady, dimensions: [canvas.width, canvas.height, canvas.videoWidth, canvas.videoHeight], paused: canvas.paused, currentTime: canvas.currentTime, error: canvas.error, readyState: canvas.readyState, audioState: canvas.engine?.audio?.audioContext?.state }
})
await testInfo.attach('mediabunny-input', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), capabilities, outcome, state, scope: 'Two actual published proxies with actual core5.4.0; native MP4 pixels/clock or explicitly unsupported Windows WebKit APIs. No candidate, HLS or AV-sync acceptance.' }) })
await testInfo.attach('mediabunny-input', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), capabilities, outcome, state, scope: 'Identified published or candidate proxy with actual core5.4.0; native MP4 pixels/clock or explicitly unsupported Windows WebKit APIs. No HLS or long-run AV-sync acceptance.' }) })
await page.evaluate(() => window.art.destroy(false))
}
})
+550
View File
@@ -0,0 +1,550 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
// eslint-disable-next-line test/no-import-node-test -- Verify the actual coordinator with controlled asynchronous operations.
import test from 'node:test'
import { mbCandidate, mbEnvironment } from './helpers/mediabunny.js'
import { tracklessMp4 } from './helpers/trackless-mp4.js'
const implementation = await mbCandidate()
const flush = () => new Promise(resolve => setImmediate(resolve))
const media = new Blob([fs.readFileSync(new URL('./browser/media/pattern.mp4', import.meta.url))])
function deferred() {
let resolve, reject
const promise = new Promise((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
function environment() {
const env = mbEnvironment(implementation)
for (const name of ['playing', 'seeking', 'seeked', 'waiting', 'loadstart', 'loadeddata', 'canplay', 'canplaythrough', 'durationchange', 'progress', 'ended']) {
if (!env.eventNames.includes(name))
env.eventNames.push(name)
}
const canvas = env.factory()(env.art)
const engine = canvas.engine
const calls = []
for (const name of env.eventNames) canvas.events.addEventListener(name, () => calls.push(name))
engine.audio.play = async () => calls.push('audio:play')
engine.audio.pause = () => calls.push('audio:pause')
engine.audio.seek = async time => calls.push(`audio:seek:${time}`)
engine.video.start = () => calls.push('video:start')
engine.video.stop = () => calls.push('video:stop')
engine.video.seek = async time => calls.push(`video:seek:${time}`)
env.globals.console = { log() {}, warn() {}, error() {}, debug() {} }
return { ...env, canvas, engine, calls }
}
test('MediaBunny coordinator preserves ordinary play, seek-resume and synchronous pause ordering', async () => {
const env = environment()
await env.engine.play()
assert.deepEqual(env.calls, ['audio:play', 'video:start', 'play', 'playing'])
env.calls.length = 0
await env.engine.seek(3)
assert.deepEqual(env.calls, ['seeking', 'waiting', 'audio:pause', 'video:stop', 'pause', 'audio:seek:3', 'video:seek:3', 'seeked', 'audio:play', 'video:start', 'play', 'playing'])
env.calls.length = 0
assert.equal(env.engine.pause(), undefined)
assert.deepEqual(env.calls, ['audio:pause', 'video:stop', 'pause'])
env.art.emit('destroy')
})
for (const operation of ['pause', 'destroy', 'source']) {
test(`MediaBunny coordinator discards pending play after ${operation}`, async () => {
const env = environment()
const audio = deferred()
env.engine.audio.play = () => audio.promise
const playing = env.engine.play()
if (operation === 'pause') {
env.engine.pause()
}
else if (operation === 'destroy') {
env.art.emit('destroy')
}
else {
env.engine.performLoad = async () => {}
await env.engine.load('new.mp4')
}
env.calls.length = 0
audio.resolve()
await playing
assert.equal(env.engine.paused, true)
assert.equal(env.calls.includes('video:start'), false)
assert.equal(env.calls.includes('play'), false)
assert.equal(env.calls.includes('playing'), false)
env.art.emit('destroy')
})
}
test('MediaBunny coordinator restores paused state after a play rejection and allows retry', async () => {
const env = environment()
const error = new Error('resume denied')
let attempts = 0
env.engine.audio.play = async () => {
if (++attempts === 1)
throw error
}
await assert.rejects(env.engine.play(), value => value === error)
assert.equal(env.engine.paused, true)
await env.engine.play()
assert.equal(attempts, 2)
assert.equal(env.engine.paused, false)
assert.equal(env.calls.filter(name => name === 'video:start').length, 1)
env.art.emit('destroy')
})
test('MediaBunny coordinator shares delayed audio resume across play-pause-play and starts video once', async () => {
const env = environment()
const audio = deferred()
let attempts = 0
env.engine.audio.play = () => {
attempts++
return audio.promise
}
const first = env.engine.play()
env.engine.pause()
const second = env.engine.play()
audio.resolve()
await Promise.all([first, second])
assert.equal(attempts, 1)
assert.equal(env.engine.paused, false)
assert.equal(env.calls.filter(name => name === 'video:start').length, 1)
assert.equal(env.calls.filter(name => name === 'play').length, 1)
env.art.emit('destroy')
})
test('MediaBunny coordinator stops the play event sequence when a listener pauses', async () => {
const env = environment()
env.canvas.events.addEventListener('play', () => env.engine.pause())
await env.engine.play()
assert.equal(env.engine.paused, true)
assert.equal(env.calls.includes('playing'), false)
env.art.emit('destroy')
})
test('MediaBunny coordinator honors an explicit pause while a playing seek is pending', async () => {
const env = environment()
await env.engine.play()
const video = deferred()
env.engine.video.seek = () => video.promise
const seeking = env.engine.seek(4)
env.engine.pause()
env.calls.length = 0
video.resolve()
await seeking
assert.equal(env.engine.paused, true)
assert.equal(env.engine.seeking, false)
assert.deepEqual(env.calls.filter(name => ['seeked', 'video:start', 'play'].includes(name)), ['seeked'])
env.art.emit('destroy')
})
test('MediaBunny coordinator keeps the newest seek active when an earlier seek finishes first', async () => {
const env = environment()
const first = deferred()
const second = deferred()
env.engine.video.seek = time => time === 1 ? first.promise : second.promise
const a = env.engine.seek(1)
const b = env.engine.seek(2)
first.resolve()
await a
assert.equal(env.engine.seeking, true)
assert.equal(env.calls.includes('seeked'), false)
second.resolve()
await b
assert.equal(env.engine.seeking, false)
assert.equal(env.calls.filter(name => name === 'seeked').length, 1)
env.art.emit('destroy')
})
test('MediaBunny coordinator drops an older seek result after the newest result and preserves playing intent', async () => {
const env = environment()
await env.engine.play()
const first = deferred()
const second = deferred()
env.engine.video.seek = time => time === 1 ? first.promise : second.promise
const a = env.engine.seek(1)
const b = env.engine.seek(2)
env.calls.length = 0
second.resolve()
await b
assert.equal(env.engine.paused, false)
first.resolve()
await a
assert.equal(env.calls.filter(name => name === 'seeked').length, 1)
assert.equal(env.calls.filter(name => name === 'video:start').length, 1)
env.art.emit('destroy')
})
for (const operation of ['destroy', 'source']) {
test(`MediaBunny coordinator prevents pending seek completion and resume after ${operation}`, async () => {
const env = environment()
await env.engine.play()
const video = deferred()
env.engine.video.seek = () => video.promise
const seeking = env.engine.seek(4)
if (operation === 'destroy') {
env.art.emit('destroy')
}
else {
env.engine.performLoad = async () => {}
await env.engine.load('new.mp4')
}
env.calls.length = 0
video.resolve()
await seeking
assert.equal(env.calls.includes('video:start'), false)
assert.equal(env.calls.includes('audio:play'), false)
assert.equal(env.calls.includes('seeked'), false)
env.art.emit('destroy')
})
}
test('MediaBunny coordinator does not continue old seek after a seeking listener requests another position', async () => {
const env = environment()
let replacement
let first = true
env.canvas.events.addEventListener('seeking', () => {
if (first) {
first = false
replacement = env.engine.seek(7)
}
})
await env.engine.seek(2)
await replacement
assert.equal(env.calls.includes('audio:seek:2'), false)
assert.equal(env.calls.includes('video:seek:2'), false)
assert.equal(env.calls.filter(name => name === 'seeked').length, 1)
env.art.emit('destroy')
})
test('MediaBunny coordinator waits for pending seek when play is requested, without a resume deadlock', { timeout: 3000 }, async () => {
const env = environment()
const video = deferred()
env.engine.video.seek = () => video.promise
const seeking = env.engine.seek(3)
const playing = env.engine.play()
await flush()
assert.equal(env.calls.includes('audio:play'), false)
video.resolve()
await Promise.all([seeking, playing])
assert.equal(env.engine.paused, false)
assert.equal(env.calls.filter(name => name === 'video:start').length, 1)
env.art.emit('destroy')
})
test('MediaBunny coordinator resets seeking after an active failure and permits another seek', async () => {
const env = environment()
const error = new Error('seek failed')
let attempts = 0
env.engine.video.seek = async () => {
if (++attempts === 1)
throw error
}
await assert.rejects(env.engine.seek(2), value => value === error)
assert.equal(env.engine.seeking, false)
await env.engine.seek(3)
assert.equal(attempts, 2)
assert.equal(env.engine.seeking, false)
env.art.emit('destroy')
})
test('MediaBunny coordinator replay after ended seeks to zero then starts normally', async () => {
const env = environment()
env.engine.ended = true
await env.engine.play()
assert.equal(env.engine.ended, false)
assert.equal(env.engine.paused, false)
assert.deepEqual(env.calls, ['seeking', 'waiting', 'audio:seek:0', 'video:seek:0', 'seeked', 'audio:play', 'video:start', 'play', 'playing'])
env.art.emit('destroy')
})
test('MediaBunny coordinator preserves a resume-play rejection from the seek Promise', async () => {
const env = environment()
await env.engine.play()
const failure = new Error('resume failed')
env.engine.audio.play = async () => {
throw failure
}
await assert.rejects(env.engine.seek(3), error => error === failure)
assert.equal(env.engine.paused, true)
assert.equal(env.engine.seeking, false)
env.art.emit('destroy')
})
test('MediaBunny coordinator keeps original play rejection when cleanup fails and still stops video', async () => {
const env = environment()
const failure = new Error('resume failed')
env.engine.audio.play = async () => {
throw failure
}
env.engine.audio.pause = () => {
throw new Error('cleanup failed')
}
await assert.rejects(env.engine.play(), error => error === failure)
assert.equal(env.calls.includes('video:stop'), true)
assert.equal(env.engine.paused, true)
env.engine.audio.pause = () => {}
env.art.emit('destroy')
})
test('MediaBunny coordinator attempts both decoder cleanups when audio teardown throws', () => {
const env = environment()
const failure = new Error('audio teardown failed')
let videoDestroyed = 0
env.engine.audio.destroy = () => {
throw failure
}
env.engine.video.destroy = () => videoDestroyed++
assert.throws(() => env.art.emit('destroy'), error => error === failure)
assert.equal(videoDestroyed, 1)
assert.equal(env.canvas.events.listeners.size, 0)
assert.doesNotThrow(() => env.engine.destroy())
assert.equal(videoDestroyed, 1)
})
test('MediaBunny coordinator does not resume if a seeked listener pauses', async () => {
const env = environment()
await env.engine.play()
env.canvas.events.addEventListener('seeked', () => env.engine.pause())
env.calls.length = 0
await env.engine.seek(3)
assert.equal(env.engine.paused, true)
assert.equal(env.calls.includes('video:start'), false)
env.art.emit('destroy')
})
test('MediaBunny coordinator settles canceled play before audio resume returns and observes its late rejection', async () => {
const env = environment()
const audio = deferred()
env.engine.audio.play = () => audio.promise
let settled = false
const playing = env.engine.play().then(() => settled = true)
env.art.emit('destroy')
await flush()
assert.equal(settled, true)
audio.reject(new Error('late resume failure'))
await playing
await flush()
assert.equal(env.calls.includes('video:start'), false)
})
test('MediaBunny coordinator ignores stale seek failure after a newer seek succeeds', async () => {
const env = environment()
const first = deferred()
env.engine.video.seek = time => time === 1 ? first.promise : Promise.resolve()
const a = env.engine.seek(1)
await env.engine.seek(2)
first.reject(new Error('obsolete seek error'))
await a
await flush()
assert.equal(env.engine.error, null)
assert.equal(env.engine.seeking, false)
assert.equal(env.calls.includes('error'), false)
env.art.emit('destroy')
})
test('MediaBunny synchronous currentTime setter observes an active seek rejection after one error event', async () => {
const env = environment()
env.engine.video.seek = async () => {
throw new Error('seek rejected')
}
assert.equal(Reflect.set(env.canvas, 'currentTime', 3), true)
await flush()
assert.equal(env.engine.error.message, 'seek rejected')
assert.equal(env.engine.seeking, false)
assert.equal(env.calls.filter(name => name === 'error').length, 1)
env.art.emit('destroy')
})
async function loadingEnvironment() {
const env = environment()
const video = deferred()
const audio = deferred()
const metadata = {}
env.engine.video.load = (media, callback) => {
metadata.video = callback
return video.promise
}
env.engine.audio.load = (media, callback) => {
metadata.audio = callback
return audio.promise
}
const loading = env.engine.load(media)
await flush()
assert.equal(typeof metadata.video, 'function')
assert.equal(typeof metadata.audio, 'function')
return { ...env, video, audio, metadata, loading }
}
test('MediaBunny coordinator waits for both metadata participants and publishes readiness once', async () => {
const env = await loadingEnvironment()
env.metadata.video()
assert.equal(env.calls.includes('loadedmetadata'), false)
env.metadata.audio()
env.metadata.video()
env.metadata.audio()
env.video.resolve()
env.audio.resolve()
await env.loading
assert.deepEqual(env.calls.filter(name => ['loadedmetadata', 'durationchange', 'loadeddata', 'canplay', 'canplaythrough'].includes(name)), ['loadedmetadata', 'durationchange', 'loadeddata', 'canplay', 'canplaythrough'])
assert.equal(env.engine.readyState, 4)
env.art.emit('destroy')
})
for (const event of ['loadedmetadata', 'loadeddata']) {
test(`MediaBunny coordinator stops the old readiness sequence when ${event} starts another source`, async () => {
const env = await loadingEnvironment()
let replacement
env.canvas.events.addEventListener(event, () => {
env.engine.performLoad = async () => {}
replacement = env.engine.load('new.mp4')
})
env.metadata.video()
env.metadata.audio()
env.video.resolve()
env.audio.resolve()
await env.loading
await replacement
const cursor = env.calls.indexOf(event)
assert(cursor >= 0)
assert.equal(env.calls.slice(cursor + 1).includes('canplay'), false)
if (event === 'loadedmetadata')
assert.equal(env.calls.slice(cursor + 1).includes('durationchange'), false)
assert.equal(env.engine.readyState, 0)
env.art.emit('destroy')
})
}
test('MediaBunny coordinator keeps failed decoder setup from becoming ready through a later metadata callback', async () => {
const env = await loadingEnvironment()
env.video.reject(new Error('decoder unavailable'))
await env.loading
const cursor = env.calls.length
env.metadata.audio()
env.audio.resolve()
await flush()
assert.equal(env.engine.error.message, 'decoder unavailable')
assert.equal(env.engine.readyState, 0)
assert.deepEqual(env.calls.slice(cursor), [])
env.art.emit('destroy')
})
test('MediaBunny coordinator rejects a real trackless container before decoder or AudioContext setup', async () => {
const env = environment()
const bytes = tracklessMp4(fs.readFileSync(new URL('./browser/media/pattern.mp4', import.meta.url)))
let setups = 0
env.engine.video.load = async () => setups++
env.engine.audio.load = async () => setups++
await env.engine.load(new Blob([bytes]))
assert.equal(setups, 0)
assert.deepEqual({ ...env.engine.error }, { code: 4, message: 'Input has no audio or video tracks.' })
assert.equal(env.engine.readyState, 0)
assert.equal(env.calls.includes('loadedmetadata'), false)
assert.equal(env.calls.includes('canplay'), false)
env.art.emit('destroy')
})
for (const [method, query] of [['selectHlsQuality', 'getVideoTracks'], ['selectHlsAudio', 'getAudioTracks']]) {
for (const ending of ['source', 'destroy', 'active']) {
test(`MediaBunny ${method} ${ending} query rejection respects source ownership`, async () => {
const env = environment()
const pending = deferred()
const failure = new Error('track query failed')
env.engine.input = { [query]: () => pending.promise }
env.engine.media = { isHls: true }
let replaced = 0
env.engine.replaceTracks = async () => replaced++
const selecting = env.engine[method]('auto')
if (ending === 'source') {
env.engine.performLoad = async () => {}
await env.engine.load('new.mp4')
}
else if (ending === 'destroy') {
env.art.emit('destroy')
}
pending.reject(failure)
if (ending === 'active')
await assert.rejects(selecting, error => error === failure)
else await selecting
assert.equal(replaced, 0)
assert.equal(env.engine.error, null)
env.art.emit('destroy')
})
}
test(`MediaBunny ${method} retains active replacement rejection after media identity changes`, async () => {
const env = environment()
const track = { id: 1, canBePairedWith: () => true }
env.engine.input = { [query]: async () => [track] }
env.engine.media = { isHls: true, videoTrack: track, audioTrack: track }
const failure = new Error('replacement failed')
env.engine.replaceTracks = async () => {
env.engine.media = { ...env.engine.media }
throw failure
}
await assert.rejects(env.engine[method](1), error => error === failure)
env.art.emit('destroy')
})
}
function replacementEnvironment() {
const env = environment()
const track = { isLive: async () => false }
const input = { getDurationFromMetadata: async () => 8 }
env.engine.media = { input, videoTrack: track, audioTrack: null, isHls: true, videoMode: 'auto', audioMode: 'auto', duration: 8 }
env.engine.video.load = async () => env.calls.push('video:load')
env.engine.audio.load = async () => env.calls.push('audio:load')
return env
}
test('MediaBunny track replacement preserves normal pause, readiness, seek and resume ordering', async () => {
const env = replacementEnvironment()
await env.engine.play()
env.calls.length = 0
await env.engine.replaceTracks({ videoMode: 'manual' })
assert.deepEqual(env.calls, ['audio:pause', 'video:stop', 'pause', 'seeking', 'waiting', 'video:load', 'audio:load', 'video:seek:0', 'audio:seek:0', 'loadedmetadata', 'durationchange', 'progress', 'loadeddata', 'canplay', 'canplaythrough', 'seeked', 'audio:play', 'video:start', 'play', 'playing'])
assert.equal(env.engine.readyState, 4)
assert.equal(env.engine.media.videoMode, 'manual')
env.art.emit('destroy')
})
test('MediaBunny track replacement honors pause during delayed decoder setup', async () => {
const env = replacementEnvironment()
await env.engine.play()
const loading = deferred()
env.engine.video.load = () => loading.promise
const replacing = env.engine.replaceTracks({ videoMode: 'manual' })
await flush()
env.engine.pause()
env.calls.length = 0
loading.resolve()
await replacing
assert.equal(env.engine.paused, true)
assert.equal(env.calls.includes('video:start'), false)
assert.equal(env.calls.includes('seeked'), true)
env.art.emit('destroy')
})
for (const ending of ['source', 'destroy']) {
test(`MediaBunny track replacement cannot seek or publish readiness after ${ending}`, async () => {
const env = replacementEnvironment()
const loading = deferred()
env.engine.video.load = () => loading.promise
const replacing = env.engine.replaceTracks({ videoMode: 'manual' })
await flush()
if (ending === 'destroy') {
env.art.emit('destroy')
}
else {
env.engine.performLoad = async () => {}
await env.engine.load('new.mp4')
}
env.calls.length = 0
loading.resolve()
await replacing
await flush()
assert.deepEqual(env.calls.filter(name => !['waiting', 'loadstart'].includes(name)), [])
env.art.emit('destroy')
})
}