refactor(mediabunny): [PKG-MB-07] own HLS selection intent and menu lifecycle

This commit is contained in:
Harvey Zhao committed 2026-09-12 20:51:31 +08:00
1 parent ccee9157a7
commit 8a2b56e829
27 files changed
+4217 -680

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,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
!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),L=(e,t,i)=>(M(e,t,"access private method"),i),z=(e,t,i,r)=>({set _(r){O(e,t,r,i)},get _(){return D(e,t,r)}});function U(e){const t=[];for(const r of e)try{r()}catch(i){t.push(i)}if(t.length)throw t[0]}function q(e,t,i){const r={control:!1,setting:!1};let a=null;function s(i){r[i]&&(r[i]=!1,"control"===i?e.controls.cache.has(t)&&e.controls.remove(t):e.setting.find(t)&&e.setting.remove(t))}function n(){a=null,U([()=>s("control"),()=>s("setting")])}return{update:function(o,c,l,u){if(!o)return void n();a=o;const h=()=>a===o&&l(),d=async e=>h()&&o.selector.some(t=>t.value===e.value)?u(e,o.title):e.html;c.control?(r.control=!0,e.controls.update({name:t,position:"right",html:o.html,style:{padding:"0 10px"},selector:o.selector,onSelect:d})):s("control"),h()&&(c.setting?(r.setting=!0,e.setting.update({name:t,tooltip:o.html,html:o.title,icon:i,width:200,selector:o.selector,onSelect:d})):s("setting"))},clear:n}}function W(e){const t=new Map,i=[];for(const r of e){const e=t.get(r.html);void 0!==r.html&&void 0!==e?r.default&&(i[e]=r):(t.set(r.html,i.length),i.push(r))}return i}function H({art:e,shim:t,option:i}){if(!i.m3u8)return;let r=!1,a=0,s=0;const n=q(e,"mediabunny-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>'),o=q(e,"mediabunny-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>'),c=e=>!r&&!t.engine.destroyed&&t.engine.loadSeq===e;async function l(){if(r||t.engine.destroyed)return;const e=++a,s=t.engine.loadSeq,l=()=>e===a&&c(s);try{const e=await t.getM3u8State();if(!l())return;const r=i.m3u8?.quality||{},a=function(e,t,i){if(!t.control&&!t.setting||!e?.levels.length)return null;const r=t.auto||"Auto",a=t.getName||(e=>e.name||`${e.height}P`),s=e.currentLevel?a(e.currentLevel):r,n=[];for(const l of e.levels){if(!i())return null;n.push({html:a(l),value:l.id,default:e.currentLevel?.id===l.id})}const o=new Map(e.levels.map(e=>[e.id,e.height])),c=W(n).sort((e,t)=>("auto"===t.value?0:o.get(t.value)||0)-("auto"===e.value?0:o.get(e.value)||0));return c.push({html:r,value:"auto",default:!e.currentLevel}),{title:t.title||"Quality",html:s,selector:c}}(e,r,l);if(!l())return;if(n.update(a,r,()=>c(s),(e,t)=>u("quality",e,t)),!l())return;const h=i.m3u8?.audio||{},d=function(e,t,i){if(!t.control&&!t.setting||!e||e.audios.length<2)return null;const r=t.auto||"Auto",a=t.getName||(e=>e.name||e.lang||e.language),s=e.currentAudio?a(e.currentAudio):r,n=[];for(const c of e.audios){if(!i())return null;n.push({html:a(c),value:c.id,default:e.currentAudio?.id===c.id})}const o=W(n);return o.push({html:r,value:"auto",default:!e.currentAudio}),{title:t.title||"Audio",html:s,selector:o}}(e,h,l);l()&&o.update(d,h,()=>c(s),(e,t)=>u("audio",e,t))}catch(h){if(l())throw h}}async function u(i,r,n){const o=++s,u=t.engine.loadSeq;a++;const d=()=>o===s&&c(u);try{"quality"===i?await t.switchM3u8Quality(r.value):await t.switchM3u8Audio(r.value),d()&&(await l(),d()&&(e.notice.show=`${n}: ${r.html}`))}catch(f){if(d()&&(await h(),d()))throw f}return r.html}function h(){return l().catch(e=>console.warn("MediaBunny HLS menu update:",e))}function d(){a++,s++;try{U([n.clear,o.clear])}catch(e){console.warn("MediaBunny HLS menu cleanup:",e)}}return e.on("video:loadedmetadata",h),e.on("restart",h),e.on("video:loadstart",d),e.on("video:error",d),e.on("destroy",function t(){if(!r){r=!0,d();try{U([()=>e.off("video:loadedmetadata",h),()=>e.off("restart",h),()=>e.off("video:loadstart",d),()=>e.off("video:error",d),()=>e.off("destLine 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,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
!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,L=Object.prototype.propertyIsEnumerable,z=(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))L.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[z("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},ee=e=>{var t,i=e[z("asyncIterator")],r=!1,n={};return null==i?(i=e[z("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[z("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[z("asyncIterator")])?t.call(e):(e=e[z("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){const t=[];for(const r of e)try{r()}catch(i){t.push(i)}if(t.length)throw t[0]}function re(e,t,i){const r={control:!1,setting:!1};let n=null;function s(i){r[i]&&(r[i]=!1,"control"===i?e.controls.cache.has(t)&&e.controls.remove(t):e.setting.find(t)&&e.setting.remove(t))}function a(){n=null,ie([()=>s("control"),()=>s("setting")])}return{update:function(o,l,c,u){if(!o)return void a();n=o;const d=()=>n===o&&c(),h=e=>Y(null,null,function*(){return d()&&o.selector.some(t=>t.value===e.value)?u(e,o.title):e.html});l.control?(r.control=!0,e.controls.update({name:t,position:"right",html:o.html,style:{padding:"0 10px"},selector:o.selector,onSelect:h})):s("control"),d()&&(l.setting?(r.setting=!0,e.setting.update({name:t,tooltip:o.html,html:o.title,icon:i,width:200,selector:o.selector,onSelect:h})):s("setting"))},clear:a}}function ne(e){const t=new Map,i=[];for(const r of e){const e=t.get(r.html);void 0!==r.html&&void 0!==e?r.default&&(i[e]=r):(t.set(r.html,i.length),i.push(r))}return i}function se({art:e,shim:t,option:i}){if(!i.m3u8)return;let r=!1,n=0,s=0;const a=re(e,"mediabunny-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>'),o=re(e,"mediabunny-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>'),l=e=>!r&&!t.engine.destroyed&&t.engine.loadSeq===e;function c(){return Y(this,null,function*(){var e,s;if(r||t.engine.destroyed)return;const c=++n,d=t.engine.loadSeq,h=()=>c===n&&l(d);try{const r=yield t.getM3u8State();if(!h())return;const n=(null==(e=i.m3u8)?void 0:e.quality)||{},c=function(e,t,i){vaLine truncated
+267 -207
View File
@@ -23,164 +23,206 @@ var __privateWrapper = (obj, member, setter, getter) => ({
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) {
const seen = /* @__PURE__ */ new Map();
return array.filter((item) => {
const key = item[property];
if (key === void 0) {
return true;
function releaseAll(actions) {
const failures = [];
for (const action of actions) {
try {
action();
} catch (error) {
failures.push(error);
}
return !seen.has(key) && seen.set(key, 1);
});
}
if (failures.length)
throw failures[0];
}
function createMenu(art, name, icon) {
const owned = { control: false, setting: false };
let model = null;
function remove(surface) {
if (!owned[surface])
return;
owned[surface] = false;
if (surface === "control") {
if (art.controls.cache.has(name))
art.controls.remove(name);
} else if (art.setting.find(name)) {
art.setting.remove(name);
}
}
function clear() {
model = null;
releaseAll([() => remove("control"), () => remove("setting")]);
}
function update(next, config, active, select2) {
if (!next) {
clear();
return;
}
model = next;
const valid = () => model === next && active();
const onSelect = async (item) => {
if (!valid() || !next.selector.some((entry) => entry.value === item.value))
return item.html;
return select2(item, next.title);
};
if (config.control) {
owned.control = true;
art.controls.update({ name, position: "right", html: next.html, style: { padding: "0 10px" }, selector: next.selector, onSelect });
} else {
remove("control");
}
if (!valid())
return;
if (config.setting) {
owned.setting = true;
art.setting.update({ name, tooltip: next.html, html: next.title, icon, width: 200, selector: next.selector, onSelect });
} else {
remove("setting");
}
}
return { update, clear };
}
function unique(items) {
const positions = /* @__PURE__ */ new Map();
const output = [];
for (const item of items) {
const position = positions.get(item.html);
if (item.html !== void 0 && position !== void 0) {
if (item.default)
output[position] = item;
} else {
positions.set(item.html, output.length);
output.push(item);
}
}
return output;
}
function qualityModel(state2, config, active) {
if (!config.control && !config.setting || !state2?.levels.length)
return null;
const auto = config.auto || "Auto";
const getName = config.getName || ((level) => level.name || `${level.height}P`);
const html = state2.currentLevel ? getName(state2.currentLevel) : auto;
const items = [];
for (const level of state2.levels) {
if (!active())
return null;
items.push({ html: getName(level), value: level.id, default: state2.currentLevel?.id === level.id });
}
const heights = new Map(state2.levels.map((level) => [level.id, level.height]));
const selector = unique(items).sort((left, right) => (right.value === "auto" ? 0 : heights.get(right.value) || 0) - (left.value === "auto" ? 0 : heights.get(left.value) || 0));
selector.push({ html: auto, value: "auto", default: !state2.currentLevel });
return { title: config.title || "Quality", html, selector };
}
function audioModel(state2, config, active) {
if (!config.control && !config.setting || !state2 || state2.audios.length < 2)
return null;
const auto = config.auto || "Auto";
const getName = config.getName || ((track) => track.name || track.lang || track.language);
const html = state2.currentAudio ? getName(state2.currentAudio) : auto;
const items = [];
for (const track of state2.audios) {
if (!active())
return null;
items.push({ html: getName(track), value: track.id, default: state2.currentAudio?.id === track.id });
}
const selector = unique(items);
selector.push({ html: auto, value: "auto", default: !state2.currentAudio });
return { title: config.title || "Audio", html, selector };
}
function setupM3u8Controls({ art, shim, option }) {
if (!option.m3u8)
return;
function removeControl(name) {
if (art.controls.cache.has(name)) {
art.controls.remove(name);
}
}
function removeSetting(name) {
if (art.setting.find(name)) {
art.setting.remove(name);
}
}
function clearQuality() {
removeControl("mediabunny-quality");
removeSetting("mediabunny-quality");
}
function clearAudio() {
removeControl("mediabunny-audio");
removeSetting("mediabunny-audio");
}
function canRender(config = {}) {
return config.control || config.setting;
}
async function updateQuality(state2) {
const config = option.m3u8?.quality || {};
if (!canRender(config) || !state2?.levels.length) {
clearQuality();
return;
}
const auto = config.auto || "Auto";
const title = config.title || "Quality";
const getName = config.getName || ((level) => level.name || `${level.height}P`);
const defaultHtml = state2.currentLevel ? getName(state2.currentLevel) : auto;
const selector = uniqBy(
state2.levels.map((item) => {
return {
html: getName(item),
value: item.id,
default: state2.currentLevel?.id === item.id
};
}),
"html"
).sort((a, b) => {
const left = state2.levels.find((item) => item.id === a.value);
const right = state2.levels.find((item) => item.id === b.value);
return (right?.height || 0) - (left?.height || 0);
});
selector.push({
html: auto,
value: "auto",
default: !state2.currentLevel
});
const onSelect = async (item) => {
await shim.switchM3u8Quality(item.value);
art.notice.show = `${title}: ${item.html}`;
await update();
return item.html;
};
if (config.control) {
art.controls.update({
name: "mediabunny-quality",
position: "right",
html: defaultHtml,
style: { padding: "0 10px" },
selector,
onSelect
});
}
if (config.setting) {
art.setting.update({
name: "mediabunny-quality",
tooltip: defaultHtml,
html: title,
icon: $quality,
width: 200,
selector,
onSelect
});
}
}
async function updateAudio(state2) {
const config = option.m3u8?.audio || {};
if (!canRender(config) || !state2?.audios.length || state2.audios.length < 2) {
clearAudio();
return;
}
const auto = config.auto || "Auto";
const title = config.title || "Audio";
const getName = config.getName || ((track) => track.name || track.lang || track.language);
const defaultHtml = state2.currentAudio ? getName(state2.currentAudio) : auto;
const selector = uniqBy(
state2.audios.map((item) => {
return {
html: getName(item),
value: item.id,
default: state2.currentAudio?.id === item.id
};
}),
"html"
);
selector.push({
html: auto,
value: "auto",
default: !state2.currentAudio
});
const onSelect = async (item) => {
await shim.switchM3u8Audio(item.value);
art.notice.show = `${title}: ${item.html}`;
await update();
return item.html;
};
if (config.control) {
art.controls.update({
name: "mediabunny-audio",
position: "right",
html: defaultHtml,
style: { padding: "0 10px" },
selector,
onSelect
});
}
if (config.setting) {
art.setting.update({
name: "mediabunny-audio",
tooltip: defaultHtml,
html: title,
icon: $audio,
width: 200,
selector,
onSelect
});
}
}
let closed = false;
let revision = 0;
let selection = 0;
const quality2 = createMenu(art, "mediabunny-quality", $quality);
const audio2 = createMenu(art, "mediabunny-audio", $audio);
const current = (source) => !closed && !shim.engine.destroyed && shim.engine.loadSeq === source;
async function update() {
const state2 = await shim.getM3u8State();
if (!state2) {
clearQuality();
clearAudio();
if (closed || shim.engine.destroyed)
return;
const version = ++revision;
const source = shim.engine.loadSeq;
const active = () => version === revision && current(source);
try {
const state2 = await shim.getM3u8State();
if (!active())
return;
const qualityConfig = option.m3u8?.quality || {};
const qualityView = qualityModel(state2, qualityConfig, active);
if (!active())
return;
quality2.update(qualityView, qualityConfig, () => current(source), (item, title) => select2("quality", item, title));
if (!active())
return;
const audioConfig = option.m3u8?.audio || {};
const audioView = audioModel(state2, audioConfig, active);
if (active())
audio2.update(audioView, audioConfig, () => current(source), (item, title) => select2("audio", item, title));
} catch (error) {
if (active())
throw error;
}
await Promise.all([
updateQuality(state2),
updateAudio(state2)
]);
}
art.on("video:loadedmetadata", update);
art.on("restart", update);
async function select2(kind, item, title) {
const intent = ++selection;
const source = shim.engine.loadSeq;
revision++;
const active = () => intent === selection && current(source);
try {
if (kind === "quality")
await shim.switchM3u8Quality(item.value);
else
await shim.switchM3u8Audio(item.value);
if (active()) {
await update();
if (active())
art.notice.show = `${title}: ${item.html}`;
}
} catch (error) {
if (active()) {
await refresh();
if (active())
throw error;
}
}
return item.html;
}
function refresh() {
return update().catch((error) => console.warn("MediaBunny HLS menu update:", error));
}
function invalidate() {
revision++;
selection++;
try {
releaseAll([quality2.clear, audio2.clear]);
} catch (error) {
console.warn("MediaBunny HLS menu cleanup:", error);
}
}
function destroy() {
if (closed)
return;
closed = true;
invalidate();
try {
releaseAll([
() => art.off("video:loadedmetadata", refresh),
() => art.off("restart", refresh),
() => art.off("video:loadstart", invalidate),
() => art.off("video:error", invalidate),
() => art.off("destroy", destroy)
]);
} catch (error) {
console.warn("MediaBunny HLS listener cleanup:", error);
}
}
art.on("video:loadedmetadata", refresh);
art.on("restart", refresh);
art.on("video:loadstart", invalidate);
art.on("video:error", invalidate);
art.on("destroy", destroy);
return { update };
}
class EventTarget {
@@ -24301,15 +24343,15 @@ function publish(events, current, names) {
}
function metadataBarrier(ready, current) {
let video = false;
let audio = false;
let audio2 = false;
let published = false;
function complete(part) {
if (!current() || published)
return;
if (part === "video")
video = true;
else audio = true;
if (video && audio) {
else audio2 = true;
if (video && audio2) {
published = true;
ready();
}
@@ -24651,61 +24693,78 @@ play_fn = async function(task, generation) {
throw error;
}
};
async function selectQuality(host, value) {
const { input, media, loadSeq } = host;
if (!media?.isHls || !input || host.destroyed)
const requests = /* @__PURE__ */ new WeakMap();
async function select(host, value, resolve) {
const { input, loadSeq } = host;
if (!host.media?.isHls || !input || host.destroyed)
return;
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media;
let selection;
try {
const tracks = await input.getVideoTracks();
if (!current())
const token = {};
requests.set(host, token);
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && requests.get(host) === token;
while (current()) {
const media = host.media;
if (!media?.isHls)
return;
const videoTrack = value === "auto" ? await input.getPrimaryVideoTrack() : tracks.find((track) => track.id === value) ?? media.videoTrack;
if (!current() || !videoTrack)
return;
let audioTrack = media.audioTrack;
let audioMode = media.audioMode;
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack();
audioMode = "auto";
let selection;
try {
selection = await resolve(input, media, value, current);
} catch (error) {
if (!current())
return;
if (host.media === media)
throw error;
continue;
}
selection = { videoTrack, audioTrack, videoMode: value === "auto" ? "auto" : "manual", audioMode };
} catch (error) {
if (!current())
return;
throw error;
if (host.media !== media)
continue;
if (selection) {
try {
await host.replaceTracks(selection);
} catch (error) {
if (current())
throw error;
}
}
return;
}
if (current())
await host.replaceTracks(selection);
}
async function selectAudio(host, value) {
const { input, media, loadSeq } = host;
if (!media?.isHls || !input || host.destroyed)
return;
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media;
let selection;
try {
const tracks = await input.getAudioTracks();
if (!current())
return;
const audioTrack = value === "auto" ? media.videoTrack ? await media.videoTrack.getPrimaryPairableAudioTrack() : await input.getPrimaryAudioTrack() : tracks.find((track) => track.id === value) ?? media.audioTrack;
if (!current() || !audioTrack)
return;
let videoTrack = media.videoTrack;
let videoMode = media.videoMode;
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack();
videoMode = "auto";
}
selection = { videoTrack, audioTrack, videoMode, audioMode: value === "auto" ? "auto" : "manual" };
} catch (error) {
if (!current())
return;
throw error;
async function quality(input, media, value, current) {
const tracks = await input.getVideoTracks();
if (!current())
return null;
const videoTrack = value === "auto" ? await input.getPrimaryVideoTrack() : tracks.find((track) => track.id === value) ?? media.videoTrack;
if (!current() || !videoTrack)
return null;
let audioTrack = media.audioTrack;
let audioMode = media.audioMode;
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack();
audioMode = "auto";
}
if (current())
await host.replaceTracks(selection);
return { videoTrack, audioTrack, videoMode: value === "auto" ? "auto" : "manual", audioMode };
}
async function audio(input, media, value, current) {
const tracks = await input.getAudioTracks();
if (!current())
return null;
const audioTrack = value === "auto" ? media.videoTrack ? await media.videoTrack.getPrimaryPairableAudioTrack() : await input.getPrimaryAudioTrack() : tracks.find((track) => track.id === value) ?? media.audioTrack;
if (!current() || !audioTrack)
return null;
let videoTrack = media.videoTrack;
let videoMode = media.videoMode;
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack();
videoMode = "auto";
}
return { videoTrack, audioTrack, videoMode, audioMode: value === "auto" ? "auto" : "manual" };
}
function selectQuality(host, value) {
return select(host, value, quality);
}
function selectAudio(host, value) {
return select(host, value, audio);
}
async function getTrackBitrate(track) {
return await track.getAverageBitrate() ?? await track.getBitrate() ?? 0;
@@ -24985,9 +25044,9 @@ class Playback {
}
seek(time, resume) {
return this.position(async (current) => {
const audio = this.host.audio.seek(time);
const audio2 = this.host.audio.seek(time);
const video = current() ? this.host.video.seek(time) : Promise.resolve();
await Promise.all([audio, video]);
await Promise.all([audio2, video]);
}, ["seeked"], false, resume);
}
replace(media, time) {
@@ -25438,8 +25497,8 @@ class VideoEngine {
render() {
__privateGet(this, _renderer).render();
}
start(audio) {
__privateGet(this, _renderer).start(audio);
start(audio2) {
__privateGet(this, _renderer).start(audio2);
}
stop() {
__privateGet(this, _renderer).stop();
@@ -25692,8 +25751,8 @@ class MediaBunnyEngine {
}, current);
try {
const video = this.video.load(media, metadata.video);
const audio = current() ? this.audio.load(media, metadata.audio) : Promise.resolve();
await Promise.all([video, audio]);
const audio2 = current() ? this.audio.load(media, metadata.audio) : Promise.resolve();
await Promise.all([video, audio2]);
if (!current())
return;
this.readyState = 4;
@@ -25729,13 +25788,14 @@ class MediaBunnyEngine {
}
async getHlsState() {
const media = this.media;
const source = this.loadSeq;
if (this.destroyed)
return null;
try {
const state2 = await getHlsState(media);
return !this.destroyed && this.media === media ? state2 : null;
return !this.destroyed && this.loadSeq === source && this.media === media ? state2 : null;
} catch (error) {
if (this.destroyed || this.media !== media)
if (this.destroyed || this.loadSeq !== source || this.media !== media)
return null;
throw error;
}
+2 -2
View File
@@ -51,7 +51,7 @@
"ci:build": "yarn build:types && yarn build all && yarn build:i18n && yarn build:ts && yarn build:docs && yarn test:imports",
"test:imports": "node --test test/esm.test.js test/i18n.test.js test/ssr.test.js",
"typecheck": "node scripts/typecheck.mjs",
"test:unit": "node --test test/mediabunny.test.js test/mediabunny-shim.test.js test/mediabunny-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: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-hls.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-audio.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-hls.test.js test/mediabunny-load.test.js test/mediabunny-input.test.js"
},
"browserslist": "last 1 Chrome version",
"devDependencies": {
@@ -18,7 +18,7 @@ declarations, the factory signature, canvas forwarding, or package entrypoints.
| `MediaBunnyEngine.ts` | Load/readiness coordinator, source identity, errors and terminal teardown |
| `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 |
| `hls-selection.ts` | Latest selection intent, stable-media pairing queries 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 |
@@ -33,12 +33,15 @@ declarations, the factory signature, canvas forwarding, or package entrypoints.
| `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 |
| `m3u8.js` | Current ArtPlayer control/setting integration |
| `m3u8.ts` | HLS refresh/selection revisions, source guards, events and terminal cleanup |
| `m3u8-model.ts` | Display names, selected IDs, label deduplication and sorted selector models |
| `m3u8-menu.ts` | Owned control/setting surfaces and stale callback guards |
| `m3u8-types.ts` | Narrow internal UI/shim contracts without changing public factory declarations |
The twenty-six production TypeScript modules use strict checking with `skipLibCheck: false`
The thirty 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.
Only the public factory remains JavaScript, scheduled in PKG-MB-08.
Do not describe the whole package as migrated yet.
The shim keeps its existing own-property order and direct prototype surface because
@@ -100,8 +103,9 @@ event the coordinator rechecks ownership, since a listener may synchronously pau
load another source or destroy. Active play errors reject their Promise and restore paused
state; active seek errors additionally publish one media error. The synchronous currentTime
setter observes its otherwise unreturnable rejection; direct engine.seek still rejects.
Active replacement errors remain visible even after the media object changes. Stale HLS query
errors are ignored only when their captured source no longer owns the result.
Active replacement errors remain visible even after the media object changes. HLS queries
and replacement errors also carry a selection intent; obsolete intents cannot replace newer
results or surface an old failure. A still-current query retries if its media snapshot changed.
Metadata publishes once after both audio/video participants report; repeated or obsolete
callbacks cannot ready the current source. A parsed container with neither track fails with
@@ -180,6 +184,42 @@ and repeated destroy is inert. Real-browser teardown assertions poll the native
Tests deliberately delay native resume completion or decoded buffer delivery, without
substituting fake audio decoding for native acceptance.
## HLS selection and UI ownership
Quality and audio requests share a per-engine intent token. A newer request supersedes an
unresolved older query, including cross-kind requests, before it can call replaceTracks.
Queries capture source generation and Input identity. If a prior replacement installs new
media while the latest query is awaiting track metadata or pairing, recompute against the
current media instead of dropping the latest intent or restoring an old counterpart.
The resolver rechecks cancellation between SDK calls; active failures still reject the caller.
An already-started replacement remains governed by Playback until the next replacement
begins. This does not claim that issuing a selection synchronously cancels SDK decoding.
Pairing preserves the current compatible counterpart and its mode; fallback chooses the
SDK primary pairable track and resets only the counterpart mode to auto. Invalid IDs retain
the historical current-track fallback. Media state getters suppress stale source results.
The UI has separate refresh revisions, selection intent and source identity. Old reads
cannot overwrite newer models; callbacks from replaced menus or a previous source are inert.
The same visible menu accepts a newer click while a prior selection is pending. Its own
loadedmetadata refresh does not cancel that valid selection's completion. Active failures
refresh actual track highlights before rejecting; stale failures, including a failure
superseded during recovery, cannot disturb the current intent. Background event-driven
refreshes observe failures through a warning instead of unhandled Promise rejections.
Models retain the existing menu names, title/Auto/getName defaults, sort order and notice
format. Auto remains an action, while the actual selected track ID controls the highlighted
item and displayed track label. Label deduplication is retained; if labels collide, keep the
selected ID as that label's representative so it is not hidden by the first item. Height
lookups use a map rather than repeated array searches during sorting. User getName callbacks
are guarded against synchronous source/destroy reentry between calls and before UI writes.
Each menu owns control and setting independently. Disabling either removes that surface;
video-only/single-audio topology removes audio menus, non-HLS clears both, and loadstart/error/
destroy invalidate pending work and clear owned UI. Teardown attempts every surface and
listener even after one failure, and cannot prevent the entry's shim destroy callback.
Control/setting callbacks retain their Promise and selected-label result; no new public
methods or declarations are exposed by the internal modules.
## Types and dependencies
The locked runtime SDK remains MediaBunny 1.56.1. Its pinned WebCodecs declaration
@@ -201,6 +241,7 @@ yarn test:browser test/browser/mediabunny-inputs.spec.js test/browser/mediabunny
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
yarn test:browser test/browser/mediabunny-hls.spec.js
```
Node tests use actual SDK parsing and controlled lifecycle interleavings. Browser tests
@@ -217,8 +258,8 @@ inputs, not a rebuilt dist. Package-specific evidence and remaining tasks are in
PKG-MB-04 covers duplicate/trackless readiness, reentrant event sequences and
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
replace long-run synchronization and combination validation. PKG-MB-07 covers HLS UI/selection
races using the actual new/old cores and SDK with controlled query ordering. 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,
not successful playback or a claim about all Safari installations.
@@ -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,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
!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),L=(e,t,i)=>(M(e,t,"access private method"),i),z=(e,t,i,r)=>({set _(r){O(e,t,r,i)},get _(){return D(e,t,r)}});function U(e){const t=[];for(const r of e)try{r()}catch(i){t.push(i)}if(t.length)throw t[0]}function q(e,t,i){const r={control:!1,setting:!1};let a=null;function s(i){r[i]&&(r[i]=!1,"control"===i?e.controls.cache.has(t)&&e.controls.remove(t):e.setting.find(t)&&e.setting.remove(t))}function n(){a=null,U([()=>s("control"),()=>s("setting")])}return{update:function(o,c,l,u){if(!o)return void n();a=o;const h=()=>a===o&&l(),d=async e=>h()&&o.selector.some(t=>t.value===e.value)?u(e,o.title):e.html;c.control?(r.control=!0,e.controls.update({name:t,position:"right",html:o.html,style:{padding:"0 10px"},selector:o.selector,onSelect:d})):s("control"),h()&&(c.setting?(r.setting=!0,e.setting.update({name:t,tooltip:o.html,html:o.title,icon:i,width:200,selector:o.selector,onSelect:d})):s("setting"))},clear:n}}function W(e){const t=new Map,i=[];for(const r of e){const e=t.get(r.html);void 0!==r.html&&void 0!==e?r.default&&(i[e]=r):(t.set(r.html,i.length),i.push(r))}return i}function H({art:e,shim:t,option:i}){if(!i.m3u8)return;let r=!1,a=0,s=0;const n=q(e,"mediabunny-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>'),o=q(e,"mediabunny-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>'),c=e=>!r&&!t.engine.destroyed&&t.engine.loadSeq===e;async function l(){if(r||t.engine.destroyed)return;const e=++a,s=t.engine.loadSeq,l=()=>e===a&&c(s);try{const e=await t.getM3u8State();if(!l())return;const r=i.m3u8?.quality||{},a=function(e,t,i){if(!t.control&&!t.setting||!e?.levels.length)return null;const r=t.auto||"Auto",a=t.getName||(e=>e.name||`${e.height}P`),s=e.currentLevel?a(e.currentLevel):r,n=[];for(const l of e.levels){if(!i())return null;n.push({html:a(l),value:l.id,default:e.currentLevel?.id===l.id})}const o=new Map(e.levels.map(e=>[e.id,e.height])),c=W(n).sort((e,t)=>("auto"===t.value?0:o.get(t.value)||0)-("auto"===e.value?0:o.get(e.value)||0));return c.push({html:r,value:"auto",default:!e.currentLevel}),{title:t.title||"Quality",html:s,selector:c}}(e,r,l);if(!l())return;if(n.update(a,r,()=>c(s),(e,t)=>u("quality",e,t)),!l())return;const h=i.m3u8?.audio||{},d=function(e,t,i){if(!t.control&&!t.setting||!e||e.audios.length<2)return null;const r=t.auto||"Auto",a=t.getName||(e=>e.name||e.lang||e.language),s=e.currentAudio?a(e.currentAudio):r,n=[];for(const c of e.audios){if(!i())return null;n.push({html:a(c),value:c.id,default:e.currentAudio?.id===c.id})}const o=W(n);return o.push({html:r,value:"auto",default:!e.currentAudio}),{title:t.title||"Audio",html:s,selector:o}}(e,h,l);l()&&o.update(d,h,()=>c(s),(e,t)=>u("audio",e,t))}catch(h){if(l())throw h}}async function u(i,r,n){const o=++s,u=t.engine.loadSeq;a++;const d=()=>o===s&&c(u);try{"quality"===i?await t.switchM3u8Quality(r.value):await t.switchM3u8Audio(r.value),d()&&(await l(),d()&&(e.notice.show=`${n}: ${r.html}`))}catch(f){if(d()&&(await h(),d()))throw f}return r.html}function h(){return l().catch(e=>console.warn("MediaBunny HLS menu update:",e))}function d(){a++,s++;try{U([n.clear,o.clear])}catch(e){console.warn("MediaBunny HLS menu cleanup:",e)}}return e.on("video:loadedmetadata",h),e.on("restart",h),e.on("video:loadstart",d),e.on("video:error",d),e.on("destroy",function t(){if(!r){r=!0,d();try{U([()=>e.off("video:loadedmetadata",h),()=>e.off("restart",h),()=>e.off("video:loadstart",d),()=>e.off("video:error",d),()=>e.off("destLine 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,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
!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,L=Object.prototype.propertyIsEnumerable,z=(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))L.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[z("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},ee=e=>{var t,i=e[z("asyncIterator")],r=!1,n={};return null==i?(i=e[z("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[z("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[z("asyncIterator")])?t.call(e):(e=e[z("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){const t=[];for(const r of e)try{r()}catch(i){t.push(i)}if(t.length)throw t[0]}function re(e,t,i){const r={control:!1,setting:!1};let n=null;function s(i){r[i]&&(r[i]=!1,"control"===i?e.controls.cache.has(t)&&e.controls.remove(t):e.setting.find(t)&&e.setting.remove(t))}function a(){n=null,ie([()=>s("control"),()=>s("setting")])}return{update:function(o,l,c,u){if(!o)return void a();n=o;const d=()=>n===o&&c(),h=e=>Y(null,null,function*(){return d()&&o.selector.some(t=>t.value===e.value)?u(e,o.title):e.html});l.control?(r.control=!0,e.controls.update({name:t,position:"right",html:o.html,style:{padding:"0 10px"},selector:o.selector,onSelect:h})):s("control"),d()&&(l.setting?(r.setting=!0,e.setting.update({name:t,tooltip:o.html,html:o.title,icon:i,width:200,selector:o.selector,onSelect:h})):s("setting"))},clear:a}}function ne(e){const t=new Map,i=[];for(const r of e){const e=t.get(r.html);void 0!==r.html&&void 0!==e?r.default&&(i[e]=r):(t.set(r.html,i.length),i.push(r))}return i}function se({art:e,shim:t,option:i}){if(!i.m3u8)return;let r=!1,n=0,s=0;const a=re(e,"mediabunny-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>'),o=re(e,"mediabunny-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>'),l=e=>!r&&!t.engine.destroyed&&t.engine.loadSeq===e;function c(){return Y(this,null,function*(){var e,s;if(r||t.engine.destroyed)return;const c=++n,d=t.engine.loadSeq,h=()=>c===n&&l(d);try{const r=yield t.getM3u8State();if(!h())return;const n=(null==(e=i.m3u8)?void 0:e.quality)||{},c=function(e,t,i){vaLine truncated
@@ -23,164 +23,206 @@ var __privateWrapper = (obj, member, setter, getter) => ({
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) {
const seen = /* @__PURE__ */ new Map();
return array.filter((item) => {
const key = item[property];
if (key === void 0) {
return true;
function releaseAll(actions) {
const failures = [];
for (const action of actions) {
try {
action();
} catch (error) {
failures.push(error);
}
return !seen.has(key) && seen.set(key, 1);
});
}
if (failures.length)
throw failures[0];
}
function createMenu(art, name, icon) {
const owned = { control: false, setting: false };
let model = null;
function remove(surface) {
if (!owned[surface])
return;
owned[surface] = false;
if (surface === "control") {
if (art.controls.cache.has(name))
art.controls.remove(name);
} else if (art.setting.find(name)) {
art.setting.remove(name);
}
}
function clear() {
model = null;
releaseAll([() => remove("control"), () => remove("setting")]);
}
function update(next, config, active, select2) {
if (!next) {
clear();
return;
}
model = next;
const valid = () => model === next && active();
const onSelect = async (item) => {
if (!valid() || !next.selector.some((entry) => entry.value === item.value))
return item.html;
return select2(item, next.title);
};
if (config.control) {
owned.control = true;
art.controls.update({ name, position: "right", html: next.html, style: { padding: "0 10px" }, selector: next.selector, onSelect });
} else {
remove("control");
}
if (!valid())
return;
if (config.setting) {
owned.setting = true;
art.setting.update({ name, tooltip: next.html, html: next.title, icon, width: 200, selector: next.selector, onSelect });
} else {
remove("setting");
}
}
return { update, clear };
}
function unique(items) {
const positions = /* @__PURE__ */ new Map();
const output = [];
for (const item of items) {
const position = positions.get(item.html);
if (item.html !== void 0 && position !== void 0) {
if (item.default)
output[position] = item;
} else {
positions.set(item.html, output.length);
output.push(item);
}
}
return output;
}
function qualityModel(state2, config, active) {
if (!config.control && !config.setting || !state2?.levels.length)
return null;
const auto = config.auto || "Auto";
const getName = config.getName || ((level) => level.name || `${level.height}P`);
const html = state2.currentLevel ? getName(state2.currentLevel) : auto;
const items = [];
for (const level of state2.levels) {
if (!active())
return null;
items.push({ html: getName(level), value: level.id, default: state2.currentLevel?.id === level.id });
}
const heights = new Map(state2.levels.map((level) => [level.id, level.height]));
const selector = unique(items).sort((left, right) => (right.value === "auto" ? 0 : heights.get(right.value) || 0) - (left.value === "auto" ? 0 : heights.get(left.value) || 0));
selector.push({ html: auto, value: "auto", default: !state2.currentLevel });
return { title: config.title || "Quality", html, selector };
}
function audioModel(state2, config, active) {
if (!config.control && !config.setting || !state2 || state2.audios.length < 2)
return null;
const auto = config.auto || "Auto";
const getName = config.getName || ((track) => track.name || track.lang || track.language);
const html = state2.currentAudio ? getName(state2.currentAudio) : auto;
const items = [];
for (const track of state2.audios) {
if (!active())
return null;
items.push({ html: getName(track), value: track.id, default: state2.currentAudio?.id === track.id });
}
const selector = unique(items);
selector.push({ html: auto, value: "auto", default: !state2.currentAudio });
return { title: config.title || "Audio", html, selector };
}
function setupM3u8Controls({ art, shim, option }) {
if (!option.m3u8)
return;
function removeControl(name) {
if (art.controls.cache.has(name)) {
art.controls.remove(name);
}
}
function removeSetting(name) {
if (art.setting.find(name)) {
art.setting.remove(name);
}
}
function clearQuality() {
removeControl("mediabunny-quality");
removeSetting("mediabunny-quality");
}
function clearAudio() {
removeControl("mediabunny-audio");
removeSetting("mediabunny-audio");
}
function canRender(config = {}) {
return config.control || config.setting;
}
async function updateQuality(state2) {
const config = option.m3u8?.quality || {};
if (!canRender(config) || !state2?.levels.length) {
clearQuality();
return;
}
const auto = config.auto || "Auto";
const title = config.title || "Quality";
const getName = config.getName || ((level) => level.name || `${level.height}P`);
const defaultHtml = state2.currentLevel ? getName(state2.currentLevel) : auto;
const selector = uniqBy(
state2.levels.map((item) => {
return {
html: getName(item),
value: item.id,
default: state2.currentLevel?.id === item.id
};
}),
"html"
).sort((a, b) => {
const left = state2.levels.find((item) => item.id === a.value);
const right = state2.levels.find((item) => item.id === b.value);
return (right?.height || 0) - (left?.height || 0);
});
selector.push({
html: auto,
value: "auto",
default: !state2.currentLevel
});
const onSelect = async (item) => {
await shim.switchM3u8Quality(item.value);
art.notice.show = `${title}: ${item.html}`;
await update();
return item.html;
};
if (config.control) {
art.controls.update({
name: "mediabunny-quality",
position: "right",
html: defaultHtml,
style: { padding: "0 10px" },
selector,
onSelect
});
}
if (config.setting) {
art.setting.update({
name: "mediabunny-quality",
tooltip: defaultHtml,
html: title,
icon: $quality,
width: 200,
selector,
onSelect
});
}
}
async function updateAudio(state2) {
const config = option.m3u8?.audio || {};
if (!canRender(config) || !state2?.audios.length || state2.audios.length < 2) {
clearAudio();
return;
}
const auto = config.auto || "Auto";
const title = config.title || "Audio";
const getName = config.getName || ((track) => track.name || track.lang || track.language);
const defaultHtml = state2.currentAudio ? getName(state2.currentAudio) : auto;
const selector = uniqBy(
state2.audios.map((item) => {
return {
html: getName(item),
value: item.id,
default: state2.currentAudio?.id === item.id
};
}),
"html"
);
selector.push({
html: auto,
value: "auto",
default: !state2.currentAudio
});
const onSelect = async (item) => {
await shim.switchM3u8Audio(item.value);
art.notice.show = `${title}: ${item.html}`;
await update();
return item.html;
};
if (config.control) {
art.controls.update({
name: "mediabunny-audio",
position: "right",
html: defaultHtml,
style: { padding: "0 10px" },
selector,
onSelect
});
}
if (config.setting) {
art.setting.update({
name: "mediabunny-audio",
tooltip: defaultHtml,
html: title,
icon: $audio,
width: 200,
selector,
onSelect
});
}
}
let closed = false;
let revision = 0;
let selection = 0;
const quality2 = createMenu(art, "mediabunny-quality", $quality);
const audio2 = createMenu(art, "mediabunny-audio", $audio);
const current = (source) => !closed && !shim.engine.destroyed && shim.engine.loadSeq === source;
async function update() {
const state2 = await shim.getM3u8State();
if (!state2) {
clearQuality();
clearAudio();
if (closed || shim.engine.destroyed)
return;
const version = ++revision;
const source = shim.engine.loadSeq;
const active = () => version === revision && current(source);
try {
const state2 = await shim.getM3u8State();
if (!active())
return;
const qualityConfig = option.m3u8?.quality || {};
const qualityView = qualityModel(state2, qualityConfig, active);
if (!active())
return;
quality2.update(qualityView, qualityConfig, () => current(source), (item, title) => select2("quality", item, title));
if (!active())
return;
const audioConfig = option.m3u8?.audio || {};
const audioView = audioModel(state2, audioConfig, active);
if (active())
audio2.update(audioView, audioConfig, () => current(source), (item, title) => select2("audio", item, title));
} catch (error) {
if (active())
throw error;
}
await Promise.all([
updateQuality(state2),
updateAudio(state2)
]);
}
art.on("video:loadedmetadata", update);
art.on("restart", update);
async function select2(kind, item, title) {
const intent = ++selection;
const source = shim.engine.loadSeq;
revision++;
const active = () => intent === selection && current(source);
try {
if (kind === "quality")
await shim.switchM3u8Quality(item.value);
else
await shim.switchM3u8Audio(item.value);
if (active()) {
await update();
if (active())
art.notice.show = `${title}: ${item.html}`;
}
} catch (error) {
if (active()) {
await refresh();
if (active())
throw error;
}
}
return item.html;
}
function refresh() {
return update().catch((error) => console.warn("MediaBunny HLS menu update:", error));
}
function invalidate() {
revision++;
selection++;
try {
releaseAll([quality2.clear, audio2.clear]);
} catch (error) {
console.warn("MediaBunny HLS menu cleanup:", error);
}
}
function destroy() {
if (closed)
return;
closed = true;
invalidate();
try {
releaseAll([
() => art.off("video:loadedmetadata", refresh),
() => art.off("restart", refresh),
() => art.off("video:loadstart", invalidate),
() => art.off("video:error", invalidate),
() => art.off("destroy", destroy)
]);
} catch (error) {
console.warn("MediaBunny HLS listener cleanup:", error);
}
}
art.on("video:loadedmetadata", refresh);
art.on("restart", refresh);
art.on("video:loadstart", invalidate);
art.on("video:error", invalidate);
art.on("destroy", destroy);
return { update };
}
class EventTarget {
@@ -24301,15 +24343,15 @@ function publish(events, current, names) {
}
function metadataBarrier(ready, current) {
let video = false;
let audio = false;
let audio2 = false;
let published = false;
function complete(part) {
if (!current() || published)
return;
if (part === "video")
video = true;
else audio = true;
if (video && audio) {
else audio2 = true;
if (video && audio2) {
published = true;
ready();
}
@@ -24651,61 +24693,78 @@ play_fn = async function(task, generation) {
throw error;
}
};
async function selectQuality(host, value) {
const { input, media, loadSeq } = host;
if (!media?.isHls || !input || host.destroyed)
const requests = /* @__PURE__ */ new WeakMap();
async function select(host, value, resolve) {
const { input, loadSeq } = host;
if (!host.media?.isHls || !input || host.destroyed)
return;
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media;
let selection;
try {
const tracks = await input.getVideoTracks();
if (!current())
const token = {};
requests.set(host, token);
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && requests.get(host) === token;
while (current()) {
const media = host.media;
if (!media?.isHls)
return;
const videoTrack = value === "auto" ? await input.getPrimaryVideoTrack() : tracks.find((track) => track.id === value) ?? media.videoTrack;
if (!current() || !videoTrack)
return;
let audioTrack = media.audioTrack;
let audioMode = media.audioMode;
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack();
audioMode = "auto";
let selection;
try {
selection = await resolve(input, media, value, current);
} catch (error) {
if (!current())
return;
if (host.media === media)
throw error;
continue;
}
selection = { videoTrack, audioTrack, videoMode: value === "auto" ? "auto" : "manual", audioMode };
} catch (error) {
if (!current())
return;
throw error;
if (host.media !== media)
continue;
if (selection) {
try {
await host.replaceTracks(selection);
} catch (error) {
if (current())
throw error;
}
}
return;
}
if (current())
await host.replaceTracks(selection);
}
async function selectAudio(host, value) {
const { input, media, loadSeq } = host;
if (!media?.isHls || !input || host.destroyed)
return;
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media;
let selection;
try {
const tracks = await input.getAudioTracks();
if (!current())
return;
const audioTrack = value === "auto" ? media.videoTrack ? await media.videoTrack.getPrimaryPairableAudioTrack() : await input.getPrimaryAudioTrack() : tracks.find((track) => track.id === value) ?? media.audioTrack;
if (!current() || !audioTrack)
return;
let videoTrack = media.videoTrack;
let videoMode = media.videoMode;
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack();
videoMode = "auto";
}
selection = { videoTrack, audioTrack, videoMode, audioMode: value === "auto" ? "auto" : "manual" };
} catch (error) {
if (!current())
return;
throw error;
async function quality(input, media, value, current) {
const tracks = await input.getVideoTracks();
if (!current())
return null;
const videoTrack = value === "auto" ? await input.getPrimaryVideoTrack() : tracks.find((track) => track.id === value) ?? media.videoTrack;
if (!current() || !videoTrack)
return null;
let audioTrack = media.audioTrack;
let audioMode = media.audioMode;
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack();
audioMode = "auto";
}
if (current())
await host.replaceTracks(selection);
return { videoTrack, audioTrack, videoMode: value === "auto" ? "auto" : "manual", audioMode };
}
async function audio(input, media, value, current) {
const tracks = await input.getAudioTracks();
if (!current())
return null;
const audioTrack = value === "auto" ? media.videoTrack ? await media.videoTrack.getPrimaryPairableAudioTrack() : await input.getPrimaryAudioTrack() : tracks.find((track) => track.id === value) ?? media.audioTrack;
if (!current() || !audioTrack)
return null;
let videoTrack = media.videoTrack;
let videoMode = media.videoMode;
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack();
videoMode = "auto";
}
return { videoTrack, audioTrack, videoMode, audioMode: value === "auto" ? "auto" : "manual" };
}
function selectQuality(host, value) {
return select(host, value, quality);
}
function selectAudio(host, value) {
return select(host, value, audio);
}
async function getTrackBitrate(track) {
return await track.getAverageBitrate() ?? await track.getBitrate() ?? 0;
@@ -24985,9 +25044,9 @@ class Playback {
}
seek(time, resume) {
return this.position(async (current) => {
const audio = this.host.audio.seek(time);
const audio2 = this.host.audio.seek(time);
const video = current() ? this.host.video.seek(time) : Promise.resolve();
await Promise.all([audio, video]);
await Promise.all([audio2, video]);
}, ["seeked"], false, resume);
}
replace(media, time) {
@@ -25438,8 +25497,8 @@ class VideoEngine {
render() {
__privateGet(this, _renderer).render();
}
start(audio) {
__privateGet(this, _renderer).start(audio);
start(audio2) {
__privateGet(this, _renderer).start(audio2);
}
stop() {
__privateGet(this, _renderer).stop();
@@ -25692,8 +25751,8 @@ class MediaBunnyEngine {
}, current);
try {
const video = this.video.load(media, metadata.video);
const audio = current() ? this.audio.load(media, metadata.audio) : Promise.resolve();
await Promise.all([video, audio]);
const audio2 = current() ? this.audio.load(media, metadata.audio) : Promise.resolve();
await Promise.all([video, audio2]);
if (!current())
return;
this.readyState = 4;
@@ -25729,13 +25788,14 @@ class MediaBunnyEngine {
}
async getHlsState() {
const media = this.media;
const source = this.loadSeq;
if (this.destroyed)
return null;
try {
const state2 = await getHlsState(media);
return !this.destroyed && this.media === media ? state2 : null;
return !this.destroyed && this.loadSeq === source && this.media === media ? state2 : null;
} catch (error) {
if (this.destroyed || this.media !== media)
if (this.destroyed || this.loadSeq !== source || this.media !== media)
return null;
throw error;
}
@@ -199,14 +199,15 @@ export default class MediaBunnyEngine implements EnginePort {
async getHlsState() {
const media = this.media
const source = this.loadSeq
if (this.destroyed)
return null
try {
const state = await getHlsState(media)
return !this.destroyed && this.media === media ? state : null
return !this.destroyed && this.loadSeq === source && this.media === media ? state : null
}
catch (error) {
if (this.destroyed || this.media !== media)
if (this.destroyed || this.loadSeq !== source || this.media !== media)
return null
throw error
}
@@ -21,6 +21,8 @@ export interface EngineOptions {
// Stable coordinator-facing surface used by the public shim.
export interface EnginePort {
readonly loadSeq: number
readonly destroyed: boolean
readonly currentTime: number
readonly duration: number
readonly videoWidth: number
@@ -2,7 +2,6 @@ import type { Input } from 'mediabunny'
import type { PlaybackMedia } from './media'
export type SelectedTracks = Pick<PlaybackMedia, 'videoTrack' | 'audioTrack' | 'videoMode' | 'audioMode'>
interface SelectionHost {
input: Input | null
media: PlaybackMedia | null
@@ -10,65 +9,86 @@ interface SelectionHost {
loadSeq: number
replaceTracks: (tracks: SelectedTracks) => Promise<void>
}
type ResolveTracks = (input: Input, media: PlaybackMedia, value: unknown, current: () => boolean) => Promise<SelectedTracks | null>
export async function selectQuality(host: SelectionHost, value: unknown): Promise<void> {
const { input, media, loadSeq } = host
if (!media?.isHls || !input || host.destroyed)
const requests = new WeakMap<SelectionHost, object>()
async function select(host: SelectionHost, value: unknown, resolve: ResolveTracks): Promise<void> {
const { input, loadSeq } = host
if (!host.media?.isHls || !input || host.destroyed)
return
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media
let selection: SelectedTracks
try {
const tracks = await input.getVideoTracks()
if (!current())
const token = {}
requests.set(host, token)
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && requests.get(host) === token
while (current()) {
const media: PlaybackMedia | null = host.media
if (!media?.isHls)
return
const videoTrack = value === 'auto' ? await input.getPrimaryVideoTrack() : tracks.find(track => track.id === value) ?? media.videoTrack
if (!current() || !videoTrack)
return
let audioTrack = media.audioTrack
let audioMode = media.audioMode
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack()
audioMode = 'auto'
let selection: SelectedTracks | null
try {
selection = await resolve(input, media, value, current)
}
catch (error) {
if (!current())
return
if (host.media === media)
throw error
continue
}
selection = { videoTrack, audioTrack, videoMode: value === 'auto' ? 'auto' : 'manual', audioMode }
}
catch (error) {
if (!current())
return
throw error
if (host.media !== media)
continue
if (selection) {
try {
await host.replaceTracks(selection)
}
catch (error) {
if (current())
throw error
}
}
return
}
if (current())
await host.replaceTracks(selection)
}
export async function selectAudio(host: SelectionHost, value: unknown): Promise<void> {
const { input, media, loadSeq } = host
if (!media?.isHls || !input || host.destroyed)
return
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media
let selection: SelectedTracks
try {
const tracks = await input.getAudioTracks()
if (!current())
return
const audioTrack = value === 'auto'
? media.videoTrack ? await media.videoTrack.getPrimaryPairableAudioTrack() : await input.getPrimaryAudioTrack()
: tracks.find(track => track.id === value) ?? media.audioTrack
if (!current() || !audioTrack)
return
let videoTrack = media.videoTrack
let videoMode = media.videoMode
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack()
videoMode = 'auto'
}
selection = { videoTrack, audioTrack, videoMode, audioMode: value === 'auto' ? 'auto' : 'manual' }
async function quality(input: Input, media: PlaybackMedia, value: unknown, current: () => boolean): Promise<SelectedTracks | null> {
const tracks = await input.getVideoTracks()
if (!current())
return null
const videoTrack = value === 'auto' ? await input.getPrimaryVideoTrack() : tracks.find(track => track.id === value) ?? media.videoTrack
if (!current() || !videoTrack)
return null
let audioTrack = media.audioTrack
let audioMode = media.audioMode
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack()
audioMode = 'auto'
}
catch (error) {
if (!current())
return
throw error
}
if (current())
await host.replaceTracks(selection)
return { videoTrack, audioTrack, videoMode: value === 'auto' ? 'auto' : 'manual', audioMode }
}
async function audio(input: Input, media: PlaybackMedia, value: unknown, current: () => boolean): Promise<SelectedTracks | null> {
const tracks = await input.getAudioTracks()
if (!current())
return null
const audioTrack = value === 'auto'
? media.videoTrack ? await media.videoTrack.getPrimaryPairableAudioTrack() : await input.getPrimaryAudioTrack()
: tracks.find(track => track.id === value) ?? media.audioTrack
if (!current() || !audioTrack)
return null
let videoTrack = media.videoTrack
let videoMode = media.videoMode
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack()
videoMode = 'auto'
}
return { videoTrack, audioTrack, videoMode, audioMode: value === 'auto' ? 'auto' : 'manual' }
}
export function selectQuality(host: SelectionHost, value: unknown): Promise<void> {
return select(host, value, quality)
}
export function selectAudio(host: SelectionHost, value: unknown): Promise<void> {
return select(host, value, audio)
}
@@ -0,0 +1,64 @@
import type { DisplayConfig, Label, MenuHost, MenuModel, SelectorItem } from './m3u8-types'
export function releaseAll(actions: (() => void)[]): void {
const failures: unknown[] = []
for (const action of actions) {
try {
action()
}
catch (error) { failures.push(error) }
}
if (failures.length)
throw failures[0]
}
export default function createMenu(art: MenuHost, name: string, icon: string) {
const owned = { control: false, setting: false }
let model: MenuModel | null = null
function remove(surface: 'control' | 'setting'): void {
if (!owned[surface])
return
owned[surface] = false
if (surface === 'control') {
if (art.controls.cache.has(name))
art.controls.remove(name)
}
else if (art.setting.find(name)) {
art.setting.remove(name)
}
}
function clear(): void {
model = null
releaseAll([() => remove('control'), () => remove('setting')])
}
function update(next: MenuModel | null, config: DisplayConfig, active: () => boolean, select: (item: SelectorItem, title: string) => Promise<Label>): void {
if (!next) {
clear()
return
}
model = next
const valid = () => model === next && active()
const onSelect = async (item: SelectorItem): Promise<Label> => {
if (!valid() || !next.selector.some(entry => entry.value === item.value))
return item.html
return select(item, next.title)
}
if (config.control) {
owned.control = true
art.controls.update({ name, position: 'right', html: next.html, style: { padding: '0 10px' }, selector: next.selector, onSelect })
}
else {
remove('control')
}
if (!valid())
return
if (config.setting) {
owned.setting = true
art.setting.update({ name, tooltip: next.html, html: next.title, icon, width: 200, selector: next.selector, onSelect })
}
else {
remove('setting')
}
}
return { update, clear }
}
@@ -0,0 +1,56 @@
import type { ProxyOptions } from './engine-types'
import type { Label, MenuModel, SelectorItem, State } from './m3u8-types'
type Options = NonNullable<ProxyOptions['m3u8']>
function unique(items: SelectorItem[]): SelectorItem[] {
const positions = new Map<Label, number>()
const output: SelectorItem[] = []
for (const item of items) {
const position = positions.get(item.html)
if (item.html !== undefined && position !== undefined) {
if (item.default)
output[position] = item
}
else {
positions.set(item.html, output.length)
output.push(item)
}
}
return output
}
export function qualityModel(state: State | null, config: NonNullable<Options['quality']>, active: () => boolean): MenuModel | null {
if ((!config.control && !config.setting) || !state?.levels.length)
return null
const auto = config.auto || 'Auto'
const getName = config.getName || (level => level.name || `${level.height}P`)
const html = state.currentLevel ? getName(state.currentLevel) : auto
const items: SelectorItem[] = []
for (const level of state.levels) {
if (!active())
return null
items.push({ html: getName(level), value: level.id, default: state.currentLevel?.id === level.id })
}
const heights = new Map(state.levels.map(level => [level.id, level.height]))
const selector = unique(items).sort((left, right) => (right.value === 'auto' ? 0 : heights.get(right.value) || 0) - (left.value === 'auto' ? 0 : heights.get(left.value) || 0))
selector.push({ html: auto, value: 'auto', default: !state.currentLevel })
return { title: config.title || 'Quality', html, selector }
}
export function audioModel(state: State | null, config: NonNullable<Options['audio']>, active: () => boolean): MenuModel | null {
if ((!config.control && !config.setting) || !state || state.audios.length < 2)
return null
const auto = config.auto || 'Auto'
const getName = config.getName || (track => track.name || track.lang || track.language)
const html = state.currentAudio ? getName(state.currentAudio) : auto
const items: SelectorItem[] = []
for (const track of state.audios) {
if (!active())
return null
items.push({ html: getName(track), value: track.id, default: state.currentAudio?.id === track.id })
}
const selector = unique(items)
selector.push({ html: auto, value: 'auto', default: !state.currentAudio })
return { title: config.title || 'Audio', html, selector }
}
@@ -0,0 +1,53 @@
import type { HlsState, ProxyOptions } from './engine-types'
export type State = NonNullable<HlsState>
export type Level = State['levels'][number]
export type Audio = State['audios'][number]
export type Label = string | null | undefined
export interface DisplayConfig {
control?: boolean
setting?: boolean
}
export interface SelectorItem {
html: Label
value: number | 'auto'
default: boolean
}
export interface MenuModel {
title: string
html: Label
selector: SelectorItem[]
}
export interface MenuOption {
name: string
html: Label
selector: SelectorItem[]
onSelect: (item: SelectorItem) => Promise<Label>
position?: 'right'
style?: { padding: string }
tooltip?: Label
icon?: string
width?: number
}
interface Registry {
update: (menu: MenuOption) => unknown
remove: (name: string) => unknown
}
export interface MenuHost {
controls: Registry & { cache: { has: (name: string) => boolean } }
setting: Registry & { find: (name: string) => unknown }
notice: { show: string }
on: (name: string, callback: () => unknown) => unknown
off: (name: string, callback: () => unknown) => unknown
}
export interface MenuShim {
engine: { readonly loadSeq: number, readonly destroyed: boolean }
getM3u8State: () => Promise<HlsState>
switchM3u8Quality: (value: unknown) => Promise<void>
switchM3u8Audio: (value: unknown) => Promise<void>
}
export interface SetupOptions {
art: MenuHost
shim: MenuShim
option: ProxyOptions
}
@@ -1,191 +0,0 @@
import $audio from '../../artplayer-plugin-hls-control/src/audio.svg?raw'
import $quality from '../../artplayer-plugin-hls-control/src/quality.svg?raw'
function uniqBy(array, property) {
const seen = new Map()
return array.filter((item) => {
const key = item[property]
if (key === undefined) {
return true
}
return !seen.has(key) && seen.set(key, 1)
})
}
export function setupM3u8Controls({ art, shim, option }) {
if (!option.m3u8)
return
function removeControl(name) {
if (art.controls.cache.has(name)) {
art.controls.remove(name)
}
}
function removeSetting(name) {
if (art.setting.find(name)) {
art.setting.remove(name)
}
}
function clearQuality() {
removeControl('mediabunny-quality')
removeSetting('mediabunny-quality')
}
function clearAudio() {
removeControl('mediabunny-audio')
removeSetting('mediabunny-audio')
}
function canRender(config = {}) {
return config.control || config.setting
}
async function updateQuality(state) {
const config = option.m3u8?.quality || {}
if (!canRender(config) || !state?.levels.length) {
clearQuality()
return
}
const auto = config.auto || 'Auto'
const title = config.title || 'Quality'
const getName = config.getName || (level => level.name || `${level.height}P`)
const defaultHtml = state.currentLevel
? getName(state.currentLevel)
: auto
const selector = uniqBy(
state.levels.map((item) => {
return {
html: getName(item),
value: item.id,
default: state.currentLevel?.id === item.id,
}
}),
'html',
).sort((a, b) => {
const left = state.levels.find(item => item.id === a.value)
const right = state.levels.find(item => item.id === b.value)
return (right?.height || 0) - (left?.height || 0)
})
selector.push({
html: auto,
value: 'auto',
default: !state.currentLevel,
})
const onSelect = async (item) => {
await shim.switchM3u8Quality(item.value)
art.notice.show = `${title}: ${item.html}`
await update()
return item.html
}
if (config.control) {
art.controls.update({
name: 'mediabunny-quality',
position: 'right',
html: defaultHtml,
style: { padding: '0 10px' },
selector,
onSelect,
})
}
if (config.setting) {
art.setting.update({
name: 'mediabunny-quality',
tooltip: defaultHtml,
html: title,
icon: $quality,
width: 200,
selector,
onSelect,
})
}
}
async function updateAudio(state) {
const config = option.m3u8?.audio || {}
if (!canRender(config) || !state?.audios.length || state.audios.length < 2) {
clearAudio()
return
}
const auto = config.auto || 'Auto'
const title = config.title || 'Audio'
const getName = config.getName || (track => track.name || track.lang || track.language)
const defaultHtml = state.currentAudio
? getName(state.currentAudio)
: auto
const selector = uniqBy(
state.audios.map((item) => {
return {
html: getName(item),
value: item.id,
default: state.currentAudio?.id === item.id,
}
}),
'html',
)
selector.push({
html: auto,
value: 'auto',
default: !state.currentAudio,
})
const onSelect = async (item) => {
await shim.switchM3u8Audio(item.value)
art.notice.show = `${title}: ${item.html}`
await update()
return item.html
}
if (config.control) {
art.controls.update({
name: 'mediabunny-audio',
position: 'right',
html: defaultHtml,
style: { padding: '0 10px' },
selector,
onSelect,
})
}
if (config.setting) {
art.setting.update({
name: 'mediabunny-audio',
tooltip: defaultHtml,
html: title,
icon: $audio,
width: 200,
selector,
onSelect,
})
}
}
async function update() {
const state = await shim.getM3u8State()
if (!state) {
clearQuality()
clearAudio()
return
}
await Promise.all([
updateQuality(state),
updateAudio(state),
])
}
art.on('video:loadedmetadata', update)
art.on('restart', update)
return { update }
}
@@ -0,0 +1,104 @@
import type { Label, SelectorItem, SetupOptions } from './m3u8-types'
import $audio from '../../artplayer-plugin-hls-control/src/audio.svg?raw'
import $quality from '../../artplayer-plugin-hls-control/src/quality.svg?raw'
import createMenu, { releaseAll } from './m3u8-menu'
import { audioModel, qualityModel } from './m3u8-model'
export function setupM3u8Controls({ art, shim, option }: SetupOptions) {
if (!option.m3u8)
return
let closed = false
let revision = 0
let selection = 0
const quality = createMenu(art, 'mediabunny-quality', $quality)
const audio = createMenu(art, 'mediabunny-audio', $audio)
const current = (source: number) => !closed && !shim.engine.destroyed && shim.engine.loadSeq === source
async function update(): Promise<void> {
if (closed || shim.engine.destroyed)
return
const version = ++revision
const source = shim.engine.loadSeq
const active = () => version === revision && current(source)
try {
const state = await shim.getM3u8State()
if (!active())
return
const qualityConfig = option.m3u8?.quality || {}
const qualityView = qualityModel(state, qualityConfig, active)
if (!active())
return
quality.update(qualityView, qualityConfig, () => current(source), (item, title) => select('quality', item, title))
if (!active())
return
const audioConfig = option.m3u8?.audio || {}
const audioView = audioModel(state, audioConfig, active)
if (active())
audio.update(audioView, audioConfig, () => current(source), (item, title) => select('audio', item, title))
}
catch (error) {
if (active())
throw error
}
}
async function select(kind: 'quality' | 'audio', item: SelectorItem, title: string): Promise<Label> {
const intent = ++selection
const source = shim.engine.loadSeq
revision++
const active = () => intent === selection && current(source)
try {
if (kind === 'quality')
await shim.switchM3u8Quality(item.value)
else
await shim.switchM3u8Audio(item.value)
if (active()) {
await update()
if (active())
art.notice.show = `${title}: ${item.html}`
}
}
catch (error) {
if (active()) {
await refresh()
if (active())
throw error
}
}
return item.html
}
function refresh(): Promise<void> {
return update().catch(error => console.warn('MediaBunny HLS menu update:', error))
}
function invalidate(): void {
revision++
selection++
try {
releaseAll([quality.clear, audio.clear])
}
catch (error) { console.warn('MediaBunny HLS menu cleanup:', error) }
}
function destroy(): void {
if (closed)
return
closed = true
invalidate()
try {
releaseAll([
() => art.off('video:loadedmetadata', refresh),
() => art.off('restart', refresh),
() => art.off('video:loadstart', invalidate),
() => art.off('video:error', invalidate),
() => art.off('destroy', destroy),
])
}
catch (error) { console.warn('MediaBunny HLS listener cleanup:', error) }
}
art.on('video:loadedmetadata', refresh)
art.on('restart', refresh)
art.on('video:loadstart', invalidate)
art.on('video:error', invalidate)
art.on('destroy', destroy)
return { update }
}
@@ -4,5 +4,5 @@
"allowJs": false,
"lib": ["ES2020", "DOM", "DOM.Iterable", "ESNext.Disposable"]
},
"include": ["src/**/*.ts", "types/**/*.d.ts"]
"include": ["src/**/*.ts", "types/**/*.d.ts", "../../types/assets.d.ts"]
}
+13
View File
@@ -0,0 +1,13 @@
{
"task": "PKG-MB-07",
"sourceCommit": "ccee9157a70725e7f6bf77dc43ff2566bfa14540",
"sourceSha256LF": {
"packages/artplayer-proxy-mediabunny/src/m3u8.js": "4639f09e0c743dc2b24078f48ca1e58eaaedaa3f5e6b863583cbd4bc4a7d3142",
"packages/artplayer-proxy-mediabunny/src/hls-selection.ts": "a8632c0ea7d0c59434de005eee81bbf8d1dea8ae73c690673b603e5d6fa3cfd6",
"packages/artplayer-proxy-mediabunny/src/hls-state.ts": "ff9ffd742f9763349df1f50eac6b6bd488a80d05aaab8ca198f387f0d17d5e25",
"packages/artplayer-proxy-mediabunny/src/MediaBunnyEngine.ts": "3d51f6ecd247d1622809b5b679b906eb9b5ad4f9332a5d52dd38e5e2e29f38e9",
"packages/artplayer-proxy-mediabunny/src/playback.ts": "e2a65782c89627198752ff1fe691d4eea0bef215d883a59f0bdcd3e904a4fb47",
"packages/artplayer-proxy-mediabunny/src/index.js": "4e1be8937908ff6e6ef8a4e863e22b4468312cf31d66ac299322e475757773bb",
"packages/artplayer-proxy-mediabunny/dist/artplayer-proxy-mediabunny.js": "c80fd9015dd5d9c338ea780948dcb82f97a32f8395de20e19daf5cc65f05eb57"
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,70 @@
# PKG-MB-07:HLS配对、选择意图与UI拓扑
任务通过完整CI后标记done,并立即以专用本地提交交付。来源ccee9157a70725e7f6bf77dc43ff2566bfa14540,
冻结源码及main见[来源基线](../baselines/mb-hls-source.json),最终检查、报告和哈希见
[验证记录](../baselines/mb-hls-validation.json)。
## 实际结构
删除m3u8.js,迁移为m3u8.ts,拆出m3u8-model.ts、m3u8-menu.ts、m3u8-types.ts。
协调模块管理refresh版本、选择意图、source与事件;model生成label、排序和selector;
menu拥有controls/setting两个独立入口,types限定所需UI/shim能力,避免接收不受约束的art。
包内30个生产TS模块,剩index.js由MB-08接续。tsconfig引用现有共享assets.d.ts声明SVG导入;
没有新依赖,public Option/Result/factory声明未修改。
hls-selection.ts统一质量/音频请求的意图标识与source/Input检查,并分别保留配对策略。
较新的请求使尚在查询的旧请求失效,包括质量和音频交叉选择。已进入replaceTracks的操作
仍由Playback控制,到下一个replacement开始才被替代,不声称点击即同步终止SDK解码。
当先前replacement在最新查询期间安装另一份media时,最新查询对新的media重新计算,
避免简单的media相等检查丢弃最新意图,或Auto音轨按旧视频配对。当前查询失败仍拒绝;
过期查询/替换失败被观察,不覆盖当前选择。状态getter同时检查source代数和media身份。
## 兼容与生命周期修复
保留原有mediabunny-quality/audio名称、右侧control、setting宽度200、图标、间距、
标题/Auto/getName默认值、质量降序、通知格式、onSelect Promise与label返回值。
Auto仍是操作入口,显示与高亮基于实际选择轨道;自动模式下也不会只按mode伪造高亮。
相同label仍合并,但合并组中保留实际选中的ID,修复选中项被前一个同名条目遮蔽的问题。
排序改为提前建立height索引,避免比较器反复find数组。
refresh版本与选择意图分开:迟到状态不覆盖新模型,同一可见菜单在首个选择未完成时
仍接受下一次点击;自身loadedmetadata刷新不使有效选择的完成失效。被替换菜单或旧source
回调不再切轨、通知或刷新。选择失败恢复实际轨道高亮后保持原拒绝,恢复期间被新意图替代的
旧失败不再外溢;事件自动refresh明确观察拒绝。getName和registry回调的同步切源/销毁
在每个后续阶段前重新检查,避免重建已关闭UI。
control/setting分别拥有与移除。关闭一侧会清理它并保留另一侧;单音轨/纯视频清理audio,
非HLS清理两类菜单;loadstart/error/destroy立即使旧工作失效并清理已拥有入口。
清理尝试全部入口和全部监听器,即使其中一个抛错也继续,并保证入口的shim销毁仍执行。
这些模块不对外新增canvas属性或API;EnginePort补充已有只读source/destroyed能力用于内部检查。
## 复现与验证
- 首批27条旧main断言5通过/22失败;最终43条旧main为11通过/32失败,无取消或跳过。
候选全部通过,覆盖乱序刷新、旧回调、单入口关闭、失败恢复、清理异常、实际选中ID、
连续/跨类型轨道选择和配对期间media变化。配对和失败恢复的中间候选缺陷另有复现日志。
- 候选包专项226通过;main/legacy各202通过,每个产物命令含198候选断言及4冻结正常事件对照。
- 新增30浏览器场景:两套真实核心×五类场景×三个引擎。Chromium/Firefox共20项真实
MediaBunny解析/解码和DOM菜单验证;Windows WebKit10项明确媒体能力缺失对照。
- 同30条浏览器断言对旧main:14通过/16失败。四个正常菜单场景和十个能力对照通过;
失败分布在单侧control关闭、旧source/destroy状态写回、最新选择被早期请求覆盖。
单侧关闭失败是明确等待目标UI被删除未满足,其余是状态/写入次数/最终轨道断言。
- 真实菜单场景点击quality控件和audio设置项,再切Auto,验证实际配对与两个入口高亮;
每次替换恰好一套loadedmetadata/loadeddata/canplay/canplaythrough/seeked,三次共三套。
- 首次Firefox检查在解码完成前读取事件计数(为0)而失败,改用原超时等待精确完成序列;
下一轮暴露轮询未处理状态getter合法的临时null,改为等待非null目标值。没有放宽事件数量、
静默跳过或拉长超时,失败报告/results均保留。
- 最终构建main运行全部七个MediaBunny浏览器文件;详细结果在验证记录中核验。
- 严格TS、lint、三格式build、ESM默认factory及dist/docs逐字节一致性单独检查。
根test:unit与test:mediabunny加入HLS断言,完整CI持续执行。
最终完整CI1387项(1232单元+14工程+141基线)通过,另44重复契约;320生产TS。
全部七个MediaBunny浏览器文件108项通过,其中29项为Windows WebKit能力缺失对照。
最终源码/测试/产物/报告哈希已核验,main与浏览器所测文件字节一致,dist与docs三格式一致。
## 后续与回退
MB-LIFE-01仍open,剩长播放、完整媒体/核心/插件组合和物理设备证据归MB-09;编码能力/
undecodable fallback与公共shim/factory声明由MB-08及MB-CAP-01/MB-READY-01处理。
MB-10验证demo、真实安装包和MPL/source通知。本项通过不构成npm发布验收。
回退revert专用提交,历史冻结源码与报告保留。下一项MB-08完成入口TS、能力与声明边界。
+9
View File
@@ -71,3 +71,12 @@ main/legacy各159通过。最终78浏览器含16新增原生音频场景及8新
同24音频浏览器断言对旧main20通过/4失败,失败为实际暂停后的节点清理;其余为回归对照。
短时1×/2×帧/音频时钟偏差验证不替代长播放、听觉及设备验收;全部报告/results已归档。
完整CI1344项及另44重复契约通过,316生产TS;完成后立即专用提交,MB-LIFE-01剩余范围仍open。
## PKG-MB-07 HLS配对、选择与UI拓扑
实际m3u8迁移并拆成四个TS模块,包内30生产TS,仅index.js由08接续。
[变更](changes/2026-09-12-PKG-MB-07-hls.md)记录刷新/选择/source归属、稳定media配对、
兼容默认值、清理及回退;[证据](baselines/mb-hls-validation.json)记录43条断言旧main
11通过/32失败,候选专项226及main/legacy各202通过。最终108浏览器含20新增原生HLS
新旧核心/UI/SDK场景与10新增能力对照。旧main同30项14通过/16失败,未把能力缺失计作播放。
全部报告/results已归档;完整CI1387项及44重复契约通过,320生产TS;完成后立即专用提交,MB-LIFE-01剩余组合/长播范围仍open。
+3 -2
View File
@@ -4,7 +4,7 @@
基线:`40fcda6a37d0049d42e49c1e64e70d4fd9ba5f7f`。总任务 222 项,范围 22 个包及工作区/示例。
状态:todo 112 / doing 7 / blocked 0 / done 103 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
状态:todo 111 / doing 7 / blocked 0 / done 104 / deferred 0。风险 L/M/H 表示兼容风险,不表示工期。
前置依赖是启动条件;验收是完成条件。任务可以继续拆分,但不能复用或悄悄删除旧 ID。
@@ -335,7 +335,7 @@
| 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 | done |
| PKG-MB-07 | artplayer-proxy-mediabunny<br>整理 HLS 配对轨道与 selector | PKG-MB-05, PKG-MB-06, CORE-14 | m3u8 配对、质量/音频选择及拓扑清理 | 实际选择高亮、切到无轨道来源清理、无重复 readiness | H | todo |
| PKG-MB-07 | artplayer-proxy-mediabunny<br>整理 HLS 配对轨道与 selector | PKG-MB-05, PKG-MB-06, CORE-14 | m3u8 配对、质量/音频选择及拓扑清理 | 实际选择高亮、切到无轨道来源清理、无重复 readiness | H | done |
| 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 |
| PKG-MB-10 | artplayer-proxy-mediabunny<br>完成分发和文档 | PKG-MB-09, ENG-07 | mediabunny.js、README、依赖版本、三产物和许可 | tarball 可消费、无意外依赖升级、旧调用与回退可用 | H | todo |
@@ -526,4 +526,5 @@
- 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-MB-07: [记录](baselines/mb-hls-source.json) [记录](changes/2026-09-12-PKG-MB-07-hls.md) [记录](baselines/mb-hls-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)
+13
View File
@@ -1,5 +1,18 @@
# 进度与证据
## PKG-MB-07完成:HLS配对、选择意图与UI拓扑
m3u8实际迁移为四个TS模块,包内30生产TS,仅剩index.js待MB-08。
旧查询/菜单回调不再覆盖当前source;control/setting各自清理,失败恢复实际轨道高亮。
质量/音频共享最新选择意图,media在配对查询期间变化会重新计算,保留匹配轨道与正常默认行为。
43断言旧main11通过/32失败,候选专项226、main/legacy各202通过;旧main30项HLS浏览器
14通过/16失败,候选20新增原生新旧核心/UI/SDK场景和10能力对照通过。最终全部七个
MediaBunny文件108浏览器通过,29项为能力对照,不计作播放。完整CI1387及44重复契约通过,
320生产TS;源码/产物/报告哈希及三格式复制一致。
见[变更](changes/2026-09-12-PKG-MB-07-hls.md)与[证据](baselines/mb-hls-validation.json)。
222项:104 done、7 doing、111 todo;立即专用本地提交,下一项MB-08入口、能力与声明。
长播放、全组合、设备及安装包仍待后续;未放行npm发布。
## PKG-MB-06完成:音频解码、时钟与资源归属
AudioEngine实际迁移TS,拆清clock、context、nodes、pump、task职责,删除最后音频声明桥。
+4 -2
View File
@@ -3648,7 +3648,7 @@
"PKG-MB-07",
"PKG-MB-09"
],
"compatibleResolution": "Reproduce pending operations and destroy/source-switch races before migrating each owner; preserve public event and promise behavior while preventing stale resource use. MB-02 now reproduces late load rejection and uncancelled waiting/loadstart timers after destroy in all three historical implementations using a controlled host. Other interleavings and full-core native behavior are not yet verified. Expanded MB-02 baseline reproduces play-after-pause/destroy, rejected-play false state, seek-after-destroy and retained successful-load timeout timers in all three actual historical implementations; Range and active-timeout normal controls remain separate. MB-03 candidate owns pending Input before track selection and cancels load/HEAD/timers on source replacement, timeout and destroy; source/main/legacy regressions pass. Pending play/seek, late decoder work and full readiness coordination remain MB-04/05/06, so this risk stays open. MB-04 shim checkpoint fixes synthetic RAF retention and terminal event-listener cleanup, including engine cleanup exceptions. Closing the event bridge does not prove pending engine operations stopped; coordination remains in progress. 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.",
"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. MB-07 migrates HLS UI to four checked modules, guards stale refresh/selection callbacks, cleans each surface and owns terminal listeners. Quality/audio queries share a latest-intent token and retry pairing against changed media snapshots. Forty-three old-main assertions yield 11 pass/32 fail; sixteen old native UI/query cases fail and twenty candidate native cases pass across old/new cores. Long-run AV sync and full combinations remain MB-09; capability/type/package gates remain MB-08/10.",
"closureCriteria": "Historical failing interleavings and candidate cancellation/cleanup pass with real media and event evidence, including long playback and track topology.",
"status": "open",
"evidence": [
@@ -3666,7 +3666,9 @@
"refactor/changes/2026-09-12-PKG-MB-05-video.md",
"refactor/baselines/mb-video-validation.json",
"refactor/changes/2026-09-12-PKG-MB-06-audio.md",
"refactor/baselines/mb-audio-validation.json"
"refactor/baselines/mb-audio-validation.json",
"refactor/changes/2026-09-12-PKG-MB-07-hls.md",
"refactor/baselines/mb-hls-validation.json"
]
},
{
+6 -2
View File
@@ -3524,11 +3524,15 @@
"PKG-MB-06",
"CORE-14"
],
"status": "todo",
"status": "done",
"risk": "H",
"deliverable": "m3u8 配对、质量/音频选择及拓扑清理",
"acceptance": "实际选择高亮、切到无轨道来源清理、无重复 readiness",
"evidence": []
"evidence": [
"baselines/mb-hls-source.json",
"changes/2026-09-12-PKG-MB-07-hls.md",
"baselines/mb-hls-validation.json"
]
},
{
"id": "PKG-MB-08",
+203
View File
@@ -0,0 +1,203 @@
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 media = 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 core of ['published', 'candidate']) {
for (const scenario of ['menus', 'topology', 'stale-source', 'stale-destroy', 'latest']) {
test(`MediaBunny ${implementation.name}: ${core} core native HLS ${scenario}`, async ({ page, browserName }, testInfo) => {
await page.route('**/mb-ui-hls/**', async (route) => {
const name = new URL(route.request().url()).pathname.split('/').at(-1)
const body = media.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=${core}`)
await page.addScriptTag({ content: `(() => { const module = { exports: {} }; const exports = module.exports;
${implementation.code}; window.mbFactory = module.exports.default || module.exports; })();` })
await page.evaluate(() => {
window.mbEvents = []
window.mbErrors = []
window.mbWrites = []
window.mbOption = { m3u8: { quality: { control: true, setting: true }, audio: { control: true, setting: true } } }
window.mbFlush = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
window.art = new window.Artplayer({ container: '.player', url: '/mb-ui-hls/master.m3u8', muted: true, setting: true, proxy: window.mbFactory(window.mbOption) })
window.mbCanvas = window.art.video
for (const event of ['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'seeked', 'error'])
window.art.on(`video:${event}`, () => window.mbEvents.push(event))
document.querySelector('#play').onclick = () => window.art.play().catch(error => window.mbErrors.push(error.message))
})
const capabilities = await page.evaluate(() => ({ AudioContext: typeof AudioContext, webkitAudioContext: typeof window.webkitAudioContext, AudioDecoder: typeof AudioDecoder, VideoDecoder: typeof VideoDecoder, coreVersion: window.Artplayer.version }))
let outcome = 'unverified'
let result
try {
if (capabilities.AudioContext === 'undefined' && capabilities.webkitAudioContext === 'undefined') {
expect(browserName).toBe('webkit')
await expect.poll(() => page.evaluate(() => window.mbCanvas.error?.code)).toBe(4)
expect(await page.evaluate(() => window.art.isReady)).toBe(false)
expect(await page.locator('.art-control-mediabunny-quality').count()).toBe(0)
outcome = 'unsupported-capability-control'
return
}
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
await expect(page.locator('.art-control-mediabunny-quality .art-selector-value')).toHaveText('180P')
await expect(page.locator('.art-control-mediabunny-audio .art-selector-value')).toHaveText('English')
await page.evaluate(() => {
for (const name of ['controls', 'setting']) {
const registry = window.art[name]
const update = registry.update.bind(registry)
registry.update = (option) => {
if (option.name?.startsWith('mediabunny-'))
window.mbWrites.push({ surface: name, name: option.name })
return update(option)
}
}
})
if (scenario === 'menus') {
await page.click('#play')
await expect.poll(() => page.evaluate(() => window.mbCanvas.currentTime)).toBeGreaterThan(0.2)
await page.evaluate(() => {
window.art.pause()
window.mbEvents.length = 0
})
const quality = page.locator('.art-control-mediabunny-quality')
await quality.hover()
await quality.locator('.art-selector-item').filter({ hasText: /^90P$/ }).click()
await expect.poll(() => page.evaluate(() => window.mbCanvas.getM3u8State().then(state => state?.currentLevel?.height))).toBe(90)
await expect(page.locator('.art-control-mediabunny-quality .art-selector-value')).toHaveText('90P')
await expect.poll(() => page.evaluate(() => Object.fromEntries(['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'seeked'].map(name => [name, window.mbEvents.filter(event => event === name).length])))).toEqual({ loadedmetadata: 1, loadeddata: 1, canplay: 1, canplaythrough: 1, seeked: 1 })
await page.evaluate(() => {
const menu = window.art.setting.find('mediabunny-audio')
window.art.setting.show = true
window.art.setting.render(menu.selector)
menu.selector.find(item => item.html === 'French').$item.click()
})
await expect.poll(() => page.evaluate(() => window.mbCanvas.getM3u8State().then(state => state?.currentAudio?.lang))).toBe('fr')
await expect(page.locator('.art-control-mediabunny-audio .art-selector-value')).toHaveText('French')
await expect.poll(() => page.evaluate(() => window.mbEvents.filter(event => event === 'seeked').length)).toBe(2)
await page.evaluate(() => {
window.art.setting.show = false
})
await quality.hover()
await quality.locator('.art-selector-item').filter({ hasText: /^Auto$/ }).click()
await expect.poll(() => page.evaluate(() => window.mbCanvas.getM3u8State().then(state => state?.videoMode))).toBe('auto')
await expect(page.locator('.art-control-mediabunny-quality .art-selector-value')).toHaveText('180P')
await expect.poll(() => page.evaluate(() => Object.fromEntries(['loadedmetadata', 'loadeddata', 'canplay', 'canplaythrough', 'seeked'].map(name => [name, window.mbEvents.filter(event => event === name).length])))).toEqual({ loadedmetadata: 3, loadeddata: 3, canplay: 3, canplaythrough: 3, seeked: 3 })
result = await page.evaluate(async () => {
const state = await window.mbCanvas.getM3u8State()
const quality = window.art.controls.cache.get('mediabunny-quality').option
const audio = window.art.setting.find('mediabunny-audio')
return { currentLevel: state.currentLevel.id, qualitySelected: quality.selector.filter(item => item.default).map(item => item.value), currentAudio: state.currentAudio.id, audioSelected: audio.selector.filter(item => item.default).map(item => item.value), paired: state.currentLevel.track.canBePairedWith(state.currentAudio.track), errors: window.mbErrors }
})
expect(result.qualitySelected).toEqual([result.currentLevel])
expect(result.audioSelected).toEqual([result.currentAudio])
expect(result.paired).toBe(true)
expect(result.errors).toEqual([])
}
else if (scenario === 'topology') {
await page.evaluate(() => {
window.mbOption.m3u8.quality.control = false
window.mbOption.m3u8.audio.control = false
window.art.emit('restart')
})
await expect(page.locator('.art-control-mediabunny-quality')).toHaveCount(0)
await expect(page.locator('.art-control-mediabunny-audio')).toHaveCount(0)
expect(await page.evaluate(() => Boolean(window.art.setting.find('mediabunny-quality') && window.art.setting.find('mediabunny-audio')))).toBe(true)
await page.evaluate(async () => {
window.mbOption.m3u8.quality.control = true
window.mbOption.m3u8.audio.control = true
await window.mbCanvas.engine.load('/mb-ui-hls/video-only.m3u8')
})
await expect(page.locator('.art-control-mediabunny-quality')).toHaveCount(1)
await expect(page.locator('.art-control-mediabunny-audio')).toHaveCount(0)
expect(await page.evaluate(() => Boolean(window.art.setting.find('mediabunny-audio')))).toBe(false)
await page.evaluate(() => window.mbCanvas.engine.load('/test/pattern.mp4'))
await expect(page.locator('[class*="art-control-mediabunny-"]')).toHaveCount(0)
expect(await page.evaluate(() => Boolean(window.art.setting.find('mediabunny-quality') || window.art.setting.find('mediabunny-audio')))).toBe(false)
result = { settingsSurviveControlDisable: true, audioRemovedForVideoOnly: true, allRemovedForMp4: true }
}
else if (scenario.startsWith('stale-')) {
await page.evaluate(() => {
const engine = window.mbCanvas.engine
const getState = engine.getHlsState.bind(engine)
let first = true
engine.getHlsState = async () => {
const state = await getState()
if (first) {
first = false
return new Promise((resolve) => {
window.mbRelease = () => resolve(state)
})
}
return state
}
window.art.emit('restart')
})
await expect.poll(() => page.evaluate(() => typeof window.mbRelease)).toBe('function')
result = await page.evaluate(async (scenario) => {
if (scenario === 'stale-source')
await window.mbCanvas.engine.load('/test/pattern.mp4')
else window.art.destroy(false)
const before = window.mbWrites.length
window.mbRelease()
await window.mbFlush()
return { before, after: window.mbWrites.length, menus: document.querySelectorAll('[class*="art-control-mediabunny-"]').length }
}, scenario)
expect(result.after).toBe(result.before)
expect(result.menus).toBe(0)
}
else {
await page.evaluate(async () => {
const engine = window.mbCanvas.engine
const input = engine.input
const getTracks = input.getVideoTracks.bind(input)
const tracks = await getTracks()
window.mbDesired = tracks.find(track => track.id !== engine.media.videoTrack.id).id
let calls = 0
input.getVideoTracks = () => {
calls++
if (calls === 1)
return new Promise((resolve) => { window.mbReleaseOlder = () => resolve(tracks) })
if (calls === 2)
return new Promise((resolve) => { window.mbReleaseNewer = () => resolve(tracks) })
return getTracks()
}
window.mbOlder = window.mbCanvas.switchM3u8Quality(engine.media.videoTrack.id)
window.mbNewer = window.mbCanvas.switchM3u8Quality(window.mbDesired)
window.mbReleaseOlder()
await window.mbOlder
window.mbReleaseNewer()
await window.mbNewer
})
result = await page.evaluate(async () => {
const state = await window.mbCanvas.getM3u8State()
return { desired: window.mbDesired, selected: state.currentLevel.id, ready: window.mbCanvas.readyState, events: window.mbEvents }
})
expect(result.selected).toBe(result.desired)
expect(result.ready).toBe(4)
}
outcome = 'native-hls-ui-and-selection-observed'
}
finally {
const state = await page.evaluate(() => {
window.mbRelease?.()
window.mbReleaseOlder?.()
window.mbReleaseNewer?.()
window.mbContext = window.mbCanvas.engine.audio.audioContext
window.art.destroy(false)
return { events: window.mbEvents, errors: window.mbErrors, writes: window.mbWrites, error: window.mbCanvas.error, destroyed: window.mbCanvas.engine.destroyed }
})
await expect.poll(() => page.evaluate(() => window.mbContext?.state || 'not-created')).toMatch(/^(?:closed|not-created)$/)
await testInfo.attach('mediabunny-hls-ui', { contentType: 'application/json', body: JSON.stringify({ implementation: implementation.name, sha256: hash(implementation.code), core, scenario, capabilities, outcome, result, state, scope: 'Actual old/candidate core, SDK HLS parsing and native decoding; rendered control/settings clicks, topology and controlled state/query delays. Not installed-package or long-run/device acceptance.' }) })
}
})
}
}
+539
View File
@@ -0,0 +1,539 @@
import assert from 'node:assert/strict'
// eslint-disable-next-line test/no-import-node-test -- Actual proxy, controlled SDK/UI interleavings and immutable old artifact controls.
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 }
}
function state(selected = 1) {
const levels = [
{ id: 1, index: 0, name: '720P', height: 720, bitrate: 2000 },
{ id: 2, index: 1, name: '1080P', height: 1080, bitrate: 4000 },
]
const audios = [
{ id: 3, index: 0, name: 'English', lang: 'en', language: 'en', bitrate: 128 },
{ id: 4, index: 1, name: 'French', lang: 'fr', language: 'fr', bitrate: 128 },
]
return { levels, audios, currentLevel: levels.find(track => track.id === selected), currentAudio: audios[0], videoMode: 'auto', audioMode: 'auto' }
}
function environment(option = { m3u8: { quality: { control: true, setting: true }, audio: { control: true, setting: true } } }) {
const env = mbEnvironment(implementation)
const calls = []
const warnings = []
const notices = []
const controls = new Map()
const settings = new Map()
env.globals.console = { ...console, warn: (...args) => warnings.push(args), error() {} }
const registry = (cache, kind) => ({
cache,
find: name => cache.get(name),
update(menu) {
calls.push([kind, 'update', menu.name])
cache.set(menu.name, menu)
},
remove(name) {
calls.push([kind, 'remove', name])
cache.delete(name)
},
})
env.art.controls = registry(controls, 'control')
env.art.setting = registry(settings, 'setting')
env.art.notice = {
get show() { return notices.at(-1) },
set show(value) { notices.push(value) },
}
env.art.off = (name, callback) => {
env.handlers.set(name, (env.handlers.get(name) || []).filter(item => item !== callback))
}
const canvas = env.factory(option)(env.art)
canvas.engine.getHlsState = async () => state()
const refresh = async () => {
await Promise.all((env.handlers.get('video:loadedmetadata') || []).map(callback => callback()))
}
return { ...env, canvas, option, controls, settings, calls, warnings, notices, refresh }
}
test('MediaBunny HLS retains normal menu names, ordering, defaults and notices', async () => {
const env = environment()
await env.refresh()
const quality = env.controls.get('mediabunny-quality')
assert.equal(quality.html, '720P')
assert.deepEqual(Array.from(quality.selector, item => [item.value, item.default]), [[2, false], [1, true], ['auto', false]])
assert.equal(env.settings.get('mediabunny-quality').width, 200)
assert.equal(env.settings.get('mediabunny-audio').html, 'Audio')
const selected = []
env.canvas.engine.selectHlsQuality = async value => selected.push(value)
assert.equal(await quality.onSelect(quality.selector[0]), '1080P')
assert.deepEqual(selected, [2])
assert.deepEqual(env.notices, ['Quality: 1080P'])
env.art.emit('destroy')
})
test('MediaBunny disabled HLS integration installs no menu callbacks', () => {
const env = environment({})
assert.equal(env.handlers.get('restart'), undefined)
assert.equal(env.controls.size, 0)
env.art.emit('destroy')
})
test('MediaBunny HLS clears all menus for a settled non-HLS source', async () => {
const env = environment()
await env.refresh()
env.canvas.engine.getHlsState = async () => null
await env.refresh()
assert.equal(env.controls.size + env.settings.size, 0)
env.art.emit('destroy')
})
for (const surface of ['control', 'setting']) {
test(`MediaBunny HLS removes disabled ${surface} while preserving the other surface`, async () => {
const env = environment()
await env.refresh()
env.option.m3u8.quality[surface] = false
env.option.m3u8.audio[surface] = false
await env.refresh()
assert.equal(env[surface === 'control' ? 'controls' : 'settings'].size, 0)
assert.equal(env[surface === 'control' ? 'settings' : 'controls'].size, 2)
env.art.emit('destroy')
})
}
for (const ending of ['newer', 'source', 'destroy']) {
test(`MediaBunny HLS pending state cannot paint after ${ending}`, async () => {
const env = environment()
const pending = deferred()
env.canvas.engine.getHlsState = () => pending.promise
const refresh = env.refresh()
if (ending === 'newer') {
env.canvas.engine.getHlsState = async () => state(2)
await env.refresh()
}
else if (ending === 'source') {
env.canvas.engine.loadSeq++
}
else {
env.art.emit('destroy')
}
pending.resolve(state(1))
await refresh
if (ending === 'newer')
assert.equal(env.controls.get('mediabunny-quality').html, '1080P')
else assert.equal(env.controls.size + env.settings.size, 0)
env.art.emit('destroy')
})
}
for (const event of ['video:loadstart', 'video:error', 'destroy']) {
test(`MediaBunny HLS ${event} clears rendered menus immediately`, async () => {
const env = environment()
await env.refresh()
env.art.emit(event)
assert.equal(env.controls.size + env.settings.size, 0)
env.art.emit('destroy')
})
}
for (const ending of ['menu', 'source', 'destroy']) {
test(`MediaBunny stale HLS selection callback is inert after ${ending}`, async () => {
const env = environment()
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
const calls = []
env.canvas.engine.selectHlsQuality = async value => calls.push(value)
if (ending === 'menu')
await env.refresh()
else if (ending === 'source')
env.canvas.engine.loadSeq++
else env.art.emit('destroy')
assert.equal(await menu.onSelect(menu.selector[0]), '1080P')
assert.deepEqual(calls, [])
assert.deepEqual(env.notices, [])
env.art.emit('destroy')
})
}
for (const ending of ['newer', 'source', 'destroy']) {
test(`MediaBunny pending HLS selection cannot notify after ${ending}`, async () => {
const env = environment()
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
const pending = deferred()
env.canvas.engine.selectHlsQuality = () => pending.promise
const selecting = menu.onSelect(menu.selector[0])
if (ending === 'newer') {
env.canvas.engine.selectHlsQuality = async () => {}
await menu.onSelect(menu.selector[1])
}
else if (ending === 'source') {
env.canvas.engine.loadSeq++
}
else {
env.art.emit('destroy')
}
const notices = env.notices.slice()
pending.resolve()
await selecting
assert.deepEqual(env.notices, notices)
env.art.emit('destroy')
})
}
test('MediaBunny background HLS refresh failure is observed without an unhandled rejection', async () => {
const env = environment()
const failure = new Error('state unavailable')
env.canvas.engine.getHlsState = async () => {
throw failure
}
await env.refresh()
assert.equal(env.warnings.length, 1)
assert.equal(env.warnings[0][1], failure)
env.art.emit('destroy')
})
test('MediaBunny current HLS selection failure retains its Promise rejection without success notice', async () => {
const env = environment()
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
const failure = new Error('selection unavailable')
env.canvas.engine.selectHlsQuality = async () => {
throw failure
}
await assert.rejects(menu.onSelect(menu.selector[0]), error => error === failure)
assert.deepEqual(env.notices, [])
env.art.emit('destroy')
})
test('MediaBunny same-label levels retain the actually selected ID and one highlight', async () => {
const env = environment()
env.option.m3u8.quality.getName = () => 'Same'
env.canvas.engine.getHlsState = async () => state(2)
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
assert.deepEqual(Array.from(menu.selector, item => [item.value, item.default]), [[2, true], ['auto', false]])
env.art.emit('destroy')
})
test('MediaBunny HLS single audio topology removes only audio menus', async () => {
const env = environment()
await env.refresh()
const single = state()
single.audios.length = 1
env.canvas.engine.getHlsState = async () => single
await env.refresh()
assert.deepEqual([...env.controls.keys()], ['mediabunny-quality'])
assert.deepEqual([...env.settings.keys()], ['mediabunny-quality'])
env.art.emit('destroy')
})
test('MediaBunny HLS name callback destroy cannot recreate controls or settings', async () => {
const env = environment()
env.option.m3u8.quality.getName = (level) => {
env.art.emit('destroy')
return level.name
}
await env.refresh()
assert.equal(env.controls.size + env.settings.size, 0)
})
test('MediaBunny HLS control update reentry cannot write later settings or audio menus', async () => {
const env = environment()
const update = env.art.controls.update
env.art.controls.update = (menu) => {
update(menu)
env.art.emit('destroy')
}
await env.refresh()
assert.equal(env.controls.size + env.settings.size, 0)
})
test('MediaBunny HLS destruction removes its refresh listeners and preserves proxy teardown', async () => {
const env = environment()
await env.refresh()
const metadataBefore = env.handlers.get('video:loadedmetadata').length
env.art.emit('destroy')
assert.equal(env.canvas.engine.destroyed, true)
assert.equal(env.art.mediabunny, undefined)
assert.equal(env.handlers.get('video:loadedmetadata').length, metadataBefore - 1)
assert.equal(env.handlers.get('restart')?.length || 0, 0)
})
function selectionEnvironment() {
const env = environment({})
const pairs = []
const videos = [1, 2].map(id => ({ id, canBePairedWith: () => true }))
const audios = [3, 4].map(id => ({ id, canBePairedWith: () => true }))
env.canvas.engine.input = { getVideoTracks: async () => videos, getAudioTracks: async () => audios }
env.canvas.engine.media = { isHls: true, videoTrack: videos[0], audioTrack: audios[0], videoMode: 'auto', audioMode: 'auto' }
env.canvas.engine.replaceTracks = async (tracks) => {
pairs.push(tracks)
env.canvas.engine.media = { ...env.canvas.engine.media, ...tracks }
}
return { ...env, engine: env.canvas.engine, videos, audios, pairs }
}
for (const [method, query, field, ids] of [['selectHlsQuality', 'getVideoTracks', 'videoTrack', [1, 2]], ['selectHlsAudio', 'getAudioTracks', 'audioTrack', [3, 4]]]) {
test(`MediaBunny ${method} honors the latest request when the older query resolves first`, async () => {
const env = selectionEnvironment()
const first = deferred()
const second = deferred()
const values = await env.engine.input[query]()
let queries = 0
env.engine.input[query] = () => (++queries === 1 ? first.promise : second.promise)
const older = env.engine[method](ids[0])
const newer = env.engine[method](ids[1])
first.resolve(values)
await flush()
second.resolve(values)
await Promise.all([older, newer])
assert.deepEqual(env.pairs.map(pair => pair[field].id), [ids[1]])
env.art.emit('destroy')
})
}
test('MediaBunny later audio intent supersedes an unresolved quality query', async () => {
const env = selectionEnvironment()
const pending = deferred()
env.engine.input.getVideoTracks = () => pending.promise
const older = env.engine.selectHlsQuality(2)
const newer = env.engine.selectHlsAudio(4)
pending.resolve(env.videos)
await Promise.all([older, newer])
assert.equal(env.pairs.length, 1)
assert.equal(env.pairs[0].audioTrack.id, 4)
assert.equal(env.pairs[0].videoTrack.id, 1)
env.art.emit('destroy')
})
test('MediaBunny HLS selection survives its own loadedmetadata refresh without a duplicate notice', async () => {
const env = environment()
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
env.canvas.engine.selectHlsQuality = async () => {
env.canvas.engine.getHlsState = async () => state(2)
await env.refresh()
}
await menu.onSelect(menu.selector[0])
assert.equal(env.controls.get('mediabunny-quality').html, '1080P')
assert.deepEqual(env.notices, ['Quality: 1080P'])
env.art.emit('destroy')
})
test('MediaBunny HLS selection invalidates an older state read before it can paint', async () => {
const env = environment()
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
const read = deferred()
const switchTrack = deferred()
env.canvas.engine.getHlsState = () => read.promise
const updating = env.refresh()
env.canvas.engine.selectHlsQuality = () => switchTrack.promise
const selecting = menu.onSelect(menu.selector[0])
const writes = env.calls.length
read.resolve(state())
await updating
assert.equal(env.calls.length, writes)
env.canvas.engine.getHlsState = async () => state(2)
switchTrack.resolve()
await selecting
assert.equal(env.controls.get('mediabunny-quality').html, '1080P')
env.art.emit('destroy')
})
test('MediaBunny failed HLS selection restores highlight from actual tracks before rejecting', async () => {
const env = environment()
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
for (const item of menu.selector) item.default = item.value === 2
const failure = new Error('track failed')
env.canvas.engine.selectHlsQuality = async () => {
throw failure
}
await assert.rejects(menu.onSelect(menu.selector[0]), error => error === failure)
assert.deepEqual(Array.from(env.controls.get('mediabunny-quality').selector).filter(item => item.default).map(item => item.value), [1])
env.art.emit('destroy')
})
test('MediaBunny stale HLS selection rejection is observed without disturbing the current source', async () => {
const env = environment()
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
const pending = deferred()
env.canvas.engine.selectHlsQuality = () => pending.promise
const selecting = menu.onSelect(menu.selector[0])
env.canvas.engine.loadSeq++
pending.reject(new Error('old source closed'))
await selecting
assert.equal(env.notices.length + env.warnings.length, 0)
env.art.emit('destroy')
})
test('MediaBunny HLS clears all owned surfaces even when one registry cleanup fails', async () => {
const env = environment()
await env.refresh()
const failure = new Error('control cleanup failed')
const remove = env.art.controls.remove
env.art.controls.remove = (name) => {
remove(name)
throw failure
}
env.art.emit('destroy')
assert.equal(env.controls.size + env.settings.size, 0)
assert.equal(env.canvas.engine.destroyed, true)
assert.equal(env.handlers.get('restart').length, 0)
assert.equal(env.warnings[0][1], failure)
})
test('MediaBunny HLS failing listener removal still attempts remaining removals and proxy teardown', async () => {
const env = environment()
const off = env.art.off
const removed = []
env.art.off = (name, callback) => {
removed.push(name)
off(name, callback)
if (name === 'restart')
throw new Error('off failed')
}
env.art.emit('destroy')
assert.equal(removed.length, 5)
assert.equal(env.canvas.engine.destroyed, true)
assert.equal(env.warnings.length, 1)
})
test('MediaBunny runtime HLS disable clears menus on the next refresh', async () => {
const env = environment()
await env.refresh()
env.option.m3u8 = undefined
await env.refresh()
assert.equal(env.controls.size + env.settings.size, 0)
env.art.emit('destroy')
})
test('MediaBunny HLS custom labels retain Auto text while highlighting the actual selected track', async () => {
const env = environment()
env.option.m3u8.audio = { control: true, setting: true, title: 'Language', auto: 'Automatic', getName: track => track.lang }
await env.refresh()
const menu = env.controls.get('mediabunny-audio')
assert.equal(menu.html, 'en')
assert.deepEqual(Array.from(menu.selector, item => [item.html, item.default]), [['en', true], ['fr', false], ['Automatic', false]])
const values = []
env.canvas.engine.selectHlsAudio = async value => values.push(value)
await menu.onSelect(menu.selector[2])
assert.deepEqual(values, ['auto'])
assert.deepEqual(env.notices, ['Language: Automatic'])
assert.equal(env.controls.get('mediabunny-audio').html, 'en')
env.art.emit('destroy')
})
for (const kind of ['quality', 'audio']) {
test(`MediaBunny ${kind} pairing fallback preserves a coherent pair and resets only the counterpart mode`, async () => {
const env = selectionEnvironment()
env.engine.media.videoMode = 'manual'
env.engine.media.audioMode = 'manual'
if (kind === 'quality') {
env.videos[1].canBePairedWith = () => false
env.videos[1].getPrimaryPairableAudioTrack = async () => env.audios[1]
await env.engine.selectHlsQuality(2)
assert.deepEqual([env.pairs[0].videoTrack.id, env.pairs[0].audioTrack.id, env.pairs[0].videoMode, env.pairs[0].audioMode], [2, 4, 'manual', 'auto'])
}
else {
env.audios[1].canBePairedWith = () => false
env.audios[1].getPrimaryPairableVideoTrack = async () => env.videos[1]
await env.engine.selectHlsAudio(4)
assert.deepEqual([env.pairs[0].videoTrack.id, env.pairs[0].audioTrack.id, env.pairs[0].videoMode, env.pairs[0].audioMode], [2, 4, 'auto', 'manual'])
}
env.art.emit('destroy')
})
}
test('MediaBunny latest selection still applies when an earlier replacement changes media during its query', async () => {
const env = selectionEnvironment()
const replacing = deferred()
const query = deferred()
env.engine.replaceTracks = async (tracks) => {
env.pairs.push(tracks)
if (env.pairs.length === 1)
await replacing.promise
env.engine.media = { ...env.engine.media, ...tracks }
}
const older = env.engine.selectHlsQuality(1)
await flush()
env.engine.input.getVideoTracks = () => query.promise
const latest = env.engine.selectHlsQuality(2)
replacing.resolve()
await older
query.resolve(env.videos)
await latest
assert.equal(env.engine.media.videoTrack.id, 2)
env.art.emit('destroy')
})
test('MediaBunny obsolete selection query rejection cannot replace the newer selection result', async () => {
const env = selectionEnvironment()
const query = deferred()
env.engine.input.getVideoTracks = () => query.promise
const older = env.engine.selectHlsQuality(2)
await env.engine.selectHlsAudio(4)
query.reject(new Error('obsolete query'))
await older
assert.equal(env.engine.media.audioTrack.id, 4)
env.art.emit('destroy')
})
test('MediaBunny Auto audio resolves against the current video when media changes during pairing', async () => {
const env = selectionEnvironment()
const pending = deferred()
env.videos[0].getPrimaryPairableAudioTrack = () => pending.promise
env.videos[1].getPrimaryPairableAudioTrack = async () => env.audios[1]
const selecting = env.engine.selectHlsAudio('auto')
await flush()
env.engine.media = { ...env.engine.media, videoTrack: env.videos[1] }
pending.resolve(env.audios[0])
await selecting
assert.equal(env.pairs[0].videoTrack.id, 2)
assert.equal(env.pairs[0].audioTrack.id, 4)
env.art.emit('destroy')
})
for (const ending of ['resolve', 'reject']) {
test(`MediaBunny state ${ending} is discarded when the source generation changes before media replacement`, async () => {
const env = environment({})
delete env.canvas.engine.getHlsState
const query = deferred()
env.canvas.engine.media = { isHls: true, input: { getVideoTracks: () => query.promise }, videoTrack: null }
const reading = env.canvas.engine.getHlsState()
env.canvas.engine.loadSeq++
if (ending === 'resolve')
query.resolve([])
else query.reject(new Error('obsolete state'))
assert.equal(await reading, null)
env.art.emit('destroy')
})
}
test('MediaBunny selection failure superseded during UI recovery does not reject after the newer intent', async () => {
const env = environment()
await env.refresh()
const menu = env.controls.get('mediabunny-quality')
const recovery = deferred()
env.canvas.engine.getHlsState = () => recovery.promise
env.canvas.engine.selectHlsQuality = async () => {
throw new Error('first selection failed')
}
const older = menu.onSelect(menu.selector[0]).then(value => ({ value }), error => ({ error }))
await flush()
env.canvas.engine.getHlsState = async () => state()
env.canvas.engine.selectHlsQuality = async () => {}
await menu.onSelect(menu.selector[1])
recovery.resolve(state())
assert.equal((await older).error, undefined)
assert.deepEqual(env.notices, ['Quality: 720P'])
env.art.emit('destroy')
})