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
4 changes: 4 additions & 0 deletions .github/workflows/player.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
9 changes: 7 additions & 2 deletions src/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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");
}
Expand Down
3 changes: 3 additions & 0 deletions test/playlist-stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
58 changes: 58 additions & 0 deletions web/scripts/check-media-playlist.mjs
Original file line number Diff line number Diff line change
@@ -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:'<video autoplay muted></video><canvas width="1" height="1"></canvas>'});
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})}
13 changes: 10 additions & 3 deletions web/src/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand All @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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");
}
}
Expand Down
25 changes: 25 additions & 0 deletions web/test/player.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("@profullstack/player").attachSource>[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();
});
Loading