@@ -90,6 +90,13 @@ export const DEFAULTS = Object.freeze({
9090 /** Pixel budget for the CPU fallback; frames are processed downscaled to fit. */
9191 maxCPUPixels : 512 * 512 ,
9292
93+ /**
94+ * Milliseconds of no decoded-frame progress (while playing) before firing
95+ * `'stalled'` — catches a track that goes silent while readyState/paused/
96+ * ended still look healthy (e.g. a WebRTC failure). `<= 0` disables it.
97+ */
98+ stallTimeout : 4000 ,
99+
93100 /** Attributes applied to the <video> element created when `source` is a URL. */
94101 videoAttributes : Object . freeze ( {
95102 muted : true ,
@@ -103,6 +110,9 @@ export const DEFAULTS = Object.freeze({
103110/** Options that select shader variants / backends / plugins and can't change after construction. */
104111const IMMUTABLE_OPTIONS = [ 'channel' , 'forceCanvas2D' , 'videoAttributes' , 'autoTune' ] ;
105112
113+ /** Watchdog polling cadence for stall detection (see `_checkStall`). Fixed; `stallTimeout` is the tunable. */
114+ const STALL_CHECK_INTERVAL_MS = 500 ;
115+
106116function clamp ( x , lo , hi ) { return x < lo ? lo : x > hi ? hi : x ; }
107117
108118/** GLSL-compatible smoothstep, used by the CPU fallback fade ramps. */
@@ -631,6 +641,12 @@ function syncAspectRatio(target, video, state) {
631641 * - `'autotune'` — detail: the result object of every {@link autoTune} run.
632642 * - `'pluginerror'` — detail: `{ plugin, error }`, fired when a plugin hook
633643 * throws (the plugin is detached; the player keeps running).
644+ * - `'stalled'` — detail: `{ elapsedMs }`. Fired once when no new decoded
645+ * frame has arrived for `options.stallTimeout` ms while playing (see
646+ * issue #3). The canvas keeps showing the last good frame; the page
647+ * decides what to do (spinner, reconnect, etc).
648+ * - `'recovered'` — detail: `{ elapsedMs }`. Fired once frame delivery
649+ * resumes after a `'stalled'` event.
634650 */
635651export class ChromaKeyVideo extends EventTarget {
636652 /**
@@ -700,6 +716,11 @@ export class ChromaKeyVideo extends EventTarget {
700716
701717 this . _startLoop ( ) ;
702718
719+ this . _lastFrameQualityCount = - 1 ;
720+ this . _stallStartedAt = 0 ;
721+ this . _isStalled = false ;
722+ this . _stallWatchdog = setInterval ( ( ) => this . _checkStall ( ) , STALL_CHECK_INTERVAL_MS ) ;
723+
703724 if ( this . options . autoTune ) {
704725 this . use ( autoTunePlugin ( { adaptive : this . options . autoTune === 'adaptive' } ) ) ;
705726 }
@@ -928,6 +949,7 @@ export class ChromaKeyVideo extends EventTarget {
928949 this . video . cancelVideoFrameCallback ( this . _rvfcHandle ) ;
929950 }
930951 if ( this . _rafHandle ) cancelAnimationFrame ( this . _rafHandle ) ;
952+ if ( this . _stallWatchdog ) clearInterval ( this . _stallWatchdog ) ;
931953 if ( this . _resizeObserver ) this . _resizeObserver . disconnect ( ) ;
932954 for ( const type of [ 'loadedmetadata' , 'resize' , 'loadeddata' , 'seeked' , 'error' ] ) {
933955 this . video . removeEventListener ( type , this . _onVideoEvent ) ;
@@ -1045,6 +1067,45 @@ export class ChromaKeyVideo extends EventTarget {
10451067 syncAspectRatio ( this . canvas , this . video , this ) ;
10461068 }
10471069
1070+ /**
1071+ * Watchdog tick: fires `'stalled'`/`'recovered'` off decoded-frame progress
1072+ * (see `options.stallTimeout`). Uses `getVideoPlaybackQuality()` rather
1073+ * than `currentTime` — for a live MediaStream, `currentTime` keeps
1074+ * advancing on the stream's own clock even when no new frame is decoded,
1075+ * so it would miss exactly the stall this exists to catch. See issue #3.
1076+ */
1077+ _checkStall ( ) {
1078+ const timeout = this . options . stallTimeout ;
1079+ if ( ! timeout || timeout <= 0 ) return ;
1080+ if ( typeof this . video . getVideoPlaybackQuality !== 'function' ) return ;
1081+ if ( this . video . paused || this . video . ended ) {
1082+ this . _stallStartedAt = 0 ;
1083+ return ;
1084+ }
1085+
1086+ const count = this . video . getVideoPlaybackQuality ( ) . totalVideoFrames ;
1087+ if ( count !== this . _lastFrameQualityCount ) {
1088+ this . _lastFrameQualityCount = count ;
1089+ this . _stallStartedAt = 0 ;
1090+ if ( this . _isStalled ) {
1091+ this . _isStalled = false ;
1092+ this . dispatchEvent ( new CustomEvent ( 'recovered' , { detail : { elapsedMs : this . _stalledElapsedMs } } ) ) ;
1093+ }
1094+ return ;
1095+ }
1096+
1097+ if ( ! this . _stallStartedAt ) {
1098+ this . _stallStartedAt = Date . now ( ) ;
1099+ return ;
1100+ }
1101+ const elapsedMs = Date . now ( ) - this . _stallStartedAt ;
1102+ if ( elapsedMs >= timeout && ! this . _isStalled ) {
1103+ this . _isStalled = true ;
1104+ this . _stalledElapsedMs = elapsedMs ;
1105+ this . dispatchEvent ( new CustomEvent ( 'stalled' , { detail : { elapsedMs } } ) ) ;
1106+ }
1107+ }
1108+
10481109 /** Redundant DOM-level bottom fade (defense in depth alongside the shader fade). */
10491110 _applyCssFade ( ) {
10501111 const wanted = this . options . edgeDissolve && this . options . cssFade && this . options . fadeBottom > 0 ;
@@ -1306,6 +1367,7 @@ const ELEMENT_OPTION_ATTRIBUTES = {
13061367 'fade-top' : [ 'fadeTop' , Number ] ,
13071368 'fade-bottom' : [ 'fadeBottom' , Number ] ,
13081369 'max-pixel-ratio' : [ 'maxPixelRatio' , Number ] ,
1370+ 'stall-timeout' : [ 'stallTimeout' , Number ] ,
13091371} ;
13101372
13111373/**
@@ -1314,7 +1376,7 @@ const ELEMENT_OPTION_ATTRIBUTES = {
13141376 * Supported attributes: `src` (required), `autoplay`, `loop`, `muted`,
13151377 * `channel`, `min-key`, `bias`, `softness`, `spill`, `edge-dissolve`,
13161378 * `auto-tune` (empty = once, `"adaptive"` = continuous), `fade-top`,
1317- * `fade-bottom`, `max-pixel-ratio`.
1379+ * `fade-bottom`, `max-pixel-ratio`, `stall-timeout` .
13181380 *
13191381 * The underlying player is exposed as the element's `.player` property for
13201382 * full programmatic control (events, `update()`, the video element, etc.).
0 commit comments