Keep live players stable across polls and automatically recover each stream
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
'use strict';
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const LivePlayer = require('./web/live-player.js');
|
||||
|
||||
function fixture() {
|
||||
let now = 100, serial = 0;
|
||||
const timeouts = new Map(), intervals = new Map();
|
||||
const clock = {
|
||||
setTimeout(fn, ms) {const id=++serial;timeouts.set(id,{fn,ms});return id;},
|
||||
clearTimeout(id) {timeouts.delete(id);},
|
||||
setInterval(fn, ms) {const id=++serial;intervals.set(id,{fn,ms});return id;},
|
||||
clearInterval(id) {intervals.delete(id);}
|
||||
};
|
||||
const listeners = new Map(), indicators = {'.video-empty':{hidden:false},'.video-error':{hidden:true}};
|
||||
const video = {
|
||||
currentTime:0,readyState:0,videoWidth:0,paused:false,ended:false,error:null,
|
||||
addEventListener(e,f) {listeners.set(e,f);},removeEventListener(e) {listeners.delete(e);},
|
||||
emit(e) {listeners.get(e)?.();},
|
||||
play() {this.paused=false;this.emit('play');return Promise.resolve();},
|
||||
pause() {this.paused=true;this.emit('pause');},removeAttribute() {},load() {},
|
||||
canPlayType() {return '';}
|
||||
};
|
||||
const instances=[];
|
||||
class Hls {
|
||||
static Events={ERROR:'error',MANIFEST_PARSED:'manifest'};
|
||||
static isSupported() {return true;}
|
||||
constructor() {this.events={};instances.push(this);}
|
||||
on(e,f) {this.events[e]=f;}
|
||||
loadSource() {} attachMedia() {} destroy() {this.destroyed=true;}
|
||||
fail() {this.events.error(null,{fatal:true});}
|
||||
}
|
||||
const player=new LivePlayer(video,{querySelector:s=>indicators[s]},'/example',{Hls,clock,now:()=>now});
|
||||
return {player,video,instances,timeouts,intervals,indicators,
|
||||
advance(ms) {now+=ms;player.watch();},
|
||||
playing() {video.readyState=4;video.videoWidth=1920;video.emit('playing');},
|
||||
retry() {const [id,t]=timeouts.entries().next().value;timeouts.delete(id);now+=t.ms;t.fn();}
|
||||
};
|
||||
}
|
||||
|
||||
test('readiness, recording status and labels do not change playback identity',()=>{
|
||||
const camera={id:'one',enabled:true,ready:true,name:'A',recordingActive:false};
|
||||
const original=LivePlayer.key([camera],'hd');
|
||||
assert.equal(LivePlayer.key([{...camera,ready:false,name:'B',recordingActive:true}],'hd'),original);
|
||||
assert.notEqual(LivePlayer.key([camera],'compat'),original);
|
||||
});
|
||||
test('fatal error reconnects only its own player with bounded backoff',()=>{
|
||||
const a=fixture(),b=fixture();
|
||||
for(let i=0;i<8;i++) {
|
||||
a.instances.at(-1).fail();
|
||||
assert.equal(a.timeouts.size,1);
|
||||
assert.ok([...a.timeouts.values()][0].ms<=30000);
|
||||
a.retry();
|
||||
}
|
||||
assert.equal(b.instances.length,1);
|
||||
a.player.destroy();b.player.destroy();
|
||||
});
|
||||
test('duplicate errors schedule only one retry',()=>{
|
||||
const f=fixture();f.instances[0].fail();f.video.emit('error');
|
||||
assert.equal(f.timeouts.size,1);f.player.destroy();
|
||||
});
|
||||
test('continuous playback does not restart during repeated health checks',()=>{
|
||||
const f=fixture();f.playing();
|
||||
for(let i=0;i<72;i++) {f.video.currentTime+=5;f.advance(5000);}
|
||||
assert.equal(f.instances.length,1);assert.equal(f.timeouts.size,0);f.player.destroy();
|
||||
});
|
||||
test('stalled playback automatically reconnects',()=>{
|
||||
const f=fixture();f.playing();f.advance(30000);
|
||||
assert.equal(f.timeouts.size,1);f.retry();assert.equal(f.instances.length,2);f.player.destroy();
|
||||
});
|
||||
test('startup has enough time to wait for a source',()=>{
|
||||
const f=fixture();f.advance(60000);assert.equal(f.timeouts.size,0);
|
||||
f.advance(6000);assert.equal(f.timeouts.size,1);f.player.destroy();
|
||||
});
|
||||
test('intentional pause is preserved and resume can recover a failed connection',()=>{
|
||||
const f=fixture();f.playing();f.video.pause();f.instances[0].fail();f.advance(90000);
|
||||
assert.equal(f.instances.length,1);assert.equal(f.timeouts.size,0);
|
||||
f.video.play();assert.equal(f.instances.length,2);f.player.destroy();
|
||||
});
|
||||
test('destroy cancels timers and releases the old player',()=>{
|
||||
const f=fixture();f.instances[0].fail();f.player.destroy();
|
||||
assert.equal(f.timeouts.size,0);assert.equal(f.intervals.size,0);assert.equal(f.instances[0].destroyed,true);
|
||||
});
|
||||
test('recovery before timeout cancels pending retry',()=>{
|
||||
const f=fixture();f.instances[0].fail();f.playing();
|
||||
assert.equal(f.timeouts.size,0);assert.equal(f.indicators['.video-error'].hidden,true);f.player.destroy();
|
||||
});
|
||||
test('error from an old HLS instance cannot restart its replacement',()=>{
|
||||
const f=fixture();const old=f.instances[0];old.fail();f.retry();old.fail();
|
||||
assert.equal(f.timeouts.size,0);assert.equal(f.instances.length,2);f.player.destroy();
|
||||
});
|
||||
Reference in New Issue
Block a user