mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-08 10:56:15 -08:00
refactor(mediabunny): [PKG-MB-06] own audio clocks, buffers and context lifecycle
This commit is contained in:
1 parent
9d037dcbf9
commit
ccee9157a7
30 files changed
+16182
-736
No files matched your search
@@ -4,4 +4,4 @@
|
||||
* (c) 2017-2026 Harvey Zhao
|
||||
* Released under the MIT License.
|
||||
*/
|
||||
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(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,r,a,s,n,o,c,l,u,d,h,f,m,p,g,k,y,b,w=e=>{throw TypeError(e)},T=(e,t,i)=>t.has(e)||w("Cannot "+i),S=(e,t,i)=>(T(e,t,"read from private field"),i?i.call(e):t.get(e)),v=(e,t,i)=>t.has(e)?w("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),P=(e,t,i,r)=>(T(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),I=(e,t,i)=>(T(e,t,"access private method"),i),x=(e,t,i,r)=>({set _(r){P(e,t,r,i)},get _(){return S(e,t,r)}});function C(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 B{constructor(){v(this,e,!1),this.listeners=new Map}addEventListener(t,i){S(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(S(this,e))return;const r=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{S(this,e)||t(r)})}destroy(){P(this,e,!0),this.listeners.clear()}}function E(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const _=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},R=e=>e&&e[e.length-1],F=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)},A=(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=F(e);return 0===(1&t)?-(t>>1):t+1>>1},M=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),N=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),O=new TextDecoder,z=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),L={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},U=z(L),q={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},W=z(q),H={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},V=z(H),j=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,K={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},X=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class ${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 G=/^[0-9a-fA-F]+$/,Q=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),Y=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,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},Z=(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},ee=(e,t,i)=>{const r=Z(e,i(t),i);e.splice(r+1,0,t)},te=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},ie=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},re=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},ae=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},se=e=>{throw new Error(`Unexpected value: ${e}`)},ne=(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},oe=(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)},ce=(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}}),le=(e,t,i)=>Math.max(t,Math.min(i,e)),ue="und",de=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},he=(e,t)=>Math.round(e/t)*t,fe=(e,t)=>Math.round(e*t)/t,me=(e,t)=>Math.floor(e/t)*t,pe=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},ge=/^[a-z]{3}$/,ke=e=>ge.test(e),ye=1e6*(1+Number.EPSILON),be=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?we(e.headers):{},a=t.headers?we(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},we=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)}catcLine 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,r,a,s,n,o,c,l,u,h,d,f,m,p,g,k,y,b,w,T,S,v,P,I,x,C,B,E,_,R,F,A=e=>{throw TypeError(e)},M=(e,t,i)=>t.has(e)||A("Cannot "+i),D=(e,t,i)=>(M(e,t,"read from private field"),i?i.call(e):t.get(e)),N=(e,t,i)=>t.has(e)?A("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),O=(e,t,i,r)=>(M(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),z=(e,t,i)=>(M(e,t,"access private method"),i),L=(e,t,i,r)=>({set _(r){O(e,t,r,i)},get _(){return D(e,t,r)}});function U(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 q{constructor(){N(this,e,!1),this.listeners=new Map}addEventListener(t,i){D(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(D(this,e))return;const r=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{D(this,e)||t(r)})}destroy(){O(this,e,!0),this.listeners.clear()}}function W(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const H=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},V=e=>e&&e[e.length-1],j=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)},K=(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)},X=e=>{const t=j(e);return 0===(1&t)?-(t>>1):t+1>>1},$=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),Q=new TextDecoder,Y=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),J={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},Z=Y(J),ee={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},te=Y(ee),ie={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},re=Y(ie),ae=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,se={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},ne=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class oe{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 ce=/^[0-9a-fA-F]+$/,le=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),ue=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,he=(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},de=(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},fe=(e,t,i)=>{const r=de(e,i(t),i);e.splice(r+1,0,t)},me=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},pe=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},ge=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},ke=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},ye=e=>{throw new Error(`Unexpected value: ${e}`)},be=(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},we=(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)},Te=(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}}),Se=(e,t,i)=>Math.max(t,Math.min(i,e)),ve="und",Pe=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},Ie=(e,t)=>Math.round(e/t)*t,xe=(e,t)=>Math.round(e*t)/t,Ce=(e,t)=>Math.floor(e/t)*t,Be=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},Ee=/^[a-z]{3}$/,_e=e=>Ee.test(e),Re=1e6*(1+Number.EPSILON),Fe=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?Ae(e.headers):{},a=t.headers?Ae(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},Ae=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},Me=async(e,t,i,r,a)=>{lLine 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,i,r,n,s,a,o,l,c,u,d,h,f,m,p,g,k,y,b,T=Object.defineProperty,v=Object.defineProperties,S=Object.getOwnPropertyDescriptors,w=Object.getOwnPropertySymbols,P=Object.prototype.hasOwnProperty,I=Object.prototype.propertyIsEnumerable,x=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),C=e=>{throw TypeError(e)},B=Math.pow,E=(e,t,i)=>t in e?T(e,t,{enumerable:!0,configurable:!0,writable:!0,value:i}):e[t]=i,_=(e,t)=>{for(var i in t||(t={}))P.call(t,i)&&E(e,i,t[i]);if(w)for(var i of w(t))I.call(t,i)&&E(e,i,t[i]);return e},R=(e,t)=>v(e,S(t)),F=(e,t,i)=>t.has(e)||C("Cannot "+i),A=(e,t,i)=>(F(e,t,"read from private field"),i?i.call(e):t.get(e)),D=(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),M=(e,t,i,r)=>(F(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),N=(e,t,i)=>(F(e,t,"access private method"),i),O=(e,t,i,r)=>({set _(r){M(e,t,r,i)},get _(){return A(e,t,r)}}),z=(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())}),L=function(e,t){this[0]=e,this[1]=t},U=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof L,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[x("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},q=e=>{var t,i=e[x("asyncIterator")],r=!1,n={};return null==i?(i=e[x("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 L(new Promise(r=>{var n=i[e](t);n instanceof Object||C("Object expected"),r(n)}),1)}}),n[x("iterator")]=()=>n,t("next"),"throw"in i?t("throw"):n.throw=e=>{throw e},"return"in i&&t("return"),n},W=(e,t,i)=>(t=e[x("asyncIterator")])?t.call(e):(e=e[x("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 H(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(){D(this,e,!1),this.listeners=new Map}addEventListener(t,i){var r;A(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(A(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{A(this,e)||t(n)})}destroy(){M(this,e,!0),this.listeners.clear()}}function j(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const K=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},X=e=>e&&e[e.length-1],$=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)},G=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-B(2,r))},Q=e=>{const t=$(e);return 0===(1&t)?-(t>>1):t+1>>1},Y=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),J=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),Z=new TextDecoder,ee=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),te={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},ie=ee(te),re={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},ne=ee(re),se={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},ae=ee(se),oe=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,le={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},ce=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class ue{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}acquire(){return z(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 de=/^[0-9a-fA-F]+$/,he=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),fe=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&167Line 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,r,n,s,a,o,l,c,u,d,h,f,m,p,g,k,y,b,T,v,w,S,P,I,x,C,B,E,_,R,F,A=Object.defineProperty,M=Object.defineProperties,D=Object.getOwnPropertyDescriptors,N=Object.getOwnPropertySymbols,O=Object.prototype.hasOwnProperty,z=Object.prototype.propertyIsEnumerable,L=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),U=e=>{throw TypeError(e)},q=Math.pow,W=(e,t,i)=>t in e?A(e,t,{enumerable:!0,configurable:!0,writable:!0,value:i}):e[t]=i,H=(e,t)=>{for(var i in t||(t={}))O.call(t,i)&&W(e,i,t[i]);if(N)for(var i of N(t))z.call(t,i)&&W(e,i,t[i]);return e},V=(e,t)=>M(e,D(t)),j=(e,t,i)=>t.has(e)||U("Cannot "+i),K=(e,t,i)=>(j(e,t,"read from private field"),i?i.call(e):t.get(e)),X=(e,t,i)=>t.has(e)?U("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),$=(e,t,i,r)=>(j(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),G=(e,t,i)=>(j(e,t,"access private method"),i),Q=(e,t,i,r)=>({set _(r){$(e,t,r,i)},get _(){return K(e,t,r)}}),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())}),J=function(e,t){this[0]=e,this[1]=t},Z=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof J,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},ee=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 J(new Promise(r=>{var n=i[e](t);n instanceof Object||U("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},te=(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 ie(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 re{constructor(){X(this,e,!1),this.listeners=new Map}addEventListener(t,i){var r;K(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(K(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{K(this,e)||t(n)})}destroy(){$(this,e,!0),this.listeners.clear()}}function ne(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const se=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},ae=e=>e&&e[e.length-1],oe=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)},le=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-q(2,r))},ce=e=>{const t=oe(e);return 0===(1&t)?-(t>>1):t+1>>1},ue=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),de=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),he=new TextDecoder,fe=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),me={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},pe=fe(me),ge={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},ke=fe(ge),ye={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},be=fe(ye),Te=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,ve={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},we=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class Se{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 Pe=/^[0-9a-fA-F]+$/,Ie=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),xe=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<Line truncated
|
||||
@@ -20,7 +20,7 @@ var __privateWrapper = (obj, member, setter, getter) => ({
|
||||
return __privateGet(obj, member, getter);
|
||||
}
|
||||
});
|
||||
var _closed, _generation, _iteration, _loading, _createSink, _usedSink, _destroyed, _preparing, _poster, _renderer, _onError, _VideoEngine_instances, current_fn, context_fn, report_fn, release_fn, draw_fn, load_fn, reset_fn, _playback;
|
||||
var _closed, _closed2, _generation, _contexts, _nodes, _pump, _playTask, _playing, _loading, _onError, _AudioEngine_instances, load_fn, startPump_fn, play_fn, _generation2, _iteration, _loading2, _createSink, _usedSink, _destroyed, _preparing, _poster, _renderer, _onError2, _VideoEngine_instances, current_fn, context_fn, report_fn, release_fn, draw_fn, load_fn2, reset_fn, _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) {
|
||||
@@ -5317,13 +5317,13 @@ class IsobmffDemuxer extends Demuxer {
|
||||
const entryCount = readU32Be(slice);
|
||||
for (let i = 0; i < entryCount; i++) {
|
||||
const segmentDuration = version === 1 ? readU64Be(slice) : readU32Be(slice);
|
||||
const mediaTime = version === 1 ? readI64Be(slice) : readI32Be(slice);
|
||||
const mediaTime2 = version === 1 ? readI64Be(slice) : readI32Be(slice);
|
||||
const mediaRate = readFixed_16_16(slice);
|
||||
if (relevantEntryFound) {
|
||||
Logging._warn("Unsupported edit list: multiple edits are not currently supported. Only using first edit.");
|
||||
break;
|
||||
}
|
||||
if (mediaTime === -1) {
|
||||
if (mediaTime2 === -1) {
|
||||
previousSegmentDurations += segmentDuration;
|
||||
continue;
|
||||
}
|
||||
@@ -5332,7 +5332,7 @@ class IsobmffDemuxer extends Demuxer {
|
||||
break;
|
||||
}
|
||||
track.editListPreviousSegmentDurations = previousSegmentDurations;
|
||||
track.editListOffset = mediaTime;
|
||||
track.editListOffset = mediaTime2;
|
||||
relevantEntryFound = true;
|
||||
}
|
||||
}
|
||||
@@ -24076,8 +24076,344 @@ class Id3V2Reader {
|
||||
return this.readId3V2Text(encoding, until);
|
||||
}
|
||||
}
|
||||
function mediaTime(clock) {
|
||||
if (clock.paused || !clock.audioContext)
|
||||
return clock.playbackTimeAtStart;
|
||||
return (clock.audioContext.currentTime - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart;
|
||||
}
|
||||
function bufferTiming(clock, timestamp, duration, now) {
|
||||
const startAt = clock.audioContextStartTime + (timestamp - clock.playbackTimeAtStart) / clock.playbackRate;
|
||||
const endAt = startAt + duration / clock.playbackRate;
|
||||
const endMediaTime = (endAt - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart;
|
||||
return { startAt: Math.max(startAt, now), offset: startAt < now ? (now - startAt) * clock.playbackRate : null, endMediaTime };
|
||||
}
|
||||
class ContextOwner {
|
||||
constructor(host) {
|
||||
this.host = host;
|
||||
this.closed = false;
|
||||
this.resumes = /* @__PURE__ */ new WeakMap();
|
||||
}
|
||||
ensure(sampleRate) {
|
||||
if (this.closed)
|
||||
return null;
|
||||
if (this.host.audioContext)
|
||||
return this.host.audioContext;
|
||||
const Context = window.AudioContext || window.webkitAudioContext;
|
||||
let context;
|
||||
try {
|
||||
context = new Context({ sampleRate });
|
||||
} catch {
|
||||
context = new Context();
|
||||
}
|
||||
if (this.closed) {
|
||||
void context.close().catch((error) => console.warn("MediaBunny obsolete context cleanup:", error));
|
||||
return null;
|
||||
}
|
||||
let gain = null;
|
||||
try {
|
||||
gain = context.createGain();
|
||||
gain.connect(context.destination);
|
||||
const volume = this.host.muted ? 0 : this.host.volume;
|
||||
gain.gain.value = volume * volume;
|
||||
if (this.closed) {
|
||||
gain.disconnect();
|
||||
void context.close().catch((error) => console.warn("MediaBunny obsolete context cleanup:", error));
|
||||
return null;
|
||||
}
|
||||
this.host.audioContext = context;
|
||||
this.host.gainNode = gain;
|
||||
return context;
|
||||
} catch (error) {
|
||||
try {
|
||||
gain?.disconnect();
|
||||
} catch (failure) {
|
||||
console.warn("MediaBunny gain setup cleanup:", failure);
|
||||
}
|
||||
void context.close().catch((failure) => console.warn("MediaBunny context setup cleanup:", failure));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
updateGain() {
|
||||
if (!this.host.gainNode)
|
||||
return;
|
||||
const volume = this.host.muted ? 0 : this.host.volume;
|
||||
this.host.gainNode.gain.value = volume * volume;
|
||||
}
|
||||
resume(context) {
|
||||
const previous = this.resumes.get(context);
|
||||
if (previous)
|
||||
return previous;
|
||||
const pending = context.resume();
|
||||
this.resumes.set(context, pending);
|
||||
const clear = () => {
|
||||
if (this.resumes.get(context) === pending)
|
||||
this.resumes.delete(context);
|
||||
};
|
||||
pending.then(clear, clear);
|
||||
return pending;
|
||||
}
|
||||
destroy() {
|
||||
if (this.closed)
|
||||
return;
|
||||
this.closed = true;
|
||||
const { audioContext: context, gainNode: gain } = this.host;
|
||||
this.host.audioContext = null;
|
||||
this.host.gainNode = null;
|
||||
try {
|
||||
gain?.disconnect();
|
||||
} finally {
|
||||
if (context)
|
||||
void context.close().catch((error) => console.warn("MediaBunny context close error:", error));
|
||||
}
|
||||
}
|
||||
}
|
||||
class NodeQueue {
|
||||
constructor(host) {
|
||||
this.host = host;
|
||||
}
|
||||
release(node, stop) {
|
||||
this.host.queuedNodes.delete(node);
|
||||
node.onended = null;
|
||||
let failed = false;
|
||||
let failure;
|
||||
try {
|
||||
if (stop)
|
||||
node.stop();
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
failure = error;
|
||||
}
|
||||
try {
|
||||
node.disconnect();
|
||||
} catch (error) {
|
||||
if (!failed) {
|
||||
failed = true;
|
||||
failure = error;
|
||||
}
|
||||
}
|
||||
if (failed)
|
||||
throw failure;
|
||||
}
|
||||
stop() {
|
||||
const failures = [];
|
||||
for (const node of [...this.host.queuedNodes]) {
|
||||
try {
|
||||
this.release(node, true);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
if (failures.length)
|
||||
throw failures[0];
|
||||
}
|
||||
schedule({ buffer, timestamp }) {
|
||||
const context = this.host.audioContext;
|
||||
const gain = this.host.gainNode;
|
||||
if (!context || !gain)
|
||||
return;
|
||||
const timing = bufferTiming(this.host, timestamp, buffer.duration, context.currentTime);
|
||||
if (timing.offset !== null && timing.offset >= buffer.duration)
|
||||
return;
|
||||
const node = context.createBufferSource();
|
||||
this.host.queuedNodes.add(node);
|
||||
try {
|
||||
node.buffer = buffer;
|
||||
node.connect(gain);
|
||||
node.playbackRate.value = this.host.playbackRate;
|
||||
node.onended = () => {
|
||||
if (!this.host.queuedNodes.has(node))
|
||||
return;
|
||||
try {
|
||||
this.release(node, false);
|
||||
} catch (error) {
|
||||
console.warn("MediaBunny ended node cleanup:", error);
|
||||
}
|
||||
};
|
||||
if (timing.offset === null)
|
||||
node.start(timing.startAt);
|
||||
else
|
||||
node.start(timing.startAt, timing.offset);
|
||||
this.host.latestScheduledEndTime = Math.max(this.host.latestScheduledEndTime, timing.endMediaTime);
|
||||
} catch (error) {
|
||||
try {
|
||||
this.release(node, true);
|
||||
} catch (failure) {
|
||||
console.warn("MediaBunny audio node cleanup:", failure);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
const CANCELLED = /* @__PURE__ */ Symbol("cancelled");
|
||||
class AudioTask {
|
||||
constructor() {
|
||||
this.cancelled = false;
|
||||
this.waits = /* @__PURE__ */ new Set();
|
||||
this.timers = /* @__PURE__ */ new Map();
|
||||
}
|
||||
wait(pending) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cancel = () => resolve(CANCELLED);
|
||||
if (this.cancelled)
|
||||
cancel();
|
||||
else
|
||||
this.waits.add(cancel);
|
||||
pending.then((value) => {
|
||||
this.waits.delete(cancel);
|
||||
resolve(value);
|
||||
}, (error) => {
|
||||
this.waits.delete(cancel);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
delay(ms) {
|
||||
if (this.cancelled)
|
||||
return Promise.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.timers.delete(timer);
|
||||
resolve(!this.cancelled);
|
||||
}, ms);
|
||||
this.timers.set(timer, resolve);
|
||||
});
|
||||
}
|
||||
cancel() {
|
||||
if (this.cancelled)
|
||||
return;
|
||||
this.cancelled = true;
|
||||
for (const cancel of this.waits)
|
||||
cancel();
|
||||
this.waits.clear();
|
||||
for (const [timer, resolve] of this.timers) {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
}
|
||||
this.timers.clear();
|
||||
}
|
||||
}
|
||||
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") };
|
||||
}
|
||||
class AudioPump {
|
||||
constructor(host, contexts, nodes) {
|
||||
this.host = host;
|
||||
this.contexts = contexts;
|
||||
this.nodes = nodes;
|
||||
this.task = null;
|
||||
this.closing = /* @__PURE__ */ new WeakMap();
|
||||
}
|
||||
async stopIterator() {
|
||||
const iterator = this.host.audioIterator;
|
||||
this.host.audioIterator = null;
|
||||
if (!iterator)
|
||||
return;
|
||||
let pending = this.closing.get(iterator);
|
||||
if (!pending) {
|
||||
pending = Promise.resolve().then(() => iterator.return()).then(() => {
|
||||
});
|
||||
this.closing.set(iterator, pending);
|
||||
}
|
||||
await pending;
|
||||
}
|
||||
cancel() {
|
||||
this.task?.cancel();
|
||||
this.task = null;
|
||||
void this.stopIterator().catch((error) => console.warn("MediaBunny audio iterator cleanup:", error));
|
||||
}
|
||||
async run(id) {
|
||||
if (this.host.asyncId !== id)
|
||||
return;
|
||||
this.cancel();
|
||||
const sink = this.host.audioSink;
|
||||
const context = this.host.audioContext;
|
||||
if (!sink || !context || this.host.paused || this.host.asyncId !== id)
|
||||
return;
|
||||
const task = new AudioTask();
|
||||
this.task = task;
|
||||
let iterator = null;
|
||||
const current = () => !task.cancelled && this.task === task && this.host.asyncId === id && !this.host.paused && this.host.audioContext === context && (!iterator || this.host.audioIterator === iterator);
|
||||
try {
|
||||
iterator = sink.buffers(this.host.currentTime);
|
||||
this.host.audioIterator = iterator;
|
||||
while (current()) {
|
||||
const batch = [];
|
||||
let done = false;
|
||||
for (let index = 0; index < 16; index++) {
|
||||
const result = await task.wait(iterator.next());
|
||||
if (result === CANCELLED || !current())
|
||||
return;
|
||||
if (result.done) {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
batch.push(result.value);
|
||||
}
|
||||
if (batch.length && context.state === "suspended") {
|
||||
const resumed = await task.wait(this.contexts.resume(context));
|
||||
if (resumed === CANCELLED || !current())
|
||||
return;
|
||||
publish(this.host.events, current, ["canplay", "playing"]);
|
||||
}
|
||||
for (const buffer of batch) {
|
||||
if (!current())
|
||||
return;
|
||||
this.nodes.schedule(buffer);
|
||||
}
|
||||
if (done)
|
||||
return;
|
||||
if (!await task.delay(0) || !current())
|
||||
return;
|
||||
while (this.host.latestScheduledEndTime - this.host.currentTime > 1) {
|
||||
if (!await task.delay(50) || !current())
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (current())
|
||||
throw error;
|
||||
} finally {
|
||||
task.cancel();
|
||||
if (this.task === task) {
|
||||
this.task = null;
|
||||
void this.stopIterator().catch((error) => console.warn("MediaBunny audio iterator cleanup:", error));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
class AudioEngine {
|
||||
constructor(events) {
|
||||
constructor(events, onError) {
|
||||
__privateAdd(this, _AudioEngine_instances);
|
||||
__privateAdd(this, _closed2, false);
|
||||
__privateAdd(this, _generation, 0);
|
||||
__privateAdd(this, _contexts);
|
||||
__privateAdd(this, _nodes);
|
||||
__privateAdd(this, _pump);
|
||||
__privateAdd(this, _playTask, null);
|
||||
__privateAdd(this, _playing, null);
|
||||
__privateAdd(this, _loading, null);
|
||||
__privateAdd(this, _onError);
|
||||
this.events = events;
|
||||
this.input = null;
|
||||
this.audioSink = null;
|
||||
@@ -24094,164 +24430,108 @@ class AudioEngine {
|
||||
this.playbackRate = 1;
|
||||
this.asyncId = 0;
|
||||
this.queuedNodes = /* @__PURE__ */ new Set();
|
||||
__privateSet(this, _contexts, new ContextOwner(this));
|
||||
__privateSet(this, _nodes, new NodeQueue(this));
|
||||
__privateSet(this, _pump, new AudioPump(this, __privateGet(this, _contexts), __privateGet(this, _nodes)));
|
||||
__privateSet(this, _onError, onError);
|
||||
}
|
||||
get currentTime() {
|
||||
if (this.paused)
|
||||
return this.playbackTimeAtStart;
|
||||
return (this.audioContext.currentTime - this.audioContextStartTime) * this.playbackRate + this.playbackTimeAtStart;
|
||||
return mediaTime(this);
|
||||
}
|
||||
ensureAudioContext(sampleRate) {
|
||||
if (this.audioContext)
|
||||
return;
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
try {
|
||||
this.audioContext = new AudioContext({ sampleRate });
|
||||
} catch {
|
||||
this.audioContext = new AudioContext();
|
||||
}
|
||||
this.gainNode = this.audioContext.createGain();
|
||||
this.gainNode.connect(this.audioContext.destination);
|
||||
this.updateGain();
|
||||
__privateGet(this, _contexts).ensure(sampleRate);
|
||||
}
|
||||
updateGain() {
|
||||
if (!this.gainNode)
|
||||
return;
|
||||
const v = this.muted ? 0 : this.volume;
|
||||
this.gainNode.gain.value = v * v;
|
||||
__privateGet(this, _contexts).updateGain();
|
||||
}
|
||||
stopQueuedNodes() {
|
||||
this.queuedNodes.forEach((node) => node.stop());
|
||||
this.queuedNodes.clear();
|
||||
__privateGet(this, _nodes).stop();
|
||||
}
|
||||
async stopIterator() {
|
||||
await this.audioIterator?.return();
|
||||
this.audioIterator = null;
|
||||
await __privateGet(this, _pump).stopIterator();
|
||||
}
|
||||
handleNoAudioTrack() {
|
||||
if (__privateGet(this, _closed2))
|
||||
return;
|
||||
this.audioSink = null;
|
||||
this.ensureAudioContext();
|
||||
}
|
||||
cancelPending() {
|
||||
__privateWrapper(this, _generation)._++;
|
||||
try {
|
||||
this.pause();
|
||||
} finally {
|
||||
__privateSet(this, _loading, null);
|
||||
this.input = null;
|
||||
this.audioSink = null;
|
||||
}
|
||||
}
|
||||
async load(media, onMetadata) {
|
||||
++this.asyncId;
|
||||
await this.stopIterator();
|
||||
this.stopQueuedNodes();
|
||||
this.paused = true;
|
||||
if (__privateGet(this, _closed2))
|
||||
return;
|
||||
this.cancelPending();
|
||||
const generation = __privateGet(this, _generation);
|
||||
this.playbackTimeAtStart = 0;
|
||||
this.audioContextStartTime = 0;
|
||||
const { input, audioTrack, duration } = media;
|
||||
this.input = input;
|
||||
this.duration = duration;
|
||||
if (!audioTrack) {
|
||||
this.handleNoAudioTrack();
|
||||
onMetadata?.();
|
||||
return;
|
||||
this.latestScheduledEndTime = 0;
|
||||
const pending = Promise.resolve().then(() => __privateMethod(this, _AudioEngine_instances, load_fn).call(this, media, generation, onMetadata));
|
||||
__privateSet(this, _loading, pending);
|
||||
try {
|
||||
await pending;
|
||||
} finally {
|
||||
if (__privateGet(this, _loading) === pending)
|
||||
__privateSet(this, _loading, null);
|
||||
}
|
||||
if (audioTrack.codec === null || !await audioTrack.canDecode()) {
|
||||
this.audioSink = null;
|
||||
this.ensureAudioContext();
|
||||
onMetadata?.();
|
||||
return;
|
||||
}
|
||||
this.ensureAudioContext(audioTrack.sampleRate);
|
||||
this.audioSink = new AudioBufferSink(audioTrack);
|
||||
onMetadata?.();
|
||||
}
|
||||
async runIterator(localId) {
|
||||
if (!this.audioSink)
|
||||
if (__privateGet(this, _closed2))
|
||||
return;
|
||||
await this.stopIterator();
|
||||
this.audioIterator = this.audioSink.buffers(this.currentTime);
|
||||
const BATCH_SIZE = 16;
|
||||
while (true) {
|
||||
if (localId !== this.asyncId || this.paused)
|
||||
return;
|
||||
const batch = [];
|
||||
let batchDone = false;
|
||||
try {
|
||||
for (let i = 0; i < BATCH_SIZE; i++) {
|
||||
const result = await this.audioIterator.next();
|
||||
if (result.done) {
|
||||
batchDone = true;
|
||||
break;
|
||||
}
|
||||
batch.push(result.value);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Audio iterator error:", e);
|
||||
batchDone = true;
|
||||
}
|
||||
if (localId !== this.asyncId || this.paused)
|
||||
return;
|
||||
if (batch.length > 0 && this.audioContext.state === "suspended") {
|
||||
await this.audioContext.resume();
|
||||
this.events.emit("canplay");
|
||||
this.events.emit("playing");
|
||||
}
|
||||
for (const { buffer, timestamp } of batch) {
|
||||
const node = this.audioContext.createBufferSource();
|
||||
node.buffer = buffer;
|
||||
node.connect(this.gainNode);
|
||||
node.playbackRate.value = this.playbackRate;
|
||||
const startAt = this.audioContextStartTime + (timestamp - this.playbackTimeAtStart) / this.playbackRate;
|
||||
const duration = buffer.duration;
|
||||
const endAt = startAt + duration / this.playbackRate;
|
||||
const endMediaTime = (endAt - this.audioContextStartTime) * this.playbackRate + this.playbackTimeAtStart;
|
||||
if (endMediaTime > this.latestScheduledEndTime) {
|
||||
this.latestScheduledEndTime = endMediaTime;
|
||||
}
|
||||
if (startAt >= this.audioContext.currentTime) {
|
||||
node.start(startAt);
|
||||
} else {
|
||||
node.start(
|
||||
this.audioContext.currentTime,
|
||||
(this.audioContext.currentTime - startAt) * this.playbackRate
|
||||
);
|
||||
}
|
||||
this.queuedNodes.add(node);
|
||||
node.onended = () => this.queuedNodes.delete(node);
|
||||
}
|
||||
if (batchDone)
|
||||
break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const BUFFER_AHEAD = 1;
|
||||
while (this.latestScheduledEndTime - this.currentTime > BUFFER_AHEAD) {
|
||||
if (localId !== this.asyncId || this.paused)
|
||||
return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
await __privateGet(this, _pump).run(localId);
|
||||
}
|
||||
async play() {
|
||||
if (__privateGet(this, _closed2))
|
||||
return;
|
||||
if (__privateGet(this, _playing))
|
||||
return __privateGet(this, _playing);
|
||||
if (!this.paused)
|
||||
return;
|
||||
if (!this.audioContext) {
|
||||
this.ensureAudioContext();
|
||||
const task = new AudioTask();
|
||||
__privateSet(this, _playTask, task);
|
||||
const generation = __privateGet(this, _generation);
|
||||
const pending = __privateMethod(this, _AudioEngine_instances, play_fn).call(this, task, generation);
|
||||
__privateSet(this, _playing, pending);
|
||||
try {
|
||||
await pending;
|
||||
} finally {
|
||||
if (__privateGet(this, _playTask) === task) {
|
||||
__privateSet(this, _playTask, null);
|
||||
__privateSet(this, _playing, null);
|
||||
}
|
||||
}
|
||||
if (this.audioContext.state === "suspended") {
|
||||
await this.audioContext.resume();
|
||||
}
|
||||
this.audioContextStartTime = this.audioContext.currentTime;
|
||||
this.latestScheduledEndTime = this.playbackTimeAtStart;
|
||||
this.paused = false;
|
||||
const id = ++this.asyncId;
|
||||
this.runIterator(id);
|
||||
}
|
||||
pause() {
|
||||
if (this.paused)
|
||||
return;
|
||||
this.playbackTimeAtStart = this.currentTime;
|
||||
if (!this.paused)
|
||||
this.playbackTimeAtStart = this.currentTime;
|
||||
this.paused = true;
|
||||
this.stopIterator();
|
||||
this.asyncId++;
|
||||
__privateGet(this, _playTask)?.cancel();
|
||||
__privateSet(this, _playTask, null);
|
||||
__privateSet(this, _playing, null);
|
||||
__privateGet(this, _pump).cancel();
|
||||
this.stopQueuedNodes();
|
||||
}
|
||||
async seek(time) {
|
||||
if (__privateGet(this, _closed2))
|
||||
return;
|
||||
const playing = !this.paused;
|
||||
__privateGet(this, _pump).cancel();
|
||||
this.stopQueuedNodes();
|
||||
this.playbackTimeAtStart = Math.max(0, time);
|
||||
this.audioContextStartTime = this.audioContext.currentTime;
|
||||
this.audioContextStartTime = this.audioContext?.currentTime ?? 0;
|
||||
this.latestScheduledEndTime = this.playbackTimeAtStart;
|
||||
const id = ++this.asyncId;
|
||||
if (!this.paused) {
|
||||
this.runIterator(id);
|
||||
}
|
||||
if (playing)
|
||||
__privateMethod(this, _AudioEngine_instances, startPump_fn).call(this, id);
|
||||
}
|
||||
setVolume(volume, muted) {
|
||||
this.volume = volume;
|
||||
@@ -24259,27 +24539,118 @@ class AudioEngine {
|
||||
this.updateGain();
|
||||
}
|
||||
setPlaybackRate(rate) {
|
||||
if (rate === this.playbackRate)
|
||||
if (__privateGet(this, _closed2) || rate === this.playbackRate)
|
||||
return;
|
||||
if (!this.paused) {
|
||||
this.playbackTimeAtStart = this.currentTime;
|
||||
this.audioContextStartTime = this.audioContext.currentTime;
|
||||
}
|
||||
const time = this.currentTime;
|
||||
__privateGet(this, _pump).cancel();
|
||||
this.stopQueuedNodes();
|
||||
this.playbackTimeAtStart = time;
|
||||
this.audioContextStartTime = this.audioContext?.currentTime ?? 0;
|
||||
this.playbackRate = rate;
|
||||
if (!this.paused) {
|
||||
const id = ++this.asyncId;
|
||||
this.runIterator(id);
|
||||
}
|
||||
this.latestScheduledEndTime = time;
|
||||
const id = ++this.asyncId;
|
||||
if (!this.paused)
|
||||
__privateMethod(this, _AudioEngine_instances, startPump_fn).call(this, id);
|
||||
}
|
||||
destroy() {
|
||||
this.asyncId++;
|
||||
this.pause();
|
||||
this.audioContext?.close();
|
||||
this.audioContext = null;
|
||||
if (__privateGet(this, _closed2))
|
||||
return;
|
||||
__privateSet(this, _closed2, true);
|
||||
__privateWrapper(this, _generation)._++;
|
||||
const failures = [];
|
||||
for (const release of [() => this.pause(), () => __privateGet(this, _contexts).destroy()]) {
|
||||
try {
|
||||
release();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
this.input = null;
|
||||
this.audioSink = null;
|
||||
__privateSet(this, _loading, null);
|
||||
if (failures.length)
|
||||
throw failures[0];
|
||||
}
|
||||
}
|
||||
_closed2 = new WeakMap();
|
||||
_generation = new WeakMap();
|
||||
_contexts = new WeakMap();
|
||||
_nodes = new WeakMap();
|
||||
_pump = new WeakMap();
|
||||
_playTask = new WeakMap();
|
||||
_playing = new WeakMap();
|
||||
_loading = new WeakMap();
|
||||
_onError = new WeakMap();
|
||||
_AudioEngine_instances = new WeakSet();
|
||||
load_fn = async function({ input, audioTrack, duration }, generation, onMetadata) {
|
||||
const current = () => !__privateGet(this, _closed2) && __privateGet(this, _generation) === generation;
|
||||
if (!current())
|
||||
return;
|
||||
this.input = input;
|
||||
this.duration = duration;
|
||||
try {
|
||||
const decodable = audioTrack && audioTrack.codec !== null && await audioTrack.canDecode();
|
||||
if (!current())
|
||||
return;
|
||||
if (!decodable) {
|
||||
this.handleNoAudioTrack();
|
||||
} else {
|
||||
this.ensureAudioContext(audioTrack.sampleRate);
|
||||
if (!current())
|
||||
return;
|
||||
this.audioSink = new AudioBufferSink(audioTrack);
|
||||
}
|
||||
if (current())
|
||||
onMetadata?.();
|
||||
} catch (error) {
|
||||
if (current())
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
startPump_fn = function(id) {
|
||||
void this.runIterator(id).catch((error) => {
|
||||
if (__privateGet(this, _closed2) || id !== this.asyncId)
|
||||
return;
|
||||
try {
|
||||
this.pause();
|
||||
} catch (failure) {
|
||||
console.warn("MediaBunny audio failure cleanup:", failure);
|
||||
}
|
||||
try {
|
||||
if (__privateGet(this, _onError))
|
||||
__privateGet(this, _onError).call(this, error);
|
||||
else
|
||||
this.events.emit("error", error);
|
||||
} catch (failure) {
|
||||
console.warn("MediaBunny audio error listener:", failure);
|
||||
}
|
||||
});
|
||||
};
|
||||
play_fn = async function(task, generation) {
|
||||
const current = () => !__privateGet(this, _closed2) && !task.cancelled && __privateGet(this, _generation) === generation;
|
||||
try {
|
||||
const context = __privateGet(this, _contexts).ensure();
|
||||
if (!context || !current())
|
||||
return;
|
||||
if (context.state === "suspended") {
|
||||
const resumed = await task.wait(__privateGet(this, _contexts).resume(context));
|
||||
if (resumed === CANCELLED || !current())
|
||||
return;
|
||||
}
|
||||
if (__privateGet(this, _loading)) {
|
||||
const loaded = await task.wait(__privateGet(this, _loading));
|
||||
if (loaded === CANCELLED || !current())
|
||||
return;
|
||||
}
|
||||
this.audioContextStartTime = context.currentTime;
|
||||
this.latestScheduledEndTime = this.playbackTimeAtStart;
|
||||
this.paused = false;
|
||||
__privateMethod(this, _AudioEngine_instances, startPump_fn).call(this, ++this.asyncId);
|
||||
} catch (error) {
|
||||
if (current())
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
async function selectQuality(host, value) {
|
||||
const { input, media, loadSeq } = host;
|
||||
if (!media?.isHls || !input || host.destroyed)
|
||||
@@ -24469,30 +24840,6 @@ 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;
|
||||
@@ -24537,7 +24884,6 @@ class Playback {
|
||||
this.wanted = false;
|
||||
this.playing = null;
|
||||
this.seeking = null;
|
||||
this.audioStart = null;
|
||||
}
|
||||
operation() {
|
||||
return { ...completion(), source: this.host.loadSeq, complete: completion() };
|
||||
@@ -24602,17 +24948,6 @@ class Playback {
|
||||
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 {
|
||||
@@ -24624,7 +24959,7 @@ class Playback {
|
||||
if (!current())
|
||||
return;
|
||||
this.host.paused = false;
|
||||
await this.startAudio();
|
||||
await this.host.audio.play();
|
||||
if (!current()) {
|
||||
if (!this.wanted || this.host.destroyed)
|
||||
this.host.audio.pause();
|
||||
@@ -24974,16 +25309,16 @@ class Renderer {
|
||||
class VideoEngine {
|
||||
constructor({ canvas, ctx, events, timeupdateInterval = 250, avSyncTolerance = 0.12, dropLateFrames = false, poster = "", preflightRange: preflightRange2 = false, onError }) {
|
||||
__privateAdd(this, _VideoEngine_instances);
|
||||
__privateAdd(this, _generation, 0);
|
||||
__privateAdd(this, _generation2, 0);
|
||||
__privateAdd(this, _iteration, 0);
|
||||
__privateAdd(this, _loading, null);
|
||||
__privateAdd(this, _loading2, null);
|
||||
__privateAdd(this, _createSink, null);
|
||||
__privateAdd(this, _usedSink, null);
|
||||
__privateAdd(this, _destroyed, false);
|
||||
__privateAdd(this, _preparing, false);
|
||||
__privateAdd(this, _poster, new Poster());
|
||||
__privateAdd(this, _renderer);
|
||||
__privateAdd(this, _onError);
|
||||
__privateAdd(this, _onError2);
|
||||
this.canvas = canvas;
|
||||
this.ctx = ctx;
|
||||
this.events = events;
|
||||
@@ -25007,7 +25342,7 @@ class VideoEngine {
|
||||
this.playbackRate = 1;
|
||||
this.posterDrawn = false;
|
||||
this.isFetching = false;
|
||||
__privateSet(this, _onError, onError);
|
||||
__privateSet(this, _onError2, onError);
|
||||
__privateSet(this, _renderer, new Renderer(this, () => !__privateGet(this, _preparing), (frame) => __privateMethod(this, _VideoEngine_instances, draw_fn).call(this, frame, true), (error) => __privateMethod(this, _VideoEngine_instances, report_fn).call(this, error)));
|
||||
}
|
||||
async preflight(source, signal, current) {
|
||||
@@ -25016,7 +25351,7 @@ class VideoEngine {
|
||||
drawPoster() {
|
||||
if (__privateGet(this, _destroyed) || !this.poster || this.posterDrawn)
|
||||
return;
|
||||
const generation = __privateGet(this, _generation);
|
||||
const generation = __privateGet(this, _generation2);
|
||||
__privateGet(this, _poster).draw(this.poster, () => __privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation), (image) => {
|
||||
this.clear();
|
||||
this.canvas.width = image.naturalWidth || this.canvas.width;
|
||||
@@ -25035,13 +25370,13 @@ class VideoEngine {
|
||||
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
cancelPending() {
|
||||
__privateWrapper(this, _generation)._++;
|
||||
__privateWrapper(this, _generation2)._++;
|
||||
__privateWrapper(this, _iteration)._++;
|
||||
this.stop();
|
||||
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
|
||||
__privateGet(this, _poster).cancel();
|
||||
__privateSet(this, _preparing, false);
|
||||
__privateSet(this, _loading, null);
|
||||
__privateSet(this, _loading2, null);
|
||||
__privateSet(this, _createSink, null);
|
||||
__privateSet(this, _usedSink, null);
|
||||
this.videoSink = null;
|
||||
@@ -25051,16 +25386,16 @@ class VideoEngine {
|
||||
if (__privateGet(this, _destroyed))
|
||||
return;
|
||||
this.cancelPending();
|
||||
const generation = __privateGet(this, _generation);
|
||||
const generation = __privateGet(this, _generation2);
|
||||
const iteration = __privateGet(this, _iteration);
|
||||
__privateSet(this, _preparing, true);
|
||||
const pending = Promise.resolve().then(() => __privateMethod(this, _VideoEngine_instances, load_fn).call(this, media, generation, iteration, onMetadata));
|
||||
__privateSet(this, _loading, pending);
|
||||
const pending = Promise.resolve().then(() => __privateMethod(this, _VideoEngine_instances, load_fn2).call(this, media, generation, iteration, onMetadata));
|
||||
__privateSet(this, _loading2, pending);
|
||||
try {
|
||||
await pending;
|
||||
} finally {
|
||||
if (__privateGet(this, _loading) === pending)
|
||||
__privateSet(this, _loading, null);
|
||||
if (__privateGet(this, _loading2) === pending)
|
||||
__privateSet(this, _loading2, null);
|
||||
}
|
||||
}
|
||||
handleNoVideoTrack() {
|
||||
@@ -25077,9 +25412,9 @@ class VideoEngine {
|
||||
async resetIterator(time) {
|
||||
if (__privateGet(this, _destroyed))
|
||||
return;
|
||||
const generation = __privateGet(this, _generation);
|
||||
const generation = __privateGet(this, _generation2);
|
||||
const iteration = ++__privateWrapper(this, _iteration)._;
|
||||
const loading = __privateGet(this, _loading);
|
||||
const loading = __privateGet(this, _loading2);
|
||||
this.stop();
|
||||
__privateGet(this, _poster).cancel();
|
||||
__privateSet(this, _preparing, true);
|
||||
@@ -25119,7 +25454,7 @@ class VideoEngine {
|
||||
if (__privateGet(this, _destroyed))
|
||||
return;
|
||||
__privateSet(this, _destroyed, true);
|
||||
__privateWrapper(this, _generation)._++;
|
||||
__privateWrapper(this, _generation2)._++;
|
||||
__privateGet(this, _renderer).destroy();
|
||||
__privateGet(this, _poster).cancel();
|
||||
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
|
||||
@@ -25131,19 +25466,19 @@ class VideoEngine {
|
||||
this.audioClock = null;
|
||||
}
|
||||
}
|
||||
_generation = new WeakMap();
|
||||
_generation2 = new WeakMap();
|
||||
_iteration = new WeakMap();
|
||||
_loading = new WeakMap();
|
||||
_loading2 = new WeakMap();
|
||||
_createSink = new WeakMap();
|
||||
_usedSink = new WeakMap();
|
||||
_destroyed = new WeakMap();
|
||||
_preparing = new WeakMap();
|
||||
_poster = new WeakMap();
|
||||
_renderer = new WeakMap();
|
||||
_onError = new WeakMap();
|
||||
_onError2 = new WeakMap();
|
||||
_VideoEngine_instances = new WeakSet();
|
||||
current_fn = function(generation) {
|
||||
return !__privateGet(this, _destroyed) && __privateGet(this, _generation) === generation;
|
||||
return !__privateGet(this, _destroyed) && __privateGet(this, _generation2) === generation;
|
||||
};
|
||||
context_fn = function() {
|
||||
if (!this.ctx)
|
||||
@@ -25154,8 +25489,8 @@ report_fn = function(error) {
|
||||
if (__privateGet(this, _destroyed))
|
||||
return;
|
||||
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
|
||||
if (__privateGet(this, _onError))
|
||||
__privateGet(this, _onError).call(this, error);
|
||||
if (__privateGet(this, _onError2))
|
||||
__privateGet(this, _onError2).call(this, error);
|
||||
else
|
||||
this.events.emit("error", error);
|
||||
};
|
||||
@@ -25168,7 +25503,7 @@ draw_fn = function(frame, clear) {
|
||||
this.clear();
|
||||
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).drawImage(frame.canvas, 0, 0);
|
||||
};
|
||||
load_fn = async function(media, generation, iteration, onMetadata) {
|
||||
load_fn2 = async function(media, generation, iteration, onMetadata) {
|
||||
if (!__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
|
||||
return;
|
||||
try {
|
||||
@@ -25242,7 +25577,14 @@ class MediaBunnyEngine {
|
||||
__privateAdd(this, _playback);
|
||||
this.events = events;
|
||||
this.option = option;
|
||||
this.audio = new AudioEngine(events);
|
||||
this.audio = new AudioEngine(events, (error) => {
|
||||
try {
|
||||
this.pause();
|
||||
} catch (failure) {
|
||||
console.warn("MediaBunny audio coordinator cleanup:", failure);
|
||||
}
|
||||
this.reportError(error);
|
||||
});
|
||||
this.video = new VideoEngine({
|
||||
canvas,
|
||||
ctx,
|
||||
@@ -25279,6 +25621,7 @@ class MediaBunnyEngine {
|
||||
this.ended = true;
|
||||
this.paused = true;
|
||||
__privateGet(this, _playback).ended();
|
||||
this.audio.pause();
|
||||
});
|
||||
}
|
||||
async load(src) {
|
||||
@@ -25287,6 +25630,7 @@ class MediaBunnyEngine {
|
||||
const id = ++this.loadSeq;
|
||||
__privateGet(this, _playback).invalidate();
|
||||
this.video.cancelPending();
|
||||
this.audio.cancelPending();
|
||||
this.loadSession?.cancel();
|
||||
if (id !== this.loadSeq || this.destroyed)
|
||||
return;
|
||||
@@ -25311,6 +25655,7 @@ class MediaBunnyEngine {
|
||||
const failed = ++this.loadSeq;
|
||||
__privateGet(this, _playback).invalidate();
|
||||
this.video.cancelPending();
|
||||
this.audio.cancelPending();
|
||||
session.cancel();
|
||||
if (failed !== this.loadSeq || this.destroyed)
|
||||
return;
|
||||
|
||||
+2
-2
@@ -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-coordination.test.js test/mediabunny-video.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-video.test.js test/mediabunny-audio.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-coordination.test.js test/mediabunny-video.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-video.test.js test/mediabunny-audio.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js"
|
||||
},
|
||||
"browserslist": "last 1 Chrome version",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -16,25 +16,29 @@ declarations, the factory signature, canvas forwarding, or package entrypoints.
|
||||
| `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.ts` | Load/readiness coordinator, source identity, errors and terminal teardown |
|
||||
| `playback.ts` | Playback intent, cancellable play/seek/replacement operations and shared pending audio resume |
|
||||
| `playback.ts` | Playback intent and cancellable play/seek/replacement operations |
|
||||
| `readiness.ts` | One metadata publication after both participants, guarded event sequences |
|
||||
| `hls-selection.ts` | Source-bound track queries, pairing and active replacement errors |
|
||||
| `VideoEngine.ts` | Video capability/setup, source and seek ownership, first frames and terminal cleanup |
|
||||
| `video-frames.ts` | Shared pending iterator reads, idempotent iterator release and existing frame timing policy |
|
||||
| `video-renderer.ts` | Owned RAF, stale frame rejection, clock-driven draw/stall/end events |
|
||||
| `video-poster.ts` | Per-request image callbacks, source guards and handler cleanup |
|
||||
| `AudioEngine.js` | Remaining Web Audio clock, audio decoding and scheduling implementation |
|
||||
| `AudioEngine.ts` | Audio load/play generation, clock anchors and decoder-error forwarding |
|
||||
| `audio-clock.ts` | Existing media-clock and future/partially-late buffer scheduling formulas |
|
||||
| `audio-context.ts` | Context/gain setup, sample-rate fallback, shared native resume and terminal close |
|
||||
| `audio-nodes.ts` | Scheduled source nodes, rate, ended handlers and complete stop/disconnect |
|
||||
| `audio-pump.ts` | SDK buffer iterator, bounded batches, backpressure and stale-result guards |
|
||||
| `audio-task.ts` | Cancelable waits and owned batch/backpressure timers |
|
||||
| `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` / `engine-ports.ts` | Internal shim, coordinator and decoder interfaces |
|
||||
| `AudioEngine.d.ts` | Temporary boundary for the remaining JS audio engine, removed in MB-06 |
|
||||
| `m3u8.js` | Current ArtPlayer control/setting integration |
|
||||
|
||||
The twenty production TypeScript modules use strict checking with `skipLibCheck: false`
|
||||
and no ambient Node types. The coordinator and video engine are checked implementations.
|
||||
The adjacent audio declaration is a temporary boundary, not checking of its JavaScript
|
||||
implementation. Remaining JavaScript is scheduled in PKG-MB-06/07/08.
|
||||
The twenty-six production TypeScript modules use strict checking with `skipLibCheck: false`
|
||||
and no ambient Node types. The coordinator and both decoder engines are checked
|
||||
implementations; there are no remaining adjacent JavaScript declaration bridges.
|
||||
The public factory and HLS integration remain JavaScript, scheduled in PKG-MB-07/08.
|
||||
Do not describe the whole package as migrated yet.
|
||||
|
||||
The shim keeps its existing own-property order and direct prototype surface because
|
||||
@@ -86,7 +90,10 @@ 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;
|
||||
video frame ownership is detailed below; remaining audio work belongs to MB-06.
|
||||
video and audio ownership are detailed below. Native resume coalescing belongs to the
|
||||
context owner: each new playback intent calls audio.play(), but shares the same pending
|
||||
native resume. Sharing a canceled upper-level play Promise would incorrectly start video
|
||||
before the latest audio intent is active.
|
||||
|
||||
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,
|
||||
@@ -140,6 +147,39 @@ stopIterator still rejects. Missing 2D context fails at use with a descriptive e
|
||||
than becoming falsely ready. Capability handling for existing undecodable tracks remains
|
||||
under MB-CAP-01/MB-READY-01; the current no-video fallback is preserved for that separate review.
|
||||
|
||||
## Audio clock and resource ownership
|
||||
|
||||
AudioEngine keeps source generation separate from pump IDs and cancellable play intent.
|
||||
Pause invalidates a suspended resume even when the audio clock has not started; source
|
||||
replacement invalidates audio before track parsing. Late resume, capability checks and
|
||||
buffer reads cannot install a sink, schedule nodes or resurrect a destroyed instance.
|
||||
Native AudioContext.resume is coalesced per context, while canceled callers settle early
|
||||
and late rejections stay observed. A valid no-audio-track source retains the Web Audio
|
||||
clock fallback; this is distinct from rejecting a container with neither track.
|
||||
|
||||
The clock retains the existing anchor/rate formula and squared gain with mute. Pause
|
||||
captures media time; seek/rate changes cancel the old iterator and stop/disconnect its
|
||||
scheduled nodes before rebuilding the queue at the new anchor. The renderer's ended
|
||||
event freezes the audio clock and releases remaining nodes. Source load resets position.
|
||||
Fully expired buffers do not allocate nodes; partially late buffers retain the old offset
|
||||
calculation. Audio playbackRate changes still change pitch through native source playbackRate;
|
||||
no new pitch-preservation or acoustic synchronization guarantee is introduced.
|
||||
|
||||
AudioPump owns 16-buffer batches, a zero-delay yield and the existing one-second/50ms
|
||||
backpressure policy. Its timers include ID zero and are canceled with its task. Iterator
|
||||
detachment is synchronous and return is observed once; obsolete cleanup cannot clear a
|
||||
new iterator. Native AudioBuffers have no close() contract: the SDK closes its AudioSamples,
|
||||
while the proxy releases buffer/node/iterator references. Every scheduled source is owned
|
||||
before start, cleared on ended, stopped/disconnected on cancellation, and has its callback
|
||||
removed. Cleanup attempts all nodes and context resources before throwing the first error;
|
||||
background cleanup failures are observed, and do not replace an active decoder failure.
|
||||
|
||||
ContextOwner installs context/gain references only after successful setup. Setup failure
|
||||
releases partial resources, destroy nulls references and observes the asynchronous close,
|
||||
and repeated destroy is inert. Real-browser teardown assertions poll the native closed state.
|
||||
Tests deliberately delay native resume completion or decoded buffer delivery, without
|
||||
substituting fake audio decoding for native acceptance.
|
||||
|
||||
## Types and dependencies
|
||||
|
||||
The locked runtime SDK remains MediaBunny 1.56.1. Its pinned WebCodecs declaration
|
||||
@@ -160,6 +200,7 @@ yarn build artplayer-proxy-mediabunny
|
||||
yarn test:browser test/browser/mediabunny-inputs.spec.js test/browser/mediabunny.spec.js test/browser/mediabunny-load.spec.js
|
||||
yarn test:browser test/browser/mediabunny-shim.spec.js
|
||||
yarn test:browser test/browser/mediabunny-video.spec.js
|
||||
yarn test:browser test/browser/mediabunny-audio.spec.js
|
||||
```
|
||||
|
||||
Node tests use actual SDK parsing and controlled lifecycle interleavings. Browser tests
|
||||
@@ -174,9 +215,9 @@ 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 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
|
||||
pending play/seek coordination. PKG-MB-05/06 cover late frame and audio callbacks,
|
||||
including operations already inside decoders at cancellation. These bounded cases do not
|
||||
replace long-run synchronization and combination validation. PKG-MB-07 owns HLS UI/selection
|
||||
races. PKG-MB-09 owns long-run AV sync, full core/proxy/plugin combinations and device
|
||||
validation. PKG-MB-10 owns the demo, installed package and MPL/source notice gate.
|
||||
Windows WebKit without WebCodecs/Web Audio is recorded as a capability failure,
|
||||
|
||||
@@ -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,i,r,a,s,n,o,c,l,u,d,h,f,m,p,g,k,y,b,w=e=>{throw TypeError(e)},T=(e,t,i)=>t.has(e)||w("Cannot "+i),S=(e,t,i)=>(T(e,t,"read from private field"),i?i.call(e):t.get(e)),v=(e,t,i)=>t.has(e)?w("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),P=(e,t,i,r)=>(T(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),I=(e,t,i)=>(T(e,t,"access private method"),i),x=(e,t,i,r)=>({set _(r){P(e,t,r,i)},get _(){return S(e,t,r)}});function C(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 B{constructor(){v(this,e,!1),this.listeners=new Map}addEventListener(t,i){S(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(S(this,e))return;const r=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{S(this,e)||t(r)})}destroy(){P(this,e,!0),this.listeners.clear()}}function E(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const _=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},R=e=>e&&e[e.length-1],F=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)},A=(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=F(e);return 0===(1&t)?-(t>>1):t+1>>1},M=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),N=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),O=new TextDecoder,z=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),L={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},U=z(L),q={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},W=z(q),H={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},V=z(H),j=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,K={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},X=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class ${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 G=/^[0-9a-fA-F]+$/,Q=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),Y=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,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},Z=(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},ee=(e,t,i)=>{const r=Z(e,i(t),i);e.splice(r+1,0,t)},te=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},ie=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},re=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},ae=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},se=e=>{throw new Error(`Unexpected value: ${e}`)},ne=(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},oe=(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)},ce=(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}}),le=(e,t,i)=>Math.max(t,Math.min(i,e)),ue="und",de=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},he=(e,t)=>Math.round(e/t)*t,fe=(e,t)=>Math.round(e*t)/t,me=(e,t)=>Math.floor(e/t)*t,pe=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},ge=/^[a-z]{3}$/,ke=e=>ge.test(e),ye=1e6*(1+Number.EPSILON),be=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?we(e.headers):{},a=t.headers?we(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},we=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)}catcLine 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,r,a,s,n,o,c,l,u,h,d,f,m,p,g,k,y,b,w,T,S,v,P,I,x,C,B,E,_,R,F,A=e=>{throw TypeError(e)},M=(e,t,i)=>t.has(e)||A("Cannot "+i),D=(e,t,i)=>(M(e,t,"read from private field"),i?i.call(e):t.get(e)),N=(e,t,i)=>t.has(e)?A("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),O=(e,t,i,r)=>(M(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),z=(e,t,i)=>(M(e,t,"access private method"),i),L=(e,t,i,r)=>({set _(r){O(e,t,r,i)},get _(){return D(e,t,r)}});function U(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 q{constructor(){N(this,e,!1),this.listeners=new Map}addEventListener(t,i){D(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(D(this,e))return;const r=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{D(this,e)||t(r)})}destroy(){O(this,e,!0),this.listeners.clear()}}function W(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const H=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},V=e=>e&&e[e.length-1],j=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)},K=(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)},X=e=>{const t=j(e);return 0===(1&t)?-(t>>1):t+1>>1},$=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),Q=new TextDecoder,Y=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),J={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},Z=Y(J),ee={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},te=Y(ee),ie={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},re=Y(ie),ae=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,se={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},ne=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class oe{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 ce=/^[0-9a-fA-F]+$/,le=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),ue=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,he=(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},de=(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},fe=(e,t,i)=>{const r=de(e,i(t),i);e.splice(r+1,0,t)},me=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},pe=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},ge=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},ke=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},ye=e=>{throw new Error(`Unexpected value: ${e}`)},be=(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},we=(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)},Te=(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}}),Se=(e,t,i)=>Math.max(t,Math.min(i,e)),ve="und",Pe=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},Ie=(e,t)=>Math.round(e/t)*t,xe=(e,t)=>Math.round(e*t)/t,Ce=(e,t)=>Math.floor(e/t)*t,Be=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},Ee=/^[a-z]{3}$/,_e=e=>Ee.test(e),Re=1e6*(1+Number.EPSILON),Fe=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?Ae(e.headers):{},a=t.headers?Ae(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},Ae=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},Me=async(e,t,i,r,a)=>{lLine truncated
|
||||
+1
-1
@@ -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,i,r,n,s,a,o,l,c,u,d,h,f,m,p,g,k,y,b,T=Object.defineProperty,v=Object.defineProperties,S=Object.getOwnPropertyDescriptors,w=Object.getOwnPropertySymbols,P=Object.prototype.hasOwnProperty,I=Object.prototype.propertyIsEnumerable,x=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),C=e=>{throw TypeError(e)},B=Math.pow,E=(e,t,i)=>t in e?T(e,t,{enumerable:!0,configurable:!0,writable:!0,value:i}):e[t]=i,_=(e,t)=>{for(var i in t||(t={}))P.call(t,i)&&E(e,i,t[i]);if(w)for(var i of w(t))I.call(t,i)&&E(e,i,t[i]);return e},R=(e,t)=>v(e,S(t)),F=(e,t,i)=>t.has(e)||C("Cannot "+i),A=(e,t,i)=>(F(e,t,"read from private field"),i?i.call(e):t.get(e)),D=(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),M=(e,t,i,r)=>(F(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),N=(e,t,i)=>(F(e,t,"access private method"),i),O=(e,t,i,r)=>({set _(r){M(e,t,r,i)},get _(){return A(e,t,r)}}),z=(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())}),L=function(e,t){this[0]=e,this[1]=t},U=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof L,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[x("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},q=e=>{var t,i=e[x("asyncIterator")],r=!1,n={};return null==i?(i=e[x("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 L(new Promise(r=>{var n=i[e](t);n instanceof Object||C("Object expected"),r(n)}),1)}}),n[x("iterator")]=()=>n,t("next"),"throw"in i?t("throw"):n.throw=e=>{throw e},"return"in i&&t("return"),n},W=(e,t,i)=>(t=e[x("asyncIterator")])?t.call(e):(e=e[x("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 H(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(){D(this,e,!1),this.listeners=new Map}addEventListener(t,i){var r;A(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(A(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{A(this,e)||t(n)})}destroy(){M(this,e,!0),this.listeners.clear()}}function j(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const K=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},X=e=>e&&e[e.length-1],$=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)},G=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-B(2,r))},Q=e=>{const t=$(e);return 0===(1&t)?-(t>>1):t+1>>1},Y=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),J=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),Z=new TextDecoder,ee=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),te={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},ie=ee(te),re={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},ne=ee(re),se={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},ae=ee(se),oe=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,le={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},ce=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class ue{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}acquire(){return z(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 de=/^[0-9a-fA-F]+$/,he=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),fe=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&167Line 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,r,n,s,a,o,l,c,u,d,h,f,m,p,g,k,y,b,T,v,w,S,P,I,x,C,B,E,_,R,F,A=Object.defineProperty,M=Object.defineProperties,D=Object.getOwnPropertyDescriptors,N=Object.getOwnPropertySymbols,O=Object.prototype.hasOwnProperty,z=Object.prototype.propertyIsEnumerable,L=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),U=e=>{throw TypeError(e)},q=Math.pow,W=(e,t,i)=>t in e?A(e,t,{enumerable:!0,configurable:!0,writable:!0,value:i}):e[t]=i,H=(e,t)=>{for(var i in t||(t={}))O.call(t,i)&&W(e,i,t[i]);if(N)for(var i of N(t))z.call(t,i)&&W(e,i,t[i]);return e},V=(e,t)=>M(e,D(t)),j=(e,t,i)=>t.has(e)||U("Cannot "+i),K=(e,t,i)=>(j(e,t,"read from private field"),i?i.call(e):t.get(e)),X=(e,t,i)=>t.has(e)?U("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),$=(e,t,i,r)=>(j(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),G=(e,t,i)=>(j(e,t,"access private method"),i),Q=(e,t,i,r)=>({set _(r){$(e,t,r,i)},get _(){return K(e,t,r)}}),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())}),J=function(e,t){this[0]=e,this[1]=t},Z=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof J,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},ee=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 J(new Promise(r=>{var n=i[e](t);n instanceof Object||U("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},te=(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 ie(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 re{constructor(){X(this,e,!1),this.listeners=new Map}addEventListener(t,i){var r;K(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(K(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{K(this,e)||t(n)})}destroy(){$(this,e,!0),this.listeners.clear()}}function ne(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const se=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},ae=e=>e&&e[e.length-1],oe=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)},le=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-q(2,r))},ce=e=>{const t=oe(e);return 0===(1&t)?-(t>>1):t+1>>1},ue=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),de=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),he=new TextDecoder,fe=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),me={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},pe=fe(me),ge={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},ke=fe(ge),ye={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},be=fe(ye),Te=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,ve={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},we=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class Se{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 Pe=/^[0-9a-fA-F]+$/,Ie=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),xe=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<Line truncated
|
||||
+545
-200
@@ -20,7 +20,7 @@ var __privateWrapper = (obj, member, setter, getter) => ({
|
||||
return __privateGet(obj, member, getter);
|
||||
}
|
||||
});
|
||||
var _closed, _generation, _iteration, _loading, _createSink, _usedSink, _destroyed, _preparing, _poster, _renderer, _onError, _VideoEngine_instances, current_fn, context_fn, report_fn, release_fn, draw_fn, load_fn, reset_fn, _playback;
|
||||
var _closed, _closed2, _generation, _contexts, _nodes, _pump, _playTask, _playing, _loading, _onError, _AudioEngine_instances, load_fn, startPump_fn, play_fn, _generation2, _iteration, _loading2, _createSink, _usedSink, _destroyed, _preparing, _poster, _renderer, _onError2, _VideoEngine_instances, current_fn, context_fn, report_fn, release_fn, draw_fn, load_fn2, reset_fn, _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) {
|
||||
@@ -5317,13 +5317,13 @@ class IsobmffDemuxer extends Demuxer {
|
||||
const entryCount = readU32Be(slice);
|
||||
for (let i = 0; i < entryCount; i++) {
|
||||
const segmentDuration = version === 1 ? readU64Be(slice) : readU32Be(slice);
|
||||
const mediaTime = version === 1 ? readI64Be(slice) : readI32Be(slice);
|
||||
const mediaTime2 = version === 1 ? readI64Be(slice) : readI32Be(slice);
|
||||
const mediaRate = readFixed_16_16(slice);
|
||||
if (relevantEntryFound) {
|
||||
Logging._warn("Unsupported edit list: multiple edits are not currently supported. Only using first edit.");
|
||||
break;
|
||||
}
|
||||
if (mediaTime === -1) {
|
||||
if (mediaTime2 === -1) {
|
||||
previousSegmentDurations += segmentDuration;
|
||||
continue;
|
||||
}
|
||||
@@ -5332,7 +5332,7 @@ class IsobmffDemuxer extends Demuxer {
|
||||
break;
|
||||
}
|
||||
track.editListPreviousSegmentDurations = previousSegmentDurations;
|
||||
track.editListOffset = mediaTime;
|
||||
track.editListOffset = mediaTime2;
|
||||
relevantEntryFound = true;
|
||||
}
|
||||
}
|
||||
@@ -24076,8 +24076,344 @@ class Id3V2Reader {
|
||||
return this.readId3V2Text(encoding, until);
|
||||
}
|
||||
}
|
||||
function mediaTime(clock) {
|
||||
if (clock.paused || !clock.audioContext)
|
||||
return clock.playbackTimeAtStart;
|
||||
return (clock.audioContext.currentTime - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart;
|
||||
}
|
||||
function bufferTiming(clock, timestamp, duration, now) {
|
||||
const startAt = clock.audioContextStartTime + (timestamp - clock.playbackTimeAtStart) / clock.playbackRate;
|
||||
const endAt = startAt + duration / clock.playbackRate;
|
||||
const endMediaTime = (endAt - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart;
|
||||
return { startAt: Math.max(startAt, now), offset: startAt < now ? (now - startAt) * clock.playbackRate : null, endMediaTime };
|
||||
}
|
||||
class ContextOwner {
|
||||
constructor(host) {
|
||||
this.host = host;
|
||||
this.closed = false;
|
||||
this.resumes = /* @__PURE__ */ new WeakMap();
|
||||
}
|
||||
ensure(sampleRate) {
|
||||
if (this.closed)
|
||||
return null;
|
||||
if (this.host.audioContext)
|
||||
return this.host.audioContext;
|
||||
const Context = window.AudioContext || window.webkitAudioContext;
|
||||
let context;
|
||||
try {
|
||||
context = new Context({ sampleRate });
|
||||
} catch {
|
||||
context = new Context();
|
||||
}
|
||||
if (this.closed) {
|
||||
void context.close().catch((error) => console.warn("MediaBunny obsolete context cleanup:", error));
|
||||
return null;
|
||||
}
|
||||
let gain = null;
|
||||
try {
|
||||
gain = context.createGain();
|
||||
gain.connect(context.destination);
|
||||
const volume = this.host.muted ? 0 : this.host.volume;
|
||||
gain.gain.value = volume * volume;
|
||||
if (this.closed) {
|
||||
gain.disconnect();
|
||||
void context.close().catch((error) => console.warn("MediaBunny obsolete context cleanup:", error));
|
||||
return null;
|
||||
}
|
||||
this.host.audioContext = context;
|
||||
this.host.gainNode = gain;
|
||||
return context;
|
||||
} catch (error) {
|
||||
try {
|
||||
gain?.disconnect();
|
||||
} catch (failure) {
|
||||
console.warn("MediaBunny gain setup cleanup:", failure);
|
||||
}
|
||||
void context.close().catch((failure) => console.warn("MediaBunny context setup cleanup:", failure));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
updateGain() {
|
||||
if (!this.host.gainNode)
|
||||
return;
|
||||
const volume = this.host.muted ? 0 : this.host.volume;
|
||||
this.host.gainNode.gain.value = volume * volume;
|
||||
}
|
||||
resume(context) {
|
||||
const previous = this.resumes.get(context);
|
||||
if (previous)
|
||||
return previous;
|
||||
const pending = context.resume();
|
||||
this.resumes.set(context, pending);
|
||||
const clear = () => {
|
||||
if (this.resumes.get(context) === pending)
|
||||
this.resumes.delete(context);
|
||||
};
|
||||
pending.then(clear, clear);
|
||||
return pending;
|
||||
}
|
||||
destroy() {
|
||||
if (this.closed)
|
||||
return;
|
||||
this.closed = true;
|
||||
const { audioContext: context, gainNode: gain } = this.host;
|
||||
this.host.audioContext = null;
|
||||
this.host.gainNode = null;
|
||||
try {
|
||||
gain?.disconnect();
|
||||
} finally {
|
||||
if (context)
|
||||
void context.close().catch((error) => console.warn("MediaBunny context close error:", error));
|
||||
}
|
||||
}
|
||||
}
|
||||
class NodeQueue {
|
||||
constructor(host) {
|
||||
this.host = host;
|
||||
}
|
||||
release(node, stop) {
|
||||
this.host.queuedNodes.delete(node);
|
||||
node.onended = null;
|
||||
let failed = false;
|
||||
let failure;
|
||||
try {
|
||||
if (stop)
|
||||
node.stop();
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
failure = error;
|
||||
}
|
||||
try {
|
||||
node.disconnect();
|
||||
} catch (error) {
|
||||
if (!failed) {
|
||||
failed = true;
|
||||
failure = error;
|
||||
}
|
||||
}
|
||||
if (failed)
|
||||
throw failure;
|
||||
}
|
||||
stop() {
|
||||
const failures = [];
|
||||
for (const node of [...this.host.queuedNodes]) {
|
||||
try {
|
||||
this.release(node, true);
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
if (failures.length)
|
||||
throw failures[0];
|
||||
}
|
||||
schedule({ buffer, timestamp }) {
|
||||
const context = this.host.audioContext;
|
||||
const gain = this.host.gainNode;
|
||||
if (!context || !gain)
|
||||
return;
|
||||
const timing = bufferTiming(this.host, timestamp, buffer.duration, context.currentTime);
|
||||
if (timing.offset !== null && timing.offset >= buffer.duration)
|
||||
return;
|
||||
const node = context.createBufferSource();
|
||||
this.host.queuedNodes.add(node);
|
||||
try {
|
||||
node.buffer = buffer;
|
||||
node.connect(gain);
|
||||
node.playbackRate.value = this.host.playbackRate;
|
||||
node.onended = () => {
|
||||
if (!this.host.queuedNodes.has(node))
|
||||
return;
|
||||
try {
|
||||
this.release(node, false);
|
||||
} catch (error) {
|
||||
console.warn("MediaBunny ended node cleanup:", error);
|
||||
}
|
||||
};
|
||||
if (timing.offset === null)
|
||||
node.start(timing.startAt);
|
||||
else
|
||||
node.start(timing.startAt, timing.offset);
|
||||
this.host.latestScheduledEndTime = Math.max(this.host.latestScheduledEndTime, timing.endMediaTime);
|
||||
} catch (error) {
|
||||
try {
|
||||
this.release(node, true);
|
||||
} catch (failure) {
|
||||
console.warn("MediaBunny audio node cleanup:", failure);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
const CANCELLED = /* @__PURE__ */ Symbol("cancelled");
|
||||
class AudioTask {
|
||||
constructor() {
|
||||
this.cancelled = false;
|
||||
this.waits = /* @__PURE__ */ new Set();
|
||||
this.timers = /* @__PURE__ */ new Map();
|
||||
}
|
||||
wait(pending) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cancel = () => resolve(CANCELLED);
|
||||
if (this.cancelled)
|
||||
cancel();
|
||||
else
|
||||
this.waits.add(cancel);
|
||||
pending.then((value) => {
|
||||
this.waits.delete(cancel);
|
||||
resolve(value);
|
||||
}, (error) => {
|
||||
this.waits.delete(cancel);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
delay(ms) {
|
||||
if (this.cancelled)
|
||||
return Promise.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.timers.delete(timer);
|
||||
resolve(!this.cancelled);
|
||||
}, ms);
|
||||
this.timers.set(timer, resolve);
|
||||
});
|
||||
}
|
||||
cancel() {
|
||||
if (this.cancelled)
|
||||
return;
|
||||
this.cancelled = true;
|
||||
for (const cancel of this.waits)
|
||||
cancel();
|
||||
this.waits.clear();
|
||||
for (const [timer, resolve] of this.timers) {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
}
|
||||
this.timers.clear();
|
||||
}
|
||||
}
|
||||
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") };
|
||||
}
|
||||
class AudioPump {
|
||||
constructor(host, contexts, nodes) {
|
||||
this.host = host;
|
||||
this.contexts = contexts;
|
||||
this.nodes = nodes;
|
||||
this.task = null;
|
||||
this.closing = /* @__PURE__ */ new WeakMap();
|
||||
}
|
||||
async stopIterator() {
|
||||
const iterator = this.host.audioIterator;
|
||||
this.host.audioIterator = null;
|
||||
if (!iterator)
|
||||
return;
|
||||
let pending = this.closing.get(iterator);
|
||||
if (!pending) {
|
||||
pending = Promise.resolve().then(() => iterator.return()).then(() => {
|
||||
});
|
||||
this.closing.set(iterator, pending);
|
||||
}
|
||||
await pending;
|
||||
}
|
||||
cancel() {
|
||||
this.task?.cancel();
|
||||
this.task = null;
|
||||
void this.stopIterator().catch((error) => console.warn("MediaBunny audio iterator cleanup:", error));
|
||||
}
|
||||
async run(id) {
|
||||
if (this.host.asyncId !== id)
|
||||
return;
|
||||
this.cancel();
|
||||
const sink = this.host.audioSink;
|
||||
const context = this.host.audioContext;
|
||||
if (!sink || !context || this.host.paused || this.host.asyncId !== id)
|
||||
return;
|
||||
const task = new AudioTask();
|
||||
this.task = task;
|
||||
let iterator = null;
|
||||
const current = () => !task.cancelled && this.task === task && this.host.asyncId === id && !this.host.paused && this.host.audioContext === context && (!iterator || this.host.audioIterator === iterator);
|
||||
try {
|
||||
iterator = sink.buffers(this.host.currentTime);
|
||||
this.host.audioIterator = iterator;
|
||||
while (current()) {
|
||||
const batch = [];
|
||||
let done = false;
|
||||
for (let index = 0; index < 16; index++) {
|
||||
const result = await task.wait(iterator.next());
|
||||
if (result === CANCELLED || !current())
|
||||
return;
|
||||
if (result.done) {
|
||||
done = true;
|
||||
break;
|
||||
}
|
||||
batch.push(result.value);
|
||||
}
|
||||
if (batch.length && context.state === "suspended") {
|
||||
const resumed = await task.wait(this.contexts.resume(context));
|
||||
if (resumed === CANCELLED || !current())
|
||||
return;
|
||||
publish(this.host.events, current, ["canplay", "playing"]);
|
||||
}
|
||||
for (const buffer of batch) {
|
||||
if (!current())
|
||||
return;
|
||||
this.nodes.schedule(buffer);
|
||||
}
|
||||
if (done)
|
||||
return;
|
||||
if (!await task.delay(0) || !current())
|
||||
return;
|
||||
while (this.host.latestScheduledEndTime - this.host.currentTime > 1) {
|
||||
if (!await task.delay(50) || !current())
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (current())
|
||||
throw error;
|
||||
} finally {
|
||||
task.cancel();
|
||||
if (this.task === task) {
|
||||
this.task = null;
|
||||
void this.stopIterator().catch((error) => console.warn("MediaBunny audio iterator cleanup:", error));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
class AudioEngine {
|
||||
constructor(events) {
|
||||
constructor(events, onError) {
|
||||
__privateAdd(this, _AudioEngine_instances);
|
||||
__privateAdd(this, _closed2, false);
|
||||
__privateAdd(this, _generation, 0);
|
||||
__privateAdd(this, _contexts);
|
||||
__privateAdd(this, _nodes);
|
||||
__privateAdd(this, _pump);
|
||||
__privateAdd(this, _playTask, null);
|
||||
__privateAdd(this, _playing, null);
|
||||
__privateAdd(this, _loading, null);
|
||||
__privateAdd(this, _onError);
|
||||
this.events = events;
|
||||
this.input = null;
|
||||
this.audioSink = null;
|
||||
@@ -24094,164 +24430,108 @@ class AudioEngine {
|
||||
this.playbackRate = 1;
|
||||
this.asyncId = 0;
|
||||
this.queuedNodes = /* @__PURE__ */ new Set();
|
||||
__privateSet(this, _contexts, new ContextOwner(this));
|
||||
__privateSet(this, _nodes, new NodeQueue(this));
|
||||
__privateSet(this, _pump, new AudioPump(this, __privateGet(this, _contexts), __privateGet(this, _nodes)));
|
||||
__privateSet(this, _onError, onError);
|
||||
}
|
||||
get currentTime() {
|
||||
if (this.paused)
|
||||
return this.playbackTimeAtStart;
|
||||
return (this.audioContext.currentTime - this.audioContextStartTime) * this.playbackRate + this.playbackTimeAtStart;
|
||||
return mediaTime(this);
|
||||
}
|
||||
ensureAudioContext(sampleRate) {
|
||||
if (this.audioContext)
|
||||
return;
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
try {
|
||||
this.audioContext = new AudioContext({ sampleRate });
|
||||
} catch {
|
||||
this.audioContext = new AudioContext();
|
||||
}
|
||||
this.gainNode = this.audioContext.createGain();
|
||||
this.gainNode.connect(this.audioContext.destination);
|
||||
this.updateGain();
|
||||
__privateGet(this, _contexts).ensure(sampleRate);
|
||||
}
|
||||
updateGain() {
|
||||
if (!this.gainNode)
|
||||
return;
|
||||
const v = this.muted ? 0 : this.volume;
|
||||
this.gainNode.gain.value = v * v;
|
||||
__privateGet(this, _contexts).updateGain();
|
||||
}
|
||||
stopQueuedNodes() {
|
||||
this.queuedNodes.forEach((node) => node.stop());
|
||||
this.queuedNodes.clear();
|
||||
__privateGet(this, _nodes).stop();
|
||||
}
|
||||
async stopIterator() {
|
||||
await this.audioIterator?.return();
|
||||
this.audioIterator = null;
|
||||
await __privateGet(this, _pump).stopIterator();
|
||||
}
|
||||
handleNoAudioTrack() {
|
||||
if (__privateGet(this, _closed2))
|
||||
return;
|
||||
this.audioSink = null;
|
||||
this.ensureAudioContext();
|
||||
}
|
||||
cancelPending() {
|
||||
__privateWrapper(this, _generation)._++;
|
||||
try {
|
||||
this.pause();
|
||||
} finally {
|
||||
__privateSet(this, _loading, null);
|
||||
this.input = null;
|
||||
this.audioSink = null;
|
||||
}
|
||||
}
|
||||
async load(media, onMetadata) {
|
||||
++this.asyncId;
|
||||
await this.stopIterator();
|
||||
this.stopQueuedNodes();
|
||||
this.paused = true;
|
||||
if (__privateGet(this, _closed2))
|
||||
return;
|
||||
this.cancelPending();
|
||||
const generation = __privateGet(this, _generation);
|
||||
this.playbackTimeAtStart = 0;
|
||||
this.audioContextStartTime = 0;
|
||||
const { input, audioTrack, duration } = media;
|
||||
this.input = input;
|
||||
this.duration = duration;
|
||||
if (!audioTrack) {
|
||||
this.handleNoAudioTrack();
|
||||
onMetadata?.();
|
||||
return;
|
||||
this.latestScheduledEndTime = 0;
|
||||
const pending = Promise.resolve().then(() => __privateMethod(this, _AudioEngine_instances, load_fn).call(this, media, generation, onMetadata));
|
||||
__privateSet(this, _loading, pending);
|
||||
try {
|
||||
await pending;
|
||||
} finally {
|
||||
if (__privateGet(this, _loading) === pending)
|
||||
__privateSet(this, _loading, null);
|
||||
}
|
||||
if (audioTrack.codec === null || !await audioTrack.canDecode()) {
|
||||
this.audioSink = null;
|
||||
this.ensureAudioContext();
|
||||
onMetadata?.();
|
||||
return;
|
||||
}
|
||||
this.ensureAudioContext(audioTrack.sampleRate);
|
||||
this.audioSink = new AudioBufferSink(audioTrack);
|
||||
onMetadata?.();
|
||||
}
|
||||
async runIterator(localId) {
|
||||
if (!this.audioSink)
|
||||
if (__privateGet(this, _closed2))
|
||||
return;
|
||||
await this.stopIterator();
|
||||
this.audioIterator = this.audioSink.buffers(this.currentTime);
|
||||
const BATCH_SIZE = 16;
|
||||
while (true) {
|
||||
if (localId !== this.asyncId || this.paused)
|
||||
return;
|
||||
const batch = [];
|
||||
let batchDone = false;
|
||||
try {
|
||||
for (let i = 0; i < BATCH_SIZE; i++) {
|
||||
const result = await this.audioIterator.next();
|
||||
if (result.done) {
|
||||
batchDone = true;
|
||||
break;
|
||||
}
|
||||
batch.push(result.value);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Audio iterator error:", e);
|
||||
batchDone = true;
|
||||
}
|
||||
if (localId !== this.asyncId || this.paused)
|
||||
return;
|
||||
if (batch.length > 0 && this.audioContext.state === "suspended") {
|
||||
await this.audioContext.resume();
|
||||
this.events.emit("canplay");
|
||||
this.events.emit("playing");
|
||||
}
|
||||
for (const { buffer, timestamp } of batch) {
|
||||
const node = this.audioContext.createBufferSource();
|
||||
node.buffer = buffer;
|
||||
node.connect(this.gainNode);
|
||||
node.playbackRate.value = this.playbackRate;
|
||||
const startAt = this.audioContextStartTime + (timestamp - this.playbackTimeAtStart) / this.playbackRate;
|
||||
const duration = buffer.duration;
|
||||
const endAt = startAt + duration / this.playbackRate;
|
||||
const endMediaTime = (endAt - this.audioContextStartTime) * this.playbackRate + this.playbackTimeAtStart;
|
||||
if (endMediaTime > this.latestScheduledEndTime) {
|
||||
this.latestScheduledEndTime = endMediaTime;
|
||||
}
|
||||
if (startAt >= this.audioContext.currentTime) {
|
||||
node.start(startAt);
|
||||
} else {
|
||||
node.start(
|
||||
this.audioContext.currentTime,
|
||||
(this.audioContext.currentTime - startAt) * this.playbackRate
|
||||
);
|
||||
}
|
||||
this.queuedNodes.add(node);
|
||||
node.onended = () => this.queuedNodes.delete(node);
|
||||
}
|
||||
if (batchDone)
|
||||
break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const BUFFER_AHEAD = 1;
|
||||
while (this.latestScheduledEndTime - this.currentTime > BUFFER_AHEAD) {
|
||||
if (localId !== this.asyncId || this.paused)
|
||||
return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
}
|
||||
await __privateGet(this, _pump).run(localId);
|
||||
}
|
||||
async play() {
|
||||
if (__privateGet(this, _closed2))
|
||||
return;
|
||||
if (__privateGet(this, _playing))
|
||||
return __privateGet(this, _playing);
|
||||
if (!this.paused)
|
||||
return;
|
||||
if (!this.audioContext) {
|
||||
this.ensureAudioContext();
|
||||
const task = new AudioTask();
|
||||
__privateSet(this, _playTask, task);
|
||||
const generation = __privateGet(this, _generation);
|
||||
const pending = __privateMethod(this, _AudioEngine_instances, play_fn).call(this, task, generation);
|
||||
__privateSet(this, _playing, pending);
|
||||
try {
|
||||
await pending;
|
||||
} finally {
|
||||
if (__privateGet(this, _playTask) === task) {
|
||||
__privateSet(this, _playTask, null);
|
||||
__privateSet(this, _playing, null);
|
||||
}
|
||||
}
|
||||
if (this.audioContext.state === "suspended") {
|
||||
await this.audioContext.resume();
|
||||
}
|
||||
this.audioContextStartTime = this.audioContext.currentTime;
|
||||
this.latestScheduledEndTime = this.playbackTimeAtStart;
|
||||
this.paused = false;
|
||||
const id = ++this.asyncId;
|
||||
this.runIterator(id);
|
||||
}
|
||||
pause() {
|
||||
if (this.paused)
|
||||
return;
|
||||
this.playbackTimeAtStart = this.currentTime;
|
||||
if (!this.paused)
|
||||
this.playbackTimeAtStart = this.currentTime;
|
||||
this.paused = true;
|
||||
this.stopIterator();
|
||||
this.asyncId++;
|
||||
__privateGet(this, _playTask)?.cancel();
|
||||
__privateSet(this, _playTask, null);
|
||||
__privateSet(this, _playing, null);
|
||||
__privateGet(this, _pump).cancel();
|
||||
this.stopQueuedNodes();
|
||||
}
|
||||
async seek(time) {
|
||||
if (__privateGet(this, _closed2))
|
||||
return;
|
||||
const playing = !this.paused;
|
||||
__privateGet(this, _pump).cancel();
|
||||
this.stopQueuedNodes();
|
||||
this.playbackTimeAtStart = Math.max(0, time);
|
||||
this.audioContextStartTime = this.audioContext.currentTime;
|
||||
this.audioContextStartTime = this.audioContext?.currentTime ?? 0;
|
||||
this.latestScheduledEndTime = this.playbackTimeAtStart;
|
||||
const id = ++this.asyncId;
|
||||
if (!this.paused) {
|
||||
this.runIterator(id);
|
||||
}
|
||||
if (playing)
|
||||
__privateMethod(this, _AudioEngine_instances, startPump_fn).call(this, id);
|
||||
}
|
||||
setVolume(volume, muted) {
|
||||
this.volume = volume;
|
||||
@@ -24259,27 +24539,118 @@ class AudioEngine {
|
||||
this.updateGain();
|
||||
}
|
||||
setPlaybackRate(rate) {
|
||||
if (rate === this.playbackRate)
|
||||
if (__privateGet(this, _closed2) || rate === this.playbackRate)
|
||||
return;
|
||||
if (!this.paused) {
|
||||
this.playbackTimeAtStart = this.currentTime;
|
||||
this.audioContextStartTime = this.audioContext.currentTime;
|
||||
}
|
||||
const time = this.currentTime;
|
||||
__privateGet(this, _pump).cancel();
|
||||
this.stopQueuedNodes();
|
||||
this.playbackTimeAtStart = time;
|
||||
this.audioContextStartTime = this.audioContext?.currentTime ?? 0;
|
||||
this.playbackRate = rate;
|
||||
if (!this.paused) {
|
||||
const id = ++this.asyncId;
|
||||
this.runIterator(id);
|
||||
}
|
||||
this.latestScheduledEndTime = time;
|
||||
const id = ++this.asyncId;
|
||||
if (!this.paused)
|
||||
__privateMethod(this, _AudioEngine_instances, startPump_fn).call(this, id);
|
||||
}
|
||||
destroy() {
|
||||
this.asyncId++;
|
||||
this.pause();
|
||||
this.audioContext?.close();
|
||||
this.audioContext = null;
|
||||
if (__privateGet(this, _closed2))
|
||||
return;
|
||||
__privateSet(this, _closed2, true);
|
||||
__privateWrapper(this, _generation)._++;
|
||||
const failures = [];
|
||||
for (const release of [() => this.pause(), () => __privateGet(this, _contexts).destroy()]) {
|
||||
try {
|
||||
release();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
this.input = null;
|
||||
this.audioSink = null;
|
||||
__privateSet(this, _loading, null);
|
||||
if (failures.length)
|
||||
throw failures[0];
|
||||
}
|
||||
}
|
||||
_closed2 = new WeakMap();
|
||||
_generation = new WeakMap();
|
||||
_contexts = new WeakMap();
|
||||
_nodes = new WeakMap();
|
||||
_pump = new WeakMap();
|
||||
_playTask = new WeakMap();
|
||||
_playing = new WeakMap();
|
||||
_loading = new WeakMap();
|
||||
_onError = new WeakMap();
|
||||
_AudioEngine_instances = new WeakSet();
|
||||
load_fn = async function({ input, audioTrack, duration }, generation, onMetadata) {
|
||||
const current = () => !__privateGet(this, _closed2) && __privateGet(this, _generation) === generation;
|
||||
if (!current())
|
||||
return;
|
||||
this.input = input;
|
||||
this.duration = duration;
|
||||
try {
|
||||
const decodable = audioTrack && audioTrack.codec !== null && await audioTrack.canDecode();
|
||||
if (!current())
|
||||
return;
|
||||
if (!decodable) {
|
||||
this.handleNoAudioTrack();
|
||||
} else {
|
||||
this.ensureAudioContext(audioTrack.sampleRate);
|
||||
if (!current())
|
||||
return;
|
||||
this.audioSink = new AudioBufferSink(audioTrack);
|
||||
}
|
||||
if (current())
|
||||
onMetadata?.();
|
||||
} catch (error) {
|
||||
if (current())
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
startPump_fn = function(id) {
|
||||
void this.runIterator(id).catch((error) => {
|
||||
if (__privateGet(this, _closed2) || id !== this.asyncId)
|
||||
return;
|
||||
try {
|
||||
this.pause();
|
||||
} catch (failure) {
|
||||
console.warn("MediaBunny audio failure cleanup:", failure);
|
||||
}
|
||||
try {
|
||||
if (__privateGet(this, _onError))
|
||||
__privateGet(this, _onError).call(this, error);
|
||||
else
|
||||
this.events.emit("error", error);
|
||||
} catch (failure) {
|
||||
console.warn("MediaBunny audio error listener:", failure);
|
||||
}
|
||||
});
|
||||
};
|
||||
play_fn = async function(task, generation) {
|
||||
const current = () => !__privateGet(this, _closed2) && !task.cancelled && __privateGet(this, _generation) === generation;
|
||||
try {
|
||||
const context = __privateGet(this, _contexts).ensure();
|
||||
if (!context || !current())
|
||||
return;
|
||||
if (context.state === "suspended") {
|
||||
const resumed = await task.wait(__privateGet(this, _contexts).resume(context));
|
||||
if (resumed === CANCELLED || !current())
|
||||
return;
|
||||
}
|
||||
if (__privateGet(this, _loading)) {
|
||||
const loaded = await task.wait(__privateGet(this, _loading));
|
||||
if (loaded === CANCELLED || !current())
|
||||
return;
|
||||
}
|
||||
this.audioContextStartTime = context.currentTime;
|
||||
this.latestScheduledEndTime = this.playbackTimeAtStart;
|
||||
this.paused = false;
|
||||
__privateMethod(this, _AudioEngine_instances, startPump_fn).call(this, ++this.asyncId);
|
||||
} catch (error) {
|
||||
if (current())
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
async function selectQuality(host, value) {
|
||||
const { input, media, loadSeq } = host;
|
||||
if (!media?.isHls || !input || host.destroyed)
|
||||
@@ -24469,30 +24840,6 @@ 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;
|
||||
@@ -24537,7 +24884,6 @@ class Playback {
|
||||
this.wanted = false;
|
||||
this.playing = null;
|
||||
this.seeking = null;
|
||||
this.audioStart = null;
|
||||
}
|
||||
operation() {
|
||||
return { ...completion(), source: this.host.loadSeq, complete: completion() };
|
||||
@@ -24602,17 +24948,6 @@ class Playback {
|
||||
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 {
|
||||
@@ -24624,7 +24959,7 @@ class Playback {
|
||||
if (!current())
|
||||
return;
|
||||
this.host.paused = false;
|
||||
await this.startAudio();
|
||||
await this.host.audio.play();
|
||||
if (!current()) {
|
||||
if (!this.wanted || this.host.destroyed)
|
||||
this.host.audio.pause();
|
||||
@@ -24974,16 +25309,16 @@ class Renderer {
|
||||
class VideoEngine {
|
||||
constructor({ canvas, ctx, events, timeupdateInterval = 250, avSyncTolerance = 0.12, dropLateFrames = false, poster = "", preflightRange: preflightRange2 = false, onError }) {
|
||||
__privateAdd(this, _VideoEngine_instances);
|
||||
__privateAdd(this, _generation, 0);
|
||||
__privateAdd(this, _generation2, 0);
|
||||
__privateAdd(this, _iteration, 0);
|
||||
__privateAdd(this, _loading, null);
|
||||
__privateAdd(this, _loading2, null);
|
||||
__privateAdd(this, _createSink, null);
|
||||
__privateAdd(this, _usedSink, null);
|
||||
__privateAdd(this, _destroyed, false);
|
||||
__privateAdd(this, _preparing, false);
|
||||
__privateAdd(this, _poster, new Poster());
|
||||
__privateAdd(this, _renderer);
|
||||
__privateAdd(this, _onError);
|
||||
__privateAdd(this, _onError2);
|
||||
this.canvas = canvas;
|
||||
this.ctx = ctx;
|
||||
this.events = events;
|
||||
@@ -25007,7 +25342,7 @@ class VideoEngine {
|
||||
this.playbackRate = 1;
|
||||
this.posterDrawn = false;
|
||||
this.isFetching = false;
|
||||
__privateSet(this, _onError, onError);
|
||||
__privateSet(this, _onError2, onError);
|
||||
__privateSet(this, _renderer, new Renderer(this, () => !__privateGet(this, _preparing), (frame) => __privateMethod(this, _VideoEngine_instances, draw_fn).call(this, frame, true), (error) => __privateMethod(this, _VideoEngine_instances, report_fn).call(this, error)));
|
||||
}
|
||||
async preflight(source, signal, current) {
|
||||
@@ -25016,7 +25351,7 @@ class VideoEngine {
|
||||
drawPoster() {
|
||||
if (__privateGet(this, _destroyed) || !this.poster || this.posterDrawn)
|
||||
return;
|
||||
const generation = __privateGet(this, _generation);
|
||||
const generation = __privateGet(this, _generation2);
|
||||
__privateGet(this, _poster).draw(this.poster, () => __privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation), (image) => {
|
||||
this.clear();
|
||||
this.canvas.width = image.naturalWidth || this.canvas.width;
|
||||
@@ -25035,13 +25370,13 @@ class VideoEngine {
|
||||
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
cancelPending() {
|
||||
__privateWrapper(this, _generation)._++;
|
||||
__privateWrapper(this, _generation2)._++;
|
||||
__privateWrapper(this, _iteration)._++;
|
||||
this.stop();
|
||||
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
|
||||
__privateGet(this, _poster).cancel();
|
||||
__privateSet(this, _preparing, false);
|
||||
__privateSet(this, _loading, null);
|
||||
__privateSet(this, _loading2, null);
|
||||
__privateSet(this, _createSink, null);
|
||||
__privateSet(this, _usedSink, null);
|
||||
this.videoSink = null;
|
||||
@@ -25051,16 +25386,16 @@ class VideoEngine {
|
||||
if (__privateGet(this, _destroyed))
|
||||
return;
|
||||
this.cancelPending();
|
||||
const generation = __privateGet(this, _generation);
|
||||
const generation = __privateGet(this, _generation2);
|
||||
const iteration = __privateGet(this, _iteration);
|
||||
__privateSet(this, _preparing, true);
|
||||
const pending = Promise.resolve().then(() => __privateMethod(this, _VideoEngine_instances, load_fn).call(this, media, generation, iteration, onMetadata));
|
||||
__privateSet(this, _loading, pending);
|
||||
const pending = Promise.resolve().then(() => __privateMethod(this, _VideoEngine_instances, load_fn2).call(this, media, generation, iteration, onMetadata));
|
||||
__privateSet(this, _loading2, pending);
|
||||
try {
|
||||
await pending;
|
||||
} finally {
|
||||
if (__privateGet(this, _loading) === pending)
|
||||
__privateSet(this, _loading, null);
|
||||
if (__privateGet(this, _loading2) === pending)
|
||||
__privateSet(this, _loading2, null);
|
||||
}
|
||||
}
|
||||
handleNoVideoTrack() {
|
||||
@@ -25077,9 +25412,9 @@ class VideoEngine {
|
||||
async resetIterator(time) {
|
||||
if (__privateGet(this, _destroyed))
|
||||
return;
|
||||
const generation = __privateGet(this, _generation);
|
||||
const generation = __privateGet(this, _generation2);
|
||||
const iteration = ++__privateWrapper(this, _iteration)._;
|
||||
const loading = __privateGet(this, _loading);
|
||||
const loading = __privateGet(this, _loading2);
|
||||
this.stop();
|
||||
__privateGet(this, _poster).cancel();
|
||||
__privateSet(this, _preparing, true);
|
||||
@@ -25119,7 +25454,7 @@ class VideoEngine {
|
||||
if (__privateGet(this, _destroyed))
|
||||
return;
|
||||
__privateSet(this, _destroyed, true);
|
||||
__privateWrapper(this, _generation)._++;
|
||||
__privateWrapper(this, _generation2)._++;
|
||||
__privateGet(this, _renderer).destroy();
|
||||
__privateGet(this, _poster).cancel();
|
||||
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
|
||||
@@ -25131,19 +25466,19 @@ class VideoEngine {
|
||||
this.audioClock = null;
|
||||
}
|
||||
}
|
||||
_generation = new WeakMap();
|
||||
_generation2 = new WeakMap();
|
||||
_iteration = new WeakMap();
|
||||
_loading = new WeakMap();
|
||||
_loading2 = new WeakMap();
|
||||
_createSink = new WeakMap();
|
||||
_usedSink = new WeakMap();
|
||||
_destroyed = new WeakMap();
|
||||
_preparing = new WeakMap();
|
||||
_poster = new WeakMap();
|
||||
_renderer = new WeakMap();
|
||||
_onError = new WeakMap();
|
||||
_onError2 = new WeakMap();
|
||||
_VideoEngine_instances = new WeakSet();
|
||||
current_fn = function(generation) {
|
||||
return !__privateGet(this, _destroyed) && __privateGet(this, _generation) === generation;
|
||||
return !__privateGet(this, _destroyed) && __privateGet(this, _generation2) === generation;
|
||||
};
|
||||
context_fn = function() {
|
||||
if (!this.ctx)
|
||||
@@ -25154,8 +25489,8 @@ report_fn = function(error) {
|
||||
if (__privateGet(this, _destroyed))
|
||||
return;
|
||||
__privateMethod(this, _VideoEngine_instances, release_fn).call(this);
|
||||
if (__privateGet(this, _onError))
|
||||
__privateGet(this, _onError).call(this, error);
|
||||
if (__privateGet(this, _onError2))
|
||||
__privateGet(this, _onError2).call(this, error);
|
||||
else
|
||||
this.events.emit("error", error);
|
||||
};
|
||||
@@ -25168,7 +25503,7 @@ draw_fn = function(frame, clear) {
|
||||
this.clear();
|
||||
__privateMethod(this, _VideoEngine_instances, context_fn).call(this).drawImage(frame.canvas, 0, 0);
|
||||
};
|
||||
load_fn = async function(media, generation, iteration, onMetadata) {
|
||||
load_fn2 = async function(media, generation, iteration, onMetadata) {
|
||||
if (!__privateMethod(this, _VideoEngine_instances, current_fn).call(this, generation))
|
||||
return;
|
||||
try {
|
||||
@@ -25242,7 +25577,14 @@ class MediaBunnyEngine {
|
||||
__privateAdd(this, _playback);
|
||||
this.events = events;
|
||||
this.option = option;
|
||||
this.audio = new AudioEngine(events);
|
||||
this.audio = new AudioEngine(events, (error) => {
|
||||
try {
|
||||
this.pause();
|
||||
} catch (failure) {
|
||||
console.warn("MediaBunny audio coordinator cleanup:", failure);
|
||||
}
|
||||
this.reportError(error);
|
||||
});
|
||||
this.video = new VideoEngine({
|
||||
canvas,
|
||||
ctx,
|
||||
@@ -25279,6 +25621,7 @@ class MediaBunnyEngine {
|
||||
this.ended = true;
|
||||
this.paused = true;
|
||||
__privateGet(this, _playback).ended();
|
||||
this.audio.pause();
|
||||
});
|
||||
}
|
||||
async load(src) {
|
||||
@@ -25287,6 +25630,7 @@ class MediaBunnyEngine {
|
||||
const id = ++this.loadSeq;
|
||||
__privateGet(this, _playback).invalidate();
|
||||
this.video.cancelPending();
|
||||
this.audio.cancelPending();
|
||||
this.loadSession?.cancel();
|
||||
if (id !== this.loadSeq || this.destroyed)
|
||||
return;
|
||||
@@ -25311,6 +25655,7 @@ class MediaBunnyEngine {
|
||||
const failed = ++this.loadSeq;
|
||||
__privateGet(this, _playback).invalidate();
|
||||
this.video.cancelPending();
|
||||
this.audio.cancelPending();
|
||||
session.cancel();
|
||||
if (failed !== this.loadSeq || this.destroyed)
|
||||
return;
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
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,289 +0,0 @@
|
||||
/**
|
||||
* Audio Engine for MediaBunny
|
||||
* Handles audio playback using Web Audio API
|
||||
*/
|
||||
import {
|
||||
AudioBufferSink,
|
||||
} from 'mediabunny'
|
||||
|
||||
export default class AudioEngine {
|
||||
constructor(events) {
|
||||
this.events = events
|
||||
|
||||
// MediaBunny instances
|
||||
this.input = null
|
||||
this.audioSink = null
|
||||
this.audioIterator = null
|
||||
|
||||
// Web Audio API
|
||||
this.audioContext = null
|
||||
this.gainNode = null
|
||||
|
||||
// Playback state
|
||||
this.audioContextStartTime = 0
|
||||
this.playbackTimeAtStart = 0
|
||||
this.latestScheduledEndTime = 0
|
||||
this.duration = Number.NaN
|
||||
this.paused = true
|
||||
|
||||
// Audio settings
|
||||
this.volume = 0.7
|
||||
this.muted = false
|
||||
this.playbackRate = 1
|
||||
|
||||
// Async control
|
||||
this.asyncId = 0
|
||||
this.queuedNodes = new Set()
|
||||
}
|
||||
|
||||
get currentTime() {
|
||||
if (this.paused)
|
||||
return this.playbackTimeAtStart
|
||||
|
||||
return (
|
||||
(this.audioContext.currentTime - this.audioContextStartTime) * this.playbackRate
|
||||
+ this.playbackTimeAtStart
|
||||
)
|
||||
}
|
||||
|
||||
ensureAudioContext(sampleRate) {
|
||||
if (this.audioContext)
|
||||
return
|
||||
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext
|
||||
|
||||
try {
|
||||
this.audioContext = new AudioContext({ sampleRate })
|
||||
}
|
||||
catch {
|
||||
this.audioContext = new AudioContext()
|
||||
}
|
||||
|
||||
this.gainNode = this.audioContext.createGain()
|
||||
this.gainNode.connect(this.audioContext.destination)
|
||||
this.updateGain()
|
||||
}
|
||||
|
||||
updateGain() {
|
||||
if (!this.gainNode)
|
||||
return
|
||||
const v = this.muted ? 0 : this.volume
|
||||
this.gainNode.gain.value = v * v
|
||||
}
|
||||
|
||||
stopQueuedNodes() {
|
||||
this.queuedNodes.forEach(node => node.stop())
|
||||
this.queuedNodes.clear()
|
||||
}
|
||||
|
||||
async stopIterator() {
|
||||
await this.audioIterator?.return()
|
||||
this.audioIterator = null
|
||||
}
|
||||
|
||||
handleNoAudioTrack() {
|
||||
this.audioSink = null
|
||||
this.ensureAudioContext()
|
||||
}
|
||||
|
||||
async load(media, onMetadata) {
|
||||
++this.asyncId
|
||||
|
||||
await this.stopIterator()
|
||||
this.stopQueuedNodes()
|
||||
|
||||
this.paused = true
|
||||
this.playbackTimeAtStart = 0
|
||||
this.audioContextStartTime = 0
|
||||
|
||||
const { input, audioTrack, duration } = media
|
||||
this.input = input
|
||||
this.duration = duration
|
||||
|
||||
if (!audioTrack) {
|
||||
this.handleNoAudioTrack()
|
||||
onMetadata?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (audioTrack.codec === null || !(await audioTrack.canDecode())) {
|
||||
this.audioSink = null
|
||||
this.ensureAudioContext()
|
||||
onMetadata?.()
|
||||
return
|
||||
}
|
||||
|
||||
this.ensureAudioContext(audioTrack.sampleRate)
|
||||
this.audioSink = new AudioBufferSink(audioTrack)
|
||||
|
||||
onMetadata?.()
|
||||
}
|
||||
|
||||
async runIterator(localId) {
|
||||
if (!this.audioSink)
|
||||
return
|
||||
|
||||
await this.stopIterator()
|
||||
this.audioIterator = this.audioSink.buffers(this.currentTime)
|
||||
|
||||
// Batch size: read multiple audio buffers per iteration to reduce
|
||||
// IPC round-trip overhead. 16 buffers ≈ 340ms of audio.
|
||||
const BATCH_SIZE = 16
|
||||
|
||||
while (true) {
|
||||
if (localId !== this.asyncId || this.paused)
|
||||
return
|
||||
|
||||
const batch = []
|
||||
let batchDone = false
|
||||
try {
|
||||
for (let i = 0; i < BATCH_SIZE; i++) {
|
||||
const result = await this.audioIterator.next()
|
||||
if (result.done) {
|
||||
batchDone = true
|
||||
break
|
||||
}
|
||||
batch.push(result.value)
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Audio iterator error:', e)
|
||||
batchDone = true
|
||||
}
|
||||
|
||||
if (localId !== this.asyncId || this.paused)
|
||||
return
|
||||
|
||||
// Resume if was suspended
|
||||
if (batch.length > 0 && this.audioContext.state === 'suspended') {
|
||||
await this.audioContext.resume()
|
||||
this.events.emit('canplay')
|
||||
this.events.emit('playing')
|
||||
}
|
||||
|
||||
// Schedule all buffers in the batch
|
||||
for (const { buffer, timestamp } of batch) {
|
||||
const node = this.audioContext.createBufferSource()
|
||||
node.buffer = buffer
|
||||
node.connect(this.gainNode)
|
||||
node.playbackRate.value = this.playbackRate
|
||||
|
||||
const startAt
|
||||
= this.audioContextStartTime
|
||||
+ (timestamp - this.playbackTimeAtStart) / this.playbackRate
|
||||
|
||||
const duration = buffer.duration
|
||||
const endAt = startAt + duration / this.playbackRate
|
||||
|
||||
const endMediaTime = (endAt - this.audioContextStartTime) * this.playbackRate + this.playbackTimeAtStart
|
||||
if (endMediaTime > this.latestScheduledEndTime) {
|
||||
this.latestScheduledEndTime = endMediaTime
|
||||
}
|
||||
|
||||
if (startAt >= this.audioContext.currentTime) {
|
||||
node.start(startAt)
|
||||
}
|
||||
else {
|
||||
node.start(
|
||||
this.audioContext.currentTime,
|
||||
(this.audioContext.currentTime - startAt) * this.playbackRate,
|
||||
)
|
||||
}
|
||||
|
||||
this.queuedNodes.add(node)
|
||||
node.onended = () => this.queuedNodes.delete(node)
|
||||
}
|
||||
|
||||
if (batchDone)
|
||||
break
|
||||
|
||||
// Yield main thread after each batch so video rAF callbacks can run.
|
||||
// Without this, the audio pump's tight loop starves the render loop.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
|
||||
// Backpressure: if we've scheduled too far ahead, wait for playback
|
||||
// to catch up before decoding more. This prevents the audio pump from
|
||||
// consuming all WebCodecs resources and starving the video decoder.
|
||||
const BUFFER_AHEAD = 1 // seconds
|
||||
while (this.latestScheduledEndTime - this.currentTime > BUFFER_AHEAD) {
|
||||
if (localId !== this.asyncId || this.paused)
|
||||
return
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async play() {
|
||||
if (!this.paused)
|
||||
return
|
||||
|
||||
if (!this.audioContext) {
|
||||
this.ensureAudioContext()
|
||||
}
|
||||
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
await this.audioContext.resume()
|
||||
}
|
||||
|
||||
this.audioContextStartTime = this.audioContext.currentTime
|
||||
this.latestScheduledEndTime = this.playbackTimeAtStart
|
||||
this.paused = false
|
||||
|
||||
const id = ++this.asyncId
|
||||
this.runIterator(id)
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this.paused)
|
||||
return
|
||||
|
||||
this.playbackTimeAtStart = this.currentTime
|
||||
this.paused = true
|
||||
|
||||
this.stopIterator()
|
||||
this.stopQueuedNodes()
|
||||
}
|
||||
|
||||
async seek(time) {
|
||||
this.playbackTimeAtStart = Math.max(0, time)
|
||||
this.audioContextStartTime = this.audioContext.currentTime
|
||||
this.latestScheduledEndTime = this.playbackTimeAtStart
|
||||
|
||||
const id = ++this.asyncId
|
||||
if (!this.paused) {
|
||||
this.runIterator(id)
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(volume, muted) {
|
||||
this.volume = volume
|
||||
this.muted = muted
|
||||
this.updateGain()
|
||||
}
|
||||
|
||||
setPlaybackRate(rate) {
|
||||
if (rate === this.playbackRate)
|
||||
return
|
||||
|
||||
if (!this.paused) {
|
||||
this.playbackTimeAtStart = this.currentTime
|
||||
this.audioContextStartTime = this.audioContext.currentTime
|
||||
}
|
||||
|
||||
this.playbackRate = rate
|
||||
|
||||
if (!this.paused) {
|
||||
const id = ++this.asyncId
|
||||
this.runIterator(id)
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.asyncId++
|
||||
this.pause()
|
||||
this.audioContext?.close()
|
||||
this.audioContext = null
|
||||
this.input = null
|
||||
this.audioSink = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import type { AudioBufferSink as AudioSink, Input } from 'mediabunny'
|
||||
import type { AudioIterator } from './audio-pump'
|
||||
import type { AudioPort } from './engine-ports'
|
||||
import type EventTarget from './EventTarget'
|
||||
import type { PlaybackMedia } from './media'
|
||||
import { AudioBufferSink } from 'mediabunny'
|
||||
import { mediaTime } from './audio-clock'
|
||||
import ContextOwner from './audio-context'
|
||||
import NodeQueue from './audio-nodes'
|
||||
import AudioPump from './audio-pump'
|
||||
import AudioTask, { CANCELLED } from './audio-task'
|
||||
|
||||
export default class AudioEngine implements AudioPort {
|
||||
declare events: EventTarget
|
||||
declare input: Input | null
|
||||
declare audioSink: AudioSink | null
|
||||
declare audioIterator: AudioIterator | null
|
||||
declare audioContext: AudioContext | null
|
||||
declare gainNode: GainNode | null
|
||||
declare audioContextStartTime: number
|
||||
declare playbackTimeAtStart: number
|
||||
declare latestScheduledEndTime: number
|
||||
declare duration: number
|
||||
declare paused: boolean
|
||||
declare volume: number
|
||||
declare muted: boolean
|
||||
declare playbackRate: number
|
||||
declare asyncId: number
|
||||
declare queuedNodes: Set<AudioBufferSourceNode>
|
||||
#closed = false
|
||||
#generation = 0
|
||||
#contexts: ContextOwner
|
||||
#nodes: NodeQueue
|
||||
#pump: AudioPump
|
||||
#playTask: AudioTask | null = null
|
||||
#playing: Promise<void> | null = null
|
||||
#loading: Promise<void> | null = null
|
||||
#onError: ((error: unknown) => void) | undefined
|
||||
|
||||
constructor(events: EventTarget, onError?: (error: unknown) => void) {
|
||||
this.events = events
|
||||
this.input = null
|
||||
this.audioSink = null
|
||||
this.audioIterator = null
|
||||
this.audioContext = null
|
||||
this.gainNode = null
|
||||
this.audioContextStartTime = 0
|
||||
this.playbackTimeAtStart = 0
|
||||
this.latestScheduledEndTime = 0
|
||||
this.duration = Number.NaN
|
||||
this.paused = true
|
||||
this.volume = 0.7
|
||||
this.muted = false
|
||||
this.playbackRate = 1
|
||||
this.asyncId = 0
|
||||
this.queuedNodes = new Set()
|
||||
this.#contexts = new ContextOwner(this)
|
||||
this.#nodes = new NodeQueue(this)
|
||||
this.#pump = new AudioPump(this, this.#contexts, this.#nodes)
|
||||
this.#onError = onError
|
||||
}
|
||||
|
||||
get currentTime(): number {
|
||||
return mediaTime(this)
|
||||
}
|
||||
|
||||
ensureAudioContext(sampleRate?: number): void {
|
||||
this.#contexts.ensure(sampleRate)
|
||||
}
|
||||
|
||||
updateGain(): void {
|
||||
this.#contexts.updateGain()
|
||||
}
|
||||
|
||||
stopQueuedNodes(): void {
|
||||
this.#nodes.stop()
|
||||
}
|
||||
|
||||
async stopIterator(): Promise<void> {
|
||||
await this.#pump.stopIterator()
|
||||
}
|
||||
|
||||
handleNoAudioTrack(): void {
|
||||
if (this.#closed)
|
||||
return
|
||||
this.audioSink = null
|
||||
this.ensureAudioContext()
|
||||
}
|
||||
|
||||
cancelPending(): void {
|
||||
this.#generation++
|
||||
try {
|
||||
this.pause()
|
||||
}
|
||||
finally {
|
||||
this.#loading = null
|
||||
this.input = null
|
||||
this.audioSink = null
|
||||
}
|
||||
}
|
||||
|
||||
async load(media: PlaybackMedia, onMetadata?: () => void): Promise<void> {
|
||||
if (this.#closed)
|
||||
return
|
||||
this.cancelPending()
|
||||
const generation = this.#generation
|
||||
this.playbackTimeAtStart = 0
|
||||
this.audioContextStartTime = 0
|
||||
this.latestScheduledEndTime = 0
|
||||
const pending = Promise.resolve().then(() => this.#load(media, generation, onMetadata))
|
||||
this.#loading = pending
|
||||
try {
|
||||
await pending
|
||||
}
|
||||
finally {
|
||||
if (this.#loading === pending)
|
||||
this.#loading = null
|
||||
}
|
||||
}
|
||||
|
||||
async #load({ input, audioTrack, duration }: PlaybackMedia, generation: number, onMetadata?: () => void): Promise<void> {
|
||||
const current = () => !this.#closed && this.#generation === generation
|
||||
if (!current())
|
||||
return
|
||||
this.input = input
|
||||
this.duration = duration
|
||||
try {
|
||||
const decodable = audioTrack && audioTrack.codec !== null && await audioTrack.canDecode()
|
||||
if (!current())
|
||||
return
|
||||
if (!decodable) {
|
||||
this.handleNoAudioTrack()
|
||||
}
|
||||
else {
|
||||
this.ensureAudioContext(audioTrack.sampleRate)
|
||||
if (!current())
|
||||
return
|
||||
this.audioSink = new AudioBufferSink(audioTrack)
|
||||
}
|
||||
if (current())
|
||||
onMetadata?.()
|
||||
}
|
||||
catch (error) {
|
||||
if (current())
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async runIterator(localId: number): Promise<void> {
|
||||
if (this.#closed)
|
||||
return
|
||||
await this.#pump.run(localId)
|
||||
}
|
||||
|
||||
#startPump(id: number): void {
|
||||
void this.runIterator(id).catch((error) => {
|
||||
if (this.#closed || id !== this.asyncId)
|
||||
return
|
||||
try {
|
||||
this.pause()
|
||||
}
|
||||
catch (failure) { console.warn('MediaBunny audio failure cleanup:', failure) }
|
||||
try {
|
||||
if (this.#onError)
|
||||
this.#onError(error)
|
||||
else
|
||||
this.events.emit('error', error)
|
||||
}
|
||||
catch (failure) { console.warn('MediaBunny audio error listener:', failure) }
|
||||
})
|
||||
}
|
||||
|
||||
async play(): Promise<void> {
|
||||
if (this.#closed)
|
||||
return
|
||||
if (this.#playing)
|
||||
return this.#playing
|
||||
if (!this.paused)
|
||||
return
|
||||
const task = new AudioTask()
|
||||
this.#playTask = task
|
||||
const generation = this.#generation
|
||||
const pending = this.#play(task, generation)
|
||||
this.#playing = pending
|
||||
try {
|
||||
await pending
|
||||
}
|
||||
finally {
|
||||
if (this.#playTask === task) {
|
||||
this.#playTask = null
|
||||
this.#playing = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #play(task: AudioTask, generation: number): Promise<void> {
|
||||
const current = () => !this.#closed && !task.cancelled && this.#generation === generation
|
||||
try {
|
||||
const context = this.#contexts.ensure()
|
||||
if (!context || !current())
|
||||
return
|
||||
if (context.state === 'suspended') {
|
||||
const resumed = await task.wait(this.#contexts.resume(context))
|
||||
if (resumed === CANCELLED || !current())
|
||||
return
|
||||
}
|
||||
if (this.#loading) {
|
||||
const loaded = await task.wait(this.#loading)
|
||||
if (loaded === CANCELLED || !current())
|
||||
return
|
||||
}
|
||||
this.audioContextStartTime = context.currentTime
|
||||
this.latestScheduledEndTime = this.playbackTimeAtStart
|
||||
this.paused = false
|
||||
this.#startPump(++this.asyncId)
|
||||
}
|
||||
catch (error) {
|
||||
if (current())
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
if (!this.paused)
|
||||
this.playbackTimeAtStart = this.currentTime
|
||||
this.paused = true
|
||||
this.asyncId++
|
||||
this.#playTask?.cancel()
|
||||
this.#playTask = null
|
||||
this.#playing = null
|
||||
this.#pump.cancel()
|
||||
this.stopQueuedNodes()
|
||||
}
|
||||
|
||||
async seek(time: number): Promise<void> {
|
||||
if (this.#closed)
|
||||
return
|
||||
const playing = !this.paused
|
||||
this.#pump.cancel()
|
||||
this.stopQueuedNodes()
|
||||
this.playbackTimeAtStart = Math.max(0, time)
|
||||
this.audioContextStartTime = this.audioContext?.currentTime ?? 0
|
||||
this.latestScheduledEndTime = this.playbackTimeAtStart
|
||||
const id = ++this.asyncId
|
||||
if (playing)
|
||||
this.#startPump(id)
|
||||
}
|
||||
|
||||
setVolume(volume: number, muted: boolean): void {
|
||||
this.volume = volume
|
||||
this.muted = muted
|
||||
this.updateGain()
|
||||
}
|
||||
|
||||
setPlaybackRate(rate: number): void {
|
||||
if (this.#closed || rate === this.playbackRate)
|
||||
return
|
||||
const time = this.currentTime
|
||||
this.#pump.cancel()
|
||||
this.stopQueuedNodes()
|
||||
this.playbackTimeAtStart = time
|
||||
this.audioContextStartTime = this.audioContext?.currentTime ?? 0
|
||||
this.playbackRate = rate
|
||||
this.latestScheduledEndTime = time
|
||||
const id = ++this.asyncId
|
||||
if (!this.paused)
|
||||
this.#startPump(id)
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.#closed)
|
||||
return
|
||||
this.#closed = true
|
||||
this.#generation++
|
||||
const failures: unknown[] = []
|
||||
for (const release of [() => this.pause(), () => this.#contexts.destroy()]) {
|
||||
try {
|
||||
release()
|
||||
}
|
||||
catch (error) { failures.push(error) }
|
||||
}
|
||||
this.input = null
|
||||
this.audioSink = null
|
||||
this.#loading = null
|
||||
if (failures.length)
|
||||
throw failures[0]
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,15 @@ export default class MediaBunnyEngine implements EnginePort {
|
||||
constructor({ canvas, ctx, events, option = {} }: EngineOptions) {
|
||||
this.events = events
|
||||
this.option = option
|
||||
this.audio = new AudioEngine(events)
|
||||
this.audio = new AudioEngine(events, (error) => {
|
||||
try {
|
||||
this.pause()
|
||||
}
|
||||
catch (failure) {
|
||||
console.warn('MediaBunny audio coordinator cleanup:', failure)
|
||||
}
|
||||
this.reportError(error)
|
||||
})
|
||||
this.video = new VideoEngine({
|
||||
canvas,
|
||||
ctx,
|
||||
@@ -73,6 +81,7 @@ export default class MediaBunnyEngine implements EnginePort {
|
||||
this.ended = true
|
||||
this.paused = true
|
||||
this.#playback.ended()
|
||||
this.audio.pause()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,6 +91,7 @@ export default class MediaBunnyEngine implements EnginePort {
|
||||
const id = ++this.loadSeq
|
||||
this.#playback.invalidate()
|
||||
this.video.cancelPending()
|
||||
this.audio.cancelPending()
|
||||
this.loadSession?.cancel()
|
||||
if (id !== this.loadSeq || this.destroyed)
|
||||
return
|
||||
@@ -107,6 +117,7 @@ export default class MediaBunnyEngine implements EnginePort {
|
||||
const failed = ++this.loadSeq
|
||||
this.#playback.invalidate()
|
||||
this.video.cancelPending()
|
||||
this.audio.cancelPending()
|
||||
session.cancel()
|
||||
if (failed !== this.loadSeq || this.destroyed)
|
||||
return
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface AudioClock {
|
||||
audioContext: AudioContext | null
|
||||
paused: boolean
|
||||
audioContextStartTime: number
|
||||
playbackTimeAtStart: number
|
||||
playbackRate: number
|
||||
}
|
||||
|
||||
export function mediaTime(clock: AudioClock): number {
|
||||
if (clock.paused || !clock.audioContext)
|
||||
return clock.playbackTimeAtStart
|
||||
return (clock.audioContext.currentTime - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart
|
||||
}
|
||||
|
||||
export function bufferTiming(clock: AudioClock, timestamp: number, duration: number, now: number) {
|
||||
const startAt = clock.audioContextStartTime + (timestamp - clock.playbackTimeAtStart) / clock.playbackRate
|
||||
const endAt = startAt + duration / clock.playbackRate
|
||||
const endMediaTime = (endAt - clock.audioContextStartTime) * clock.playbackRate + clock.playbackTimeAtStart
|
||||
return { startAt: Math.max(startAt, now), offset: startAt < now ? (now - startAt) * clock.playbackRate : null, endMediaTime }
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
interface ContextHost {
|
||||
audioContext: AudioContext | null
|
||||
gainNode: GainNode | null
|
||||
volume: number
|
||||
muted: boolean
|
||||
}
|
||||
|
||||
export default class ContextOwner {
|
||||
private closed = false
|
||||
private resumes = new WeakMap<AudioContext, Promise<void>>()
|
||||
|
||||
constructor(private host: ContextHost) {}
|
||||
|
||||
ensure(sampleRate?: number): AudioContext | null {
|
||||
if (this.closed)
|
||||
return null
|
||||
if (this.host.audioContext)
|
||||
return this.host.audioContext
|
||||
const Context = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
|
||||
let context: AudioContext
|
||||
try {
|
||||
context = new Context({ sampleRate })
|
||||
}
|
||||
catch {
|
||||
context = new Context()
|
||||
}
|
||||
if (this.closed) {
|
||||
void context.close().catch(error => console.warn('MediaBunny obsolete context cleanup:', error))
|
||||
return null
|
||||
}
|
||||
let gain: GainNode | null = null
|
||||
try {
|
||||
gain = context.createGain()
|
||||
gain.connect(context.destination)
|
||||
const volume = this.host.muted ? 0 : this.host.volume
|
||||
gain.gain.value = volume * volume
|
||||
if (this.closed) {
|
||||
gain.disconnect()
|
||||
void context.close().catch(error => console.warn('MediaBunny obsolete context cleanup:', error))
|
||||
return null
|
||||
}
|
||||
this.host.audioContext = context
|
||||
this.host.gainNode = gain
|
||||
return context
|
||||
}
|
||||
catch (error) {
|
||||
try {
|
||||
gain?.disconnect()
|
||||
}
|
||||
catch (failure) { console.warn('MediaBunny gain setup cleanup:', failure) }
|
||||
void context.close().catch(failure => console.warn('MediaBunny context setup cleanup:', failure))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
updateGain(): void {
|
||||
if (!this.host.gainNode)
|
||||
return
|
||||
const volume = this.host.muted ? 0 : this.host.volume
|
||||
this.host.gainNode.gain.value = volume * volume
|
||||
}
|
||||
|
||||
resume(context: AudioContext): Promise<void> {
|
||||
const previous = this.resumes.get(context)
|
||||
if (previous)
|
||||
return previous
|
||||
const pending = context.resume()
|
||||
this.resumes.set(context, pending)
|
||||
const clear = () => {
|
||||
if (this.resumes.get(context) === pending)
|
||||
this.resumes.delete(context)
|
||||
}
|
||||
pending.then(clear, clear)
|
||||
return pending
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.closed)
|
||||
return
|
||||
this.closed = true
|
||||
const { audioContext: context, gainNode: gain } = this.host
|
||||
this.host.audioContext = null
|
||||
this.host.gainNode = null
|
||||
try {
|
||||
gain?.disconnect()
|
||||
}
|
||||
finally {
|
||||
if (context)
|
||||
void context.close().catch(error => console.warn('MediaBunny context close error:', error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { WrappedAudioBuffer } from 'mediabunny'
|
||||
import type { AudioClock } from './audio-clock'
|
||||
import { bufferTiming } from './audio-clock'
|
||||
|
||||
interface QueueHost extends AudioClock {
|
||||
gainNode: GainNode | null
|
||||
queuedNodes: Set<AudioBufferSourceNode>
|
||||
latestScheduledEndTime: number
|
||||
}
|
||||
|
||||
export default class NodeQueue {
|
||||
constructor(private host: QueueHost) {}
|
||||
|
||||
private release(node: AudioBufferSourceNode, stop: boolean): void {
|
||||
this.host.queuedNodes.delete(node)
|
||||
node.onended = null
|
||||
let failed = false
|
||||
let failure: unknown
|
||||
try {
|
||||
if (stop)
|
||||
node.stop()
|
||||
}
|
||||
catch (error) {
|
||||
failed = true
|
||||
failure = error
|
||||
}
|
||||
try {
|
||||
node.disconnect()
|
||||
}
|
||||
catch (error) {
|
||||
if (!failed) {
|
||||
failed = true
|
||||
failure = error
|
||||
}
|
||||
}
|
||||
if (failed)
|
||||
throw failure
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
const failures: unknown[] = []
|
||||
for (const node of [...this.host.queuedNodes]) {
|
||||
try {
|
||||
this.release(node, true)
|
||||
}
|
||||
catch (error) { failures.push(error) }
|
||||
}
|
||||
if (failures.length)
|
||||
throw failures[0]
|
||||
}
|
||||
|
||||
schedule({ buffer, timestamp }: WrappedAudioBuffer): void {
|
||||
const context = this.host.audioContext
|
||||
const gain = this.host.gainNode
|
||||
if (!context || !gain)
|
||||
return
|
||||
const timing = bufferTiming(this.host, timestamp, buffer.duration, context.currentTime)
|
||||
if (timing.offset !== null && timing.offset >= buffer.duration)
|
||||
return
|
||||
const node = context.createBufferSource()
|
||||
this.host.queuedNodes.add(node)
|
||||
try {
|
||||
node.buffer = buffer
|
||||
node.connect(gain)
|
||||
node.playbackRate.value = this.host.playbackRate
|
||||
node.onended = () => {
|
||||
if (!this.host.queuedNodes.has(node))
|
||||
return
|
||||
try {
|
||||
this.release(node, false)
|
||||
}
|
||||
catch (error) { console.warn('MediaBunny ended node cleanup:', error) }
|
||||
}
|
||||
if (timing.offset === null)
|
||||
node.start(timing.startAt)
|
||||
else
|
||||
node.start(timing.startAt, timing.offset)
|
||||
this.host.latestScheduledEndTime = Math.max(this.host.latestScheduledEndTime, timing.endMediaTime)
|
||||
}
|
||||
catch (error) {
|
||||
try {
|
||||
this.release(node, true)
|
||||
}
|
||||
catch (failure) { console.warn('MediaBunny audio node cleanup:', failure) }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { AudioBufferSink, WrappedAudioBuffer } from 'mediabunny'
|
||||
import type ContextOwner from './audio-context'
|
||||
import type NodeQueue from './audio-nodes'
|
||||
import type EventTarget from './EventTarget'
|
||||
import AudioTask, { CANCELLED } from './audio-task'
|
||||
import { publish } from './readiness'
|
||||
|
||||
export type AudioIterator = Pick<AsyncGenerator<WrappedAudioBuffer, void, unknown>, 'next' | 'return'>
|
||||
export interface PumpHost {
|
||||
audioSink: AudioBufferSink | null
|
||||
audioIterator: AudioIterator | null
|
||||
audioContext: AudioContext | null
|
||||
paused: boolean
|
||||
asyncId: number
|
||||
readonly currentTime: number
|
||||
latestScheduledEndTime: number
|
||||
events: EventTarget
|
||||
}
|
||||
|
||||
export default class AudioPump {
|
||||
private task: AudioTask | null = null
|
||||
private closing = new WeakMap<AudioIterator, Promise<void>>()
|
||||
|
||||
constructor(private host: PumpHost, private contexts: ContextOwner, private nodes: NodeQueue) {}
|
||||
|
||||
async stopIterator(): Promise<void> {
|
||||
const iterator = this.host.audioIterator
|
||||
this.host.audioIterator = null
|
||||
if (!iterator)
|
||||
return
|
||||
let pending = this.closing.get(iterator)
|
||||
if (!pending) {
|
||||
pending = Promise.resolve().then(() => iterator.return()).then(() => {})
|
||||
this.closing.set(iterator, pending)
|
||||
}
|
||||
await pending
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.task?.cancel()
|
||||
this.task = null
|
||||
void this.stopIterator().catch(error => console.warn('MediaBunny audio iterator cleanup:', error))
|
||||
}
|
||||
|
||||
async run(id: number): Promise<void> {
|
||||
if (this.host.asyncId !== id)
|
||||
return
|
||||
this.cancel()
|
||||
const sink = this.host.audioSink
|
||||
const context = this.host.audioContext
|
||||
if (!sink || !context || this.host.paused || this.host.asyncId !== id)
|
||||
return
|
||||
const task = new AudioTask()
|
||||
this.task = task
|
||||
let iterator: AudioIterator | null = null
|
||||
const current = () => !task.cancelled && this.task === task && this.host.asyncId === id && !this.host.paused && this.host.audioContext === context && (!iterator || this.host.audioIterator === iterator)
|
||||
try {
|
||||
iterator = sink.buffers(this.host.currentTime)
|
||||
this.host.audioIterator = iterator
|
||||
while (current()) {
|
||||
const batch: WrappedAudioBuffer[] = []
|
||||
let done = false
|
||||
for (let index = 0; index < 16; index++) {
|
||||
const result = await task.wait(iterator.next())
|
||||
if (result === CANCELLED || !current())
|
||||
return
|
||||
if (result.done) {
|
||||
done = true
|
||||
break
|
||||
}
|
||||
batch.push(result.value)
|
||||
}
|
||||
if (batch.length && context.state === 'suspended') {
|
||||
const resumed = await task.wait(this.contexts.resume(context))
|
||||
if (resumed === CANCELLED || !current())
|
||||
return
|
||||
publish(this.host.events, current, ['canplay', 'playing'])
|
||||
}
|
||||
for (const buffer of batch) {
|
||||
if (!current())
|
||||
return
|
||||
this.nodes.schedule(buffer)
|
||||
}
|
||||
if (done)
|
||||
return
|
||||
if (!(await task.delay(0)) || !current())
|
||||
return
|
||||
while (this.host.latestScheduledEndTime - this.host.currentTime > 1) {
|
||||
if (!(await task.delay(50)) || !current())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
if (current())
|
||||
throw error
|
||||
}
|
||||
finally {
|
||||
task.cancel()
|
||||
if (this.task === task) {
|
||||
this.task = null
|
||||
void this.stopIterator().catch(error => console.warn('MediaBunny audio iterator cleanup:', error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export const CANCELLED = Symbol('cancelled')
|
||||
|
||||
export default class AudioTask {
|
||||
cancelled = false
|
||||
private waits = new Set<() => void>()
|
||||
private timers = new Map<ReturnType<typeof setTimeout>, (active: boolean) => void>()
|
||||
|
||||
wait<T>(pending: Promise<T>): Promise<T | typeof CANCELLED> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cancel = () => resolve(CANCELLED)
|
||||
if (this.cancelled)
|
||||
cancel()
|
||||
else
|
||||
this.waits.add(cancel)
|
||||
pending.then((value) => {
|
||||
this.waits.delete(cancel)
|
||||
resolve(value)
|
||||
}, (error) => {
|
||||
this.waits.delete(cancel)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
delay(ms: number): Promise<boolean> {
|
||||
if (this.cancelled)
|
||||
return Promise.resolve(false)
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.timers.delete(timer)
|
||||
resolve(!this.cancelled)
|
||||
}, ms)
|
||||
this.timers.set(timer, resolve)
|
||||
})
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.cancelled)
|
||||
return
|
||||
this.cancelled = true
|
||||
for (const cancel of this.waits)
|
||||
cancel()
|
||||
this.waits.clear()
|
||||
for (const [timer, resolve] of this.timers) {
|
||||
clearTimeout(timer)
|
||||
resolve(false)
|
||||
}
|
||||
this.timers.clear()
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type EventTarget from './EventTarget'
|
||||
import type { PlaybackMedia } from './media'
|
||||
|
||||
export interface AudioPort {
|
||||
cancelPending: () => void
|
||||
readonly currentTime: number
|
||||
duration: number
|
||||
paused: boolean
|
||||
|
||||
@@ -28,7 +28,6 @@ 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) {}
|
||||
|
||||
@@ -103,19 +102,6 @@ export default class Playback {
|
||||
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 {
|
||||
@@ -127,7 +113,7 @@ export default class Playback {
|
||||
if (!current())
|
||||
return
|
||||
this.host.paused = false
|
||||
await this.startAudio()
|
||||
await this.host.audio.play()
|
||||
if (!current()) {
|
||||
if (!this.wanted || this.host.destroyed)
|
||||
this.host.audio.pause()
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"task": "PKG-MB-06",
|
||||
"sourceCommit": "9d037dcbf913fef2d2a9eb4f3924d6abf3ab5597",
|
||||
"sourceSha256LF": {
|
||||
"packages/artplayer-proxy-mediabunny/src/AudioEngine.js": "910d6831372e4895d55dec78010f281a199bc88f99735630a5a3b621f192ac35",
|
||||
"packages/artplayer-proxy-mediabunny/src/MediaBunnyEngine.ts": "237d3d29fbf61a74df83dea11d862450dfd8f3b7fbd555dd5552b630ff0191e9",
|
||||
"packages/artplayer-proxy-mediabunny/src/playback.ts": "0512f6a1b253d754b6644266327e368e0e18cce1cf9a3af62b6747ed55d691f7",
|
||||
"packages/artplayer-proxy-mediabunny/dist/artplayer-proxy-mediabunny.js": "9c89a729197593ffcab11b64813b312f588f5515d2cb8acb0537d0a389a5209c"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,72 @@
|
||||
# PKG-MB-06:音频解码、时钟与资源归属
|
||||
|
||||
任务通过完整CI后标记done,并立即以专用本地提交交付。来源是9d037dcbf913fef2d2a9eb4f3924d6abf3ab5597,
|
||||
冻结旧AudioEngine、协调器、playback和main的LF哈希见[源码基线](../baselines/mb-audio-source.json)。
|
||||
候选文件、测试、产物和浏览器报告见[验证记录](../baselines/mb-audio-validation.json)。
|
||||
|
||||
## 实现与拆分
|
||||
|
||||
实际删除AudioEngine.js及临时AudioEngine.d.ts,迁移到AudioEngine.ts;拆出audio-clock、
|
||||
audio-context、audio-nodes、audio-pump、audio-task五个模块,分别管理时钟计算、
|
||||
Context/Gain、源节点、SDK迭代器/背压、取消等待/计时器。模块地图和维护命令同步到
|
||||
包内ARCHITECTURE.md。包内26个生产TS模块,没有遗留JS声明桥;index.js和m3u8.js
|
||||
分别由MB-08/07接续,不能据此声称整个包已经完成TS化。
|
||||
|
||||
source、play和pump分别拥有取消状态。暂停会取消尚未完成的resume;新source在解析轨道
|
||||
之前就使旧音频操作失效。迟到的canDecode、resume、next不能重建context、写入sink、
|
||||
发出旧playing或排队旧节点。迭代器同步脱离引用、return只调用一次且观察拒绝。
|
||||
每批16buffer、0ms让出及1秒/50ms背压参数保留,任务释放包含ID零的计时器。
|
||||
复查新实现时消除了对单个长期未决Promise反复race的设计:每次读取完成就移除取消回调,
|
||||
避免正常长播放累积已完成读取的等待监听。这是源码审查改进,不声称已做长时间堆内存验收。
|
||||
|
||||
节点在start前即登记归属,自然ended也断开连接并清除回调;暂停、seek、倍速与destroy
|
||||
停止/断开旧队列。一个节点失败仍尝试其余节点及Context/Gain清理,保留首个失败。
|
||||
后台解码失败先停止播放并报告一次MediaError;清理失败不会替换解码原始错误。
|
||||
Context/Gain设置成功后才安装引用;失败释放部分资源,销毁清空引用并观察close拒绝。
|
||||
真正异步的AudioContext.close通过浏览器状态轮询验证,不以固定两帧声称关闭完成。
|
||||
|
||||
## 兼容边界与修复行为
|
||||
|
||||
公开factory、canvas转发、声明、默认选项、三种分发入口及MediaBunny 1.56.1依赖保持原有契约。
|
||||
时钟仍按锚点和倍速计算,volume仍用平方增益,mute为零;暂停冻结位置,seek/rate更新锚点。
|
||||
无音轨但有视频仍使用Web Audio时钟,完全无轨道容器继续MB-04的明确拒绝行为。
|
||||
已完全过期的buffer不再创建无效节点,部分迟到buffer保留原offset计算。视频结束会停止
|
||||
音频时钟和余留节点,倍速切换会停止旧速节点。仍使用原生source.playbackRate,未引入保调算法。
|
||||
|
||||
原先playback层缓存整个audio.play Promise;如今音频play可在取消时提前完成,该缓存不再
|
||||
能代表底层resume状态,因此去重下移到ContextOwner。协调器新意图各调用audio.play,
|
||||
实际原生resume仍共享一次。受控协调器测试的audio.play调用数由1改为2,新增实际AudioEngine
|
||||
测试验证play→pause→play的原生resume次数仍为1,只有当前意图启动视频。
|
||||
这项变化是内部归属修正,不能把stub调用数变化隐瞒为原样不变的测试。
|
||||
|
||||
## 验证与证据边界
|
||||
|
||||
- 37项新增音频断言:最终候选全部通过;同一断言对旧main为8通过/29失败,无取消或跳过。
|
||||
首轮旧测试的一项等待夹具超时已归档;修正夹具在finally释放受控读后,最终旧基线没有超时。
|
||||
- 包专项183通过,main/legacy各159通过;每个产物命令含155候选断言及4个冻结正常事件对照。
|
||||
- 新增24项浏览器:Chromium/Firefox各8项实际Web Audio/SDK解码场景,Windows WebKit
|
||||
8项精确能力缺失对照。包括暂停恢复、1×/2×、增益/静音、无音轨时钟、迟到buffer与resume、
|
||||
seek后的HLS音画时钟取样。短时实际绘制帧与音频时钟偏差上限为250ms,报告保存原始样本;
|
||||
这不是听觉输出、长时间漂移、物理设备或所有Safari安装的验收。
|
||||
- 同24项对旧main:20通过/4失败;失败是Chromium/Firefox音频与HLS暂停后节点/回调
|
||||
仍保留的状态断言,不是加载超时。其他旧浏览器场景通过属于回归对照,不能算作复现缺陷。
|
||||
- 最终构建main运行全部六个MediaBunny浏览器文件,共78项:包含既有视频竞态、输入、
|
||||
Stream取消及synthetic RAF。报告与整个results目录在下一次运行前归档。
|
||||
- 严格TS、lint、三格式构建、ESM默认factory和dist/docs逐字节一致性独立核验。
|
||||
- 本项不增加依赖;根test:unit与test:mediabunny加入音频测试,使完整CI持续执行。
|
||||
|
||||
最终完整CI1344项(1189单元+14工程+141基线)通过,另44重复契约;316生产TS。
|
||||
最终78浏览器报告中,Chromium 27个播放帧取样最大偏差39.34ms,Firefox 28个取样最大
|
||||
偏差37.67ms。所有源码/测试/产物及报告哈希已复核,dist与docs/compiled三个格式一致。
|
||||
|
||||
## 环境问题与后续
|
||||
|
||||
一次Windows copyfile UNKNOWN发生在构建复制既有legacy生成文件时。保留失败日志和旧生成文件,
|
||||
核对唯一绝对路径后删除该生成目标,再由正常yarn build重建;未手改生成内容或停止用户进程。
|
||||
首轮78浏览器报告也保留,因为取消等待实现随后改进,最终产物重新构建并重跑对应验证。
|
||||
|
||||
MB-LIFE-01仍open:音视频各自的取消与短时组合已覆盖,HLS UI拓扑/并发选择、长播放/AV同步、
|
||||
codec能力矩阵和全核心/插件组合仍由MB-07/08/09及能力风险条目处理。MB-10承担真实安装包、
|
||||
demo与MPL/source通知;本项通过不是npm发布许可或发布验收。
|
||||
|
||||
回退通过revert本项专用提交,冻结旧实现保持可追溯。下一步MB-07整理m3u8.ts选择与拓扑清理。
|
||||
@@ -61,3 +61,13 @@ VideoEngine与迭代器/渲染/海报职责已实际迁移TS,删除视频声
|
||||
执行yarn test:mediabunny及新增mediabunny-video.spec.js,按实际报告区分普通播放、
|
||||
真实帧竞态、能力对照和synthetic RAF。[变更](changes/2026-09-12-PKG-MB-05-video.md)与
|
||||
[验证](baselines/mb-video-validation.json)记录38个新断言、旧产物复现及真实解码场景。
|
||||
|
||||
## PKG-MB-06 音频归属与时钟
|
||||
|
||||
实际AudioEngine迁移并拆为六个TS模块,包内26生产TS,无JS声明桥;m3u8/index由07/08接续。
|
||||
[变更](changes/2026-09-12-PKG-MB-06-audio.md)记录文件地图、resume归属下移、兼容边界和回退,
|
||||
[证据](baselines/mb-audio-validation.json)记录旧main 37断言8通过/29失败、候选专项183及
|
||||
main/legacy各159通过。最终78浏览器含16新增原生音频场景及8新增WebKit能力对照。
|
||||
同24音频浏览器断言对旧main20通过/4失败,失败为实际暂停后的节点清理;其余为回归对照。
|
||||
短时1×/2×帧/音频时钟偏差验证不替代长播放、听觉及设备验收;全部报告/results已归档。
|
||||
完整CI1344项及另44重复契约通过,316生产TS;完成后立即专用提交,MB-LIFE-01剩余范围仍open。
|
||||
+3
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 222 项,范围 22 个包及工作区/示例。
|
||||
|
||||
状态:todo 113 / doing 7 / blocked 0 / done 102 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
状态:todo 112 / doing 7 / blocked 0 / done 103 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
|
||||
|
||||
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
|
||||
|
||||
@@ -334,7 +334,7 @@
|
||||
| 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 | done |
|
||||
| PKG-MB-05 | artplayer-proxy-mediabunny<br>整理视频解码、seek 和帧释放 | PKG-MB-04 | VideoEngine 调度、队列、晚帧处理和资源释放 | 暂停/seek/切源无旧帧覆盖,默认丢帧策略不变 | H | done |
|
||||
| PKG-MB-06 | artplayer-proxy-mediabunny<br>整理音频解码、时钟与同步 | PKG-MB-04 | AudioEngine 时钟、缓冲和 AudioContext 归属 | AV sync、倍速、无音轨、静音/音量和暂停恢复通过 | H | todo |
|
||||
| PKG-MB-06 | artplayer-proxy-mediabunny<br>整理音频解码、时钟与同步 | PKG-MB-04 | AudioEngine 时钟、缓冲和 AudioContext 归属 | AV sync、倍速、无音轨、静音/音量和暂停恢复通过 | H | done |
|
||||
| PKG-MB-07 | artplayer-proxy-mediabunny<br>整理 HLS 配对轨道与 selector | PKG-MB-05, PKG-MB-06, CORE-14 | m3u8 配对、质量/音频选择及拓扑清理 | 实际选择高亮、切到无轨道来源清理、无重复 readiness | H | todo |
|
||||
| PKG-MB-08 | artplayer-proxy-mediabunny<br>完成 TS 与媒体能力声明 | PKG-MB-07, ENG-04, ENG-06 | 8 个自有 JS 模块迁移与 Result/shim 类型 | 解码器/Stream/DOM 类型清楚,旧 Option 和 art.mediabunny 使用保持 | H | todo |
|
||||
| PKG-MB-09 | artplayer-proxy-mediabunny<br>完成新旧核心和真实媒体组合 | PKG-MB-08, CORE-22 | 跨浏览器能力、长播放、DPiP、HLS 音轨/质量报告 | 资源释放、事件顺序、AV sync 与支持范围满足基线 | H | todo |
|
||||
@@ -525,4 +525,5 @@
|
||||
- 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) [记录](changes/2026-09-12-PKG-MB-04-coordination.md) [记录](baselines/mb-coordination-validation.json)
|
||||
- PKG-MB-05: [记录](baselines/mb-video-source.json) [记录](changes/2026-09-12-PKG-MB-05-video.md) [记录](baselines/mb-video-validation.json)
|
||||
- PKG-MB-06: [记录](baselines/mb-audio-source.json) [记录](changes/2026-09-12-PKG-MB-06-audio.md) [记录](baselines/mb-audio-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)
|
||||
@@ -1,5 +1,18 @@
|
||||
# 进度与证据
|
||||
|
||||
## PKG-MB-06完成:音频解码、时钟与资源归属
|
||||
|
||||
AudioEngine实际迁移TS,拆清clock、context、nodes、pump、task职责,删除最后音频声明桥。
|
||||
暂停/切源/销毁取消迟到resume和buffer,seek/rate释放旧速节点;时钟、增益与无音轨回退
|
||||
保持契约,播放协调器的原生resume去重下移到context owner。包内26生产TS,剩m3u8/index
|
||||
由07/08接续。37音频断言旧main8通过/29失败,候选专项183、main/legacy各159通过。
|
||||
最终78浏览器通过:含16新增原生音频场景及8新增能力对照;旧main同24音频浏览器
|
||||
20通过/4节点清理失败。短时1×/2×音画取样最大偏差约39/38ms,长播/听觉/设备仍MB-09。
|
||||
完整CI1344项通过,另44重复契约;316生产TS。源码/产物/报告哈希及三格式复制一致。
|
||||
见[变更](changes/2026-09-12-PKG-MB-06-audio.md)与[证据](baselines/mb-audio-validation.json)。
|
||||
222项:103 done、7 doing、112 todo;立即专用本地提交,下一项MB-07 HLS UI/拓扑。
|
||||
MB-LIFE-01剩余长播放、全组合及HLS UI范围仍open;不构成npm发布验收。
|
||||
|
||||
## PKG-MB-05完成:视频迭代器、seek、晚帧与RAF
|
||||
|
||||
VideoEngine实际迁移TS,拆出frame读取/释放、renderer与poster职责,删除视频声明桥。
|
||||
|
||||
+4
-2
@@ -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. 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. MB-05 migrates VideoEngine and owns iterators, late first/second seek frames, pending reads, per-instance RAF and poster callbacks; same native race assertions fail on old main and pass on candidate. Independent CanvasSink pools also prevent multiple obsolete seek samples from overwriting queued current-frame pixels. Audio nodes, long-run AV sync and HLS UI topology remain MB-06/07/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. 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. MB-05 migrates VideoEngine and owns iterators, late first/second seek frames, pending reads, per-instance RAF and poster callbacks; same native race assertions fail on old main and pass on candidate. Independent CanvasSink pools also prevent multiple obsolete seek samples from overwriting queued current-frame pixels. Audio nodes, long-run AV sync and HLS UI topology remain MB-06/07/09. MB-06 migrates AudioEngine into six checked modules, owns context/gain/nodes/iterators and removable cancellation waits, preserves clock and gain formulas, and invalidates pending resume/decoding across pause/source/destroy. Same old main fails 29 of 37 audio assertions and four native pause-cleanup cases; candidate passes with short native 1x/2x AV-clock samples. Long-run AV sync, full combinations and HLS UI topology remain MB-07/09, so 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": [
|
||||
@@ -3664,7 +3664,9 @@
|
||||
"refactor/changes/2026-09-12-PKG-MB-04-coordination.md",
|
||||
"refactor/baselines/mb-coordination-validation.json",
|
||||
"refactor/changes/2026-09-12-PKG-MB-05-video.md",
|
||||
"refactor/baselines/mb-video-validation.json"
|
||||
"refactor/baselines/mb-video-validation.json",
|
||||
"refactor/changes/2026-09-12-PKG-MB-06-audio.md",
|
||||
"refactor/baselines/mb-audio-validation.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+6
-2
@@ -3502,11 +3502,15 @@
|
||||
"dependsOn": [
|
||||
"PKG-MB-04"
|
||||
],
|
||||
"status": "todo",
|
||||
"status": "done",
|
||||
"risk": "H",
|
||||
"deliverable": "AudioEngine 时钟、缓冲和 AudioContext 归属",
|
||||
"acceptance": "AV sync、倍速、无音轨、静音/音量和暂停恢复通过",
|
||||
"evidence": []
|
||||
"evidence": [
|
||||
"baselines/mb-audio-source.json",
|
||||
"changes/2026-09-12-PKG-MB-06-audio.md",
|
||||
"baselines/mb-audio-validation.json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "PKG-MB-07",
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import { hash } from '../../refactor/scripts/releases.mjs'
|
||||
import { mbCandidate } from '../helpers/mediabunny.js'
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
const implementation = await mbCandidate()
|
||||
const manifest = JSON.parse(fs.readFileSync(new URL('./media/hls/manifest.json', import.meta.url)))
|
||||
const hls = new Map(Object.entries(manifest.files).map(([name, expected]) => {
|
||||
const bytes = fs.readFileSync(new URL(`./media/hls/${name}`, import.meta.url))
|
||||
assert.equal(hash(bytes), expected.sha256)
|
||||
return [name, bytes]
|
||||
}))
|
||||
|
||||
for (const scenario of ['playback', 'buffer-pause', 'buffer-rate', 'resume-pause', 'resume-source', 'resume-destroy', 'av-clock', 'no-track']) {
|
||||
test(`MediaBunny ${implementation.name}: native audio ownership ${scenario}`, async ({ page, browserName }, testInfo) => {
|
||||
await page.route('**/mb-audio-hls/**', async (route) => {
|
||||
const name = new URL(route.request().url()).pathname.split('/').at(-1)
|
||||
const body = hls.get(name)
|
||||
await route.fulfill({ status: body ? 200 : 404, body: body || 'Missing fixture', contentType: name.endsWith('.m3u8') ? 'application/vnd.apple.mpegurl' : 'video/mp2t' })
|
||||
})
|
||||
await page.goto('/test/player.html?core=published')
|
||||
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports;
|
||||
${implementation.code}; window.mbFactory = module.exports.default || module.exports; })();` })
|
||||
const capabilities = await page.evaluate(() => ({ AudioContext: typeof AudioContext, webkitAudioContext: typeof window.webkitAudioContext, AudioDecoder: typeof AudioDecoder, VideoDecoder: typeof VideoDecoder }))
|
||||
let outcome = 'unverified'
|
||||
let result
|
||||
await page.evaluate(async (scenario) => {
|
||||
const listeners = new Map()
|
||||
window.mbEvents = []
|
||||
window.mbNodes = []
|
||||
window.mbClockSamples = []
|
||||
window.mbFlush = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
window.mbHost = {
|
||||
constructor: window.Artplayer,
|
||||
option: { url: '', autoSize: false },
|
||||
template: { $player: document.querySelector('.player') },
|
||||
on(name, fn) {
|
||||
const list = listeners.get(name) || []
|
||||
list.push(fn)
|
||||
listeners.set(name, list)
|
||||
},
|
||||
emit(name, ...args) {
|
||||
window.mbEvents.push(name)
|
||||
for (const fn of listeners.get(name) || []) fn(...args)
|
||||
},
|
||||
}
|
||||
const canvas = window.mbFactory()(window.mbHost)
|
||||
window.mbCanvas = canvas
|
||||
document.querySelector('.player').append(canvas)
|
||||
await canvas.engine.load(scenario === 'av-clock' ? '/mb-audio-hls/master.m3u8' : scenario === 'no-track' ? '/test/pattern.mp4' : '/test/audio-tone.m4a')
|
||||
const audio = canvas.engine.audio
|
||||
const context = audio.audioContext
|
||||
window.mbContext = context
|
||||
if (context) {
|
||||
const create = context.createBufferSource.bind(context)
|
||||
context.createBufferSource = () => {
|
||||
const node = create()
|
||||
const record = { node, stops: 0, disconnects: 0, starts: [] }
|
||||
const stop = node.stop.bind(node)
|
||||
const disconnect = node.disconnect.bind(node)
|
||||
const start = node.start.bind(node)
|
||||
node.stop = (...args) => {
|
||||
record.stops++
|
||||
return stop(...args)
|
||||
}
|
||||
node.disconnect = (...args) => {
|
||||
record.disconnects++
|
||||
return disconnect(...args)
|
||||
}
|
||||
node.start = (...args) => {
|
||||
record.starts.push({ args, rate: node.playbackRate.value, at: context.currentTime })
|
||||
return start(...args)
|
||||
}
|
||||
window.mbNodes.push(record)
|
||||
return node
|
||||
}
|
||||
}
|
||||
canvas.muted = true
|
||||
document.querySelector('#play').onclick = () => {
|
||||
window.mbPlay = canvas.play()
|
||||
}
|
||||
}, scenario)
|
||||
try {
|
||||
if (capabilities.AudioContext === 'undefined' && capabilities.webkitAudioContext === 'undefined') {
|
||||
expect(browserName).toBe('webkit')
|
||||
expect(capabilities.AudioDecoder).toBe('undefined')
|
||||
expect(await page.evaluate(() => window.mbCanvas.error?.code)).toBe(4)
|
||||
expect(await page.evaluate(() => window.mbCanvas.readyState)).toBeLessThan(4)
|
||||
outcome = 'unsupported-capability-control'
|
||||
return
|
||||
}
|
||||
expect(await page.evaluate(() => window.mbCanvas.error)).toBeNull()
|
||||
expect(await page.evaluate(() => window.mbCanvas.readyState)).toBe(4)
|
||||
if (scenario.startsWith('resume-')) {
|
||||
await page.evaluate(async () => {
|
||||
const context = window.mbContext
|
||||
await context.suspend()
|
||||
const resume = context.resume.bind(context)
|
||||
context.resume = () => {
|
||||
const native = resume()
|
||||
window.mbResumeCalled = true
|
||||
return native.then(() => new Promise((resolve) => {
|
||||
window.mbReleaseResume = resolve
|
||||
}))
|
||||
}
|
||||
})
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => typeof window.mbReleaseResume)).toBe('function')
|
||||
result = await page.evaluate(async (scenario) => {
|
||||
if (scenario === 'resume-pause')
|
||||
window.mbCanvas.pause()
|
||||
else if (scenario === 'resume-source')
|
||||
await window.mbCanvas.engine.load('/test/pattern.mp4')
|
||||
else window.mbHost.emit('destroy')
|
||||
window.mbReleaseResume()
|
||||
await window.mbPlay
|
||||
await window.mbFlush()
|
||||
const audio = window.mbCanvas.engine.audio
|
||||
return { paused: audio.paused, queued: audio.queuedNodes.size, created: window.mbNodes.length, iterator: Boolean(audio.audioIterator), audioSink: Boolean(audio.audioSink) }
|
||||
}, scenario)
|
||||
expect(result).toMatchObject({ paused: true, queued: 0, created: 0, iterator: false })
|
||||
if (scenario !== 'resume-pause')
|
||||
expect(result.audioSink).toBe(false)
|
||||
}
|
||||
else if (scenario.startsWith('buffer-')) {
|
||||
await page.evaluate(() => {
|
||||
const sink = window.mbCanvas.engine.audio.audioSink
|
||||
const buffers = sink.buffers.bind(sink)
|
||||
let first = true
|
||||
sink.buffers = (...args) => {
|
||||
const iterator = buffers(...args)
|
||||
const next = iterator.next.bind(iterator)
|
||||
iterator.next = async (...args) => {
|
||||
const value = await next(...args)
|
||||
if (first && !value.done) {
|
||||
first = false
|
||||
window.mbHeldBuffer = value.value.buffer
|
||||
return new Promise((resolve) => {
|
||||
window.mbReleaseBuffer = () => resolve(value)
|
||||
})
|
||||
}
|
||||
return value
|
||||
}
|
||||
return iterator
|
||||
}
|
||||
})
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => typeof window.mbReleaseBuffer)).toBe('function')
|
||||
await page.evaluate((scenario) => {
|
||||
if (scenario === 'buffer-pause')
|
||||
window.mbCanvas.pause()
|
||||
else window.mbCanvas.playbackRate = 2
|
||||
window.mbReleaseBuffer()
|
||||
}, scenario)
|
||||
if (scenario === 'buffer-rate')
|
||||
await expect.poll(() => page.evaluate(() => window.mbNodes.length)).toBeGreaterThan(0)
|
||||
result = await page.evaluate(async () => {
|
||||
await window.mbFlush()
|
||||
const audio = window.mbCanvas.engine.audio
|
||||
return { obsoleteScheduled: window.mbNodes.some(record => record.node.buffer === window.mbHeldBuffer), rates: window.mbNodes.flatMap(record => record.starts.map(start => start.rate)), queued: audio.queuedNodes.size, paused: audio.paused }
|
||||
})
|
||||
expect(result.obsoleteScheduled).toBe(false)
|
||||
if (scenario === 'buffer-pause')
|
||||
expect(result).toMatchObject({ queued: 0, paused: true })
|
||||
else expect(result.rates.every(rate => rate === 2)).toBe(true)
|
||||
}
|
||||
else {
|
||||
if (scenario === 'av-clock') {
|
||||
await page.evaluate(async () => {
|
||||
const video = window.mbCanvas.engine.video
|
||||
const times = new WeakMap()
|
||||
const prototype = Object.getPrototypeOf(video.videoSink)
|
||||
const canvases = prototype.canvases
|
||||
prototype.canvases = function (...args) {
|
||||
const iterator = canvases.apply(this, args)
|
||||
const next = iterator.next.bind(iterator)
|
||||
iterator.next = async (...values) => {
|
||||
const result = await next(...values)
|
||||
if (!result.done)
|
||||
times.set(result.value.canvas, result.value.timestamp)
|
||||
return result
|
||||
}
|
||||
return iterator
|
||||
}
|
||||
const draw = video.ctx.drawImage.bind(video.ctx)
|
||||
video.ctx.drawImage = (...args) => {
|
||||
if (times.has(args[0]))
|
||||
window.mbClockSamples.push({ frame: times.get(args[0]), audio: window.mbCanvas.engine.audio.currentTime, rate: window.mbCanvas.playbackRate, paused: window.mbCanvas.paused })
|
||||
return draw(...args)
|
||||
}
|
||||
await window.mbCanvas.engine.seek(1)
|
||||
})
|
||||
}
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.mbCanvas.currentTime)).toBeGreaterThan(scenario === 'av-clock' ? 1.3 : 0.3)
|
||||
if (scenario !== 'no-track')
|
||||
await expect.poll(() => page.evaluate(() => window.mbCanvas.engine.audio.queuedNodes.size)).toBeGreaterThan(0)
|
||||
const pause = await page.evaluate(async () => {
|
||||
window.mbCanvas.pause()
|
||||
const time = window.mbCanvas.currentTime
|
||||
await window.mbFlush()
|
||||
return { time, after: window.mbCanvas.currentTime, queued: window.mbCanvas.engine.audio.queuedNodes.size, retained: window.mbNodes.filter(record => !record.disconnects || record.node.onended !== null).length }
|
||||
})
|
||||
expect(pause.after).toBe(pause.time)
|
||||
expect(pause).toMatchObject({ queued: 0, retained: 0 })
|
||||
await page.evaluate(() => {
|
||||
window.mbCanvas.volume = 0.5
|
||||
window.mbCanvas.muted = false
|
||||
})
|
||||
expect(await page.evaluate(() => window.mbCanvas.engine.audio.gainNode.gain.value)).toBeCloseTo(0.25, 6)
|
||||
await page.evaluate(() => {
|
||||
window.mbCanvas.muted = true
|
||||
window.mbCanvas.playbackRate = 2
|
||||
})
|
||||
expect(await page.evaluate(() => window.mbCanvas.engine.audio.gainNode.gain.value)).toBe(0)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.mbCanvas.currentTime)).toBeGreaterThan(pause.time + 0.35)
|
||||
result = await page.evaluate(() => {
|
||||
const audio = window.mbCanvas.engine.audio
|
||||
const clock = audio.currentTime
|
||||
const elapsed = audio.audioContext.currentTime - audio.audioContextStartTime
|
||||
const nodes = [...audio.queuedNodes]
|
||||
return { clock, rate: audio.playbackRate, anchor: audio.playbackTimeAtStart, elapsed, nodeRates: nodes.map(node => node.playbackRate.value), hasSink: Boolean(audio.audioSink), samples: window.mbClockSamples, pause: window.mbCanvas.paused }
|
||||
})
|
||||
expect(Math.abs(result.clock - (result.anchor + result.elapsed * 2))).toBeLessThan(0.03)
|
||||
expect(result).toMatchObject({ rate: 2, pause: false, hasSink: scenario !== 'no-track' })
|
||||
if (scenario === 'no-track') {
|
||||
expect(result.nodeRates).toEqual([])
|
||||
}
|
||||
else {
|
||||
expect(result.nodeRates.length).toBeGreaterThan(0)
|
||||
expect(result.nodeRates.every(rate => rate === 2)).toBe(true)
|
||||
}
|
||||
if (scenario === 'av-clock') {
|
||||
const playing = result.samples.filter(sample => !sample.paused)
|
||||
expect(playing.filter(sample => sample.rate === 1).length).toBeGreaterThan(2)
|
||||
expect(playing.filter(sample => sample.rate === 2).length).toBeGreaterThan(2)
|
||||
expect(Math.max(...playing.map(sample => Math.abs(sample.frame - sample.audio)))).toBeLessThan(0.25)
|
||||
}
|
||||
}
|
||||
outcome = 'native-audio-ownership-observed'
|
||||
}
|
||||
finally {
|
||||
await page.evaluate(() => window.mbHost.emit('destroy'))
|
||||
if (capabilities.AudioContext !== 'undefined' || capabilities.webkitAudioContext !== 'undefined') {
|
||||
await expect.poll(() => page.evaluate(() => window.mbContext.state)).toBe('closed')
|
||||
expect(await page.evaluate(() => window.mbCanvas.engine.audio.queuedNodes.size)).toBe(0)
|
||||
}
|
||||
const state = await page.evaluate(() => ({ context: window.mbContext?.state, nodes: window.mbNodes.map(record => ({ stops: record.stops, disconnects: record.disconnects, starts: record.starts })), error: window.mbCanvas.error, events: window.mbEvents }))
|
||||
await testInfo.attach('mediabunny-audio', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), scenario, capabilities, outcome, result, state, scope: 'Native AudioContext, BufferSourceNodes and SDK decode. Delayed resume/buffer delivery is controlled. AV samples compare decoded canvas timestamps to the audio clock over short 1x/2x playback; acoustic output, long-run drift and physical devices remain MB-09.' }) })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,642 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Actual proxy engine with controlled Web Audio and SDK pump interleavings.
|
||||
import test from 'node:test'
|
||||
import { mbCandidate, mbEnvironment } from './helpers/mediabunny.js'
|
||||
|
||||
const implementation = await mbCandidate()
|
||||
const flush = () => new Promise(resolve => setImmediate(resolve))
|
||||
function deferred() {
|
||||
let resolve, reject
|
||||
const promise = new Promise((yes, no) => {
|
||||
resolve = yes
|
||||
reject = no
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
const item = value => ({ value, done: !value })
|
||||
const buffer = (timestamp = 0, duration = 0.1) => ({ timestamp, buffer: { duration } })
|
||||
function iterator(values = []) {
|
||||
let reads = 0
|
||||
let returns = 0
|
||||
return {
|
||||
async next() {
|
||||
reads++
|
||||
return item(values.shift())
|
||||
},
|
||||
async return() {
|
||||
returns++
|
||||
return item()
|
||||
},
|
||||
get reads() { return reads },
|
||||
get returns() { return returns },
|
||||
}
|
||||
}
|
||||
function environment(options = {}) {
|
||||
const env = mbEnvironment(implementation, options)
|
||||
const contexts = []
|
||||
const nodes = []
|
||||
const warnings = []
|
||||
env.globals.console = { ...console, error() {}, warn: (...args) => warnings.push(args) }
|
||||
env.eventNames.push('canplay', 'playing', 'ended')
|
||||
env.globals.window.AudioContext = class {
|
||||
constructor(options) {
|
||||
this.options = options
|
||||
this.currentTime = 10
|
||||
this.state = 'running'
|
||||
this.closed = 0
|
||||
this.resumes = 0
|
||||
this.destination = {}
|
||||
contexts.push(this)
|
||||
}
|
||||
|
||||
createGain() {
|
||||
this.gain = {
|
||||
gain: { value: 0 },
|
||||
connect() {},
|
||||
disconnect() { this.disconnected = true },
|
||||
}
|
||||
return this.gain
|
||||
}
|
||||
|
||||
createBufferSource() {
|
||||
const node = {
|
||||
playbackRate: { value: 1 },
|
||||
starts: [],
|
||||
stops: 0,
|
||||
disconnects: 0,
|
||||
onended: null,
|
||||
connect() {},
|
||||
start(...args) { this.starts.push(args) },
|
||||
stop() { this.stops++ },
|
||||
disconnect() { this.disconnects++ },
|
||||
}
|
||||
nodes.push(node)
|
||||
return node
|
||||
}
|
||||
|
||||
async resume() {
|
||||
this.resumes++
|
||||
this.state = 'running'
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.closed++
|
||||
this.state = 'closed'
|
||||
}
|
||||
}
|
||||
const canvas = env.factory()(env.art)
|
||||
return { ...env, canvas, audio: canvas.engine.audio, contexts, nodes, warnings }
|
||||
}
|
||||
function withContext() {
|
||||
const env = environment()
|
||||
env.audio.ensureAudioContext()
|
||||
return { ...env, context: env.contexts[0] }
|
||||
}
|
||||
|
||||
test('MediaBunny stale audio pump cannot cancel the current pending read', async () => {
|
||||
const env = withContext()
|
||||
const read = deferred()
|
||||
let returns = 0
|
||||
const frames = { next: () => read.promise, return: async () => {
|
||||
returns++
|
||||
return item()
|
||||
} }
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers: () => frames }
|
||||
const pumping = env.audio.runIterator(env.audio.asyncId)
|
||||
const obsolete = env.audio.runIterator(env.audio.asyncId - 1)
|
||||
try {
|
||||
await flush()
|
||||
assert.equal(returns, 0)
|
||||
assert.equal(env.audio.audioIterator, frames)
|
||||
}
|
||||
finally {
|
||||
env.audio.pause()
|
||||
read.resolve(item())
|
||||
await Promise.all([pumping, obsolete])
|
||||
env.art.emit('destroy')
|
||||
}
|
||||
})
|
||||
|
||||
test('MediaBunny failed audio iterator construction remains retryable', async () => {
|
||||
const env = withContext()
|
||||
const failure = new Error('sink failed')
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers() {
|
||||
throw failure
|
||||
} }
|
||||
await assert.rejects(env.audio.runIterator(env.audio.asyncId), error => error === failure)
|
||||
assert.equal(env.audio.audioIterator, null)
|
||||
env.audio.audioSink = { buffers: () => iterator() }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny source cancellation clears sink and input after node cleanup failure', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.input = {}
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
const failure = new Error('stop failed')
|
||||
env.nodes[0].stop = () => {
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => env.audio.cancelPending(), error => error === failure)
|
||||
assert.equal(env.audio.input, null)
|
||||
assert.equal(env.audio.audioSink, null)
|
||||
assert.equal(env.audio.queuedNodes.size, 0)
|
||||
assert.equal(env.nodes[0].disconnects, 1)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny gain setup failure releases context without installing partial references', async () => {
|
||||
const env = environment()
|
||||
const Context = env.globals.window.AudioContext
|
||||
const failure = new Error('gain failed')
|
||||
env.globals.window.AudioContext = class extends Context {
|
||||
createGain() {
|
||||
const gain = super.createGain()
|
||||
Object.defineProperty(gain.gain, 'value', {
|
||||
get() { return 0 },
|
||||
set() { throw failure },
|
||||
})
|
||||
return gain
|
||||
}
|
||||
}
|
||||
assert.throws(() => env.audio.ensureAudioContext(), error => error === failure)
|
||||
assert.equal(env.contexts[0].closed, 1)
|
||||
assert.equal(env.contexts[0].gain.disconnected, true)
|
||||
assert.equal(env.audio.audioContext, null)
|
||||
assert.equal(env.audio.gainNode, null)
|
||||
env.globals.window.AudioContext = Context
|
||||
env.audio.ensureAudioContext()
|
||||
assert.equal(env.audio.audioContext, env.contexts[1])
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny context setup cannot resurrect audio after synchronous destroy', () => {
|
||||
const env = environment()
|
||||
const Context = env.globals.window.AudioContext
|
||||
env.globals.window.AudioContext = class extends Context {
|
||||
createGain() {
|
||||
const gain = super.createGain()
|
||||
gain.connect = () => env.audio.destroy()
|
||||
return gain
|
||||
}
|
||||
}
|
||||
env.audio.ensureAudioContext()
|
||||
assert.equal(env.audio.audioContext, null)
|
||||
assert.equal(env.audio.gainNode, null)
|
||||
assert.equal(env.contexts[0].closed, 1)
|
||||
assert.equal(env.contexts[0].gain.disconnected, true)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny ended node disconnect failure is observed after removing retained references', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
const failure = new Error('disconnect failed')
|
||||
env.nodes[0].disconnect = () => {
|
||||
throw failure
|
||||
}
|
||||
env.nodes[0].onended()
|
||||
assert.equal(env.audio.queuedNodes.size, 0)
|
||||
assert.equal(env.nodes[0].onended, null)
|
||||
assert.equal(env.warnings[0][1], failure)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny audio preserves clock, gain square law, mute and paused position', async () => {
|
||||
const env = withContext()
|
||||
env.audio.setVolume(0.5, false)
|
||||
assert.equal(env.context.gain.gain.value, 0.25)
|
||||
env.audio.setVolume(0.5, true)
|
||||
assert.equal(env.context.gain.gain.value, 0)
|
||||
await env.audio.seek(3)
|
||||
await env.audio.play()
|
||||
env.context.currentTime = 12
|
||||
assert.equal(env.audio.currentTime, 5)
|
||||
env.audio.pause()
|
||||
env.context.currentTime = 20
|
||||
assert.equal(env.audio.currentTime, 5)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny audio preserves current media time during a playing rate change', async () => {
|
||||
const env = withContext()
|
||||
await env.audio.seek(3)
|
||||
await env.audio.play()
|
||||
env.context.currentTime = 12
|
||||
env.audio.setPlaybackRate(2)
|
||||
assert.equal(env.audio.currentTime, 5)
|
||||
env.context.currentTime = 13
|
||||
assert.equal(env.audio.currentTime, 7)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny audio preserves future and partial late buffer scheduling math', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioContextStartTime = 10
|
||||
env.audio.playbackTimeAtStart = 2
|
||||
env.audio.playbackRate = 2
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(1.8, 0.4), buffer(2.4, 0.4)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
assert.deepEqual(env.nodes.map(node => node.starts[0]), [[10, 0.1999999999999993], [10.2]])
|
||||
assert.deepEqual(env.nodes.map(node => node.playbackRate.value), [2, 2])
|
||||
assert(Math.abs(env.audio.latestScheduledEndTime - 2.8) < 0.000001)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny audio detaches iterator synchronously and releases it once', async () => {
|
||||
const env = environment()
|
||||
const release = deferred()
|
||||
let returns = 0
|
||||
env.audio.audioIterator = { return: () => {
|
||||
returns++
|
||||
return release.promise
|
||||
} }
|
||||
const first = env.audio.stopIterator()
|
||||
const detached = env.audio.audioIterator
|
||||
const second = env.audio.stopIterator()
|
||||
release.resolve(item())
|
||||
await Promise.all([first, second])
|
||||
assert.equal(detached, null)
|
||||
assert.equal(returns, 1)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny old audio iterator disposal cannot erase a current iterator', async () => {
|
||||
const env = environment()
|
||||
const release = deferred()
|
||||
env.audio.audioIterator = { return: () => release.promise }
|
||||
const closing = env.audio.stopIterator()
|
||||
const current = iterator()
|
||||
env.audio.audioIterator = current
|
||||
release.resolve(item())
|
||||
await closing
|
||||
assert.equal(env.audio.audioIterator, current)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
for (const ending of ['pause', 'destroy', 'source']) {
|
||||
test(`MediaBunny pending audio resume cannot restart after ${ending}`, async () => {
|
||||
const env = withContext()
|
||||
env.context.state = 'suspended'
|
||||
const resume = deferred()
|
||||
env.context.resume = () => resume.promise
|
||||
const playing = env.audio.play()
|
||||
if (ending === 'pause') {
|
||||
env.audio.pause()
|
||||
}
|
||||
else if (ending === 'destroy') {
|
||||
env.art.emit('destroy')
|
||||
}
|
||||
else {
|
||||
env.canvas.engine.performLoad = async () => {}
|
||||
await env.canvas.engine.load('next.mp4')
|
||||
}
|
||||
resume.resolve()
|
||||
await playing
|
||||
assert.equal(env.audio.paused, true)
|
||||
assert.equal(env.nodes.length, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
for (const ending of ['destroy', 'source']) {
|
||||
test(`MediaBunny stale audio capability cannot recreate context or metadata after ${ending}`, async () => {
|
||||
const env = environment()
|
||||
const capability = deferred()
|
||||
let metadata = 0
|
||||
const loading = env.audio.load({ input: {}, audioTrack: { codec: 'aac', canDecode: () => capability.promise }, duration: 8 }, () => metadata++)
|
||||
await flush()
|
||||
if (ending === 'destroy') {
|
||||
env.art.emit('destroy')
|
||||
}
|
||||
else {
|
||||
env.canvas.engine.performLoad = async () => {}
|
||||
await env.canvas.engine.load('next.mp4')
|
||||
}
|
||||
capability.resolve(false)
|
||||
await loading
|
||||
assert.equal(metadata, 0)
|
||||
assert.equal(env.contexts.length, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
test('MediaBunny audio nodes disconnect and clear ended listeners after pause', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(11), buffer(12)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
assert.equal(env.nodes.length, 2)
|
||||
env.audio.pause()
|
||||
assert.deepEqual(env.nodes.map(node => [node.stops, node.disconnects, node.onended]), [[1, 1, null], [1, 1, null]])
|
||||
assert.equal(env.audio.queuedNodes.size, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny naturally ended audio node disconnects and releases its callback', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
const node = env.nodes[0]
|
||||
node.onended()
|
||||
assert.equal(node.disconnects, 1)
|
||||
assert.equal(node.onended, null)
|
||||
assert.equal(env.audio.queuedNodes.size, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny audio cleanup attempts all nodes when one stop throws', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(11), buffer(12)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
const failure = new Error('stop failed')
|
||||
env.nodes[0].stop = () => {
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => env.audio.stopQueuedNodes(), error => error === failure)
|
||||
assert.equal(env.nodes[1].stops, 1)
|
||||
assert.equal(env.nodes[0].disconnects, 1)
|
||||
assert.equal(env.audio.queuedNodes.size, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
for (const ending of ['pause', 'destroy', 'rate']) {
|
||||
test(`MediaBunny audio pump cannot schedule a batch after pending resume and ${ending}`, async () => {
|
||||
const env = withContext()
|
||||
env.context.state = 'suspended'
|
||||
env.audio.paused = false
|
||||
env.audio.audioContextStartTime = 10
|
||||
const resume = deferred()
|
||||
env.context.resume = () => resume.promise
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(1)]) }
|
||||
const pumping = env.audio.runIterator(env.audio.asyncId)
|
||||
await flush()
|
||||
if (ending === 'pause') {
|
||||
env.audio.pause()
|
||||
}
|
||||
else if (ending === 'destroy') {
|
||||
env.art.emit('destroy')
|
||||
}
|
||||
else {
|
||||
env.audio.audioSink = null
|
||||
env.audio.setPlaybackRate(2)
|
||||
}
|
||||
env.emitted.length = 0
|
||||
resume.resolve()
|
||||
await pumping
|
||||
assert.equal(env.nodes.length, 0)
|
||||
assert.equal(env.emitted.some(event => ['video:canplay', 'video:playing'].includes(event.name)), false)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
}
|
||||
|
||||
test('MediaBunny rate change cancels already scheduled old-rate audio nodes', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
const old = env.nodes[0]
|
||||
env.audio.audioSink = null
|
||||
env.audio.setPlaybackRate(2)
|
||||
assert.equal(old.stops, 1)
|
||||
assert.equal(old.disconnects, 1)
|
||||
assert.equal(env.audio.queuedNodes.size, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny seeking before first context exists preserves paused position without throwing', async () => {
|
||||
const env = environment()
|
||||
await env.audio.seek(3)
|
||||
assert.equal(env.audio.currentTime, 3)
|
||||
assert.equal(env.audio.paused, true)
|
||||
assert.equal(env.contexts.length, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny video ended stops the shared audio clock and scheduled nodes', async () => {
|
||||
const env = withContext()
|
||||
await env.audio.play()
|
||||
env.canvas.engine.paused = false
|
||||
env.context.currentTime = 12
|
||||
env.canvas.events.emit('ended')
|
||||
const stoppedAt = env.audio.currentTime
|
||||
env.context.currentTime = 14
|
||||
assert.equal(env.audio.paused, true)
|
||||
assert.equal(env.audio.currentTime, stoppedAt)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny audio destruction disconnects gain and observes context close', async () => {
|
||||
const env = withContext()
|
||||
const gain = env.context.gain
|
||||
env.art.emit('destroy')
|
||||
await flush()
|
||||
assert.equal(gain.disconnected, true)
|
||||
assert.equal(env.audio.gainNode, null)
|
||||
assert.equal(env.context.closed, 1)
|
||||
assert.equal(env.audio.audioContext, null)
|
||||
env.art.emit('destroy')
|
||||
assert.equal(env.context.closed, 1)
|
||||
})
|
||||
|
||||
test('MediaBunny actual audio owner shares native resume across coordinator play-pause-play', async () => {
|
||||
const env = withContext()
|
||||
const pending = deferred()
|
||||
let resumes = 0
|
||||
env.context.state = 'suspended'
|
||||
env.context.resume = () => {
|
||||
resumes++
|
||||
return pending.promise
|
||||
}
|
||||
let starts = 0
|
||||
env.canvas.engine.video.start = () => starts++
|
||||
const first = env.canvas.play()
|
||||
env.canvas.pause()
|
||||
const second = env.canvas.play()
|
||||
pending.resolve()
|
||||
await Promise.all([first, second])
|
||||
assert.equal(resumes, 1)
|
||||
assert.equal(starts, 1)
|
||||
assert.equal(env.audio.paused, false)
|
||||
assert.equal(env.canvas.paused, false)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny canceled audio play settles without waiting for native resume and observes its rejection', async () => {
|
||||
const env = withContext()
|
||||
const pending = deferred()
|
||||
env.context.state = 'suspended'
|
||||
env.context.resume = () => pending.promise
|
||||
let settled = false
|
||||
const playing = env.audio.play().then(() => settled = true)
|
||||
env.audio.pause()
|
||||
await flush()
|
||||
assert.equal(settled, true)
|
||||
pending.reject(new Error('obsolete resume rejection'))
|
||||
await playing
|
||||
await flush()
|
||||
assert.equal(env.audio.paused, true)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny active resume rejection preserves the error and allows another play', async () => {
|
||||
const env = withContext()
|
||||
const failure = new Error('resume denied')
|
||||
env.context.state = 'suspended'
|
||||
env.context.resume = async () => {
|
||||
throw failure
|
||||
}
|
||||
await assert.rejects(env.audio.play(), error => error === failure)
|
||||
assert.equal(env.audio.paused, true)
|
||||
env.context.resume = async () => {}
|
||||
await env.audio.play()
|
||||
assert.equal(env.audio.paused, false)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny audio preserves no-track metadata and fallback context construction', async () => {
|
||||
const env = environment()
|
||||
const Context = env.globals.window.AudioContext
|
||||
const attempts = []
|
||||
env.globals.window.AudioContext = class extends Context {
|
||||
constructor(option) {
|
||||
attempts.push(option)
|
||||
if (option)
|
||||
throw new Error('sample rate not supported')
|
||||
super()
|
||||
}
|
||||
}
|
||||
env.audio.ensureAudioContext(96000)
|
||||
assert.deepEqual(attempts.map(option => option?.sampleRate), [96000, undefined])
|
||||
let metadata = 0
|
||||
await env.audio.load({ input: {}, audioTrack: null, duration: 8 }, () => metadata++)
|
||||
assert.equal(metadata, 1)
|
||||
assert.equal(env.audio.duration, 8)
|
||||
assert.equal(env.contexts.length, 1)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny expired buffers do not create silent retained source nodes', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioContextStartTime = 0
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(1, 0.1)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
assert.equal(env.nodes.length, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny audio seek stops old scheduled nodes even when called directly while playing', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
const old = env.nodes[0]
|
||||
env.audio.audioSink = null
|
||||
await env.audio.seek(3)
|
||||
assert.equal(old.stops, 1)
|
||||
assert.equal(old.disconnects, 1)
|
||||
assert.equal(env.audio.currentTime, 3)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny background audio decode error stops playback and reports one media error', async () => {
|
||||
const env = withContext()
|
||||
const failure = new Error('decoder failed')
|
||||
env.audio.audioSink = { buffers: () => ({ next: async () => {
|
||||
throw failure
|
||||
}, return: async () => item() }) }
|
||||
await env.canvas.play()
|
||||
await flush()
|
||||
assert.deepEqual({ ...env.canvas.error }, { code: 4, message: 'decoder failed' })
|
||||
assert.equal(env.canvas.paused, true)
|
||||
assert.equal(env.audio.paused, true)
|
||||
assert.equal(env.emitted.filter(event => event.name === 'video:error').length, 1)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny decoder error is not replaced by iterator release failure', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
const failure = new Error('decoder failed')
|
||||
env.audio.audioSink = { buffers: () => ({ next: async () => {
|
||||
throw failure
|
||||
}, return: async () => {
|
||||
throw new Error('cleanup failed')
|
||||
} }) }
|
||||
await assert.rejects(env.audio.runIterator(env.audio.asyncId), error => error === failure)
|
||||
await flush()
|
||||
assert.equal(env.warnings.length, 1)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny destroy attempts context cleanup after a node stop error and preserves the first failure', async () => {
|
||||
const env = withContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioSink = { buffers: () => iterator([buffer(11)]) }
|
||||
await env.audio.runIterator(env.audio.asyncId)
|
||||
const failure = new Error('node failed')
|
||||
env.nodes[0].stop = () => {
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => env.audio.destroy(), error => error === failure)
|
||||
assert.equal(env.context.closed, 1)
|
||||
assert.equal(env.context.gain.disconnected, true)
|
||||
assert.equal(env.audio.audioContext, null)
|
||||
assert.equal(env.audio.audioIterator, null)
|
||||
assert.equal(env.audio.queuedNodes.size, 0)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny destroyed audio context close rejection remains observed', async () => {
|
||||
const env = withContext()
|
||||
const failure = new Error('close failed')
|
||||
env.context.close = async () => {
|
||||
throw failure
|
||||
}
|
||||
env.art.emit('destroy')
|
||||
await flush()
|
||||
assert.equal(env.warnings.length, 1)
|
||||
assert.equal(env.warnings[0][1], failure)
|
||||
assert.equal(env.audio.audioContext, null)
|
||||
})
|
||||
|
||||
test('MediaBunny paused audio pump clears owned batch and backpressure timers', async () => {
|
||||
const timers = new Map()
|
||||
let next = 0
|
||||
const env = environment({ setTimeout(callback, delay) {
|
||||
const id = next++
|
||||
timers.set(id, { callback, delay })
|
||||
return id
|
||||
}, clearTimeout(id) { timers.delete(id) } })
|
||||
env.audio.ensureAudioContext()
|
||||
env.audio.paused = false
|
||||
env.audio.audioContextStartTime = 10
|
||||
const frames = iterator(Array.from({ length: 32 }, (_, index) => buffer(index / 10)))
|
||||
env.audio.audioSink = { buffers: () => frames }
|
||||
const pumping = env.audio.runIterator(env.audio.asyncId)
|
||||
await flush()
|
||||
assert.equal(frames.reads, 16)
|
||||
assert.deepEqual([...timers.values()].map(timer => timer.delay), [0])
|
||||
const [id, timer] = [...timers][0]
|
||||
timers.delete(id)
|
||||
timer.callback()
|
||||
await flush()
|
||||
assert.deepEqual([...timers.values()].map(timer => timer.delay), [50])
|
||||
env.audio.pause()
|
||||
assert.equal(timers.size, 0)
|
||||
await pumping
|
||||
assert.equal(frames.returns, 1)
|
||||
assert.equal(env.nodes.every(node => node.disconnects === 1), true)
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
@@ -94,7 +94,7 @@ test('MediaBunny coordinator restores paused state after a play rejection and al
|
||||
env.art.emit('destroy')
|
||||
})
|
||||
|
||||
test('MediaBunny coordinator shares delayed audio resume across play-pause-play and starts video once', async () => {
|
||||
test('MediaBunny coordinator delegates each new play intent to the audio owner and starts video once', async () => {
|
||||
const env = environment()
|
||||
const audio = deferred()
|
||||
let attempts = 0
|
||||
@@ -107,7 +107,7 @@ test('MediaBunny coordinator shares delayed audio resume across play-pause-play
|
||||
const second = env.engine.play()
|
||||
audio.resolve()
|
||||
await Promise.all([first, second])
|
||||
assert.equal(attempts, 1)
|
||||
assert.equal(attempts, 2)
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user