diff --git a/.github/workflows/player.yml b/.github/workflows/player.yml
index b8470c4..185f85c 100644
--- a/.github/workflows/player.yml
+++ b/.github/workflows/player.yml
@@ -58,6 +58,10 @@ jobs:
env:
NIXAMP_PLAYWRIGHT_MODULE: ${{ runner.temp }}/nixamp-browser/node_modules/playwright/index.mjs
run: bun web/scripts/check-parties.mjs
+ - name: Real HLS playlist decoding across mixed source files
+ env:
+ NIXAMP_PLAYWRIGHT_MODULE: ${{ runner.temp }}/nixamp-browser/node_modules/playwright/index.mjs
+ run: bun web/scripts/check-media-playlist.mjs
- name: Live playlist row stability and mouse scrolling
env:
NIXAMP_PLAYWRIGHT_MODULE: ${{ runner.temp }}/nixamp-browser/node_modules/playwright/index.mjs
diff --git a/package.json b/package.json
index 161a141..434ec3a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "nixamp",
- "version": "0.28.36",
+ "version": "0.28.37",
"description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
"license": "MIT",
"type": "module",
diff --git a/src/channels.ts b/src/channels.ts
index 2537cd4..5c3cfa0 100644
--- a/src/channels.ts
+++ b/src/channels.ts
@@ -455,8 +455,11 @@ export class Channel {
? { format: "mpegts", open: async (signal: AbortSignal) => playlistInput.open(signal) }
: this.options.through?.(this.info, from, input, audio) ?? null;
this.info.teed = through !== null && !playlistInput;
+ // AAC's AudioSpecificConfig is produced by the bitstream filter on
+ // its first packet. Writing an empty moov earlier omits that config:
+ // HLS remuxing then emits AAC with no ADTS headers and browsers fail.
const playlistEncode = !playlistInput ? null : this.info.kind === "video"
- ? ["-c", "copy", "-bsf:a", "aac_adtstoasc", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"]
+ ? ["-c", "copy", "-bsf:a", "aac_adtstoasc", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof+delay_moov"]
: ["-af", "aresample=async=1:first_pts=0", ...encode];
const child = spawn(
command,
@@ -792,7 +795,9 @@ export class Channel {
// few kilobytes (slides, static cameras, or a paused game screen).
while (this.recent.length > 0) {
const oldest = this.fragmentTimes.get(this.recent[0]!);
- if (oldest === undefined || time - oldest < BACKLOG_SECONDS) break;
+ // Audio frame rounding and floating-point subtraction must not
+ // retain one extra two-second fragment at the window boundary.
+ if (oldest === undefined || time - oldest < BACKLOG_SECONDS - 0.05) break;
do { this.recentBytes -= this.recent.shift()!.byteLength; }
while (this.recent.length && boxType(this.recent[0]!) !== "moof");
}
diff --git a/test/playlist-stream.test.ts b/test/playlist-stream.test.ts
index 5002e3b..3d3dbdc 100644
--- a/test/playlist-stream.test.ts
+++ b/test/playlist-stream.test.ts
@@ -47,6 +47,9 @@ test("a directory live crosses codecs and subdirectories in one decodable stream
const bytes = Buffer.concat(chunks);
assert.equal(boxes(bytes).filter(one => one.type === "moov").length, 1, "one stream header across every file");
const out = join(root, "out.mp4"); writeFileSync(out, bytes);
+ const probed = JSON.parse(execFileSync("ffprobe", ["-v", "error", "-show_entries", "stream=codec_name,extradata_size", "-of", "json", out], { encoding: "utf8" }));
+ assert.ok(probed.streams.find((stream: { codec_name: string }) => stream.codec_name === "aac")?.extradata_size >= 2,
+ "the initial MP4 header must contain AAC config before an HLS packager or browser receives it");
const pixels = ff(["-i", out, "-an", "-vf", "scale=1:1", "-pix_fmt", "rgb24", "-f", "rawvideo", "pipe:1"]);
const colors: string[] = [];
for (let at = 0; at < pixels.length; at += 3) {
diff --git a/web/scripts/check-media-playlist.mjs b/web/scripts/check-media-playlist.mjs
new file mode 100644
index 0000000..3e0d37d
--- /dev/null
+++ b/web/scripts/check-media-playlist.mjs
@@ -0,0 +1,58 @@
+// Real encoders, HLS packaging, and browser decoding; synthetic media only.
+import assert from 'node:assert/strict';
+import {mkdtempSync,writeFileSync,readFileSync,readdirSync,statSync,rmSync} from 'node:fs';
+import {join} from 'node:path';
+import {tmpdir} from 'node:os';
+import {fileURLToPath} from 'node:url';
+import {execFileSync} from 'node:child_process';
+import {Channels} from '../../src/channels.ts';
+const {chromium}=await import(process.env.NIXAMP_PLAYWRIGHT_MODULE || 'playwright');
+const root=mkdtempSync(join(tmpdir(),'nixamp-browser-media-'));
+const assets=fileURLToPath(new URL('../dist/assets',import.meta.url));
+const ff=args=>execFileSync('ffmpeg',['-hide_banner','-loglevel','error','-y',...args],{timeout:20000});
+let browser,channels;
+try{
+ const files=['red','blue','lime'].map((color,i)=>{
+ const file=join(root,`${i}.mp4`);
+ ff(['-f','lavfi','-i',`color=c=${color}:s=160x90:r=10:d=2`,'-f','lavfi','-i',`sine=frequency=${440+i*220}:sample_rate=${i===1?44100:48000}:duration=2`,
+ '-c:v',i===1?'mpeg4':'libx264','-threads','1','-g','10','-c:a','aac','-t','2',file]);
+ return file;
+ });
+ let finish;const done=new Promise(r=>finish=r);
+ channels=new Channels({ffmpeg:['ffmpeg'],ffprobe:['ffprobe'],onEnd:finish});
+ const channel=channels.pull('test','Test',files[0],[],'video',false,10000,[],'',{live:true,position:0,playlist:files},{video:'h264',audio:'aac',container:'mp4',width:160,height:90});
+ const chunks=[];channel.listen({write(chunk){chunks.push(chunk);return true},end(){}});
+ await done;assert.equal(channel.info.error,undefined);
+ const mp4=join(root,'live.mp4');writeFileSync(mp4,Buffer.concat(chunks));
+ ff(['-i',mp4,'-c','copy','-f','hls','-hls_time','2','-hls_list_size','0','-hls_flags','independent_segments','-hls_segment_filename',join(root,'seg%d.ts'),join(root,'index.m3u8')]);
+ const library=readdirSync(assets).filter(name=>/^hls-.*\.js$/.test(name)).sort((a,b)=>statSync(join(assets,b)).size-statSync(join(assets,a)).size)[0];
+ browser=await chromium.launch({headless:true,...(process.env.NIXAMP_CHROMIUM_PATH?{executablePath:process.env.NIXAMP_CHROMIUM_PATH}:{}),args:['--autoplay-policy=no-user-gesture-required']});
+ const page=await browser.newPage({serviceWorkers:'block'});
+ await page.route('https://media.example/**',route=>{
+ const path=new URL(route.request().url()).pathname;
+ if(path==='/')return route.fulfill({contentType:'text/html',body:''});
+ const file=path.startsWith('/assets/')?join(assets,path.slice(8)):join(root,path.slice(1));
+ return route.fulfill({body:readFileSync(file),contentType:path.endsWith('.js')?'text/javascript':path.endsWith('.m3u8')?'application/vnd.apple.mpegurl':'video/mp2t'});
+ });
+ await page.goto('https://media.example/');
+ await page.evaluate(async library=>{
+ const {default:Hls}=await import(`/assets/${library}`);
+ const video=document.querySelector('video');
+ window.mediaErrors=[];window.colors=[];
+ video.addEventListener('error',()=>window.mediaErrors.push(video.error?.message));
+ const hls=new Hls();window.testHls=hls;
+ hls.on(Hls.Events.ERROR,(_e,d)=>{if(d.fatal||d.details==='fragParsingError')window.mediaErrors.push(d.details)});
+ const context=document.querySelector('canvas').getContext('2d');
+ video.addEventListener('timeupdate',()=>{
+ if(!video.videoWidth)return;
+ context.drawImage(video,0,0,1,1);const [r,g,b]=context.getImageData(0,0,1,1).data;
+ const color=r>150?'red':b>150?'blue':g>150?'green':'unknown';
+ if(color!==window.colors.at(-1))window.colors.push(color);
+ });
+ hls.attachMedia(video);hls.loadSource('/index.m3u8');await video.play();
+ },library);
+ await page.waitForFunction(()=>document.querySelector('video').ended,{},{timeout:15000});
+ const result=await page.evaluate(()=>({errors:window.mediaErrors,colors:window.colors,time:document.querySelector('video').currentTime}));
+ assert.deepEqual(result.errors,[]);assert.deepEqual(result.colors,['red','blue','green']);assert.ok(result.time>=5.9);
+ console.log('Real HLS video and audio decode through three files and two source codecs with no parsing or media errors.');
+}finally{channels?.stopAll();await browser?.close();rmSync(root,{recursive:true,force:true})}
diff --git a/web/src/player.ts b/web/src/player.ts
index fa2793f..e54faba 100644
--- a/web/src/player.ts
+++ b/web/src/player.ts
@@ -194,6 +194,7 @@ export class BrowserPlayer {
constructor(
private readonly elements: PlayerElements,
private readonly handlers: PlayerHandlers,
+ private readonly attach: typeof attachSource = attachSource,
) {
this.active = elements.audio;
for (const element of [elements.audio, elements.video]) {
@@ -217,7 +218,7 @@ export class BrowserPlayer {
if (element === this.active) this.handlers.onState(false);
});
element.addEventListener("error", () => {
- if (element === this.active) this.handlers.onError(mediaError(element));
+ if (element === this.active && (!this.attached || this.attached.engine === "native")) this.handlers.onError(mediaError(element));
});
const busy = (is: boolean) => () => {
if (element === this.active) this.handlers.onBusy?.(is);
@@ -391,15 +392,18 @@ export class BrowserPlayer {
this.attached?.destroy();
this.attached = null;
try {
- this.attached = await attachSource(this.active, {
+ this.attached = await this.attach(this.active, {
src: track.url,
kind,
// A film the browser has no decoder for is the ordinary case in a
// library of downloads, and silence is the worst way to say so.
unplayableAdvice: "VLC or mpv will play it; nixamp can only hand it to your browser.",
onError: (message) => this.handlers.onError(message),
+ // The engine owns transient recovery. Turning its notice into an
+ // error reloads the source in the middle of recoverMediaError/startLoad
+ // and spends the room's rejoin budget before playback can resume.
onNotice: (message) => {
- if (message) this.handlers.onError(message);
+ if (message) this.handlers.onBusy?.(true);
},
});
} catch (error) {
@@ -432,6 +436,9 @@ export class BrowserPlayer {
this.handlers.onState(false);
return;
}
+ // HLS/MSE recovery replaces its MediaSource and aborts the pending play
+ // request. The engine still owns that recovery, including terminal errors.
+ if (error instanceof Error && error.name === "AbortError" && this.attached?.engine !== "native") return;
this.handlers.onError(error instanceof Error ? error.message : "playback was refused");
}
}
diff --git a/web/test/player.test.ts b/web/test/player.test.ts
index c1ab612..8039fc8 100644
--- a/web/test/player.test.ts
+++ b/web/test/player.test.ts
@@ -164,3 +164,28 @@ test('the intro retries a suspended graph once per media element and leaves the
else Reflect.deleteProperty(globalThis, 'AudioContext');
}
});
+
+test("engine recovery notices and aborted play requests do not reload a live source", async () => {
+ const audio = new MediaElement();
+ const errors: string[] = [];
+ const busy: boolean[] = [];
+ let sourceOptions: Parameters[1] | undefined;
+ let destroyed = 0;
+ const player = new BrowserPlayer({ audio: audio as unknown as HTMLAudioElement, video: new MediaElement() as unknown as HTMLVideoElement }, {
+ onTime() {}, onEnded() {}, onState() {}, onError: message => errors.push(message), onBusy: value => busy.push(value),
+ }, async (_media, options) => {
+ sourceOptions = options;
+ return { engine: "hls", kind: "hls", levels: () => [], destroy() { destroyed++; } };
+ });
+ await player.load({ title: "Live", artist: "", album: "", duration: 0, url: "https://server.example/live", video: false, objectUrl: false, kind: "audio" }, false);
+ sourceOptions!.onNotice?.("Recovering…");
+ audio.dispatchEvent(new Event("error"));
+ audio.refusal = new DOMException("MediaSource replaced during recovery", "AbortError");
+ await player.play();
+ assert.deepEqual(errors, [], "transient engine recovery must not consume the room's rejoin budget");
+ assert.equal(destroyed, 0);
+ assert.ok(busy.includes(true));
+ sourceOptions!.onError?.("Recovery failed");
+ assert.deepEqual(errors, ["Recovery failed"], "terminal engine failures still reach the app");
+ player.stop();
+});