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

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

No files matched your search

@@ -15,19 +15,22 @@ declarations, the factory signature, canvas forwarding, or package entrypoints.
| `hls-state.ts` | HLS track snapshots, bitrate/name/language values and actual selected track identity |
| `preflight.ts` | Optional HEAD Range check, historical warning/error policy, cancellation and stale-result guard |
| `load-session.ts` | Ownership of the pending/active SDK Input, HEAD AbortController, deferred load events and deadline |
| `MediaBunnyEngine.js` | Current coordinator; connects session cancellation to load, source replacement, errors and destroy |
| `MediaBunnyEngine.ts` | Load/readiness coordinator, source identity, errors and terminal teardown |
| `playback.ts` | Playback intent, cancellable play/seek/replacement operations and shared pending audio resume |
| `readiness.ts` | One metadata publication after both participants, guarded event sequences |
| `hls-selection.ts` | Source-bound track queries, pairing and active replacement errors |
| `VideoEngine.js` / `AudioEngine.js` | Current native decoding, rendering, seeking, Web Audio clock and scheduling |
| `VideoShim.ts` / `EventTarget.ts` | Video-like descriptors, synchronous/Promise forwarding and owned event lifecycle |
| `shim-values.ts` | Existing volume coercion and synthetic TimeRanges values |
| `shim-frames.ts` | Synthetic frame callbacks, per-instance RAF ownership and terminal cleanup |
| `engine-types.ts` / `MediaBunnyEngine.d.ts` | Explicit temporary coordinator boundary while its JavaScript implementation is migrated in MB-04 |
| `engine-types.ts` / `engine-ports.ts` | Internal shim, coordinator and decoder interfaces |
| `AudioEngine.d.ts` / `VideoEngine.d.ts` | Temporary boundaries for JS decoders, removed in MB-06/05 |
| `m3u8.js` | Current ArtPlayer control/setting integration |
The eleven TypeScript modules use strict checking with `skipLibCheck: false` and no
ambient Node types. The adjacent coordinator declaration is a transitional boundary,
not proof that the JavaScript coordinator has been checked. Replace it with the actual
strict implementation before completing MB-04. Remaining JavaScript is explicitly
scheduled in PKG-MB-04/05/06/07/08.
The sixteen production TypeScript modules use strict checking with `skipLibCheck: false`
and no ambient Node types. The coordinator is an actual checked implementation. The two
adjacent decoder declarations are temporary boundaries, not checking of their JavaScript
implementations. Remaining JavaScript is scheduled in PKG-MB-05/06/07/08.
Do not describe the whole package as migrated yet.
The shim keeps its existing own-property order and direct prototype surface because
@@ -40,7 +43,8 @@ EventTarget retains duplicate listeners, first-match removal, live-array `forEac
mutation semantics, detail identity and listener exceptions during normal dispatch.
Its terminal state stops callbacks later in a dispatch when a listener destroys the
shim. Shim teardown closes events in `finally`, even if engine cleanup throws, and
subsequent teardown is inert. Engine playback/readiness race fixes remain in progress.
subsequent teardown is inert. Engine teardown attempts input, audio and video cleanup
even when an earlier release throws, then preserves the first failure.
## Input and cancellation flow
@@ -69,6 +73,32 @@ HLS matching preserves the old case-insensitive `.m3u8` suffix/query/hash rule.
One explicit InputOptions assertion leaves unsupported values to SDK validation, preserving
the old error owner rather than silently converting arbitrary inputs.
## Playback and readiness ownership
Playback intent is separate from the temporary paused state during seek. Explicit pause
cancels pending play and prevents automatic resume; a newer seek settles the old operation
without allowing its completion or rejection to overwrite current state. Pending play waits
for the seek completion signal, resolved before optional resume to avoid a cycle.
A pending AudioContext resume is shared across play-pause-play; only the current operation
starts video and emits play/playing. Source change and destroy invalidate both operations.
Cancellation settles the public operation while observing the underlying late result;
actual late decoder work still belongs to MB-05/06.
Normal play, pause, seek/resume and HLS replacement event order is retained. Before each
event the coordinator rechecks ownership, since a listener may synchronously pause, seek,
load another source or destroy. Active play errors reject their Promise and restore paused
state; active seek errors additionally publish one media error. The synchronous currentTime
setter observes its otherwise unreturnable rejection; direct engine.seek still rejects.
Active replacement errors remain visible even after the media object changes. Stale HLS query
errors are ignored only when their captured source no longer owns the result.
Metadata publishes once after both audio/video participants report; repeated or obsolete
callbacks cannot ready the current source. A parsed container with neither track fails with
code 4 and message `Input has no audio or video tracks.` before decoder/AudioContext setup.
This corrects the old false-ready state; normal valid input event order is preserved.
Existing-track unsupported-codec handling remains a decoder capability task, not evidence
that every capability failure has been fixed.
## Types and dependencies
The locked runtime SDK remains MediaBunny 1.56.1. Its pinned WebCodecs declaration
@@ -101,7 +131,7 @@ The source of historical truth is `refactor/baselines/mb-release.json` and its G
inputs, not a rebuilt dist. Package-specific evidence and remaining tasks are in
`refactor/mb-validation.md`. The full refactor plan is at the repository root.
PKG-MB-04 still owns duplicate/trackless readiness, reentrant event sequences and
PKG-MB-04 covers duplicate/trackless readiness, reentrant event sequences and
pending play/seek coordination. PKG-MB-05/06 own late frame and audio callbacks,
including operations already inside decoders at cancellation; releasing Input alone
does not prove those resources are fully handled. PKG-MB-07 owns HLS UI/selection
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t=e=>{throw TypeError(e)},i=(e,i,r)=>i.has(e)||t("Cannot "+r),r=(e,t,r)=>(i(e,t,"read from private field"),r?r.call(e):t.get(e));function a(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class s{constructor(){var i,r,a;i=this,a=!1,(r=e).has(i)?t("Cannot add the same private member more than once"):r instanceof WeakSet?r.add(i):r.set(i,a),this.listeners=new Map}addEventListener(t,i){r(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),this.listeners.get(t)?.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){if(r(this,e))return;const a=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{r(this,e)||t(a)})}destroy(){var t,r,a,s;a=!0,i(t=this,r=e,"write to private field"),s?s.call(t,a):r.set(t,a),this.listeners.clear()}}function n(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const o=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},c=e=>e&&e[e.length-1],l=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},u=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-2**r)},d=e=>{const t=l(e);return 0===(1&t)?-(t>>1):t+1>>1},h=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),f=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),m=new TextDecoder,p=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),g={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},k=p(g),y={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},b=p(y),w={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},T=p(w),S=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,v={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},P=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class I{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}async acquire(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,await i,e}}const x=/^[0-9a-fA-F]+$/,C=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),B=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,E=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+a>>1,o=i(e[n]);o===t?(s=n,a=n-1):o<t?r=n+1:a=n-1}return s},_=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+(a-r+1)/2|0;i(e[n])<=t?(s=n,r=n+1):a=n-1}return s},R=(e,t,i)=>{const r=_(e,i(t),i);e.splice(r+1,0,t)},F=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},A=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},D=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},M=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},N=e=>{throw new Error(`Unexpected value: ${e}`)},O=(e,t,i)=>{const r=e.getUint8(t),a=e.getUint8(t+1),s=e.getUint8(t+2);return i?r|a<<8|s<<16:r<<16|a<<8|s},L=(e,t,i,r)=>{i>>>=0,i&=16777215,e.setUint8(t,i>>>16&255),e.setUint8(t+1,i>>>8&255),e.setUint8(t+2,255&i)},z=(e,t)=>({async next(){const i=await e.next();return i.done?{value:void 0,done:!0}:{value:t(i.value),done:!1}},return:()=>e.return(),throw:t=>e.throw(t),[Symbol.asyncIterator](){return this}}),U=(e,t,i)=>Math.max(t,Math.min(i,e)),q="und",W=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},H=(e,t)=>Math.round(e/t)*t,V=(e,t)=>Math.round(e*t)/t,j=(e,t)=>Math.floor(e/t)*t,K=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},X=/^[a-z]{3}$/,$=e=>X.test(e),G=1e6*(1+Number.EPSILON),Q=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?Y(e.headers):{},a=t.headers?Y(t.headers):{},s={...r};Object.entries(a).forEach(([e,t])=>{const i=Object.keys(s).find(t=>t.toLowerCase()===e.toLowerCase());i&&delete s[i],s[e]=t}),i.headers=s}return i},Y=e=>{if(e instanceof Headers){const t={};return e.forEach((e,i)=>{t[i]=e}),t}if(Array.isArray(e)){const t={};return e.forEach(([e,i])=>{t[e]=i}),t}return e},J=async(e,t,i,r,a)=>{let s=0;for(;;)try{return await e(t,i)}catch(n){if(a())throw n;s++;const e=r(s,n,t);if(null===e)throw n;if(ve._error("Retrying failed fetch. Error:",n),!Number.isFinite(e)||e<0)throw new TypeError("Retry delay must be aLine truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i=e=>{throw TypeError(e)},r=(e,t,r)=>t.has(e)||i("Cannot "+r),a=(e,t,i)=>(r(e,t,"read from private field"),i?i.call(e):t.get(e)),s=(e,t,r)=>t.has(e)?i("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,r),n=(e,t,i,a)=>(r(e,t,"write to private field"),a?a.call(e,i):t.set(e,i),i);function o(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class c{constructor(){s(this,e,!1),this.listeners=new Map}addEventListener(t,i){a(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),this.listeners.get(t)?.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){if(a(this,e))return;const r=Object.assign(new Event(t),{detail:i});this.listeners.get(t)?.forEach(t=>{a(this,e)||t(r)})}destroy(){n(this,e,!0),this.listeners.clear()}}function l(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const u=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},d=e=>e&&e[e.length-1],h=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},f=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-2**r)},m=e=>{const t=h(e);return 0===(1&t)?-(t>>1):t+1>>1},p=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),g=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),k=new TextDecoder,y=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),b={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},w=y(b),T={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},S=y(T),v={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},P=y(v),I=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,x={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},C=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class B{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}async acquire(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,await i,e}}const E=/^[0-9a-fA-F]+$/,_=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),R=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,F=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+a>>1,o=i(e[n]);o===t?(s=n,a=n-1):o<t?r=n+1:a=n-1}return s},A=(e,t,i)=>{let r=0,a=e.length-1,s=-1;for(;r<=a;){const n=r+(a-r+1)/2|0;i(e[n])<=t?(s=n,r=n+1):a=n-1}return s},D=(e,t,i)=>{const r=A(e,i(t),i);e.splice(r+1,0,t)},M=()=>{let e,t;return{promise:new Promise((i,r)=>{e=i,t=r}),resolve:e,reject:t}},N=(e,t)=>{const i=e.indexOf(t);-1!==i&&e.splice(i,1)},O=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return e[i]},L=(e,t)=>{for(let i=e.length-1;i>=0;i--)if(t(e[i]))return i;return-1},z=e=>{throw new Error(`Unexpected value: ${e}`)},U=(e,t,i)=>{const r=e.getUint8(t),a=e.getUint8(t+1),s=e.getUint8(t+2);return i?r|a<<8|s<<16:r<<16|a<<8|s},q=(e,t,i,r)=>{i>>>=0,i&=16777215,e.setUint8(t,i>>>16&255),e.setUint8(t+1,i>>>8&255),e.setUint8(t+2,255&i)},W=(e,t)=>({async next(){const i=await e.next();return i.done?{value:void 0,done:!0}:{value:t(i.value),done:!1}},return:()=>e.return(),throw:t=>e.throw(t),[Symbol.asyncIterator](){return this}}),H=(e,t,i)=>Math.max(t,Math.min(i,e)),V="und",j=e=>{const t=Math.round(e);return Math.abs(e/t-1)<10*Number.EPSILON?t:e},K=(e,t)=>Math.round(e/t)*t,X=(e,t)=>Math.round(e*t)/t,$=(e,t)=>Math.floor(e/t)*t,G=e=>{let t=0;for(;0!==e;)e&=e-1,t++;return t},Q=/^[a-z]{3}$/,Y=e=>Q.test(e),J=1e6*(1+Number.EPSILON),Z=(e,t)=>{const i={...e,...t};if(e.headers||t.headers){const r=e.headers?ee(e.headers):{},a=t.headers?ee(t.headers):{},s={...r};Object.entries(a).forEach(([e,t])=>{const i=Object.keys(s).find(t=>t.toLowerCase()===e.toLowerCase());i&&delete s[i],s[e]=t}),i.headers=s}return i},ee=e=>{if(e instanceof Headers){const t={};return e.forEach((e,i)=>{t[i]=e}),t}if(Array.isArray(e)){const t={};return e.forEach(([e,i])=>{t[e]=i}),t}return e},te=async(e,t,i,r,a)=>{let s=0;for(;;)try{return await e(t,i)}catch(n){if(a())throw n;s++;const e=r(s,n,t);if(null===e)throw n;if(xe._error("Retrying failed fetch. Error:",n),!Number.isFinite(e)||e<0)throw new TypeError("Retry delayLine truncated
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t=Object.defineProperty,i=Object.defineProperties,r=Object.getOwnPropertyDescriptors,n=Object.getOwnPropertySymbols,s=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,o=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),l=e=>{throw TypeError(e)},c=Math.pow,u=(e,i,r)=>i in e?t(e,i,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[i]=r,d=(e,t)=>{for(var i in t||(t={}))s.call(t,i)&&u(e,i,t[i]);if(n)for(var i of n(t))a.call(t,i)&&u(e,i,t[i]);return e},h=(e,t)=>i(e,r(t)),f=(e,t,i)=>t.has(e)||l("Cannot "+i),m=(e,t,i)=>(f(e,t,"read from private field"),i?i.call(e):t.get(e)),p=(e,t,i)=>new Promise((r,n)=>{var s=e=>{try{o(i.next(e))}catch(t){n(t)}},a=e=>{try{o(i.throw(e))}catch(t){n(t)}},o=e=>e.done?r(e.value):Promise.resolve(e.value).then(s,a);o((i=i.apply(e,t)).next())}),g=function(e,t){this[0]=e,this[1]=t},k=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof g,l=a.done;Promise.resolve(o?t[0]:t).then(i=>o?n("return"===e?e:"next",t[1]?{done:i.done,value:i.value}:i,r,s):r({value:i,done:l})).catch(e=>n("throw",e,r,s))}catch(c){s(c)}},s=(e,t,i,s)=>a[e]=a=>(t=new Promise((t,i,s)=>(s=()=>n(e,a,t,i),r?r.then(s):s())),s=()=>r===i&&(r=0),r=i=t.then(s,s),t),a={};return i=i.apply(e,t),a[o("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},y=e=>{var t,i=e[o("asyncIterator")],r=!1,n={};return null==i?(i=e[o("iterator")](),t=e=>n[e]=t=>i[e](t)):(i=i.call(e),t=e=>n[e]=t=>{if(r){if(r=!1,"throw"===e)throw t;return t}return r=!0,{done:!1,value:new g(new Promise(r=>{var n=i[e](t);n instanceof Object||l("Object expected"),r(n)}),1)}}),n[o("iterator")]=()=>n,t("next"),"throw"in i?t("throw"):n.throw=e=>{throw e},"return"in i&&t("return"),n},b=(e,t,i)=>(t=e[o("asyncIterator")])?t.call(e):(e=e[o("iterator")](),t={},(i=(i,r)=>(r=e[i])&&(t[i]=t=>new Promise((i,n,s)=>(t=r.call(e,t),s=t.done,Promise.resolve(t.value).then(e=>i({value:e,done:s}),n)))))("next"),i("return"),t);function T(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class v{constructor(){var t,i,r;t=this,r=!1,(i=e).has(t)?l("Cannot add the same private member more than once"):i instanceof WeakSet?i.add(t):i.set(t,r),this.listeners=new Map}addEventListener(t,i){var r;m(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),null==(r=this.listeners.get(t))||r.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){var r;if(m(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{m(this,e)||t(n)})}destroy(){var t,i,r,n;r=!0,f(t=this,i=e,"write to private field"),n?n.call(t,r):i.set(t,r),this.listeners.clear()}}function S(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const w=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},P=e=>e&&e[e.length-1],I=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},x=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-c(2,r))},C=e=>{const t=I(e);return 0===(1&t)?-(t>>1):t+1>>1},B=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),E=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),_=new TextDecoder,R=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),F={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},A=R(F),D={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},M=R(D),N={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},O=R(N),L=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,z={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},U=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class q{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}acquire(){return p(this,null,function*(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,yield i,e})}}const W=/^[0-9a-fA-F]+$/,H=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),V=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,j=(e,t,i)=>{let r=0,n=e.length-1,s=-1;for(;r<=n;){const a=r+n>>1,o=i(e[a]);o===t?(s=a,n=a-1):o<t?r=a+1:n=a-1}return s},Line truncated
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(e.artplayerProxyMediabunny=t(),define(function(){return e.artplayerProxyMediabunny})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerProxyMediabunny=t()}(this,function(){"use strict";var e,t,i=Object.defineProperty,r=Object.defineProperties,n=Object.getOwnPropertyDescriptors,s=Object.getOwnPropertySymbols,a=Object.prototype.hasOwnProperty,o=Object.prototype.propertyIsEnumerable,l=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),c=e=>{throw TypeError(e)},u=Math.pow,d=(e,t,r)=>t in e?i(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,h=(e,t)=>{for(var i in t||(t={}))a.call(t,i)&&d(e,i,t[i]);if(s)for(var i of s(t))o.call(t,i)&&d(e,i,t[i]);return e},f=(e,t)=>r(e,n(t)),m=(e,t,i)=>t.has(e)||c("Cannot "+i),p=(e,t,i)=>(m(e,t,"read from private field"),i?i.call(e):t.get(e)),g=(e,t,i)=>t.has(e)?c("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,i),k=(e,t,i,r)=>(m(e,t,"write to private field"),r?r.call(e,i):t.set(e,i),i),y=(e,t,i)=>new Promise((r,n)=>{var s=e=>{try{o(i.next(e))}catch(t){n(t)}},a=e=>{try{o(i.throw(e))}catch(t){n(t)}},o=e=>e.done?r(e.value):Promise.resolve(e.value).then(s,a);o((i=i.apply(e,t)).next())}),b=function(e,t){this[0]=e,this[1]=t},T=(e,t,i)=>{var r,n=(e,t,r,s)=>{try{var a=i[e](t),o=(t=a.value)instanceof b,l=a.done;Promise.resolve(o?t[0]:t).then(i=>o?n("return"===e?e:"next",t[1]?{done:i.done,value:i.value}:i,r,s):r({value:i,done:l})).catch(e=>n("throw",e,r,s))}catch(c){s(c)}},s=(e,t,i,s)=>a[e]=a=>(t=new Promise((t,i,s)=>(s=()=>n(e,a,t,i),r?r.then(s):s())),s=()=>r===i&&(r=0),r=i=t.then(s,s),t),a={};return i=i.apply(e,t),a[l("asyncIterator")]=()=>a,s("next"),s("throw"),s("return"),a},v=e=>{var t,i=e[l("asyncIterator")],r=!1,n={};return null==i?(i=e[l("iterator")](),t=e=>n[e]=t=>i[e](t)):(i=i.call(e),t=e=>n[e]=t=>{if(r){if(r=!1,"throw"===e)throw t;return t}return r=!0,{done:!1,value:new b(new Promise(r=>{var n=i[e](t);n instanceof Object||c("Object expected"),r(n)}),1)}}),n[l("iterator")]=()=>n,t("next"),"throw"in i?t("throw"):n.throw=e=>{throw e},"return"in i&&t("return"),n},S=(e,t,i)=>(t=e[l("asyncIterator")])?t.call(e):(e=e[l("iterator")](),t={},(i=(i,r)=>(r=e[i])&&(t[i]=t=>new Promise((i,n,s)=>(t=r.call(e,t),s=t.done,Promise.resolve(t.value).then(e=>i({value:e,done:s}),n)))))("next"),i("return"),t);function w(e,t){const i=new Map;return e.filter(e=>{const r=e[t];return void 0===r||!i.has(r)&&i.set(r,1)})}class P{constructor(){g(this,e,!1),this.listeners=new Map}addEventListener(t,i){var r;p(this,e)||(this.listeners.has(t)||this.listeners.set(t,[]),null==(r=this.listeners.get(t))||r.push(i))}removeEventListener(e,t){const i=this.listeners.get(e);if(!i)return;const r=i.indexOf(t);r>=0&&i.splice(r,1)}emit(t,i){var r;if(p(this,e))return;const n=Object.assign(new Event(t),{detail:i});null==(r=this.listeners.get(t))||r.forEach(t=>{p(this,e)||t(n)})}destroy(){k(this,e,!0),this.listeners.clear()}}function I(e){if(!e)throw new Error("Assertion failed.")}e=new WeakMap;const x=e=>{const t=(e%360+360)%360;if(0===t||90===t||180===t||270===t)return t;throw new Error(`Invalid rotation ${e}.`)},C=e=>e&&e[e.length-1],B=e=>{let t=0;for(;0===e.readBits(1)&&t<32;)t++;if(t>=32)throw new Error("Invalid exponential-Golomb code.");return(1<<t)-1+e.readBits(t)},E=(e,t)=>{const i=t+1,r=Math.floor(Math.log2(i));e.writeBits(r,0),e.writeBits(1,1),e.writeBits(r,i-u(2,r))},_=e=>{const t=B(e);return 0===(1&t)?-(t>>1):t+1>>1},R=e=>e.constructor===Uint8Array?e:ArrayBuffer.isView(e)?new Uint8Array(e.buffer,e.byteOffset,e.byteLength):new Uint8Array(e),F=e=>e.constructor===DataView?e:ArrayBuffer.isView(e)?new DataView(e.buffer,e.byteOffset,e.byteLength):new DataView(e),A=new TextDecoder,D=e=>Object.fromEntries(Object.entries(e).map(([e,t])=>[t,e])),M={bt709:1,bt470bg:5,smpte170m:6,bt2020:9,smpte432:12},N=D(M),O={bt709:1,smpte170m:6,linear:8,"iec61966-2-1":13,pq:16,hlg:18},L=D(O),z={rgb:0,bt709:1,bt470bg:5,smpte170m:6,"bt2020-ncl":9},U=D(z),q=e=>!!e&&!!e.primaries&&!!e.transfer&&!!e.matrix&&void 0!==e.fullRange,W={primaries:void 0,transfer:void 0,matrix:void 0,fullRange:void 0},H=e=>e instanceof ArrayBuffer||"undefined"!==typeof SharedArrayBuffer&&e instanceof SharedArrayBuffer||ArrayBuffer.isView(e);class V{constructor(){this.currentPromise=Promise.resolve(),this.pending=0}acquire(){return y(this,null,function*(){let e;const t=new Promise(t=>{let i=!1;e=()=>{i||(t(),this.pending--,i=!0)}}),i=this.currentPromise;return this.currentPromise=t,this.pending++,yield i,e})}}const j=/^[0-9a-fA-F]+$/,K=e=>[...e].map(e=>e.toString(16).padStart(2,"0")).join(""),X=e=>(e=(e=(e=(e=(e=e>>1&1431655765|(1431655765&e)<<1)>>2&858993459|(858993459&e)<<2)>>4&252645135|(252645135&e)<<4)>>8&16711935|(16711935&e)<<8)>>16&65535|(65535&e)<<16)>>>0,$=(e,t,i)=>{let r=0,n=e.length-1,s=-1;for(;r<=n;){const a=r+n>>1,o=i(e[a]);o===t?(s=a,n=a-1):o<t?r=a+1:n=a-1}retuLine truncated
@@ -11,7 +11,7 @@ var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot
var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj));
var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value);
var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value);
var _closed;
var _closed, _playback;
const $audio = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M256 80C149.9 80 62.4 159.4 49.6 262c9.4-3.8 19.6-6 30.4-6c26.5 0 48 21.5 48 48l0 128c0 26.5-21.5 48-48 48c-44.2 0-80-35.8-80-80l0-16 0-48 0-48C0 146.6 114.6 32 256 32s256 114.6 256 256l0 48 0 48 0 16c0 44.2-35.8 80-80 80c-26.5 0-48-21.5-48-48l0-128c0-26.5 21.5-48 48-48c10.8 0 21 2.1 30.4 6C449.6 159.4 362.1 80 256 80z"/></svg>';
const $quality = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" height="18"><path fill="#fff" d="M0 96C0 60.7 28.7 32 64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96zM323.8 202.5c-4.5-6.6-11.9-10.5-19.8-10.5s-15.4 3.9-19.8 10.5l-87 127.6L170.7 297c-4.6-5.7-11.5-9-18.7-9s-14.2 3.3-18.7 9l-64 80c-5.8 7.2-6.9 17.1-2.9 25.4s12.4 13.6 21.6 13.6l96 0 32 0 208 0c8.9 0 17.1-4.9 21.2-12.8s3.6-17.4-1.4-24.7l-120-176zM112 192a48 48 0 1 0 0-96 48 48 0 1 0 0 96z"/></svg>';
function uniqBy(array, property) {
@@ -24271,6 +24271,62 @@ class AudioEngine {
this.audioSink = null;
}
}
async function selectQuality(host, value) {
const { input, media, loadSeq } = host;
if (!media?.isHls || !input || host.destroyed)
return;
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media;
let selection;
try {
const tracks = await input.getVideoTracks();
if (!current())
return;
const videoTrack = value === "auto" ? await input.getPrimaryVideoTrack() : tracks.find((track) => track.id === value) ?? media.videoTrack;
if (!current() || !videoTrack)
return;
let audioTrack = media.audioTrack;
let audioMode = media.audioMode;
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack();
audioMode = "auto";
}
selection = { videoTrack, audioTrack, videoMode: value === "auto" ? "auto" : "manual", audioMode };
} catch (error) {
if (!current())
return;
throw error;
}
if (current())
await host.replaceTracks(selection);
}
async function selectAudio(host, value) {
const { input, media, loadSeq } = host;
if (!media?.isHls || !input || host.destroyed)
return;
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media;
let selection;
try {
const tracks = await input.getAudioTracks();
if (!current())
return;
const audioTrack = value === "auto" ? media.videoTrack ? await media.videoTrack.getPrimaryPairableAudioTrack() : await input.getPrimaryAudioTrack() : tracks.find((track) => track.id === value) ?? media.audioTrack;
if (!current() || !audioTrack)
return;
let videoTrack = media.videoTrack;
let videoMode = media.videoMode;
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack();
videoMode = "auto";
}
selection = { videoTrack, audioTrack, videoMode, audioMode: value === "auto" ? "auto" : "manual" };
} catch (error) {
if (!current())
return;
throw error;
}
if (current())
await host.replaceTracks(selection);
}
async function getTrackBitrate(track) {
return await track.getAverageBitrate() ?? await track.getBitrate() ?? 0;
}
@@ -24404,6 +24460,30 @@ class LoadSession {
this.disposeInput();
}
}
function publish(events, current, names) {
for (const name of names) {
if (!current())
return;
events.emit(name);
}
}
function metadataBarrier(ready, current) {
let video = false;
let audio = false;
let published = false;
function complete(part) {
if (!current() || published)
return;
if (part === "video")
video = true;
else audio = true;
if (video && audio) {
published = true;
ready();
}
}
return { video: () => complete("video"), audio: () => complete("audio") };
}
async function resolveDuration({ input, videoTrack, audioTrack }) {
const tracks = [videoTrack, audioTrack].filter((track) => track !== null);
const referenceTrack = videoTrack || audioTrack;
@@ -24433,6 +24513,212 @@ async function selectPlaybackTracks(input, src) {
audioMode: isHls ? "auto" : "manual"
};
}
function completion() {
let resolve;
let reject;
const promise = new Promise((yes, no) => {
resolve = yes;
reject = no;
});
return { promise, resolve, reject };
}
class Playback {
constructor(host) {
this.host = host;
this.wanted = false;
this.playing = null;
this.seeking = null;
this.audioStart = null;
}
operation() {
return { ...completion(), source: this.host.loadSeq, complete: completion() };
}
current(operation, kind) {
return !this.host.destroyed && operation.source === this.host.loadSeq && this[kind] === operation;
}
cancelPlay() {
const previous = this.playing;
this.playing = null;
previous?.resolve();
}
invalidate() {
this.cancelPlay();
this.wanted = false;
const previous = this.seeking;
this.seeking = null;
previous?.complete.resolve();
previous?.resolve();
this.host.seeking = false;
}
ended() {
this.wanted = false;
this.cancelPlay();
}
stop() {
if (this.host.paused)
return;
this.host.paused = true;
this.stopEngines();
this.host.events.emit("pause");
}
stopEngines() {
const failures = [];
for (const stop of [() => this.host.audio.pause(), () => this.host.video.stop()]) {
try {
stop();
} catch (error) {
failures.push(error);
}
}
if (failures.length)
throw failures[0];
}
pause() {
this.wanted = false;
this.cancelPlay();
this.stop();
}
play() {
if (this.host.destroyed)
return Promise.resolve();
this.wanted = true;
if (this.playing)
return this.playing.promise;
if (this.host.ended)
return this.seek(0, true);
if (!this.host.paused && !this.seeking)
return Promise.resolve();
const operation = this.operation();
this.playing = operation;
this.runPlay(operation).then(operation.resolve, operation.reject);
return operation.promise;
}
async startAudio() {
if (!this.audioStart)
this.audioStart = this.host.audio.play();
const pending = this.audioStart;
try {
await pending;
} finally {
if (this.audioStart === pending)
this.audioStart = null;
}
}
async runPlay(operation) {
const current = () => this.current(operation, "playing") && this.wanted;
try {
while (this.seeking) {
await this.seeking.complete.promise;
if (!current())
return;
}
if (!current())
return;
this.host.paused = false;
await this.startAudio();
if (!current()) {
if (!this.wanted || this.host.destroyed)
this.host.audio.pause();
return;
}
this.host.video.start(this.host.audio);
publish(this.host.events, current, ["play", "playing"]);
} catch (error) {
if (!current())
return;
this.wanted = false;
this.host.paused = true;
try {
this.stopEngines();
} catch (failure) {
console.warn("MediaBunny play cleanup error:", failure);
}
throw error;
} finally {
if (this.playing === operation)
this.playing = null;
}
}
seek(time, resume) {
return this.position(async (current) => {
const audio = this.host.audio.seek(time);
const video = current() ? this.host.video.seek(time) : Promise.resolve();
await Promise.all([audio, video]);
}, ["seeked"], false, resume);
}
replace(media, time) {
return this.position(async (current) => {
media.duration = await resolveDuration(media);
if (!current())
return;
this.host.media = media;
await Promise.all([this.host.video.load(media), this.host.audio.load(media)]);
if (!current())
return;
await Promise.all([this.host.video.seek(time), this.host.audio.seek(time)]);
if (current()) {
this.host.readyState = 4;
this.host.networkState = 1;
}
}, ["loadedmetadata", "durationchange", "progress", "loadeddata", "canplay", "canplaythrough", "seeked"], true);
}
position(work, events, pauseFirst, resume) {
if (this.host.destroyed)
return Promise.resolve();
this.wanted = resume ?? (this.seeking ? this.wanted : !this.host.paused);
this.cancelPlay();
const previous = this.seeking;
const operation = this.operation();
this.seeking = operation;
previous?.complete.resolve();
previous?.resolve();
this.runPosition(operation, work, events, pauseFirst).then(operation.resolve, operation.reject);
return operation.promise;
}
async runPosition(operation, work, events, pauseFirst) {
const current = () => this.current(operation, "seeking");
let resume = false;
try {
if (pauseFirst)
this.stop();
if (!current())
return;
this.host.ended = false;
this.host.seeking = true;
publish(this.host.events, current, ["seeking", "waiting"]);
if (!current())
return;
if (!pauseFirst)
this.stop();
if (!current())
return;
await work(current);
if (!current())
return;
this.host.seeking = false;
operation.complete.resolve();
publish(this.host.events, current, events);
if (!current())
return;
this.seeking = null;
resume = this.wanted;
} catch (error) {
if (!current())
return;
this.host.seeking = false;
this.wanted = false;
this.cancelPlay();
this.host.reportError(error);
throw error;
} finally {
operation.complete.resolve();
if (this.seeking === operation)
this.seeking = null;
}
if (resume)
await this.play();
}
}
async function preflightRange(url, enabled, events, signal, isCurrent = () => true) {
if (signal?.aborted || !isCurrent())
return false;
@@ -24661,6 +24947,7 @@ class VideoEngine {
}
class MediaBunnyEngine {
constructor({ canvas, ctx, events, option = {} }) {
__privateAdd(this, _playback);
this.events = events;
this.option = option;
this.audio = new AudioEngine(events);
@@ -24685,15 +24972,20 @@ class MediaBunnyEngine {
this.media = null;
this.loadSession = null;
this.destroyed = false;
events.addEventListener?.("ended", () => {
__privateSet(this, _playback, new Playback(this));
events.addEventListener("ended", () => {
if (this.destroyed)
return;
this.ended = true;
this.paused = true;
__privateGet(this, _playback).ended();
});
}
async load(src) {
if (this.destroyed)
return;
const id = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
this.loadSession?.cancel();
if (id !== this.loadSeq || this.destroyed)
return;
@@ -24709,75 +25001,62 @@ class MediaBunnyEngine {
this.readyState = 0;
session.defer(() => this.events.emit("waiting"));
session.defer(() => this.events.emit("loadstart"));
const loadTimeout = Number.isFinite(this.option.loadTimeout) ? this.option.loadTimeout : 0;
const timeout = Number.isFinite(this.option.loadTimeout) ? this.option.loadTimeout ?? 0 : 0;
try {
await session.wait(this.performLoad(src, id, session), loadTimeout);
} catch (err) {
await session.wait(this.performLoad(src, id, session), timeout);
} catch (error) {
if (id !== this.loadSeq)
return;
const failed = ++this.loadSeq;
__privateGet(this, _playback).invalidate();
session.cancel();
if (failed !== this.loadSeq || this.destroyed)
return;
this.disposeInput();
this.error = { code: 4, message: err.message };
this.networkState = 3;
this.events.emit("error");
this.reportError(error);
}
}
async performLoad(src, id, session = this.loadSession) {
if (!await this.video.preflight(src, session?.signal, () => id === this.loadSeq) || id !== this.loadSeq)
const current = () => !this.destroyed && id === this.loadSeq;
if (!session || !await this.video.preflight(src, session.signal, current) || !current())
return;
const input = createInput(src);
if (!input) {
this.video.handleNoVideoTrack();
this.audio.handleNoAudioTrack();
if (current())
this.audio.handleNoAudioTrack();
return;
}
session.own(input);
const media = await selectPlaybackTracks(input, src);
if (id !== this.loadSeq)
if (!current())
return;
if (!media.videoTrack && !media.audioTrack) {
this.video.handleNoVideoTrack();
this.video.duration = Number.NaN;
this.audio.duration = Number.NaN;
throw new Error("Input has no audio or video tracks.");
}
this.input = input;
this.media = media;
let videoMetadataLoaded = !media.videoTrack;
let audioMetadataLoaded = !media.audioTrack;
const checkMetadata = () => {
if (videoMetadataLoaded && audioMetadataLoaded) {
this.readyState = 1;
this.events.emit("loadedmetadata");
this.events.emit("durationchange");
this.events.emit("progress");
}
};
const metadata = metadataBarrier(() => {
this.readyState = 1;
publish(this.events, current, ["loadedmetadata", "durationchange", "progress"]);
}, current);
try {
await Promise.all([
this.video.load(media, () => {
if (id !== this.loadSeq)
return;
videoMetadataLoaded = true;
checkMetadata();
}),
this.audio.load(media, () => {
if (id !== this.loadSeq)
return;
audioMetadataLoaded = true;
checkMetadata();
})
]);
if (id !== this.loadSeq)
const video = this.video.load(media, metadata.video);
const audio = current() ? this.audio.load(media, metadata.audio) : Promise.resolve();
await Promise.all([video, audio]);
if (!current())
return;
this.readyState = 4;
this.networkState = 1;
this.events.emit("loadeddata");
this.events.emit("canplay");
this.events.emit("canplaythrough");
this.events.emit("progress");
} catch (err) {
if (id !== this.loadSeq)
publish(this.events, current, ["loadeddata", "canplay", "canplaythrough", "progress"]);
} catch (error) {
if (!current())
return;
console.error("MediaBunny load error:", err);
throw err;
console.error("MediaBunny load error:", error);
throw error;
}
}
createTimeout(ms) {
@@ -24790,127 +25069,38 @@ class MediaBunnyEngine {
this.input = null;
this.media = null;
}
async replaceTracks({ videoTrack, audioTrack, videoMode, audioMode }) {
if (!this.media)
async replaceTracks(tracks) {
if (!this.media || this.destroyed)
return;
const nextMedia = {
...this.media,
videoTrack,
audioTrack,
videoMode,
audioMode
};
const shouldResume = !this.paused;
const currentTime = this.currentTime;
this.pause();
this.ended = false;
this.seeking = true;
this.events.emit("seeking");
this.events.emit("waiting");
nextMedia.duration = await resolveDuration(nextMedia);
this.media = nextMedia;
await Promise.all([
this.video.load(nextMedia),
this.audio.load(nextMedia)
]);
await Promise.all([
this.video.seek(currentTime),
this.audio.seek(currentTime)
]);
this.readyState = 4;
this.networkState = 1;
this.seeking = false;
this.events.emit("loadedmetadata");
this.events.emit("durationchange");
this.events.emit("progress");
this.events.emit("loadeddata");
this.events.emit("canplay");
this.events.emit("canplaythrough");
this.events.emit("seeked");
if (shouldResume) {
await this.play();
}
return __privateGet(this, _playback).replace({ ...this.media, ...tracks }, this.currentTime);
}
async selectHlsQuality(value) {
if (!this.media?.isHls || !this.input)
return;
const videoTracks = await this.input.getVideoTracks();
const videoTrack = value === "auto" ? await this.input.getPrimaryVideoTrack() : videoTracks.find((track) => track.id === value) ?? this.media.videoTrack;
if (!videoTrack)
return;
let audioTrack = this.media.audioTrack;
let audioMode = this.media.audioMode;
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack();
audioMode = "auto";
}
await this.replaceTracks({
videoTrack,
audioTrack,
videoMode: value === "auto" ? "auto" : "manual",
audioMode
});
return selectQuality(this, value);
}
async selectHlsAudio(value) {
if (!this.media?.isHls || !this.input)
return;
const audioTracks = await this.input.getAudioTracks();
const audioTrack = value === "auto" ? this.media.videoTrack ? await this.media.videoTrack.getPrimaryPairableAudioTrack() : await this.input.getPrimaryAudioTrack() : audioTracks.find((track) => track.id === value) ?? this.media.audioTrack;
if (!audioTrack)
return;
let videoTrack = this.media.videoTrack;
let videoMode = this.media.videoMode;
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack();
videoMode = "auto";
}
await this.replaceTracks({
videoTrack,
audioTrack,
videoMode,
audioMode: value === "auto" ? "auto" : "manual"
});
return selectAudio(this, value);
}
getHlsState() {
return getHlsState(this.media);
async getHlsState() {
const media = this.media;
if (this.destroyed)
return null;
try {
const state2 = await getHlsState(media);
return !this.destroyed && this.media === media ? state2 : null;
} catch (error) {
if (this.destroyed || this.media !== media)
return null;
throw error;
}
}
async play() {
if (!this.paused)
return;
if (this.ended) {
this.ended = false;
await this.seek(0);
}
this.paused = false;
await this.audio.play();
this.video.start(this.audio);
this.events.emit("play");
this.events.emit("playing");
return __privateGet(this, _playback).play();
}
pause() {
if (this.paused)
return;
this.paused = true;
this.audio.pause();
this.video.stop();
this.events.emit("pause");
__privateGet(this, _playback).pause();
}
async seek(time) {
const shouldResume = !this.paused;
this.ended = false;
this.seeking = true;
this.events.emit("seeking");
this.events.emit("waiting");
this.pause();
await Promise.all([
this.audio.seek(time),
this.video.seek(time)
]);
this.seeking = false;
this.events.emit("seeked");
if (shouldResume && !this.ended) {
await this.play();
}
return __privateGet(this, _playback).seek(time);
}
setVolume(volume, muted) {
this.audio.setVolume(volume, muted);
@@ -24919,18 +25109,31 @@ class MediaBunnyEngine {
this.audio.setPlaybackRate(rate);
this.video.setPlaybackRate(rate);
}
reportError(error) {
if (this.destroyed)
return;
const message = error && typeof error === "object" ? Reflect.get(error, "message") : void 0;
this.error = { code: 4, message: typeof message === "string" ? message : String(error) };
this.networkState = 3;
this.events.emit("error");
}
destroy() {
if (this.destroyed)
return;
this.destroyed = true;
this.loadSeq++;
this.loadSession?.cancel();
this.pause();
this.disposeInput();
this.audio.destroy();
this.video.destroy();
__privateGet(this, _playback).invalidate();
const failures = [];
for (const release of [() => this.loadSession?.cancel(), () => this.pause(), () => this.disposeInput(), () => this.audio.destroy(), () => this.video.destroy()]) {
try {
release();
} catch (error) {
failures.push(error);
}
}
if (failures.length)
throw failures[0];
}
// Getters
get currentTime() {
return this.audio.currentTime;
}
@@ -24944,6 +25147,7 @@ class MediaBunnyEngine {
return this.video.height;
}
}
_playback = new WeakMap();
const states = /* @__PURE__ */ new WeakMap();
function state(owner) {
let value = states.get(owner);
@@ -25049,7 +25253,8 @@ class VideoShim {
return this.engine.currentTime;
}
set currentTime(t) {
this.engine.seek(Number(t) || 0);
void Promise.resolve(this.engine.seek(Number(t) || 0)).catch(() => {
});
}
get duration() {
return this.engine.duration;
@@ -0,0 +1,6 @@
import type { AudioPort } from './engine-ports'
import type EventTarget from './EventTarget'
// Exact coordinator-facing port; implementation migration and decoder ownership are MB-06.
declare const AudioEngine: new (events: EventTarget) => AudioPort
export default AudioEngine
@@ -1,4 +0,0 @@
import type { EngineOptions, EnginePort } from './engine-types'
declare const MediaBunnyEngine: new (options: EngineOptions) => EnginePort
export default MediaBunnyEngine
@@ -1,372 +0,0 @@
/**
* Main MediaBunny Engine
* Coordinates audio and video playback
*/
import AudioEngine from './AudioEngine.js'
import { getHlsState } from './hls-state'
import { createInput } from './input'
import LoadSession from './load-session'
import { resolveDuration, selectPlaybackTracks } from './tracks'
import VideoEngine from './VideoEngine.js'
export default class MediaBunnyEngine {
constructor({ canvas, ctx, events, option = {} }) {
this.events = events
this.option = option
// Create audio and video engines
this.audio = new AudioEngine(events)
this.video = new VideoEngine({
canvas,
ctx,
events,
timeupdateInterval: option.timeupdateInterval ?? 250,
avSyncTolerance: option.avSyncTolerance ?? 0.12,
dropLateFrames: option.dropLateFrames ?? false,
poster: option.poster ?? '',
preflightRange: option.preflightRange ?? false,
})
// Playback state
this.paused = true
this.ended = false
this.readyState = 0
this.networkState = 0
this.error = null
this.seeking = false
this.loadSeq = 0
this.input = null
this.media = null
this.loadSession = null
this.destroyed = false
// Listen to ended event
events.addEventListener?.('ended', () => {
this.ended = true
this.paused = true
})
}
async load(src) {
if (this.destroyed)
return
const id = ++this.loadSeq
this.loadSession?.cancel()
if (id !== this.loadSeq || this.destroyed)
return
this.pause()
if (id !== this.loadSeq || this.destroyed)
return
this.disposeInput()
const session = new LoadSession()
this.loadSession = session
this.ended = false
this.error = null
this.networkState = 2 // NETWORK_LOADING
this.readyState = 0 // HAVE_NOTHING
session.defer(() => this.events.emit('waiting'))
session.defer(() => this.events.emit('loadstart'))
const loadTimeout = Number.isFinite(this.option.loadTimeout)
? this.option.loadTimeout
: 0
try {
await session.wait(this.performLoad(src, id, session), loadTimeout)
}
catch (err) {
if (id !== this.loadSeq)
return
const failed = ++this.loadSeq
session.cancel()
if (failed !== this.loadSeq || this.destroyed)
return
this.disposeInput()
this.error = { code: 4, message: err.message }
this.networkState = 3 // NETWORK_NO_SOURCE
this.events.emit('error')
}
}
async performLoad(src, id, session = this.loadSession) {
if (!(await this.video.preflight(src, session?.signal, () => id === this.loadSeq)) || id !== this.loadSeq)
return
const input = createInput(src)
if (!input) {
this.video.handleNoVideoTrack()
this.audio.handleNoAudioTrack()
return
}
session.own(input)
const media = await selectPlaybackTracks(input, src)
if (id !== this.loadSeq)
return
this.input = input
this.media = media
let videoMetadataLoaded = !media.videoTrack
let audioMetadataLoaded = !media.audioTrack
const checkMetadata = () => {
if (videoMetadataLoaded && audioMetadataLoaded) {
this.readyState = 1 // HAVE_METADATA
this.events.emit('loadedmetadata')
this.events.emit('durationchange')
this.events.emit('progress')
}
}
try {
await Promise.all([
this.video.load(media, () => {
if (id !== this.loadSeq)
return
videoMetadataLoaded = true
checkMetadata()
}),
this.audio.load(media, () => {
if (id !== this.loadSeq)
return
audioMetadataLoaded = true
checkMetadata()
}),
])
if (id !== this.loadSeq)
return
this.readyState = 4 // HAVE_ENOUGH_DATA
this.networkState = 1 // NETWORK_IDLE
this.events.emit('loadeddata')
this.events.emit('canplay')
this.events.emit('canplaythrough')
this.events.emit('progress')
}
catch (err) {
if (id !== this.loadSeq)
return
console.error('MediaBunny load error:', err)
throw err
}
}
createTimeout(ms) {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error('Load timeout')), ms)
})
}
disposeInput() {
this.loadSession?.disposeInput()
this.input = null
this.media = null
}
async replaceTracks({ videoTrack, audioTrack, videoMode, audioMode }) {
if (!this.media)
return
const nextMedia = {
...this.media,
videoTrack,
audioTrack,
videoMode,
audioMode,
}
const shouldResume = !this.paused
const currentTime = this.currentTime
this.pause()
this.ended = false
this.seeking = true
this.events.emit('seeking')
this.events.emit('waiting')
nextMedia.duration = await resolveDuration(nextMedia)
this.media = nextMedia
await Promise.all([
this.video.load(nextMedia),
this.audio.load(nextMedia),
])
await Promise.all([
this.video.seek(currentTime),
this.audio.seek(currentTime),
])
this.readyState = 4
this.networkState = 1
this.seeking = false
this.events.emit('loadedmetadata')
this.events.emit('durationchange')
this.events.emit('progress')
this.events.emit('loadeddata')
this.events.emit('canplay')
this.events.emit('canplaythrough')
this.events.emit('seeked')
if (shouldResume) {
await this.play()
}
}
async selectHlsQuality(value) {
if (!this.media?.isHls || !this.input)
return
const videoTracks = await this.input.getVideoTracks()
const videoTrack = value === 'auto'
? await this.input.getPrimaryVideoTrack()
: videoTracks.find(track => track.id === value) ?? this.media.videoTrack
if (!videoTrack)
return
let audioTrack = this.media.audioTrack
let audioMode = this.media.audioMode
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack()
audioMode = 'auto'
}
await this.replaceTracks({
videoTrack,
audioTrack,
videoMode: value === 'auto' ? 'auto' : 'manual',
audioMode,
})
}
async selectHlsAudio(value) {
if (!this.media?.isHls || !this.input)
return
const audioTracks = await this.input.getAudioTracks()
const audioTrack = value === 'auto'
? this.media.videoTrack
? await this.media.videoTrack.getPrimaryPairableAudioTrack()
: await this.input.getPrimaryAudioTrack()
: audioTracks.find(track => track.id === value) ?? this.media.audioTrack
if (!audioTrack)
return
let videoTrack = this.media.videoTrack
let videoMode = this.media.videoMode
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack()
videoMode = 'auto'
}
await this.replaceTracks({
videoTrack,
audioTrack,
videoMode,
audioMode: value === 'auto' ? 'auto' : 'manual',
})
}
getHlsState() {
return getHlsState(this.media)
}
async play() {
if (!this.paused)
return
if (this.ended) {
this.ended = false
await this.seek(0)
}
this.paused = false
await this.audio.play()
this.video.start(this.audio)
this.events.emit('play')
this.events.emit('playing')
}
pause() {
if (this.paused)
return
this.paused = true
this.audio.pause()
this.video.stop()
this.events.emit('pause')
}
async seek(time) {
const shouldResume = !this.paused
this.ended = false
this.seeking = true
this.events.emit('seeking')
this.events.emit('waiting')
this.pause()
await Promise.all([
this.audio.seek(time),
this.video.seek(time),
])
this.seeking = false
this.events.emit('seeked')
if (shouldResume && !this.ended) {
await this.play()
}
}
setVolume(volume, muted) {
this.audio.setVolume(volume, muted)
}
setPlaybackRate(rate) {
this.audio.setPlaybackRate(rate)
this.video.setPlaybackRate(rate)
}
destroy() {
if (this.destroyed)
return
this.destroyed = true
this.loadSeq++
this.loadSession?.cancel()
this.pause()
this.disposeInput()
this.audio.destroy()
this.video.destroy()
}
// Getters
get currentTime() {
return this.audio.currentTime
}
get duration() {
return this.media?.duration ?? this.audio.duration ?? this.video.duration
}
get videoWidth() {
return this.video.width
}
get videoHeight() {
return this.video.height
}
}
@@ -0,0 +1,255 @@
import type { Input } from 'mediabunny'
import type { AudioPort, VideoPort } from './engine-ports'
import type { EngineOptions, EnginePort, ProxyOptions } from './engine-types'
import type EventTarget from './EventTarget'
import type { SelectedTracks } from './hls-selection'
import type { PlaybackMedia } from './media'
import AudioEngine from './AudioEngine.js'
import { selectAudio, selectQuality } from './hls-selection'
import { getHlsState } from './hls-state'
import { createInput } from './input'
import LoadSession from './load-session'
import Playback from './playback'
import { metadataBarrier, publish } from './readiness'
import { selectPlaybackTracks } from './tracks'
import VideoEngine from './VideoEngine.js'
export default class MediaBunnyEngine implements EnginePort {
declare events: EventTarget
declare option: ProxyOptions
declare audio: AudioPort
declare video: VideoPort
declare paused: boolean
declare ended: boolean
declare readyState: number
declare networkState: number
declare error: { code: number, message: string } | null
declare seeking: boolean
declare loadSeq: number
declare input: Input | null
declare media: PlaybackMedia | null
declare loadSession: LoadSession | null
declare destroyed: boolean
#playback: Playback
constructor({ canvas, ctx, events, option = {} }: EngineOptions) {
this.events = events
this.option = option
this.audio = new AudioEngine(events)
this.video = new VideoEngine({
canvas,
ctx,
events,
timeupdateInterval: option.timeupdateInterval ?? 250,
avSyncTolerance: option.avSyncTolerance ?? 0.12,
dropLateFrames: option.dropLateFrames ?? false,
poster: option.poster ?? '',
preflightRange: option.preflightRange ?? false,
})
this.paused = true
this.ended = false
this.readyState = 0
this.networkState = 0
this.error = null
this.seeking = false
this.loadSeq = 0
this.input = null
this.media = null
this.loadSession = null
this.destroyed = false
this.#playback = new Playback(this)
events.addEventListener('ended', () => {
if (this.destroyed)
return
this.ended = true
this.paused = true
this.#playback.ended()
})
}
async load(src: unknown): Promise<void> {
if (this.destroyed)
return
const id = ++this.loadSeq
this.#playback.invalidate()
this.loadSession?.cancel()
if (id !== this.loadSeq || this.destroyed)
return
this.pause()
if (id !== this.loadSeq || this.destroyed)
return
this.disposeInput()
const session = new LoadSession()
this.loadSession = session
this.ended = false
this.error = null
this.networkState = 2
this.readyState = 0
session.defer(() => this.events.emit('waiting'))
session.defer(() => this.events.emit('loadstart'))
const timeout = Number.isFinite(this.option.loadTimeout) ? this.option.loadTimeout ?? 0 : 0
try {
await session.wait(this.performLoad(src, id, session), timeout)
}
catch (error) {
if (id !== this.loadSeq)
return
const failed = ++this.loadSeq
this.#playback.invalidate()
session.cancel()
if (failed !== this.loadSeq || this.destroyed)
return
this.disposeInput()
this.reportError(error)
}
}
async performLoad(src: unknown, id: number, session = this.loadSession): Promise<void> {
const current = () => !this.destroyed && id === this.loadSeq
if (!session || !(await this.video.preflight(src, session.signal, current)) || !current())
return
const input = createInput(src)
if (!input) {
this.video.handleNoVideoTrack()
if (current())
this.audio.handleNoAudioTrack()
return
}
session.own(input)
const media = await selectPlaybackTracks(input, src)
if (!current())
return
if (!media.videoTrack && !media.audioTrack) {
this.video.handleNoVideoTrack()
this.video.duration = Number.NaN
this.audio.duration = Number.NaN
throw new Error('Input has no audio or video tracks.')
}
this.input = input
this.media = media
const metadata = metadataBarrier(() => {
this.readyState = 1
publish(this.events, current, ['loadedmetadata', 'durationchange', 'progress'])
}, current)
try {
const video = this.video.load(media, metadata.video)
const audio = current() ? this.audio.load(media, metadata.audio) : Promise.resolve()
await Promise.all([video, audio])
if (!current())
return
this.readyState = 4
this.networkState = 1
publish(this.events, current, ['loadeddata', 'canplay', 'canplaythrough', 'progress'])
}
catch (error) {
if (!current())
return
console.error('MediaBunny load error:', error)
throw error
}
}
createTimeout(ms: number): Promise<never> {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error('Load timeout')), ms)
})
}
disposeInput(): void {
this.loadSession?.disposeInput()
this.input = null
this.media = null
}
async replaceTracks(tracks: SelectedTracks): Promise<void> {
if (!this.media || this.destroyed)
return
return this.#playback.replace({ ...this.media, ...tracks }, this.currentTime)
}
async selectHlsQuality(value: unknown): Promise<void> {
return selectQuality(this, value)
}
async selectHlsAudio(value: unknown): Promise<void> {
return selectAudio(this, value)
}
async getHlsState() {
const media = this.media
if (this.destroyed)
return null
try {
const state = await getHlsState(media)
return !this.destroyed && this.media === media ? state : null
}
catch (error) {
if (this.destroyed || this.media !== media)
return null
throw error
}
}
async play(): Promise<void> {
return this.#playback.play()
}
pause(): void {
this.#playback.pause()
}
async seek(time: number): Promise<void> {
return this.#playback.seek(time)
}
setVolume(volume: number, muted: boolean): void {
this.audio.setVolume(volume, muted)
}
setPlaybackRate(rate: number): void {
this.audio.setPlaybackRate(rate)
this.video.setPlaybackRate(rate)
}
reportError(error: unknown): void {
if (this.destroyed)
return
const message: unknown = error && typeof error === 'object' ? Reflect.get(error, 'message') : undefined
this.error = { code: 4, message: typeof message === 'string' ? message : String(error) }
this.networkState = 3
this.events.emit('error')
}
destroy(): void {
if (this.destroyed)
return
this.destroyed = true
this.loadSeq++
this.#playback.invalidate()
const failures: unknown[] = []
for (const release of [() => this.loadSession?.cancel(), () => this.pause(), () => this.disposeInput(), () => this.audio.destroy(), () => this.video.destroy()]) {
try {
release()
}
catch (error) { failures.push(error) }
}
if (failures.length)
throw failures[0]
}
get currentTime(): number {
return this.audio.currentTime
}
get duration(): number {
return this.media?.duration ?? this.audio.duration ?? this.video.duration
}
get videoWidth(): number {
return this.video.width
}
get videoHeight(): number {
return this.video.height
}
}
@@ -0,0 +1,5 @@
import type { VideoOptions, VideoPort } from './engine-ports'
// Exact coordinator-facing port; implementation migration and frame ownership are MB-05.
declare const VideoEngine: new (options: VideoOptions) => VideoPort
export default VideoEngine
@@ -87,7 +87,8 @@ export default class VideoShim {
}
set currentTime(t: unknown) {
this.engine.seek(Number(t) || 0)
// The coordinator reports active failures; the synchronous setter must observe rejection.
void Promise.resolve(this.engine.seek(Number(t) || 0)).catch(() => {})
}
get duration() {
@@ -0,0 +1,60 @@
import type { Input } from 'mediabunny'
import type { ProxyOptions } from './engine-types'
import type EventTarget from './EventTarget'
import type { PlaybackMedia } from './media'
export interface AudioPort {
readonly currentTime: number
duration: number
paused: boolean
load: (media: PlaybackMedia, metadata?: () => void) => Promise<void>
play: () => Promise<void>
pause: () => void
seek: (time: number) => Promise<void>
setVolume: (volume: number, muted: boolean) => void
setPlaybackRate: (rate: number) => void
handleNoAudioTrack: () => void
destroy: () => void
}
export interface VideoPort {
width: number
height: number
duration: number
preflight: (source: unknown, signal?: AbortSignal, current?: () => boolean) => Promise<boolean>
load: (media: PlaybackMedia, metadata?: () => void) => Promise<void>
start: (clock: AudioPort) => void
stop: () => void
seek: (time: number) => Promise<void>
setPlaybackRate: (rate: number) => void
handleNoVideoTrack: () => void
destroy: () => void
}
export interface VideoOptions {
canvas: HTMLCanvasElement
ctx: CanvasRenderingContext2D | null
events: EventTarget
timeupdateInterval: number
avSyncTolerance: number
dropLateFrames: boolean
poster: string
preflightRange: boolean
}
export interface PlaybackHost {
audio: AudioPort
video: VideoPort
events: EventTarget
readonly destroyed: boolean
readonly loadSeq: number
paused: boolean
ended: boolean
seeking: boolean
readyState: number
networkState: number
media: PlaybackMedia | null
input: Input | null
option: ProxyOptions
reportError: (error: unknown) => void
}
@@ -19,7 +19,7 @@ export interface EngineOptions {
option?: ProxyOptions
}
// Transitional boundary for the coordinator; replaced by its strict implementation in MB-04.
// Stable coordinator-facing surface used by the public shim.
export interface EnginePort {
readonly currentTime: number
readonly duration: number
@@ -0,0 +1,74 @@
import type { Input } from 'mediabunny'
import type { PlaybackMedia } from './media'
export type SelectedTracks = Pick<PlaybackMedia, 'videoTrack' | 'audioTrack' | 'videoMode' | 'audioMode'>
interface SelectionHost {
input: Input | null
media: PlaybackMedia | null
destroyed: boolean
loadSeq: number
replaceTracks: (tracks: SelectedTracks) => Promise<void>
}
export async function selectQuality(host: SelectionHost, value: unknown): Promise<void> {
const { input, media, loadSeq } = host
if (!media?.isHls || !input || host.destroyed)
return
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media
let selection: SelectedTracks
try {
const tracks = await input.getVideoTracks()
if (!current())
return
const videoTrack = value === 'auto' ? await input.getPrimaryVideoTrack() : tracks.find(track => track.id === value) ?? media.videoTrack
if (!current() || !videoTrack)
return
let audioTrack = media.audioTrack
let audioMode = media.audioMode
if (!audioTrack || !videoTrack.canBePairedWith(audioTrack)) {
audioTrack = await videoTrack.getPrimaryPairableAudioTrack()
audioMode = 'auto'
}
selection = { videoTrack, audioTrack, videoMode: value === 'auto' ? 'auto' : 'manual', audioMode }
}
catch (error) {
if (!current())
return
throw error
}
if (current())
await host.replaceTracks(selection)
}
export async function selectAudio(host: SelectionHost, value: unknown): Promise<void> {
const { input, media, loadSeq } = host
if (!media?.isHls || !input || host.destroyed)
return
const current = () => !host.destroyed && host.loadSeq === loadSeq && host.input === input && host.media === media
let selection: SelectedTracks
try {
const tracks = await input.getAudioTracks()
if (!current())
return
const audioTrack = value === 'auto'
? media.videoTrack ? await media.videoTrack.getPrimaryPairableAudioTrack() : await input.getPrimaryAudioTrack()
: tracks.find(track => track.id === value) ?? media.audioTrack
if (!current() || !audioTrack)
return
let videoTrack = media.videoTrack
let videoMode = media.videoMode
if (!videoTrack || !audioTrack.canBePairedWith(videoTrack)) {
videoTrack = await audioTrack.getPrimaryPairableVideoTrack()
videoMode = 'auto'
}
selection = { videoTrack, audioTrack, videoMode, audioMode: value === 'auto' ? 'auto' : 'manual' }
}
catch (error) {
if (!current())
return
throw error
}
if (current())
await host.replaceTracks(selection)
}
@@ -0,0 +1,240 @@
import type { PlaybackHost } from './engine-ports'
import type { PlaybackMedia } from './media'
import { publish } from './readiness'
import { resolveDuration } from './tracks'
interface Completion {
promise: Promise<void>
resolve: () => void
reject: (error: unknown) => void
}
interface Operation extends Completion {
source: number
complete: Completion
}
function completion(): Completion {
let resolve!: () => void
let reject!: (error: unknown) => void
const promise = new Promise<void>((yes, no) => {
resolve = yes
reject = no
})
return { promise, resolve, reject }
}
export default class Playback {
private wanted = false
private playing: Operation | null = null
private seeking: Operation | null = null
private audioStart: Promise<void> | null = null
constructor(private host: PlaybackHost) {}
private operation(): Operation {
return { ...completion(), source: this.host.loadSeq, complete: completion() }
}
private current(operation: Operation, kind: 'playing' | 'seeking'): boolean {
return !this.host.destroyed && operation.source === this.host.loadSeq && this[kind] === operation
}
private cancelPlay(): void {
const previous = this.playing
this.playing = null
previous?.resolve()
}
invalidate(): void {
this.cancelPlay()
this.wanted = false
const previous = this.seeking
this.seeking = null
previous?.complete.resolve()
previous?.resolve()
this.host.seeking = false
}
ended(): void {
this.wanted = false
this.cancelPlay()
}
private stop(): void {
if (this.host.paused)
return
this.host.paused = true
this.stopEngines()
this.host.events.emit('pause')
}
private stopEngines(): void {
const failures: unknown[] = []
for (const stop of [() => this.host.audio.pause(), () => this.host.video.stop()]) {
try {
stop()
}
catch (error) { failures.push(error) }
}
if (failures.length)
throw failures[0]
}
pause(): void {
this.wanted = false
this.cancelPlay()
this.stop()
}
play(): Promise<void> {
if (this.host.destroyed)
return Promise.resolve()
this.wanted = true
if (this.playing)
return this.playing.promise
if (this.host.ended)
return this.seek(0, true)
if (!this.host.paused && !this.seeking)
return Promise.resolve()
const operation = this.operation()
this.playing = operation
this.runPlay(operation).then(operation.resolve, operation.reject)
return operation.promise
}
private async startAudio(): Promise<void> {
if (!this.audioStart)
this.audioStart = this.host.audio.play()
const pending = this.audioStart
try {
await pending
}
finally {
if (this.audioStart === pending)
this.audioStart = null
}
}
private async runPlay(operation: Operation): Promise<void> {
const current = () => this.current(operation, 'playing') && this.wanted
try {
while (this.seeking) {
await this.seeking.complete.promise
if (!current())
return
}
if (!current())
return
this.host.paused = false
await this.startAudio()
if (!current()) {
if (!this.wanted || this.host.destroyed)
this.host.audio.pause()
return
}
this.host.video.start(this.host.audio)
publish(this.host.events, current, ['play', 'playing'])
}
catch (error) {
if (!current())
return
this.wanted = false
this.host.paused = true
try {
this.stopEngines()
}
catch (failure) { console.warn('MediaBunny play cleanup error:', failure) }
throw error
}
finally {
if (this.playing === operation)
this.playing = null
}
}
seek(time: number, resume?: boolean): Promise<void> {
return this.position(async (current) => {
const audio = this.host.audio.seek(time)
const video = current() ? this.host.video.seek(time) : Promise.resolve()
await Promise.all([audio, video])
}, ['seeked'], false, resume)
}
replace(media: PlaybackMedia, time: number): Promise<void> {
return this.position(async (current) => {
media.duration = await resolveDuration(media)
if (!current())
return
this.host.media = media
await Promise.all([this.host.video.load(media), this.host.audio.load(media)])
if (!current())
return
await Promise.all([this.host.video.seek(time), this.host.audio.seek(time)])
if (current()) {
this.host.readyState = 4
this.host.networkState = 1
}
}, ['loadedmetadata', 'durationchange', 'progress', 'loadeddata', 'canplay', 'canplaythrough', 'seeked'], true)
}
private position(work: (current: () => boolean) => Promise<void>, events: readonly string[], pauseFirst: boolean, resume?: boolean): Promise<void> {
if (this.host.destroyed)
return Promise.resolve()
this.wanted = resume ?? (this.seeking ? this.wanted : !this.host.paused)
this.cancelPlay()
const previous = this.seeking
const operation = this.operation()
this.seeking = operation
previous?.complete.resolve()
previous?.resolve()
this.runPosition(operation, work, events, pauseFirst).then(operation.resolve, operation.reject)
return operation.promise
}
private async runPosition(operation: Operation, work: (current: () => boolean) => Promise<void>, events: readonly string[], pauseFirst: boolean): Promise<void> {
const current = () => this.current(operation, 'seeking')
let resume = false
try {
if (pauseFirst)
this.stop()
if (!current())
return
this.host.ended = false
this.host.seeking = true
publish(this.host.events, current, ['seeking', 'waiting'])
if (!current())
return
if (!pauseFirst)
this.stop()
if (!current())
return
await work(current)
if (!current())
return
this.host.seeking = false
operation.complete.resolve()
publish(this.host.events, current, events)
if (!current())
return
this.seeking = null
resume = this.wanted
}
catch (error) {
if (!current())
return
this.host.seeking = false
this.wanted = false
this.cancelPlay()
this.host.reportError(error)
throw error
}
finally {
operation.complete.resolve()
if (this.seeking === operation)
this.seeking = null
}
if (resume)
await this.play()
}
}
@@ -0,0 +1,27 @@
import type EventTarget from './EventTarget'
export function publish(events: EventTarget, current: () => boolean, names: readonly string[]): void {
for (const name of names) {
if (!current())
return
events.emit(name)
}
}
export function metadataBarrier(ready: () => void, current: () => boolean) {
let video = false
let audio = false
let published = false
function complete(part: 'audio' | 'video'): void {
if (!current() || published)
return
if (part === 'video')
video = true
else audio = true
if (video && audio) {
published = true
ready()
}
}
return { video: () => complete('video'), audio: () => complete('audio') }
}