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

@@ -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"]
}