Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/tv-player.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ once eight seconds are decoded while the remaining headroom is fetched; ordinary
track changes do not reload the player. A waiting decoder may
nudge across at most two seconds of already-buffered empty time, only at the end
of the preceding range; it never jumps directly over the older programme to a
newer buffered range. Transient playlist 404/429/503 responses and network errors use at most four
retries within the same ten-second request budget, and Stop cancels the backoff. It cannot recover
newer buffered range. Transient playlist 404/429/503 responses, network errors and attempts that hang
for four seconds use at most four retries with bounded backoff, about thirty
seconds in the worst case, so one stalled read cannot reset a buffered engine.
Stop cancels the attempt and the backoff. It cannot recover
quality already lost during preparation or compensate for an upload that stays
slower than the encoded programme bitrate.

Expand Down
15 changes: 15 additions & 0 deletions tests/tv-playlist.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,21 @@ test('a transient playlist update retries within the existing request budget',as
globalThis.fetch=async()=>++calls===1?new Response('Busy',{status:503}):new Response(master);
try{assert.equal(await engine.text('http://127.0.0.1/media/index.m3u8'),master);assert.equal(calls,2);}finally{engine.destroy();globalThis.fetch=original;}
});
test('a hung playlist attempt is retried instead of resetting the engine',async()=>{
const {default:Engine}=await import('../web/tv/cmaf-player.mjs');const original=globalThis.fetch;let calls=0;const engine=new Engine();
globalThis.fetch=async(_url,{signal})=>++calls===1?new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true})):new Response(master);
try{assert.equal(await engine.text('http://127.0.0.1/media/index.m3u8'),master);assert.equal(calls,2);}finally{engine.destroy();globalThis.fetch=original;}
});
test('a busy playlist followed by a slow reply still succeeds',async()=>{
const {default:Engine}=await import('../web/tv/cmaf-player.mjs');const original=globalThis.fetch;let calls=0;const engine=new Engine();
globalThis.fetch=async()=>++calls===1?new Response('Busy',{status:503}):new Promise(resolve=>setTimeout(()=>resolve(new Response(master)),3000));
try{assert.equal(await engine.text('http://127.0.0.1/media/index.m3u8'),master);assert.equal(calls,2);}finally{engine.destroy();globalThis.fetch=original;}
});
test('Stop during a hung playlist attempt ends the read without retrying',async()=>{
const {default:Engine}=await import('../web/tv/cmaf-player.mjs');const original=globalThis.fetch;let calls=0;const engine=new Engine();
globalThis.fetch=async(_url,{signal})=>{calls++;setTimeout(()=>engine.destroy(),50);return new Promise((_,reject)=>signal.addEventListener('abort',()=>reject(signal.reason),{once:true}));};
try{await assert.rejects(()=>engine.text('http://127.0.0.1/media/index.m3u8'));assert.equal(calls,1);}finally{engine.destroy();globalThis.fetch=original;}
});
test('Stop during playlist backoff prevents another request',async()=>{
const {default:Engine}=await import('../web/tv/cmaf-player.mjs');const original=globalThis.fetch;let calls=0;const engine=new Engine();
globalThis.fetch=async()=>{calls++;queueMicrotask(()=>engine.destroy());return new Response('Busy',{status:503});};
Expand Down
12 changes: 7 additions & 5 deletions web/tv/cmaf-player.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,16 @@ export async function boundedBytes(response,limit) {
const output=new Uint8Array(size);let offset=0;for(const chunk of chunks){output.set(chunk,offset);offset+=chunk.length;}return output;
}
// Retry complete reads after a route change; never append partial media.
async function retryBytes(url,{signal,limit,retry404=false,onAttempt=()=>{},onResponse=()=>{}}){
async function retryBytes(url,{signal,limit,retry404=false,attemptMs=0,onAttempt=()=>{},onResponse=()=>{}}){
for(let attempt=0;attempt<5;attempt++){
signal.throwIfAborted();onAttempt(attempt);
try{const response=await fetch(url,{signal,redirect:'error',cache:'no-store',credentials:'omit'});onResponse(response);
// A hung attempt is retried; only the caller's signal ends the read.
const attemptSignal=attemptMs?AbortSignal.any([signal,AbortSignal.timeout(attemptMs)]):signal;
try{const response=await fetch(url,{signal:attemptSignal,redirect:'error',cache:'no-store',credentials:'omit'});onResponse(response);
if(!response.ok){await response.body?.cancel();throw Object.assign(Error(`Media HTTP ${response.status}`),{status:response.status});}
return await boundedBytes(response,limit);
}catch(error){
if(signal.aborted||attempt===4||!(error instanceof TypeError||[429,503,...(retry404?[404]:[])].includes(error.status)))throw error;
if(signal.aborted||attempt===4||!(error instanceof TypeError||error?.name==='TimeoutError'||[429,503,...(retry404?[404]:[])].includes(error.status)))throw error;
await new Promise(resolve=>{const done=()=>{clearTimeout(timer);signal.removeEventListener('abort',done);resolve();};const timer=setTimeout(done,[500,1500,3000,5000][attempt]);signal.addEventListener('abort',done,{once:true});if(signal.aborted)done();});
}
}
Expand Down Expand Up @@ -77,8 +79,8 @@ export default class CmafPlayer {
get liveSyncPosition(){return this.offset===null||this.edge===null?NaN:this.offset+this.edge-this.config.liveSyncDuration;}
get latency(){return this.offset===null||this.edge===null?0:Math.max(0,this.offset+this.edge-this.media.currentTime);}
async text(url){
const signal=AbortSignal.any([this.controller.signal,AbortSignal.timeout(10000)]);
return new TextDecoder('ascii',{fatal:true}).decode(await retryBytes(url,{signal,limit:65536,retry404:true}));
// Five 4-second attempts plus backoff stay inside the 20-30 second buffer.
return new TextDecoder('ascii',{fatal:true}).decode(await retryBytes(url,{signal:this.controller.signal,limit:65536,retry404:true,attemptMs:4000}));
}
begin(){if(this.started||!this.media||!this.url)return;this.started=true;void this.run().catch(error=>{if(!this.controller.signal.aborted)this.trigger(CmafPlayer.Events.ERROR,{fatal:true,details:error.message});});}
async append(bytes){if(this.buffer.updating)await waitEvent(this.buffer,'updateend',this.controller.signal);await waitEvent(this.buffer,'updateend',this.controller.signal,()=>this.buffer.appendBuffer(bytes));}
Expand Down
2 changes: 1 addition & 1 deletion web/tv/version.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export const PLAYER_VERSION='2026.10.27.7';
export const PLAYER_VERSION='2026.10.27.8';

export function newerPlayerVersion(value){return typeof value==='string'&&/^[0-9.]{1,32}$/.test(value)&&value.localeCompare(PLAYER_VERSION,'en',{numeric:true})>0;}
Loading