From 8e37694aa3b4b1adfe0281dc3f2e515ac9153082 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 15 Sep 2026 09:18:19 +0000 Subject: [PATCH] Keep directory lives continuous and live playlist rows stable --- .github/workflows/player.yml | 12 ++ .github/workflows/release.yml | 2 + package.json | 2 +- packages/long-string-scroller/src/index.ts | 14 +- src/audio.ts | 4 +- src/channels.ts | 168 ++++++++++----------- src/fragments.ts | 46 ++++++ src/playlist-input.ts | 99 ++++++++++++ src/server.ts | 12 +- test/playlist-stream.test.ts | 131 ++++++++++++++++ web/scripts/check-parties.mjs | 6 +- web/scripts/check-playlist.mjs | 112 ++++++++++++++ web/src/app.ts | 104 +++++++------ web/src/styles.css | 18 +-- 14 files changed, 572 insertions(+), 158 deletions(-) create mode 100644 src/playlist-input.ts create mode 100644 test/playlist-stream.test.ts create mode 100644 web/scripts/check-playlist.mjs diff --git a/.github/workflows/player.yml b/.github/workflows/player.yml index 294e465..b8470c4 100644 --- a/.github/workflows/player.yml +++ b/.github/workflows/player.yml @@ -3,6 +3,11 @@ on: pull_request: paths: - 'web/**' + - 'packages/long-string-scroller/**' + - 'src/channels.ts' + - 'src/fragments.ts' + - 'src/playlist-input.ts' + - 'test/playlist-stream.test.ts' - 'src/protocol.ts' - 'src/i18n.ts' - 'src/ui-languages.ts' @@ -30,6 +35,9 @@ jobs: bun-version: latest - run: bun install --frozen-lockfile - run: bun run typecheck + - name: Install media test tools + run: sudo apt-get update && sudo apt-get install -y ffmpeg + - run: bun test test/playlist-stream.test.ts - run: bun test test/i18n.test.ts web/test/player.test.ts web/test/web.test.ts web/test/accessibility.test.ts - run: bun run web:build - run: bun run backtoschool:build @@ -50,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: Live playlist row stability and mouse scrolling + env: + NIXAMP_PLAYWRIGHT_MODULE: ${{ runner.temp }}/nixamp-browser/node_modules/playwright/index.mjs + run: bun web/scripts/check-playlist.mjs - name: Browser screenshots if: always() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 997a700..6336492 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,8 @@ jobs: - run: bun run build - run: bun run web:build - run: bun run backtoschool:build + - name: Install media test tools + run: sudo apt-get update && sudo apt-get install -y ffmpeg - run: bun test test web/test # Prove the OpenStream envelope on the build we are about to ship, and # gate the release on it: the command exits non-zero if any codec that diff --git a/package.json b/package.json index 7f8e30c..161a141 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nixamp", - "version": "0.28.35", + "version": "0.28.36", "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.", "license": "MIT", "type": "module", diff --git a/packages/long-string-scroller/src/index.ts b/packages/long-string-scroller/src/index.ts index 6122ea4..1443572 100644 --- a/packages/long-string-scroller/src/index.ts +++ b/packages/long-string-scroller/src/index.ts @@ -6,14 +6,13 @@ export function attachLongStringScroller(viewport: HTMLElement, content: HTMLEle content.classList.add("long-string-scroller-content"); content.title = content.textContent ?? ""; let frame = 0; - let lastX = 0; let target = 0; let position = 0; let returning = false; let releaseHeight: ReturnType | null = null; const animate = (): void => { frame = 0; - const next = position + (target - position) * 0.22; + const next = matchMedia("(prefers-reduced-motion: reduce)").matches ? target : position + (target - position) * 0.22; position = Math.abs(target - next) < 0.5 ? target : next; content.style.setProperty("--path-shift", `${position}px`); if (Math.abs(target - position) >= 0.5) { @@ -34,7 +33,7 @@ export function attachLongStringScroller(viewport: HTMLElement, content: HTMLEle if (!frame) frame = requestAnimationFrame(animate); }; viewport.addEventListener("pointerenter", (event) => { - if (event.pointerType !== "mouse") return; + if (event.pointerType !== "mouse" || !matchMedia("(hover: hover) and (pointer: fine)").matches) return; if (releaseHeight !== null) { clearTimeout(releaseHeight); releaseHeight = null; @@ -47,11 +46,14 @@ export function attachLongStringScroller(viewport: HTMLElement, content: HTMLEle }); viewport.addEventListener("pointermove", (event) => { if (content.dataset["pan"] !== "true") return; - lastX = event.clientX; - const overflow = Math.max(viewport.scrollWidth, content.scrollWidth) - viewport.clientWidth; + const style = getComputedStyle(viewport); + const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); + // The viewport's scrollWidth includes the transformed child and changes + // while panning. Measure the untransformed content against the text area. + const overflow = Math.max(0, content.scrollWidth - (viewport.clientWidth - padding)); if (overflow <= 0) return; const box = viewport.getBoundingClientRect(); - const raw = Math.max(0, Math.min(1, (lastX - box.left) / Math.max(1, box.width))); + const raw = Math.max(0, Math.min(1, (event.clientX - box.left) / Math.max(1, box.width))); // Reserve a small edge band for the physical limits of a mouse: the // pointer rarely lands on the exact last pixel, but the string must still // reach both ends. Smoothstep gives the acceleration/deceleration curve in diff --git a/src/audio.ts b/src/audio.ts index e74d278..498dcb6 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -442,7 +442,7 @@ export interface Codecs { * answering other requests, and a synchronous probe per media request is how * the whole library came to be tagged with the process wedged solid. */ -export async function codecsOf(tools: Tools, path: string, input: string[] = []): Promise { +export async function codecsOf(tools: Tools, path: string, input: string[] = [], signal?: AbortSignal): Promise { const [cmd, ...rest] = tools.ffprobe; const empty: Codecs = { video: "", audio: "", container: "" }; if (!cmd) return empty; @@ -469,7 +469,7 @@ export async function codecsOf(tools: Tools, path: string, input: string[] = []) ...input, path, ], - { stdio: ["ignore", "pipe", "ignore"] }, + { stdio: ["ignore", "pipe", "ignore"], signal }, ); let out = ""; child.stdout.on("data", (chunk: Buffer) => { diff --git a/src/channels.ts b/src/channels.ts index 476989b..2537cd4 100644 --- a/src/channels.ts +++ b/src/channels.ts @@ -16,11 +16,11 @@ */ import { spawn, type ChildProcess } from "node:child_process"; import { randomBytes } from "node:crypto"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; -import { tmpdir } from "node:os"; +import { PlaylistInputStream } from "./playlist-input.ts"; import type { Readable } from "node:stream"; -import { Fragments, isOpening } from "./fragments.ts"; +import { FragmentClock, Fragments, isOpening } from "./fragments.ts"; /** Somewhere for a channel's audio to go. A response, in practice. */ export interface Listener { @@ -59,9 +59,9 @@ export interface ChannelInfo { redials?: number; /** * For a channel that plays a list: the entries, in order, and which one is - * on. When one ends the next is dialled at once, and the last is followed - * by the first: a station, not a file. A restart picks up at the entry - * that was on. + * on. Local files are normalized into one continuous stream. After the + * final entry the show ends. A restart picks up at the saved entry and + * position within it. */ playlist?: string[]; playlistAt?: number; @@ -121,6 +121,7 @@ export interface PullResume { position: number; /** For a list of things rather than one: every entry, in order. */ playlist?: string[]; + playlistAt?: number; } /** @@ -188,18 +189,9 @@ export const BACKLOG_AUDIO = 64 * 1024; */ export const LISTENER_QUEUE = 16 * 1024 * 1024; -/** - * The backlog is really a number of seconds, and four megabytes was that - * number for the stream we happened to have. - * - * Six seconds of 720p is about 4 MB. Six seconds of a 1080p transport stream - * copied straight through is nearer 12, and of 4K nearer 30 -- so a fixed - * 4 MB hands a 4K joiner under a second of video, which is the live edge with - * no cushion, which is the play-wait-play loop the backlog exists to prevent. - * So the cap follows the stream: seconds times the rate it is actually - * running at, between the old floor and a ceiling that keeps a channel's - * memory bounded whatever it is carrying. - */ +/** Media-time window for complete MP4 fragments. Byte limits are a separate + * memory ceiling; the measured byte rate is only a fallback for inputs with + * no usable fragment timestamps. It must never make low-bitrate video old. */ export const BACKLOG_SECONDS = 6; export const BACKLOG_VIDEO_MAX = 48 * 1024 * 1024; /** @@ -253,6 +245,7 @@ export function cleanId(value: unknown, fallback = "main"): string { export interface ChannelOptions { ffmpeg: string[]; + ffprobe?: string[]; onStart?: (info: ChannelInfo) => void; onEnd?: (info: ChannelInfo) => void; /** @@ -314,6 +307,8 @@ export class Channel { * can begin at; for MP3 any point will do, a frame announces itself. */ private recent: Buffer[] = []; + private fragmentClock = new FragmentClock(); + private fragmentTimes = new WeakMap(); private recentBytes = 0; /** The rate window: when it opened, what has arrived in it, and what the last closed one measured. */ private rateStart = 0; @@ -330,8 +325,6 @@ export class Channel { private readonly sourceTaps = new Set(); /** Pulls the plug on the current read-through, when there is one. */ private throughAbort: AbortController | null = null; - /** ffmpeg concat manifest for a local multi-file live. */ - private concatListPath: string | null = null; constructor( readonly info: ChannelInfo, @@ -403,42 +396,27 @@ export class Channel { if (this.info.kind === "video") this.fragments = new Fragments(); const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]]; this.info.live = resume.live; - if (!resume.live) this.info.position = Math.max(0, resume.position); + if (!resume.live || resume.playlist?.length) this.info.position = Math.max(0, resume.position); // A list plays entry by entry: the one that is on is what gets dialled, // and an entry that ended is followed by the next, at once. const list = resume.playlist && resume.playlist.length > 0 ? resume.playlist : null; - // A local directory live must be one ffmpeg input. Starting a new ffmpeg - // process for every file makes browsers see a new MP4 stream at each - // boundary, even when the HTTP listener stays attached. The concat - // demuxer keeps timestamps and the output pipe continuous. - const concatList = list && list.length > 1 && list.every((one) => !/^https?:\/\//i.test(one)) - ? list - : null; - if (this.concatListPath) { - rmSync(this.concatListPath, { force: true }); - this.concatListPath = null; - } - if (concatList) { - const dir = mkdtempSync(join(tmpdir(), "nixamp-playlist-")); - this.concatListPath = join(dir, "playlist.txt"); - const quote = (one: string): string => one.replaceAll("'", "'\\''"); - writeFileSync(this.concatListPath, concatList.map((one) => `file '${quote(one)}'`).join("\n") + "\n"); - } + const localPlaylist = list && list.length > 1 && list.every(one => !/^https?:\/\//i.test(one)) ? list : null; let at = 0; if (list) { this.info.playlist = list; - at = Math.min(Math.max(0, this.info.playlistAt ?? 0), list.length - 1); + at = Math.min(Math.max(0, resume.playlistAt ?? this.info.playlistAt ?? 0), list.length - 1); this.info.playlistAt = at; } let current = list ? (list[at] as string) : source; // The last entry is the end of the show, not a way back to the first: // a list that played through is over, and says so with the outro. - this.advance = list && list.length > 1 && !concatList + this.advance = list && list.length > 1 && !localPlaylist ? () => { if (at + 1 >= list.length) return false; at += 1; this.info.playlistAt = at; current = list[at] as string; + this.info.position = 0; return true; } : null; @@ -457,7 +435,7 @@ export class Channel { // the place was written down a moment ago and a moment of it twice is // better than a moment of it missing. A live source is joined as is, // and a film that has barely started is started. - const from = resume.live ? 0 : Math.max(0, Math.floor((this.info.position ?? 0) - REWIND)); + const from = resume.live && !list ? 0 : Math.max(0, Math.floor((this.info.position ?? 0) - REWIND)); const seek = from > 0 ? ["-ss", String(from)] : []; // Read the source here rather than in ffmpeg, when a policy wants the // original bytes and the source is the kind that can be. ffmpeg then @@ -467,26 +445,19 @@ export class Channel { this.throughAbort?.abort(); this.throughAbort = null; // The outro is a file read by ffmpeg itself: a pipe cannot loop. - const through = this.outroOn ? null : this.options.through?.(this.info, from, input, audio) ?? null; - this.info.teed = through !== null; - const concatInput = this.concatListPath - ? [ - ...(paced ? ["-re"] : []), - // Directory lives may contain different codecs, dimensions, or - // timestamp bases. Copying the first file's streams can make - // ffmpeg reject later entries and leave the audience on entry 1. - // The output settings below normalize the entire concat stream. - "-f", "concat", "-safe", "0", "-i", this.concatListPath, - ] - : null; - const concatEncode = this.concatListPath && this.info.kind === "video" - ? [ - "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", - "-pix_fmt", "yuv420p", "-g", "48", "-keyint_min", "48", "-sc_threshold", "0", - "-c:a", "aac", "-b:a", "160k", "-ac", "2", - "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof", - ] - : null; + const playlistInput = localPlaylist ? new PlaylistInputStream({ + ffmpeg: this.options.ffmpeg, + ffprobe: this.options.ffprobe ?? this.options.ffmpeg.map(one => one.replace(/ffmpeg(?=(?:\.exe)?$)/, "ffprobe")), + files: localPlaylist, at: this.info.playlistAt ?? 0, position: from, + video: this.info.kind === "video", width: this.info.codecs?.width, height: this.info.codecs?.height, + }) : null; + const through = this.outroOn ? null : playlistInput + ? { format: "mpegts", open: async (signal: AbortSignal) => playlistInput.open(signal) } + : this.options.through?.(this.info, from, input, audio) ?? null; + this.info.teed = through !== null && !playlistInput; + const playlistEncode = !playlistInput ? null : this.info.kind === "video" + ? ["-c", "copy", "-bsf:a", "aac_adtstoasc", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"] + : ["-af", "aresample=async=1:first_pts=0", ...encode]; const child = spawn( command, [ @@ -508,7 +479,7 @@ export class Channel { // headers, which only a source read through us leaves out, // are no use to a pipe and are not here (a source that needs // them is not read through us in the first place). - ...input, + ...(playlistInput ? ["-probesize", "32768", "-analyzeduration", "1000000"] : input), "-f", through.format, ...(paced ? ["-re"] : []), "-i", "pipe:0", @@ -529,12 +500,12 @@ export class Channel { // What the source's site expects on the request: a user agent, a // referer, a cookie. A link resolved by yt-dlp comes with these, // and a CDN that got them from yt-dlp and not from us answers 403. - ...(concatInput ?? [...input, ...seek, "-i", current]), + ...input, ...seek, "-i", current, // The sound, when the site keeps it apart from the picture: a // second input, dialled the same way, that the encode maps in. ...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []), ]), - ...(concatEncode ?? encode), + ...(playlistEncode ?? encode), "pipe:1", ], { stdio: [through ? "pipe" : "ignore", "pipe", "pipe", "pipe"] }, @@ -551,18 +522,26 @@ export class Channel { let progress = ""; (child.stdio[3] as Readable | null)?.on("data", (chunk: Buffer) => { progress = (progress + chunk.toString("utf8")).slice(-4000); - if (this.child !== child || resume.live) return; + if (this.child !== child || (resume.live && !list)) return; const lines = progress.split("\n"); progress = lines.pop() ?? ""; for (const line of lines) { const match = /^out_time_us=(\d+)/.exec(line.trim()); - if (match) this.info.position = from + Number(match[1]) / 1e6; + if (match) { + const seconds = Number(match[1]) / 1e6; + if (playlistInput) { + const place = playlistInput.place(seconds); + this.info.playlistAt = place.at; + this.info.position = place.position; + } else this.info.position = from + seconds; + } } }); (child.stdio[3] as Readable | null)?.on("error", () => undefined); child.stdout?.on("data", (chunk: Buffer) => { // An ffmpeg that was replaced can still have a chunk in the pipe. if (this.child !== child) return; + if (!sent) this.info.error = undefined; sent = true; this.info.bytes += chunk.byteLength; this.rearm(child); @@ -572,8 +551,12 @@ export class Channel { drain(child.stderr, (tail) => { this.stderr = tail; }); // Only the ffmpeg we are currently running gets to say the source // dropped. One that was killed to make way for a restart is not news. - child.on("error", () => { if (this.child === child) this.dropped(sent); }); - child.on("close", () => { if (this.child === child) this.dropped(sent); }); + child.on("error", () => { if (this.child === child) this.dropped(sent, false); }); + child.on("close", (code, signal) => { + if (this.child !== child) return; + if (playlistInput?.error) this.stderr = playlistInput.error.message; + this.dropped(sent, code === 0 && !signal && (!playlistInput || playlistInput.complete)); + }); }; this.redial = dial; @@ -596,6 +579,7 @@ export class Channel { const kind = this.info.kind ?? "audio"; const old = this.child; this.child = null; + this.throughAbort?.abort(); if (this.watchdog) clearTimeout(this.watchdog); this.watchdog = null; if (this.timer) clearTimeout(this.timer); @@ -642,7 +626,7 @@ export class Channel { this.info.playlistAt = 0; this.startOver(); const show = this.dialed; - this.pull(show.source, show.encode, show.paced, show.stall, show.input, show.audio, { ...show.resume, position: 0 }); + this.pull(show.source, show.encode, show.paced, show.stall, show.input, show.audio, { ...show.resume, position: 0, playlistAt: 0 }); return true; } const dial = this.redial; @@ -676,6 +660,8 @@ export class Channel { private startOver(preserveListeners = false): void { if (this.info.kind === "video") this.fragments = new Fragments(); this.recent = []; + this.fragmentClock = new FragmentClock(); + this.fragmentTimes = new WeakMap(); this.recentBytes = 0; // A new source may be a different size of stream, and the rate measured // off the old one is not evidence about this one. @@ -734,7 +720,7 @@ export class Channel { * worth dialling again, and a URL that has never once produced a byte is a * mistake somebody made, and retrying it for ever helps nobody. */ - private dropped(sent: boolean): void { + private dropped(sent: boolean, clean = false): void { if (this.closing || !this.redial) return; // An outro that stopped is over; there is nothing after it. if (this.outroOn) { @@ -742,26 +728,26 @@ export class Channel { return; } this.child = null; + this.throughAbort?.abort(); if (this.watchdog) clearTimeout(this.watchdog); this.watchdog = null; const said = lastLine(this.stderr); if (said) this.info.error = said; - this.failures = sent ? 0 : this.failures + 1; + this.failures = sent && clean ? 0 : this.failures + 1; if (this.failures >= GIVE_UP) { this.close(); return; } - // A list moves on. An entry that played to its end is not a source that - // dropped: the next is dialled now, and it is not a redial. One that - // gave nothing is skipped the same way, counted as the failure it was, - // so a list of dead links gives up rather than cycling for ever. - const moved = this.advance?.() ?? false; + // Only a clean EOF advances a per-URL playlist. Decoder errors, a + // watchdog kill, or a failed normalized input recover at the saved place + // with bounded retries; an MP4 header alone is not a completed show. + const moved = clean ? this.advance?.() ?? false : false; // A show that ended: a film or a podcast that played to its end, a // list whose last entry did. That is not a source that dropped, and it // is not dialled again from the top; it is over, and the outro says // so. A live feed that stopped is a feed that dropped, and is redialled. const list = (this.info.playlist?.length ?? 0) > 0; - if (sent && !moved && (this.info.live === false || list)) { + if (clean && sent && !moved && (this.info.live === false || list)) { void this.endShow(); return; } @@ -798,7 +784,19 @@ export class Channel { } const cap = this.backlogCap(); for (const box of this.fragments.push(chunk)) { - if (!isOpening(boxType(box))) this.remember(box, cap, true); + const time = this.fragmentClock.read(box); + if (time !== null) this.fragmentTimes.set(box, time); + if (!isOpening(boxType(box))) this.remember(box, time === null ? cap : BACKLOG_VIDEO_MAX, true); + if (time !== null) { + // Drop complete old fragments even when six seconds occupy only a + // 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; + do { this.recentBytes -= this.recent.shift()!.byteLength; } + while (this.recent.length && boxType(this.recent[0]!) !== "moof"); + } + } this.send(box); } } @@ -978,8 +976,11 @@ export class Channel { // Wait for ffmpeg to take it, or for the pipe to go: a pipe that // closed never drains, and waiting on it would hold the read open. await new Promise((done) => { - stdin.once("drain", done); - stdin.once("close", done); + const finish = (): void => { + stdin.off("drain", finish); stdin.off("close", finish); done(); + }; + stdin.once("drain", finish); + stdin.once("close", finish); }); } } @@ -1063,10 +1064,7 @@ export class Channel { this.child = null; this.throughAbort?.abort(); this.throughAbort = null; - if (this.concatListPath) { - rmSync(dirname(this.concatListPath), { force: true, recursive: true }); - this.concatListPath = null; - } + this.endTaps(); try { child?.stdin?.end(); @@ -1437,7 +1435,7 @@ export function rememberedNow(channels: Channels): RememberedChannel[] { if (one.kind) kept.kind = one.kind; if (one.codecs) kept.codecs = one.codecs; if (typeof one.live === "boolean") kept.live = one.live; - if (!one.live && typeof one.position === "number" && one.position > 0) kept.position = Math.floor(one.position); + if ((!one.live || one.playlist?.length) && typeof one.position === "number" && one.position > 0) kept.position = Math.floor(one.position); if (one.startedBy) kept.startedBy = one.startedBy; if (one.playlist && one.playlist.length > 0) { kept.playlist = one.playlist; diff --git a/src/fragments.ts b/src/fragments.ts index f197644..713c21f 100644 --- a/src/fragments.ts +++ b/src/fragments.ts @@ -64,6 +64,52 @@ export function isOpening(type: string): boolean { return type === "ftyp" || type === "moov"; } +function children(box: Buffer): Box[] { + let rest = box.subarray(box.readUInt32BE(0) === 1 ? BIG : HEADER); + const found: Box[] = []; + for (;;) { + const next = firstBox(rest); + if (!next) return found; + found.push(next.box); + rest = next.rest; + } +} + +/** Decode timestamps use each track's own timescale, never its byte rate. */ +export class FragmentClock { + private scales = new Map(); + read(box: Buffer): number | null { + const type = box.toString("latin1", 4, 8); + if (type === "moov") { + for (const track of children(box).filter(one => one.type === "trak")) { + const parts = children(track.bytes); + const tkhd = parts.find(one => one.type === "tkhd")?.bytes; + const mdia = parts.find(one => one.type === "mdia")?.bytes; + const mdhd = mdia && children(mdia).find(one => one.type === "mdhd")?.bytes; + if (!tkhd || !mdhd) continue; + const idAt = tkhd[8] === 1 ? 28 : 20; + const scaleAt = mdhd[8] === 1 ? 28 : 20; + if (tkhd.length < idAt + 4 || mdhd.length < scaleAt + 4) continue; + const scale = mdhd.readUInt32BE(scaleAt); + if (scale) this.scales.set(tkhd.readUInt32BE(idAt), scale); + } + } + if (type !== "moof") return null; + const times: number[] = []; + for (const track of children(box).filter(one => one.type === "traf")) { + const parts = children(track.bytes); + const tfhd = parts.find(one => one.type === "tfhd")?.bytes; + const tfdt = parts.find(one => one.type === "tfdt")?.bytes; + if (!tfhd || tfhd.length < 16 || !tfdt || tfdt.length < (tfdt[8] === 1 ? 20 : 16)) continue; + const scale = this.scales.get(tfhd.readUInt32BE(12)); + if (!scale) continue; + const time = tfdt[8] === 1 ? Number(tfdt.readBigUInt64BE(12)) : tfdt.readUInt32BE(12); + times.push(time / scale); + } + return times.length ? Math.min(...times) : null; + } +} + /** * A fragmented MP4 arriving in pieces, handed back a box at a time. * diff --git a/src/playlist-input.ts b/src/playlist-input.ts new file mode 100644 index 0000000..7df8442 --- /dev/null +++ b/src/playlist-input.ts @@ -0,0 +1,99 @@ +import { spawn } from "node:child_process"; +import type { Readable } from "node:stream"; +import { codecsOf } from "./audio.ts"; + +export interface PlaylistInput { + ffmpeg: string[]; + ffprobe: string[]; + files: string[]; + at: number; + position: number; + video: boolean; + width?: number; + height?: number; +} + +/** Decode each file independently; feed one persistent muxer a fixed stream + * profile and continuous timestamps. A concat demuxer cannot decode a list + * whose input codecs differ. Only one entry is encoded at a time, with pipe + * backpressure bounding read-ahead independently of the directory's size. */ +export class PlaylistInputStream { + private entries: { at: number; start: number; position: number }[] = []; + error: Error | null = null; + complete = false; + constructor(private readonly options: PlaylistInput) {} + + place(seconds: number): { at: number; position: number } { + const entry = this.entries.findLast(one => seconds >= one.start) ?? this.entries[0]; + return entry ? { at: entry.at, position: Math.max(0, seconds - entry.start + entry.position) } + : { at: this.options.at, position: this.options.position }; + } + + async *open(signal: AbortSignal): AsyncGenerator { + const o = this.options; + const [command, ...prefix] = o.ffmpeg as [string, ...string[]]; + const ratio = Math.min(1, 1280 / (o.width || 1280), 720 / (o.height || 720)); + const width = Math.max(2, Math.floor((o.width || 1280) * ratio / 2) * 2); + const height = Math.max(2, Math.floor((o.height || 720) * ratio / 2) * 2); + let offset = 0; + try { + for (let at = o.at; at < o.files.length && !signal.aborted; at++) { + const file = o.files[at]!; + const codecs = await codecsOf({ ffmpeg: o.ffmpeg, ffprobe: o.ffprobe, play: null }, file, [], AbortSignal.any([signal, AbortSignal.timeout(10_000)])); + if (signal.aborted) return; + if (!codecs.audio && !codecs.video) throw new Error(`Cannot decode playlist entry ${at + 1}`); + const position = at === o.at ? o.position : 0; + const duration = (codecs.duration ?? 0) - position; + if (duration <= 0) throw new Error(`No playable duration for playlist entry ${at + 1}`); + this.entries.push({ at, start: offset, position }); + const hasVideo = Boolean(codecs.video); + const extra = o.video && !hasVideo + ? ["-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=30`] + : o.video && !codecs.audio ? ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"] : []; + const args = [ + ...prefix, "-hide_banner", "-loglevel", "error", "-nostdin", "-filter_threads", "2", + "-threads", "2", ...(position > 0 ? ["-ss", String(position)] : []), "-i", file, + ...extra, "-t", String(duration), + ...(o.video ? [ + "-map", hasVideo ? "0:v:0" : "1:v:0", "-map", codecs.audio ? "0:a:0" : "1:a:0", + "-vf", `fps=30,scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,setpts=PTS-STARTPTS`, + "-c:v", "libx264", "-threads", "2", "-preset", "veryfast", "-tune", "zerolatency", + "-crf", "23", "-pix_fmt", "yuv420p", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0", + ] : ["-map", "0:a:0", "-vn"]), + "-af", "aresample=48000,asetpts=PTS-STARTPTS", "-c:a", "aac", "-b:a", "160k", "-ac", "2", + "-mpegts_flags", "+initial_discontinuity", "-output_ts_offset", String(offset), "-mpegts_copyts", "1", "-muxdelay", "0", "-muxpreload", "0", + "-f", "mpegts", "pipe:1", + ]; + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let tail = ""; + child.stderr.on("data", chunk => { tail = (tail + chunk.toString()).slice(-1500); }); + const closed = new Promise((resolve) => { + child.once("error", error => { tail = error.message; resolve(-1); }); + child.once("close", code => resolve(code)); + }); + const abort = (): void => { child.kill("SIGKILL"); }; + signal.addEventListener("abort", abort, { once: true }); + try { + for await (const chunk of child.stdout as Readable) { + if (signal.aborted) return; + yield chunk as Buffer; + } + const code = await closed; + if (signal.aborted) return; + if (code !== 0) throw new Error(`Playlist entry ${at + 1} failed: ${tail.trim()}`); + } finally { + signal.removeEventListener("abort", abort); + abort(); + await closed; + } + // Match the frame grid used above so rounding cannot accumulate a + // backwards timestamp over hundreds of short files. + offset += o.video ? Math.ceil(duration * 30) / 30 : duration; + } + this.complete = !signal.aborted; + } catch (error) { + this.error = error instanceof Error ? error : new Error(String(error)); + throw this.error; + } + } +} diff --git a/src/server.ts b/src/server.ts index 8c51475..0500167 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1150,7 +1150,7 @@ export async function pullChannel( const kind = codecs.video !== "" ? "video" : codecs.audio !== "" ? "audio" : (known.kind ?? "video"); if (assumed) console.log(` "${id}": the source would not say what it holds; carrying it as ${kind}.`); // A film has a length and a place to go back to; a live source has neither. - // A list is a station: joined where it is, and never seeked. + // Listeners join a playlist live; its producer resumes the saved entry and position. const playlist = known.playlist && known.playlist.length > 0 ? known.playlist : null; const live = playlist ? true : (known.live ?? !((codecs.duration ?? 0) > 0)); const encode = kind === "video" @@ -1174,13 +1174,12 @@ export async function pullChannel( const tagged = looksLikeFileName(name) && codecs.tags?.title ? codecs.tags.title : name; const channel = channels.pull( id, tagged, source, encode, kind, true, undefined, opening, kind === "video" ? audio : "", - { live, position: known.position ?? 0, ...(playlist ? { playlist } : {}) }, + { live, position: known.position ?? 0, ...(playlist ? { playlist, playlistAt: known.playlistAt ?? 0 } : {}) }, // Known before the first dial: whether the source is a transport stream // decides whether it can be read here for a source-boundary relay. assumed ? undefined : codecs, ); if (channel && !assumed) channel.info.codecs = codecs; - if (channel && playlist && known.playlistAt !== undefined) channel.info.playlistAt = known.playlistAt; if (channel) { // Its picture from wherever it came with one, and a line about it: what // was given, else the show and the album the file's own tags name. @@ -1208,7 +1207,7 @@ export async function pullChannel( // told otherwise would cut fMP4 segments for a stream that did not need // them. if (channel && kind === "video") { - channel.info.emits = encode.includes("libx264") ? "h264" : codecs.video || "h264"; + channel.info.emits = (playlist && playlist.length > 1 && playlist.every(one => !/^https?:\/\//i.test(one))) || encode.includes("libx264") ? "h264" : codecs.video || "h264"; } return channel; } @@ -6100,6 +6099,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise { const outro = new Outro({ ffmpeg: tools.ffmpeg, dir: join(stateDir(), "outro"), onEvent: (message) => console.log(message) }); const channels = new Channels({ ffmpeg: tools.ffmpeg, + ffprobe: tools.ffprobe, ...(tools.carries ? { outro: (kind) => outro.clip(kind) } : {}), onOutro: (info) => console.log(` "${info.id}" has ended; the outro plays for an hour.`), onStart: (info) => @@ -6141,7 +6141,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise { // every restart used to take CNN off the air until somebody noticed. const remembering = (list: RememberedChannel[]): void => rememberChannels(stateDir(), options.port, list); for (const one of rememberedChannels(stateDir(), options.port)) { - const where = one.position && !one.live ? `, from ${Math.floor(one.position / 60)}m${Math.floor(one.position % 60)}s` : ""; + const where = one.position && (!one.live || one.playlist?.length) ? `, from ${Math.floor(one.position / 60)}m${Math.floor(one.position % 60)}s` : ""; console.log(` Putting "${one.id}" (${one.name}) back on the air${where}.`); // With what was written down about it: what it holds, so the source is // not asked again, and where it had got to, so a film carries on. @@ -6164,7 +6164,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise { let lastRemembered = ""; setInterval(() => { const now = rememberedNow(channels); - // A list moves on without a position: which entry is on is the thing to keep. + // Both the entry and its elapsed time advance during a directory live. if (!now.some((one) => one.position !== undefined || one.playlist)) return; const text = JSON.stringify(now); if (text === lastRemembered) return; diff --git a/test/playlist-stream.test.ts b/test/playlist-stream.test.ts new file mode 100644 index 0000000..5002e3b --- /dev/null +++ b/test/playlist-stream.test.ts @@ -0,0 +1,131 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Channels, BACKLOG_SECONDS, rememberedNow } from "../src/channels.ts"; +import { firstBox, FragmentClock } from "../src/fragments.ts"; +import { pullChannel } from "../src/server.ts"; + +const available = spawnSync("ffmpeg", ["-version"]).status === 0 && spawnSync("ffprobe", ["-version"]).status === 0; +const ff = (args: string[]) => execFileSync("ffmpeg", ["-hide_banner", "-loglevel", "error", "-y", ...args], { timeout: 30_000, maxBuffer: 16 * 1024 * 1024 }); +function boxes(bytes: Buffer) { + const out = []; + for (;;) { const next = firstBox(bytes); if (!next) return out; out.push(next.box); bytes = next.rest; } +} +function clip(file: string, color: string, codec = "libx264", size = "160x90", seconds = 2) { + ff(["-f", "lavfi", "-i", `color=c=${color}:s=${size}:r=10:d=${seconds}`, "-f", "lavfi", "-i", `sine=frequency=440:duration=${seconds}`, + "-c:v", codec, "-threads", "1", "-g", "10", "-pix_fmt", "yuv420p", "-c:a", "aac", "-t", String(seconds), file]); +} + +test("a directory live crosses codecs and subdirectories in one decodable stream, then resumes at the saved entry", { skip: !available, timeout: 30_000 }, async () => { + const root = mkdtempSync(join(tmpdir(), "nixamp-playlist-test-")); + try { + mkdirSync(join(root, "02 next", "nested"), { recursive: true }); + const files = [join(root, "first.mp4"), join(root, "02 next", "second.mp4"), join(root, "02 next", "nested", "third.mp4")]; + clip(files[0]!, "red"); clip(files[1]!, "blue", "mpeg4", "320x180"); clip(files[2]!, "lime"); + for (const resume of [false, true]) { + let finish!: () => void; + const done = new Promise(resolve => { finish = resolve; }); + const set = new Channels({ ffmpeg: ["ffmpeg"], ffprobe: ["ffprobe"], onEnd: finish }); + try { + const channel = resume + ? await pullChannel(set, ["ffprobe"], "show", "Show", files[0]!, [], "", { playlist: files, playlistAt: 1, live: true }) + : set.pull("show", "Show", files[0]!, [], "video", false, 10_000, [], "", { live: true, position: 0, playlist: files }, { video: "h264", audio: "aac", container: "mp4", width: 160, height: 90 }); + assert.ok(channel); + assert.equal(rememberedNow(set)[0]?.playlistAt, resume ? 1 : 0); + const chunks: Buffer[] = []; + let ends = 0; + set.listen("show", { write(b) { chunks.push(Buffer.from(b)); return true; }, end() { ends++; } }); + await done; + assert.equal(channel.info.error, undefined); + assert.equal(channel.info.redials ?? 0, 0); + assert.equal(channel.info.playlistAt, 2); + assert.ok((channel.info.position ?? 0) >= 1.9); + assert.equal(ends, 1, "the audience stays attached until the entire show finishes"); + 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 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) { + const [r, g, b] = pixels.subarray(at, at + 3); + const color = r! > 150 ? "red" : b! > 150 ? "blue" : g! > 150 ? "green" : "unknown"; + if (color !== colors.at(-1)) colors.push(color); + } + assert.deepEqual(colors, resume ? ["blue", "green"] : ["red", "blue", "green"]); + } finally { set.stopAll(); } + } + } finally { rmSync(root, { recursive: true, force: true }); } +}); + +test("low-bitrate video join buffer contains seconds, not the first minutes of a course", { skip: !available }, () => { + const stream = ff(["-f", "lavfi", "-i", "color=c=red:s=160x90:r=10:d=120", "-an", "-c:v", "libx264", "-threads", "1", "-g", "20", "-bf", "0", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof", "pipe:1"]); + const set = new Channels({ ffmpeg: ["ffmpeg"] }); + try { + const channel = set.relayIn("show", "Show", "video", "fixture")!; + // Arrives in arbitrary chunks, potentially much faster than wall time. + for (let at = 0; at < stream.length; at += 997) channel.receive(stream.subarray(at, at + 997)); + const opening = boxes(Buffer.concat(channel.opening())); + const clock = new FragmentClock(); + const times = opening.map(one => clock.read(one.bytes)).filter((time): time is number => time !== null); + assert.ok(times.length > 1); + assert.ok(times[0]! >= 110, `joined at ${times[0]} seconds of a 120-second stream`); + assert.ok(times.at(-1)! - times[0]! <= BACKLOG_SECONDS); + assert.equal(opening[2]?.type, "moof"); + } finally { set.stopAll(); } +}); + +test("a failed playlist input retries instead of announcing the end of the show", { skip: !available, timeout: 20_000 }, async () => { + const root = mkdtempSync(join(tmpdir(), "nixamp-recovery-test-")); + let finish!: () => void; + const done = new Promise(resolve => { finish = resolve; }); + const set = new Channels({ ffmpeg: ["ffmpeg"], ffprobe: ["ffprobe"], onEnd: finish }); + try { + const files = [join(root, "first.mp4"), join(root, "missing.mp4")]; + clip(files[0]!, "red"); + const channel = set.pull("show", "Show", files[0]!, [], "video", false, 10_000, [], "", { live: true, position: 0, playlist: files }, { video: "h264", audio: "aac", container: "mp4", width: 160, height: 90 })!; + const deadline = Date.now() + 8000; + while (!channel.info.redials && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 30)); + assert.equal(channel.info.redials, 1); + assert.equal(channel.info.ended, undefined); + assert.equal(set.count, 1, "a partial stream must not be mistaken for EOF"); + assert.match(channel.info.error ?? "", /entry 2/); + const saved = rememberedNow(set)[0]!; + assert.equal(saved.playlistAt, 0); + assert.ok((saved.position ?? 0) >= 1, "live playlists persist progress within the current file"); + clip(files[1]!, "blue"); + await done; + assert.equal(channel.info.playlistAt, 1); + } finally { set.stopAll(); rmSync(root, { recursive: true, force: true }); } +}); + +test("an audio album crosses FLAC and MP3 entries without closing listeners", { skip: !available, timeout: 10_000 }, async () => { + const root = mkdtempSync(join(tmpdir(), "nixamp-album-test-")); + let finish!: () => void; + const done = new Promise(resolve => { finish = resolve; }); + const set = new Channels({ ffmpeg: ["ffmpeg"], ffprobe: ["ffprobe"], onEnd: finish }); + try { + const files = [join(root, "01.flac"), join(root, "02.mp3")]; + ff(["-f", "lavfi", "-i", "sine=frequency=220:sample_rate=44100:duration=2", files[0]!]); + ff(["-f", "lavfi", "-i", "sine=frequency=880:sample_rate=48000:duration=2", files[1]!]); + const channel = set.pull("album", "Album", files[0]!, ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"], "audio", false, 10_000, [], "", { live: true, position: 0, playlist: files })!; + const chunks: Buffer[] = []; + let ends = 0; + channel.listen({ write(chunk) { chunks.push(chunk); return true; }, end() { ends++; } }); + await done; + assert.equal(ends, 1); assert.equal(channel.info.playlistAt, 1); assert.equal(channel.info.error, undefined); + const out = join(root, "out.mp3"); writeFileSync(out, Buffer.concat(chunks)); + const pcm = ff(["-i", out, "-ac", "1", "-ar", "8000", "-f", "f32le", "pipe:1"]); + const frequency = (start: number) => { + let crossings = 0; + for (let i = start * 8000 * 4; i < (start + 0.5) * 8000 * 4; i += 4) { + if (pcm.readFloatLE(i) < 0 && pcm.readFloatLE(i + 4) >= 0) crossings++; + } + return crossings * 2; + }; + assert.ok(Math.abs(frequency(0.5) - 220) < 5); + assert.ok(Math.abs(frequency(2.5) - 880) < 5); + } finally { set.stopAll(); rmSync(root, { recursive: true, force: true }); } +}); diff --git a/web/scripts/check-parties.mjs b/web/scripts/check-parties.mjs index 80d1aa4..42d2335 100644 --- a/web/scripts/check-parties.mjs +++ b/web/scripts/check-parties.mjs @@ -82,8 +82,8 @@ try { streams[0].channels.push('New sports live'); streams[0].lineup.push({ id: 'sports', name: 'New sports live' }); bridge = [{ party: { roomId: 'room-1', slug: 'movie', origin: 'Cinema', partyCode: 'ABC123', partyUrl: 'https://cinema.example.test/watch/abc', mediaTitle: 'Movie night', positionNow: 5, playing: true }, event: { id: 'event-1', title: 'Movie night', status: 'live' }, links: { partyUrl: 'https://cinema.example.test/watch/abc', nixampUrl: 'https://nixamp.com/room/abc', roomUrl: 'https://nixamp.com/room/abc' }, host: false }]; - await page.locator('#parties-list').getByText('New sports live', { exact: true }).waitFor({ timeout: 4500 }); - await page.locator('#parties-list').getByText('Movie night', { exact: true }).waitFor({ timeout: 4500 }); + await page.locator('#parties-list').getByText('New sports live', { exact: true }).waitFor({ timeout: 6500 }); + await page.locator('#parties-list').getByText('Movie night', { exact: true }).waitFor({ timeout: 6500 }); assert.ok(await page.evaluate(() => document.activeElement === window.__partyFocus && window.__partyFocus.isConnected)); assert.deepEqual(await page.evaluate(() => ({ scroll: scrollY, panel: document.querySelector('#parties-list').scrollTop })), marker); assert.equal(await page.locator('.party-server').filter({ has: page.getByRole('heading', { name: 'Alpha server', exact: true }) }).getByText('New sports live', { exact: true }).count(), 1); @@ -92,7 +92,7 @@ try { return { scroll: scrollY, panel: document.querySelector('#parties-list').scrollTop }; }); bridge = []; - await page.locator('#parties-list').getByText('Movie night', { exact: true }).waitFor({ state: 'detached', timeout: 4500 }); + await page.locator('#parties-list').getByText('Movie night', { exact: true }).waitFor({ state: 'detached', timeout: 6500 }); assert.deepEqual(await page.locator('#remote-url').evaluate(input => [input.value, input.selectionStart, input.selectionEnd]), ['keep my draft', 2, 5]); assert.deepEqual(await page.evaluate(() => ({ scroll: scrollY, panel: document.querySelector('#parties-list').scrollTop })), draft); delayedDirectory = true; maxDirectory = 0; diff --git a/web/scripts/check-playlist.mjs b/web/scripts/check-playlist.mjs new file mode 100644 index 0000000..3d6491e --- /dev/null +++ b/web/scripts/check-playlist.mjs @@ -0,0 +1,112 @@ +import assert from 'node:assert/strict'; +import {fileURLToPath} from 'node:url'; +const {chromium} = await import(process.env.NIXAMP_PLAYWRIGHT_MODULE || 'playwright'); +import {readFile,writeFile} from 'node:fs/promises'; +const root=fileURLToPath(new URL('../dist', import.meta.url)); +const browser=await chromium.launch({headless:true, ...(process.env.NIXAMP_CHROMIUM_PATH ? {executablePath:process.env.NIXAMP_CHROMIUM_PATH} : {})}); +const context=await browser.newContext({viewport:{width:1280,height:900},locale:'en-US',serviceWorkers:'block'}); +const title='01. A deliberately long lecture filename describing security engineering and application architecture.mp4'; +const long='Complete course/01. Introduction/01. Lessons/'+title; +const state={revision:1,tracks:[{title,artist:'',album:'',duration:2,video:true,folder:''}],trackCount:1,index:0,playing:false,position:0,bars:[],levels:[0,0],silent:true,note:'',root:'/private/root'}; +const air={server:{name:'Test',nowPlaying:'',tracks:1,playing:false,live:false,code:'',url:'https://server.example/view/test'},channels:[{id:'review',name:'Review live',via:'pull',listeners:1,startedAt:Date.now(),kind:'audio',playlist:[long,long.replace('01. A','02. A')],entry:0,live:true}]}; +let directoryRequests=[]; +const silence=Buffer.alloc(44+8000*60*2); +silence.write('RIFF',0);silence.writeUInt32LE(silence.length-8,4);silence.write('WAVEfmt ',8); +silence.writeUInt32LE(16,16);silence.writeUInt16LE(1,20);silence.writeUInt16LE(1,22); +silence.writeUInt32LE(8000,24);silence.writeUInt32LE(16000,28);silence.writeUInt16LE(2,32);silence.writeUInt16LE(16,34); +silence.write('data',36);silence.writeUInt32LE(silence.length-44,40); +async function fixtures(context) { +await context.addInitScript(({state})=>{ + localStorage.setItem('nixamp.welcome','hidden'); + const media=new WeakMap(); + Object.defineProperty(HTMLMediaElement.prototype,'paused',{get(){return !media.get(this)}}); + HTMLMediaElement.prototype.play=async function(){media.set(this,true);this.dispatchEvent(new Event('play'));this.dispatchEvent(new Event('playing'))}; + HTMLMediaElement.prototype.pause=function(){media.set(this,false);this.dispatchEvent(new Event('pause'))}; + HTMLMediaElement.prototype.load=function(){}; + class Source{static all=[];readyState=1;constructor(url){this.url=String(url);Source.all.push(this);if(this.url.includes('/api/events'))setTimeout(()=>{this.onopen?.({});this.onmessage?.({data:JSON.stringify(state)})},40)}close(){}addEventListener(){}removeEventListener(){}} + window.EventSource=Source; + window.reviewTick=()=>{for(const s of Source.all)if(s.url.includes('/api/events'))s.onmessage?.({data:JSON.stringify({...state,tracks:undefined,revision:++state.revision,position:state.revision/10})})}; +},{state}); +await context.route('**/*',async route=>{ + const url=new URL(route.request().url()),path=url.pathname; + const json=(body,status=200)=>route.fulfill({status,contentType:'application/json',body:JSON.stringify(body)}); + if(!['https://nixamp.com','https://server.example'].includes(url.origin))return route.abort(); + if(path==='/api/v1/auth/me')return json({},401); + if(path==='/api/v1/auth/providers')return json({providers:[]}); + if(path==='/api/state')return json(state); + if(path==='/api/health')return json({name:'nixamp',version:'fixture'}); + if(path==='/api/streams')return json(air); + if(path==='/api/directory'){directoryRequests.push(Date.now());await new Promise(r=>setTimeout(r,250));return json({streams:[]})} + if(path==='/api/catalogs')return json({catalogs:[]}); + if(path==='/jingles/index.json')return json([]); + if(path.startsWith('/api/channels/'))return route.fulfill({status:200,contentType:'audio/wav',body:silence}); + if(path.startsWith('/api/'))return json({},404); + if(url.origin!=='https://nixamp.com')return route.abort(); + try{const file=root+(path==='/'?'/index.html':path);const body=await readFile(file);return route.fulfill({body,contentType:path.endsWith('.js')?'text/javascript':path.endsWith('.css')?'text/css':'text/html'});}catch{return route.fulfill({status:404,body:''})} +}); +} +await fixtures(context); +const page=await context.newPage();const errors=[];page.on('pageerror',e=>errors.push(e.message)); +try{ + await page.goto('https://nixamp.com/?url=https%3A%2F%2Fserver.example%2Fview%2Ftest&play=channel%3Areview'); + await page.locator('#live-playlist .row').first().waitFor(); + await page.waitForTimeout(150); + const geometry=await page.evaluate(()=>{ + const one=sel=>{const e=document.querySelector(sel),r=e.getBoundingClientRect(),c=getComputedStyle(e);return {x:r.x,width:r.width,height:r.height,padding:c.padding,display:c.display,textAlign:c.textAlign,grid:c.gridTemplateColumns,clientWidth:e.clientWidth,scrollWidth:e.scrollWidth}}; + return {live:one('#live-playlist .row'),liveNumber:one('#live-playlist .n'),liveLabel:one('#live-playlist .row-label'),liveTitleViewport:one('#live-playlist .row-label .row-value'),liveTitle:one('#live-playlist .row-file'),livePath:one('#live-playlist .row-path'),file:one('#playlist .row'),fileTitle:one('#playlist .row-file'),filePath:one('#playlist .row-path')}; + }); + await page.evaluate(()=>{window.reviewLive=document.querySelector('#live-playlist .row');window.reviewFile=document.querySelector('#playlist .row');window.reviewSpinner=document.querySelector('#parties-panel .panel-loading-spinner')}); + const pan=page.locator('#live-playlist .row > .row-value').first(); + await pan.scrollIntoViewIfNeeded();const rect=await pan.boundingBox(); + await page.mouse.move(rect.x+rect.width-4,rect.y+rect.height/2);await page.waitForTimeout(700); + const before=await pan.evaluate(e=>({shift:e.firstChild.style.getPropertyValue('--path-shift'),pan:e.firstChild.dataset.pan})); + await page.evaluate(()=>window.reviewTick());await page.waitForTimeout(40); + const after=await page.evaluate(()=>({liveSame:window.reviewLive===document.querySelector('#live-playlist .row'),liveAttached:window.reviewLive.isConnected,fileSame:window.reviewFile===document.querySelector('#playlist .row'),spinnerSame:window.reviewSpinner===document.querySelector('#parties-panel .panel-loading-spinner'),pan:document.querySelector('#live-playlist .row > .row-value').firstChild.dataset.pan,shift:document.querySelector('#live-playlist .row > .row-value').firstChild.style.getPropertyValue('--path-shift')})); + assert.equal(after.liveSame,true,'playback ticks must preserve the live row'); + assert.equal(after.fileSame,true); + assert.equal(after.spinnerSame,true); + assert.equal(after.pan,'true'); + assert.ok(Math.abs(geometry.liveTitle.x-geometry.livePath.x)<1,'title and full path must align'); + assert.ok(Math.abs(geometry.fileTitle.x-geometry.filePath.x)<1); + const end=await pan.evaluate(e=>{ + const text=e.firstChild, box=e.getBoundingClientRect(), edge=text.getBoundingClientRect().right; + return {edge, right:box.right-parseFloat(getComputedStyle(e).paddingRight)}; + }); + assert.ok(Math.abs(end.edge-end.right)<2,'far-right mouse position exposes the last character'); + const height=await page.locator('#live-playlist .row').first().evaluate(e=>e.getBoundingClientRect().height); + // Repeated notifications while moving used to detach the row and reset its pan. + for(let i=0;i<20;i++) { + await page.mouse.move(rect.x+rect.width*(0.1+i/25),rect.y+rect.height/2); + await page.evaluate(()=>window.reviewTick()); + } + await page.mouse.move(rect.x-20,rect.y-10); + await page.waitForTimeout(800); + const left=await pan.evaluate(e=>({height:e.closest('.row').getBoundingClientRect().height,shift:e.firstChild.style.getPropertyValue('--path-shift'),nowrap:getComputedStyle(e.firstChild).whiteSpace})); + assert.equal(left.height,height,'leaving must never wrap or change desktop row height'); + assert.equal(left.shift,''); assert.equal(left.nowrap,'nowrap'); + await page.locator('input[type=search]').first().focus(); + await page.evaluate(()=>{window.reviewFocus=document.activeElement;window.reviewScroll=scrollY;for(let i=0;i<20;i++)window.reviewTick()}); + await page.waitForTimeout(100); + assert.equal(await page.evaluate(()=>document.activeElement===window.reviewFocus&&scrollY===window.reviewScroll),true); + await page.waitForTimeout(10500); + const intervals=directoryRequests.slice(1).map((v,i)=>v-directoryRequests[i]).filter(v=>v>1000); + assert.ok(intervals.length>=2); + assert.ok(intervals.every(v=>v>=4500),'quiet directory polling runs every five seconds'); + assert.equal(await page.locator('#parties-panel').getAttribute('data-loading'),null); + assert.deepEqual(errors,[]); + air.channels[0].entry=1; + await page.evaluate(()=>document.dispatchEvent(new Event('visibilitychange'))); + await page.locator('#live-playlist .row[data-index="1"][aria-current="true"]').waitFor({timeout:7000}); + assert.equal(await page.evaluate(()=>window.reviewLive===document.querySelector('#live-playlist .row')),true); + const mobile=await browser.newContext({viewport:{width:390,height:844},isMobile:true,hasTouch:true,serviceWorkers:'block'}); + await fixtures(mobile); + const phone=await mobile.newPage(); + await phone.goto('https://nixamp.com/?url=https%3A%2F%2Fserver.example%2Fview%2Ftest&play=channel%3Areview'); + await phone.locator('#live-playlist .row').first().waitFor(); + const wrapped=await phone.locator('#live-playlist .row-path').first().evaluate(e=>({whiteSpace:getComputedStyle(e).whiteSpace,height:e.getBoundingClientRect().height,lineHeight:parseFloat(getComputedStyle(e).lineHeight)})); + assert.equal(wrapped.whiteSpace,'normal'); + assert.ok(wrapped.height>wrapped.lineHeight*2,'touch users can read the complete wrapped path'); + assert.equal(await phone.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true); + await mobile.close(); + console.log('Live and file rows retain identity, align, pan to both ends, return without wrapping, preserve focus/scroll, and poll quietly every five seconds.'); +}finally{await browser.close()} diff --git a/web/src/app.ts b/web/src/app.ts index ea5d2be..47904e2 100644 --- a/web/src/app.ts +++ b/web/src/app.ts @@ -1519,43 +1519,50 @@ export function start(): void { } let playlistSource: unknown = null; let playlistView = ""; + let liveQueueId = ""; + let liveQueueFiles: string[] = []; /** Wrap everywhere; on a fine pointer, let the shared scroller pan it. */ const pathMarquee = (viewport: HTMLElement, content: HTMLElement): void => { content.title = content.textContent ?? ""; attachLongStringScroller(viewport, content); }; + function fileLabel(title: string, fullPath: string): { label: HTMLElement; pathView: HTMLElement } { + const file = document.createElement("span"); file.className = "name row-file"; file.textContent = title; + const path = document.createElement("span"); path.className = "row-path"; path.textContent = fullPath; + const label = document.createElement("span"); label.className = "row-label"; + const fileView = document.createElement("span"); fileView.className = "row-value"; fileView.append(file); + const pathView = document.createElement("span"); pathView.className = "row-value"; pathView.append(path); + label.append(fileView); + pathMarquee(fileView, file); pathMarquee(pathView, path); + return { label, pathView }; + } function renderPlaylist(): void { // Meter ticks and playback clocks do not change the library. Avoid mapping, // sorting and serializing thousands of tracks for every incoming frame. const channel = channelOn ? lastAir?.channels.find((one) => one.id === channelOn?.id) : undefined; const channelPlaylist = channel?.playlist; - dom.livePlaylistPanel.hidden = !(channelOn && channelPlaylist && channelPlaylist.length > 0); - if (channelOn && channelPlaylist && channelPlaylist.length > 0) { - const queue = channelPlaylist.map((name, index) => { + const showQueue = Boolean(channelOn && channelPlaylist?.length); + if (dom.livePlaylistPanel.hidden === showQueue) dom.livePlaylistPanel.hidden = !showQueue; + const queueId = showQueue ? channelOn!.id : ""; + const queueFiles = showQueue ? channelPlaylist! : []; + if (liveQueueId !== queueId || liveQueueFiles.length !== queueFiles.length || liveQueueFiles.some((file, index) => file !== queueFiles[index])) { + liveQueueId = queueId; + liveQueueFiles = queueFiles; + const queue = queueFiles.map((name, index) => { const item = document.createElement("li"); - item.className = "row"; + item.className = "row file-row live-file-row"; item.dataset.index = String(index); - const n = document.createElement("span"); n.className = "n"; n.textContent = String(index + 1).padStart(2, " "); - const slash = name.lastIndexOf("/"); - const file = document.createElement("span"); file.className = "name row-file"; file.textContent = slash < 0 ? name : name.slice(slash + 1); - const path = document.createElement("span"); path.className = "row-path"; path.textContent = name; - const label = document.createElement("span"); label.className = "row-label"; - const fileView = document.createElement("span"); fileView.className = "row-value"; fileView.append(file); - const pathView = document.createElement("span"); pathView.className = "row-value"; pathView.append(path); - label.append(fileView); pathMarquee(fileView, file); pathMarquee(pathView, path); - item.classList.add("live-file-row"); - item.append(n, label, pathView); - item.setAttribute("aria-readonly", "true"); - item.title = channel.live === false ? "Part of this on-demand show" : "Live queue (read-only)"; - if (index === (channel.entry ?? 0)) item.setAttribute("aria-current", "true"); + const n = document.createElement("span"); n.className = "n"; n.textContent = String(index + 1); + const { label, pathView } = fileLabel(name.slice(name.lastIndexOf("/") + 1), name); + const main = document.createElement("span"); main.className = "row-main"; + main.append(label, n); + item.append(main, pathView); return item; }); replaceList(dom.livePlaylist, ...queue); - } else { - dom.livePlaylist.replaceChildren(); } const source = mode === "remote" ? snapshot.tracks : local; - const view = `${mode}:${channelOn?.id ?? ""}:${channelPlaylist?.join("|") ?? ""}:${channel?.entry ?? 0}:${openFolder}:${dom.filter.value}:${listPage}:${canGoLive()}:${dom.adminPanel.hidden}`; + const view = `${mode}:${channelOn?.id ?? ""}:${openFolder}:${dom.filter.value}:${listPage}:${canGoLive()}:${dom.adminPanel.hidden}`; if (renderedFor && playlistSource === source && playlistView === view) { markPlaylistPlaying(); return; @@ -1662,18 +1669,13 @@ export function start(): void { n.className = "n"; n.textContent = String(row.index + 1).padStart(2, " "); const pathLabel = row.folder ? `${row.folder} / ${row.name}` : row.name; - const file = document.createElement("span"); file.className = "name row-file"; file.textContent = row.name; - const path = document.createElement("span"); path.className = "row-path"; path.textContent = row.folder ? `${row.folder}/${row.name}` : row.name; - const label = document.createElement("span"); label.className = "row-label"; - const fileView = document.createElement("span"); fileView.className = "row-value"; fileView.append(file); - const pathView = document.createElement("span"); pathView.className = "row-value"; pathView.append(path); - label.append(fileView, pathView); pathMarquee(fileView, file); pathMarquee(pathView, path); + const { label, pathView } = fileLabel(row.name, row.folder ? `${row.folder}/${row.name}` : row.name); const time = document.createElement("span"); time.className = "time"; time.textContent = row.seconds > 0 ? formatTime(row.seconds) : "--:--"; const play = document.createElement("button"); play.type = "button"; play.className = "row-main"; play.setAttribute("aria-label", `Play ${pathLabel}${row.seconds > 0 ? `, ${formatTime(row.seconds)}` : ""}`); - play.append(n, label, time); item.append(play); + play.append(label, n, time); item.append(play); const playGlyph = document.createElement("span"); playGlyph.className = "row-play-glyph"; drawIcon(playGlyph, "play"); item.append(playGlyph); // The file's own address, for whoever wants it somewhere other than // here. A picked file is a blob in this tab and has no address. @@ -1710,7 +1712,9 @@ export function start(): void { for (const child of Array.from(dom.playlist.children)) { const row = child as HTMLElement; const index = Number(row.dataset.index); - const isCurrent = Number.isInteger(index) && index === current; + const track = snapshot.tracks[index]; + const path = track ? `${track.folder ? `${track.folder}/` : ""}${displayName(track)}` : ""; + const isCurrent = Boolean(path) && path === channel.playlist[current]; row.classList.toggle("selected", isCurrent); row.classList.toggle("playing", false); if (isCurrent) row.setAttribute("aria-current", "true"); @@ -3345,6 +3349,13 @@ export function start(): void { /** One bounded refresh for the directory and the parties this account may see. * No connections to every remote, and no overlap if an endpoint is slow. */ + const PARTY_REFRESH_MS = 5000; + const partyLoads = new Set(); + const partyLoading = (owner: object, on: boolean): void => { + if (on) partyLoads.add(owner); else partyLoads.delete(owner); + if (partyLoads.size) dom.partiesPanel.dataset.loading = "true"; + else delete dom.partiesPanel.dataset.loading; + }; async function loadParties(): Promise { if (classroomEmbed) return; if (partiesAccount !== meId) { @@ -3354,7 +3365,8 @@ export function start(): void { } if (partiesRequest) return; const controller = new AbortController(); partiesRequest = controller; - dom.partiesPanel.dataset.loading = "true"; + const firstLoad = !partiesLoaded; + if (firstLoad) partyLoading(controller, true); const account = meId; const timeout = setTimeout(() => controller.abort(), 8000); const get = async (path: string): Promise<{ ok: boolean; status: number; body: unknown }> => { @@ -3383,7 +3395,7 @@ export function start(): void { if (!partiesLoaded && !lastAir) uiText(dom.partiesNote, () => uiMessage("Could not refresh parties.")); } finally { clearTimeout(timeout); - delete dom.partiesPanel.dataset.loading; + if (firstLoad) partyLoading(controller, false); if (partiesRequest === controller) partiesRequest = null; } } @@ -5959,11 +5971,14 @@ export function start(): void { // Keep the loading indicator inside the title strip. A pseudo-element // could sit over the panel when a title was long; this is part of the // heading's inline content and is clipped with the title itself. - heading.replaceChildren(document.createTextNode(name)); - const spinner = document.createElement("span"); - spinner.className = "panel-loading-spinner"; - spinner.setAttribute("aria-hidden", "true"); - heading.append(spinner); + let title = heading.querySelector(".panel-heading-text"); + if (!title) { + title = document.createElement("span"); title.className = "panel-heading-text"; + const spinner = document.createElement("span"); spinner.className = "panel-loading-spinner"; + spinner.setAttribute("aria-hidden", "true"); + heading.replaceChildren(title, spinner); + } + if (title.textContent !== name) title.textContent = name; if (panel.querySelector(":scope > .panel-tools")) continue; const tools = document.createElement("span"); tools.className = "panel-tools"; @@ -6288,7 +6303,7 @@ export function start(): void { void loadOnAir(); onAirTimer = setInterval(() => { if (document.visibilityState === "visible") void loadOnAir(); - }, 5000); + }, PARTY_REFRESH_MS); }; /** Read only the connected server. A late reply must never restore a server @@ -6299,7 +6314,7 @@ export function start(): void { // The first load needs a visible cue. Polling every five seconds must stay // quiet or the panel appears to reload forever while somebody watches it. const firstLoad = lastAir === null; - if (firstLoad) dom.partiesPanel.dataset.loading = "true"; + if (firstLoad) partyLoading(controller, true); const generation = onAirGeneration; const url = remote.url("/api/streams"); const timeout = setTimeout(() => controller.abort(), 8000); @@ -6322,7 +6337,7 @@ export function start(): void { // Retain the last usable list during a short network failure. } finally { clearTimeout(timeout); - if (firstLoad) delete dom.partiesPanel.dataset.loading; + if (firstLoad) partyLoading(controller, false); if (onAirRequest === controller) onAirRequest = null; } } @@ -6523,6 +6538,8 @@ export function start(): void { fresh = true, from?: typeof nowMeta, ): Promise { + const known = lastAir?.channels.find(one => one.id === channel.id); + if (!channel.playlist && known?.playlist) channel = { ...channel, playlist: known.playlist }; watching = -1; channelOn = channel; if (fresh) rejoins = 0; @@ -6536,11 +6553,10 @@ export function start(): void { // Safari on a phone will not play the endless MP4 a channel is sent as; // it plays HLS, so it is handed the same channel as a playlist. A // browser with MediaSource plays the MP4 as it is, which is lower latency. - // A playlist live has repeated per-file MP4 init boxes at each boundary. - // Native progressive MP4 treats that as a new resource and may end or - // reload; HLS keeps one player session while the channel advances. + // Use the same transport for a folder live whether joined from its row, + // a shared room link, or immediately after putting the folder on air. const asHls = channel.video && wantsHls(); - const playlistHls = channel.video && (channel.playlist?.length ?? 0) > 1; + const playlistHls = channel.video && ((channel.playlist?.length ?? 0) > 1 || channel.id.startsWith("folder-")); await whileLoading(() => player.load({ title: channel.name, artist: "", album: "", duration: 0, url: remote.url(asHls || playlistHls @@ -7081,7 +7097,7 @@ export function start(): void { const refreshVisibleParties = (): void => { if (document.visibilityState === "visible" && !dom.partiesPanel.hasAttribute("data-closed")) void loadParties(); }; - let partiesTick = setInterval(refreshVisibleParties, 2000); + let partiesTick = setInterval(refreshVisibleParties, PARTY_REFRESH_MS); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") { void loadParties(); void loadOnAir(); } }); @@ -7094,7 +7110,7 @@ export function start(): void { globalThis.addEventListener("pageshow", event => { if (!event.persisted) return; clearInterval(partiesTick); - partiesTick = setInterval(refreshVisibleParties, 2000); + partiesTick = setInterval(refreshVisibleParties, PARTY_REFRESH_MS); refreshVisibleParties(); if (mode === "remote") watchOnAir(true); }); diff --git a/web/src/styles.css b/web/src/styles.css index cf919f2..b4e56d0 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -190,6 +190,7 @@ body { .panel::before { content: none; } .panel-loading-spinner { display: none; + flex: none; width: 10px; height: 10px; margin-left: 8px; @@ -203,7 +204,9 @@ body { .panel:has([aria-busy="true"]) > .panel-heading .panel-loading-spinner { display: inline-block; } +.panel-heading-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .panel > .panel-heading { + display: flex; align-items: center; margin: 0; font-weight: 400; max-width: calc(100% - 108px); @@ -817,16 +820,9 @@ input[type="range"]:disabled { opacity: 0.45; cursor: default; } .file-row > .n { grid-column: 1; } .file-row > .row-label { grid-column: 2 / -1; align-items: flex-start; justify-self: stretch; text-align: left; } .file-row .row-file { text-align: left; } -.live-file-row { display: grid; grid-template-columns: auto minmax(0, 1fr); row-gap: 0; } -.live-file-row > .n { grid-column: 1; } -.live-file-row > .row-label { grid-column: 2; align-items: flex-start; justify-self: stretch; text-align: left; } -.live-file-row > .row-value { grid-column: 1 / -1; width: 100%; } -.file-row > .row-main .long-string-scroller, -.live-file-row > .row-label .long-string-scroller { - padding: 0 !important; - margin: 0; - text-align: left; -} +.live-file-row { grid-template-columns: minmax(0, 1fr); row-gap: 0; cursor: default; } +.file-row > .row-main { padding: 0; } +.live-file-row > .row-main { cursor: default; } .file-row > .row-value { grid-column: 1 / -1; width: 100%; } .row:hover { background: #142019; } @@ -872,7 +868,7 @@ input[type="range"]:disabled { opacity: 0.45; cursor: default; } .row-label { display: flex; flex: 1; min-width: 0; min-height: 24px; flex-direction: column; justify-content: center; line-height: 1.2; white-space: normal; overflow: hidden; } .row-value { display: block; min-width: 0; max-width: 100%; overflow: hidden; } .long-string-scroller { box-sizing: border-box; padding-inline: 4px; } -.long-string-scroller-content { transform: translateX(var(--path-shift, 0)); transition: transform 280ms ease-out; pointer-events: none; } +.long-string-scroller-content { display: block; transform: translateX(var(--path-shift, 0)); transition: transform 280ms ease-out; pointer-events: none; } @media (hover: hover) and (pointer: fine) { .row .long-string-scroller-content { white-space: nowrap;