nixamp 0.5.9 → 0.5.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/attach.js CHANGED
@@ -23,7 +23,11 @@ import { KEY_HEADER } from "./share.js";
23
23
  const RECONNECT_MS = 1000;
24
24
  /** A remote track has no path, because no filesystem path leaves the machine. */
25
25
  export function applySnapshot(state, snapshot) {
26
- state.tracks = snapshot.tracks.map((track) => ({ path: "", ...track }));
26
+ // A frame without a list is not an empty library, it is a frame with nothing
27
+ // new to say about it -- which is every frame but the first.
28
+ if (snapshot.tracks !== undefined) {
29
+ state.tracks = snapshot.tracks.map((track) => ({ path: "", ...track }));
30
+ }
27
31
  state.index = snapshot.index;
28
32
  state.playing = snapshot.playing;
29
33
  state.position = snapshot.position;
package/dist/audio.d.ts CHANGED
@@ -58,3 +58,28 @@ export declare function peaks(pcm: Float32Array): [number, number];
58
58
  /** Interleaved stereo down to mono, for the analyser. */
59
59
  export declare function toMono(pcm: Float32Array): Float32Array;
60
60
  export declare function formatTime(seconds: number): string;
61
+ /** What is actually inside a container, as opposed to what the name suggests. */
62
+ export interface Codecs {
63
+ /** e.g. "h264", "hevc", "vp9". Empty when there is no video stream. */
64
+ video: string;
65
+ /** e.g. "aac", "ac3", "dts". Empty when there is no audio stream. */
66
+ audio: string;
67
+ }
68
+ /**
69
+ * Ask ffprobe what the streams are, without holding the event loop.
70
+ *
71
+ * Deliberately not the spawnSync `probe` above: this one runs while a server is
72
+ * answering other requests, and a synchronous probe per media request is how
73
+ * the whole library came to be tagged with the process wedged solid.
74
+ */
75
+ export declare function codecsOf(tools: Tools, path: string): Promise<Codecs>;
76
+ /**
77
+ * How to get this file into a browser, given what is inside it.
78
+ *
79
+ * A container a browser will not open says nothing about the streams within:
80
+ * most Matroska holds H.264, which every browser decodes, and only the wrapper
81
+ * is wrong. Rewrapping that costs nothing and looks identical; re-encoding it
82
+ * would cost a core per viewer and look worse. So the streams decide, one part
83
+ * at a time -- a film can have its video copied and only its DTS re-encoded.
84
+ */
85
+ export declare function videoArgs(codecs: Codecs): string[];
package/dist/audio.js CHANGED
@@ -204,3 +204,68 @@ export function formatTime(seconds) {
204
204
  const s = total % 60;
205
205
  return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
206
206
  }
207
+ /**
208
+ * Ask ffprobe what the streams are, without holding the event loop.
209
+ *
210
+ * Deliberately not the spawnSync `probe` above: this one runs while a server is
211
+ * answering other requests, and a synchronous probe per media request is how
212
+ * the whole library came to be tagged with the process wedged solid.
213
+ */
214
+ export async function codecsOf(tools, path) {
215
+ const [cmd, ...rest] = tools.ffprobe;
216
+ const empty = { video: "", audio: "" };
217
+ if (!cmd)
218
+ return empty;
219
+ return new Promise((done) => {
220
+ const child = spawn(cmd, [
221
+ ...rest,
222
+ "-v", "quiet",
223
+ "-print_format", "json",
224
+ "-show_entries", "stream=codec_type,codec_name",
225
+ path,
226
+ ], { stdio: ["ignore", "pipe", "ignore"] });
227
+ let out = "";
228
+ child.stdout.on("data", (chunk) => {
229
+ out += chunk.toString("utf8");
230
+ });
231
+ child.on("error", () => done(empty));
232
+ child.on("close", () => {
233
+ try {
234
+ const parsed = JSON.parse(out);
235
+ const streams = parsed.streams ?? [];
236
+ return done({
237
+ video: streams.find((s) => s.codec_type === "video")?.codec_name ?? "",
238
+ audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
239
+ });
240
+ }
241
+ catch {
242
+ return done(empty);
243
+ }
244
+ });
245
+ });
246
+ }
247
+ /**
248
+ * How to get this file into a browser, given what is inside it.
249
+ *
250
+ * A container a browser will not open says nothing about the streams within:
251
+ * most Matroska holds H.264, which every browser decodes, and only the wrapper
252
+ * is wrong. Rewrapping that costs nothing and looks identical; re-encoding it
253
+ * would cost a core per viewer and look worse. So the streams decide, one part
254
+ * at a time -- a film can have its video copied and only its DTS re-encoded.
255
+ */
256
+ export function videoArgs(codecs) {
257
+ // What a browser can play inside MP4 without help.
258
+ const keepVideo = codecs.video === "h264";
259
+ const keepAudio = codecs.audio === "aac" || codecs.audio === "mp3";
260
+ return [
261
+ "-c:v", keepVideo ? "copy" : "libx264",
262
+ ...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p"]),
263
+ "-c:a", keepAudio ? "copy" : "aac",
264
+ ...(keepAudio ? [] : ["-b:a", "160k", "-ac", "2"]),
265
+ "-f", "mp4",
266
+ // Fragmented, because this is a pipe: a normal MP4 writes its index at the
267
+ // end, which for a stream never arrives and for a browser means nothing
268
+ // plays at all.
269
+ "-movflags", "frag_keyframe+empty_moov+default_base_moof",
270
+ ];
271
+ }
@@ -25,7 +25,24 @@ export interface RemoteTrack {
25
25
  export interface Snapshot {
26
26
  /** Bumped on every push so a client can drop an out-of-order frame. */
27
27
  revision: number;
28
- tracks: RemoteTrack[];
28
+ /**
29
+ * The library, sent when it is news and left out when it is not.
30
+ *
31
+ * It used to ride in every frame. At twelve frames a second over a library of
32
+ * five thousand, that is five megabytes a second of JSON for a client to
33
+ * parse on the thread that is also decoding the audio -- which is exactly
34
+ * what it sounded like. It is sent on the first frame of a subscription and
35
+ * again whenever the list actually changes; absent means "the same as
36
+ * before", and a client keeps what it had.
37
+ */
38
+ tracks?: RemoteTrack[];
39
+ /**
40
+ * How many tracks there are, in every frame.
41
+ *
42
+ * A client that has not received a list yet still has to draw something, and
43
+ * a count is four bytes rather than half a megabyte.
44
+ */
45
+ trackCount: number;
29
46
  index: number;
30
47
  playing: boolean;
31
48
  position: number;
@@ -61,4 +78,19 @@ export declare const COMMAND_TYPES: readonly ["play", "toggle", "stop", "next",
61
78
  export declare function parseCommand(input: unknown): Command | null;
62
79
  /** The name a remote shows for a track. */
63
80
  export declare function remoteName(track: RemoteTrack): string;
64
- export declare function emptySnapshot(): Snapshot;
81
+ export declare function emptySnapshot(): FullSnapshot;
82
+ /**
83
+ * Fold a frame into what the client already had.
84
+ *
85
+ * A frame without `tracks` is not a frame with no tracks: it is a frame that
86
+ * had nothing new to say about them. Every client needs this, so none of them
87
+ * should write it twice.
88
+ */
89
+ export declare function merge(previous: FullSnapshot, incoming: Snapshot): FullSnapshot;
90
+ /**
91
+ * A snapshot a client has already folded, so the library is known to be there.
92
+ * Every reader wants this one; only the wire carries the other.
93
+ */
94
+ export type FullSnapshot = Snapshot & {
95
+ tracks: RemoteTrack[];
96
+ };
package/dist/protocol.js CHANGED
@@ -41,6 +41,7 @@ export function emptySnapshot() {
41
41
  return {
42
42
  revision: 0,
43
43
  tracks: [],
44
+ trackCount: 0,
44
45
  index: 0,
45
46
  playing: false,
46
47
  position: 0,
@@ -51,3 +52,13 @@ export function emptySnapshot() {
51
52
  root: "",
52
53
  };
53
54
  }
55
+ /**
56
+ * Fold a frame into what the client already had.
57
+ *
58
+ * A frame without `tracks` is not a frame with no tracks: it is a frame that
59
+ * had nothing new to say about them. Every client needs this, so none of them
60
+ * should write it twice.
61
+ */
62
+ export function merge(previous, incoming) {
63
+ return { ...incoming, tracks: incoming.tracks ?? previous.tracks };
64
+ }
package/dist/server.d.ts CHANGED
@@ -111,7 +111,8 @@ export declare function parseRange(header: string | undefined, size: number): By
111
111
  export declare function safeJoin(rootDir: string, urlPath: string): string | null;
112
112
  /** What the HTTP layer needs from a player. Tests hand it a fake. */
113
113
  export interface Engine {
114
- snapshot(): Snapshot;
114
+ /** `withTracks` false leaves the library out, for a frame that is only motion. */
115
+ snapshot(withTracks?: boolean): Snapshot;
115
116
  command(command: Command): void;
116
117
  subscribe(listener: (snapshot: Snapshot) => void): () => void;
117
118
  /** Absolute path of a track, or undefined when the index is not one. */
@@ -161,7 +162,11 @@ export declare class PlayerEngine implements Engine {
161
162
  fps?: number);
162
163
  private readonly silent;
163
164
  private consume;
164
- snapshot(): Snapshot;
165
+ /**
166
+ * The current state. `withTracks` carries the library, which is worth half a
167
+ * megabyte on a real one and is only news when it has changed.
168
+ */
169
+ snapshot(withTracks?: boolean): Snapshot;
165
170
  trackPath(index: number): string | undefined;
166
171
  command(command: Command): void;
167
172
  private clamp;
@@ -169,6 +174,13 @@ export declare class PlayerEngine implements Engine {
169
174
  private start;
170
175
  private halt;
171
176
  subscribe(listener: (snapshot: Snapshot) => void): () => void;
177
+ /**
178
+ * Send the state to everyone watching.
179
+ *
180
+ * The library goes only when `listChanged` says it has, which is what turned
181
+ * five megabytes a second into a few kilobytes: an analyser tick has nothing
182
+ * to say about the track list, and it fires twelve times a second.
183
+ */
172
184
  private push;
173
185
  stop(): void;
174
186
  replace(tracks: Track[], root: string): void;
@@ -205,6 +217,8 @@ export interface HandlerOptions {
205
217
  listenKey?: string | null;
206
218
  /** How to run ffmpeg, for the sources a browser cannot play by itself. */
207
219
  ffmpeg?: string[];
220
+ /** Where ffprobe is, for asking what is inside a file before re-encoding it. */
221
+ ffprobe?: string[];
208
222
  /** Who is listening, for the admin view. */
209
223
  connections?: Connections;
210
224
  /**
package/dist/server.js CHANGED
@@ -35,6 +35,7 @@ import { notifyAll, resendEmail, webPush } from "./notify.js";
35
35
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
36
36
  import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
37
37
  import { isRemote, playsInBrowser } from "./sources.js";
38
+ import { codecsOf, videoArgs } from "./audio.js";
38
39
  import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, lookupPublicIp, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
39
40
  import { extname, join, normalize, resolve, sep } from "node:path";
40
41
  import { fileURLToPath } from "node:url";
@@ -347,10 +348,15 @@ export class PlayerEngine {
347
348
  this.pending = joined.subarray(at);
348
349
  this.dirty = true;
349
350
  }
350
- snapshot() {
351
+ /**
352
+ * The current state. `withTracks` carries the library, which is worth half a
353
+ * megabyte on a real one and is only news when it has changed.
354
+ */
355
+ snapshot(withTracks = true) {
351
356
  return {
352
357
  revision: this.revision,
353
- tracks: toRemoteTracks(this.tracks),
358
+ ...(withTracks ? { tracks: toRemoteTracks(this.tracks) } : {}),
359
+ trackCount: this.tracks.length,
354
360
  index: this.state.index,
355
361
  playing: this.state.playing,
356
362
  position: this.state.position,
@@ -444,11 +450,18 @@ export class PlayerEngine {
444
450
  }
445
451
  };
446
452
  }
447
- push() {
453
+ /**
454
+ * Send the state to everyone watching.
455
+ *
456
+ * The library goes only when `listChanged` says it has, which is what turned
457
+ * five megabytes a second into a few kilobytes: an analyser tick has nothing
458
+ * to say about the track list, and it fires twelve times a second.
459
+ */
460
+ push(listChanged = false) {
448
461
  this.revision++;
449
462
  if (this.listeners.size === 0)
450
463
  return;
451
- const snapshot = this.snapshot();
464
+ const snapshot = this.snapshot(listChanged);
452
465
  for (const listener of this.listeners)
453
466
  listener(snapshot);
454
467
  }
@@ -467,7 +480,7 @@ export class PlayerEngine {
467
480
  this.state.index = 0;
468
481
  this.state.position = 0;
469
482
  this.state.note = "";
470
- this.push();
483
+ this.push(true);
471
484
  }
472
485
  retag(tracks, root) {
473
486
  // Dropped rather than applied if the library moved underneath: somebody
@@ -479,8 +492,9 @@ export class PlayerEngine {
479
492
  return;
480
493
  this.tracks = tracks;
481
494
  // No stop, no index reset: the only thing that changes is what the titles
482
- // say, and every remote finds out because a snapshot goes out.
483
- this.push();
495
+ // say, and every remote finds out because a snapshot goes out -- carrying
496
+ // the list, since the titles are the whole point of this one.
497
+ this.push(true);
484
498
  }
485
499
  }
486
500
  /** An engine with no library behind it, for the hosted PWA. */
@@ -1539,15 +1553,24 @@ export function createHandler(engine, options) {
1539
1553
  json(response, 404, { error: "no such track" });
1540
1554
  return;
1541
1555
  }
1542
- watch(request, response, "media", engine.snapshot().tracks[index]?.title ?? file);
1556
+ watch(request, response, "media", engine.snapshot().tracks?.[index]?.title ?? file);
1543
1557
  // A browser asks for every track here, and a matroska or an avi handed
1544
1558
  // to it raw is bytes it cannot play. Seeking is what this route is for
1545
1559
  // and transcoding gives it up, but an unseekable film beats a silent
1546
1560
  // one -- and the seekable formats are untouched.
1547
- if (playsInBrowser(file))
1561
+ if (playsInBrowser(file)) {
1548
1562
  sendFile(request, response, file);
1549
- else
1563
+ }
1564
+ else if (hasPicture(file)) {
1565
+ // A film. It used to arrive as MP3 with `-vn`, which is to say as a
1566
+ // soundtrack over a blank panel; what ffprobe finds inside decides how
1567
+ // little work it takes to keep the picture.
1568
+ const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
1569
+ pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs), "video/mp4");
1570
+ }
1571
+ else {
1550
1572
  transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
1573
+ }
1551
1574
  return;
1552
1575
  }
1553
1576
  // Whatever the source is, this comes back as MP3 a browser will play:
@@ -1562,11 +1585,11 @@ export function createHandler(engine, options) {
1562
1585
  return;
1563
1586
  }
1564
1587
  const current = engine.snapshot();
1565
- if (current.tracks.length === 0) {
1588
+ if (current.trackCount === 0) {
1566
1589
  json(response, 404, { error: "nothing is playing" });
1567
1590
  return;
1568
1591
  }
1569
- watch(request, response, "stream", current.tracks[current.index]?.title ?? "live");
1592
+ watch(request, response, "stream", current.tracks?.[current.index]?.title ?? "live");
1570
1593
  liveAudio(request, response, engine, options.ffmpeg ?? ["ffmpeg"]);
1571
1594
  return;
1572
1595
  }
@@ -1581,7 +1604,7 @@ export function createHandler(engine, options) {
1581
1604
  json(response, 403, { error: "media streaming is off" });
1582
1605
  return;
1583
1606
  }
1584
- watch(request, response, "stream", engine.snapshot().tracks[index]?.title ?? source);
1607
+ watch(request, response, "stream", engine.snapshot().tracks?.[index]?.title ?? source);
1585
1608
  transcode(request, response, source, options.ffmpeg ?? ["ffmpeg"]);
1586
1609
  return;
1587
1610
  }
@@ -1748,7 +1771,18 @@ function liveAudio(request, response, engine, ffmpeg) {
1748
1771
  * player falls back to /api/media for a local file it can seek, and uses this
1749
1772
  * for everything else.
1750
1773
  */
1774
+ /** Audio, from whatever this is: the shape every non-browser source took. */
1751
1775
  function transcode(request, response, source, ffmpeg) {
1776
+ pipeFfmpeg(request, response, source, ffmpeg, ["-vn", "-f", "mp3", "-b:a", "192k"], "audio/mpeg");
1777
+ }
1778
+ /**
1779
+ * Run ffmpeg and hand its output straight to the caller.
1780
+ *
1781
+ * The output arguments belong to the caller, because the same plumbing carries
1782
+ * a film and a song and the only difference is what ffmpeg is asked to write --
1783
+ * which for a film is decided by what ffprobe found inside it.
1784
+ */
1785
+ function pipeFfmpeg(request, response, source, ffmpeg, outputArgs, contentType) {
1752
1786
  const [command, ...prefix] = ffmpeg;
1753
1787
  const child = spawn(command, [
1754
1788
  ...prefix,
@@ -1759,9 +1793,7 @@ function transcode(request, response, source, ffmpeg) {
1759
1793
  // when they are handed to it for a file on disk.
1760
1794
  ...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
1761
1795
  "-i", source,
1762
- "-vn",
1763
- "-f", "mp3",
1764
- "-b:a", "192k",
1796
+ ...outputArgs,
1765
1797
  "-",
1766
1798
  ], { stdio: ["ignore", "pipe", "pipe"] });
1767
1799
  let failed = "";
@@ -1776,7 +1808,7 @@ function transcode(request, response, source, ffmpeg) {
1776
1808
  started = true;
1777
1809
  response.writeHead(200, {
1778
1810
  ...CORS,
1779
- "content-type": "audio/mpeg",
1811
+ "content-type": contentType,
1780
1812
  "cache-control": "no-store",
1781
1813
  // Length is unknowable up front, and a browser is happy without it.
1782
1814
  "transfer-encoding": "chunked",
@@ -2094,6 +2126,7 @@ export async function serve(argv, version = "0.1.0") {
2094
2126
  connections,
2095
2127
  paywall,
2096
2128
  ffmpeg: tools.ffmpeg,
2129
+ ffprobe: tools.ffprobe,
2097
2130
  load: (next) => loadSource(tools, next),
2098
2131
  ...(directory ? { directory } : {}),
2099
2132
  ...(follows ? { follows, vapidPublicKey } : {}),
@@ -2321,7 +2354,7 @@ export async function serve(argv, version = "0.1.0") {
2321
2354
  },
2322
2355
  nowPlaying: () => {
2323
2356
  const snapshot = engine.snapshot();
2324
- return snapshot.tracks[snapshot.index]?.title ?? "";
2357
+ return snapshot.tracks?.[snapshot.index]?.title ?? "";
2325
2358
  },
2326
2359
  onConfig: (remote) => {
2327
2360
  const next = applyRemoteConfig(paywallConfig, remote?.x402);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.5.9",
3
+ "version": "0.5.11",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/attach.ts CHANGED
@@ -26,7 +26,11 @@ const RECONNECT_MS = 1000;
26
26
 
27
27
  /** A remote track has no path, because no filesystem path leaves the machine. */
28
28
  export function applySnapshot(state: State, snapshot: Snapshot): void {
29
- state.tracks = snapshot.tracks.map((track) => ({ path: "", ...track }));
29
+ // A frame without a list is not an empty library, it is a frame with nothing
30
+ // new to say about it -- which is every frame but the first.
31
+ if (snapshot.tracks !== undefined) {
32
+ state.tracks = snapshot.tracks.map((track) => ({ path: "", ...track }));
33
+ }
30
34
  state.index = snapshot.index;
31
35
  state.playing = snapshot.playing;
32
36
  state.position = snapshot.position;
package/src/audio.ts CHANGED
@@ -231,3 +231,81 @@ export function formatTime(seconds: number): string {
231
231
  const s = total % 60;
232
232
  return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
233
233
  }
234
+
235
+ /** What is actually inside a container, as opposed to what the name suggests. */
236
+ export interface Codecs {
237
+ /** e.g. "h264", "hevc", "vp9". Empty when there is no video stream. */
238
+ video: string;
239
+ /** e.g. "aac", "ac3", "dts". Empty when there is no audio stream. */
240
+ audio: string;
241
+ }
242
+
243
+ /**
244
+ * Ask ffprobe what the streams are, without holding the event loop.
245
+ *
246
+ * Deliberately not the spawnSync `probe` above: this one runs while a server is
247
+ * answering other requests, and a synchronous probe per media request is how
248
+ * the whole library came to be tagged with the process wedged solid.
249
+ */
250
+ export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
251
+ const [cmd, ...rest] = tools.ffprobe;
252
+ const empty: Codecs = { video: "", audio: "" };
253
+ if (!cmd) return empty;
254
+
255
+ return new Promise<Codecs>((done) => {
256
+ const child = spawn(
257
+ cmd,
258
+ [
259
+ ...rest,
260
+ "-v", "quiet",
261
+ "-print_format", "json",
262
+ "-show_entries", "stream=codec_type,codec_name",
263
+ path,
264
+ ],
265
+ { stdio: ["ignore", "pipe", "ignore"] },
266
+ );
267
+ let out = "";
268
+ child.stdout.on("data", (chunk: Buffer) => {
269
+ out += chunk.toString("utf8");
270
+ });
271
+ child.on("error", () => done(empty));
272
+ child.on("close", () => {
273
+ try {
274
+ const parsed = JSON.parse(out) as { streams?: { codec_type?: string; codec_name?: string }[] };
275
+ const streams = parsed.streams ?? [];
276
+ return done({
277
+ video: streams.find((s) => s.codec_type === "video")?.codec_name ?? "",
278
+ audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
279
+ });
280
+ } catch {
281
+ return done(empty);
282
+ }
283
+ });
284
+ });
285
+ }
286
+
287
+ /**
288
+ * How to get this file into a browser, given what is inside it.
289
+ *
290
+ * A container a browser will not open says nothing about the streams within:
291
+ * most Matroska holds H.264, which every browser decodes, and only the wrapper
292
+ * is wrong. Rewrapping that costs nothing and looks identical; re-encoding it
293
+ * would cost a core per viewer and look worse. So the streams decide, one part
294
+ * at a time -- a film can have its video copied and only its DTS re-encoded.
295
+ */
296
+ export function videoArgs(codecs: Codecs): string[] {
297
+ // What a browser can play inside MP4 without help.
298
+ const keepVideo = codecs.video === "h264";
299
+ const keepAudio = codecs.audio === "aac" || codecs.audio === "mp3";
300
+ return [
301
+ "-c:v", keepVideo ? "copy" : "libx264",
302
+ ...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p"]),
303
+ "-c:a", keepAudio ? "copy" : "aac",
304
+ ...(keepAudio ? [] : ["-b:a", "160k", "-ac", "2"]),
305
+ "-f", "mp4",
306
+ // Fragmented, because this is a pipe: a normal MP4 writes its index at the
307
+ // end, which for a stream never arrives and for a browser means nothing
308
+ // plays at all.
309
+ "-movflags", "frag_keyframe+empty_moov+default_base_moof",
310
+ ];
311
+ }
package/src/protocol.ts CHANGED
@@ -27,7 +27,24 @@ export interface RemoteTrack {
27
27
  export interface Snapshot {
28
28
  /** Bumped on every push so a client can drop an out-of-order frame. */
29
29
  revision: number;
30
- tracks: RemoteTrack[];
30
+ /**
31
+ * The library, sent when it is news and left out when it is not.
32
+ *
33
+ * It used to ride in every frame. At twelve frames a second over a library of
34
+ * five thousand, that is five megabytes a second of JSON for a client to
35
+ * parse on the thread that is also decoding the audio -- which is exactly
36
+ * what it sounded like. It is sent on the first frame of a subscription and
37
+ * again whenever the list actually changes; absent means "the same as
38
+ * before", and a client keeps what it had.
39
+ */
40
+ tracks?: RemoteTrack[];
41
+ /**
42
+ * How many tracks there are, in every frame.
43
+ *
44
+ * A client that has not received a list yet still has to draw something, and
45
+ * a count is four bytes rather than half a megabyte.
46
+ */
47
+ trackCount: number;
31
48
  index: number;
32
49
  playing: boolean;
33
50
  position: number;
@@ -83,10 +100,11 @@ export function remoteName(track: RemoteTrack): string {
83
100
  return track.artist ? `${track.artist} — ${track.title}` : track.title;
84
101
  }
85
102
 
86
- export function emptySnapshot(): Snapshot {
103
+ export function emptySnapshot(): FullSnapshot {
87
104
  return {
88
105
  revision: 0,
89
106
  tracks: [],
107
+ trackCount: 0,
90
108
  index: 0,
91
109
  playing: false,
92
110
  position: 0,
@@ -97,3 +115,20 @@ export function emptySnapshot(): Snapshot {
97
115
  root: "",
98
116
  };
99
117
  }
118
+
119
+ /**
120
+ * Fold a frame into what the client already had.
121
+ *
122
+ * A frame without `tracks` is not a frame with no tracks: it is a frame that
123
+ * had nothing new to say about them. Every client needs this, so none of them
124
+ * should write it twice.
125
+ */
126
+ export function merge(previous: FullSnapshot, incoming: Snapshot): FullSnapshot {
127
+ return { ...incoming, tracks: incoming.tracks ?? previous.tracks };
128
+ }
129
+
130
+ /**
131
+ * A snapshot a client has already folded, so the library is known to be there.
132
+ * Every reader wants this one; only the wire carries the other.
133
+ */
134
+ export type FullSnapshot = Snapshot & { tracks: RemoteTrack[] };