nixamp 0.7.24 → 0.7.26

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/admin.d.ts CHANGED
@@ -39,6 +39,14 @@ export interface AdminOptions {
39
39
  label: string;
40
40
  url: string;
41
41
  }[];
42
+ /**
43
+ * The key that only watches, when this server has one.
44
+ *
45
+ * The links built from it are the ones to hand other people. Absent when
46
+ * pointed at a server by hand, since a listening key is not something you
47
+ * can work out from a control one.
48
+ */
49
+ listenKey?: string | null;
42
50
  /** What it is serving, so the admin view says so without being asked. */
43
51
  source: string;
44
52
  }
@@ -76,6 +84,8 @@ export interface View {
76
84
  url: string;
77
85
  }[];
78
86
  key: string | null;
87
+ /** The key that only watches, for the links you hand to other people. */
88
+ listenKey?: string | null;
79
89
  source: string;
80
90
  }
81
91
  export declare function draw(ui: Container, theme: Theme, view: View): void;
package/dist/admin.js CHANGED
@@ -39,6 +39,7 @@ export function resolveTarget(argv) {
39
39
  return {
40
40
  url: url_,
41
41
  key: key ?? state.key,
42
+ ...(state.listenKey ? { listenKey: state.listenKey } : {}),
42
43
  // A state file written before 0.5.3 has no list; loopback stands in.
43
44
  links: state.urls ?? [{ label: "here", url: daemonUrl(state) }],
44
45
  source: state.source,
@@ -176,7 +177,7 @@ export async function admin(argv) {
176
177
  app.on("exit", () => clearInterval(timer));
177
178
  app.render(({ ui, theme }) => draw(ui, theme, {
178
179
  url: target.url, report, snapshot, error, typing, restreaming, replacing,
179
- links: target.links, key: target.key, source: target.source,
180
+ links: target.links, key: target.key, listenKey: target.listenKey, source: target.source,
180
181
  }));
181
182
  await app.start();
182
183
  clearInterval(timer);
@@ -229,13 +230,31 @@ export function draw(ui, theme, view) {
229
230
  // reach it. The key is on them: without it every address is a 401.
230
231
  if (view.links.length > 0) {
231
232
  const width = Math.max(...view.links.map((link) => link.label.length));
232
- ui.panel({ title: "Share links", size: view.links.length + (view.source ? 3 : 2) }, (p) => {
233
+ // Both kinds, labelled, because they are not interchangeable and the
234
+ // operator had only ever been shown the one that drives. Handing that to
235
+ // somebody who wanted to watch hands them the controls with it, and
236
+ // there was nothing else to hand them.
237
+ // Counted rather than guessed: a heading of its own, the links, the second
238
+ // heading and its links when there is a viewing key, the source, and the
239
+ // two borders. A panel one line short silently drops the last row.
240
+ const rows = 1 + view.links.length
241
+ + (view.listenKey ? 1 + view.links.length : 0)
242
+ + (view.source ? 1 : 0);
243
+ ui.panel({ title: "Your links, and the ones to share", size: rows + 2 }, (p) => {
244
+ p.label("Yours -- these administer this server:");
233
245
  for (const link of view.links) {
234
- const full = shareLink(link.url, view.key);
235
- p.text(`${link.label.padEnd(width)} ${full}`, {
246
+ p.text(`${link.label.padEnd(width)} ${shareLink(link.url, view.key)}`, {
236
247
  fg: link.label === "on the internet" ? theme.accent : theme.foreground,
237
248
  });
238
249
  }
250
+ if (view.listenKey) {
251
+ p.label("To share -- these watch and listen, and change nothing:");
252
+ for (const link of view.links) {
253
+ p.text(`${link.label.padEnd(width)} ${shareLink(link.url, view.listenKey, false)}`, {
254
+ fg: link.label === "on the internet" ? theme.accent : theme.foreground,
255
+ });
256
+ }
257
+ }
239
258
  if (view.source)
240
259
  p.label(view.source);
241
260
  });
package/dist/daemon.d.ts CHANGED
@@ -4,6 +4,13 @@ export interface DaemonState {
4
4
  port: number;
5
5
  /** The share key, so `nixamp admin` can talk to it without being told. */
6
6
  key: string | null;
7
+ /**
8
+ * The key that only views, for handing to somebody who should watch.
9
+ *
10
+ * Absent on a state file written by an older nixamp, and absent on a server
11
+ * running with no key at all.
12
+ */
13
+ listenKey?: string | null;
7
14
  source: string;
8
15
  startedAt: number;
9
16
  /** Where its output went, for when it died and you want to know why. */
package/dist/daemon.js CHANGED
@@ -121,8 +121,11 @@ function spell(ms) {
121
121
  * typed the command.
122
122
  */
123
123
  export function daemonLines(state, uptimeMs) {
124
- // The daemon's own key is the one that administers, so it is an /a/ link.
124
+ // Two links per address: the one that drives this server and the one that
125
+ // only watches it. Printing just the first left the operator with nothing to
126
+ // hand anybody -- and handing over the wrong one hands over the controls.
125
127
  const link = (url) => shareLink(url, state.key ?? null);
128
+ const viewLink = (url) => shareLink(url, state.listenKey ?? null, false);
126
129
  // A state file written by an older nixamp has no list, so host and port
127
130
  // still stand in rather than printing nothing at all.
128
131
  const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
@@ -131,6 +134,15 @@ export function daemonLines(state, uptimeMs) {
131
134
  for (const { label, url } of addresses)
132
135
  lines.push(` ${label.padEnd(width)} ${link(url)}`);
133
136
  lines.push(` ${"source".padEnd(width)} ${state.source}`);
137
+ // The viewing links, said separately and said as what they are. Somebody who
138
+ // should be able to watch your library should not be handed the link that
139
+ // re-streams it, and the operator had nothing else to give them.
140
+ if (state.listenKey) {
141
+ lines.push("", " To share -- these watch and listen, and change nothing:");
142
+ for (const { label, url } of addresses) {
143
+ lines.push(` ${label.padEnd(width)} ${viewLink(url)}`);
144
+ }
145
+ }
134
146
  if (uptimeMs !== undefined)
135
147
  lines.push(` ${"up".padEnd(width)} ${spell(uptimeMs)}`);
136
148
  if (state.guessedPublic) {
@@ -227,6 +239,7 @@ async function waitForAnnounce(log, timeoutMs) {
227
239
  host: String(parsed["host"]),
228
240
  port: Number(parsed["port"]),
229
241
  key: parsed["key"] ?? null,
242
+ ...(typeof parsed["listenKey"] === "string" ? { listenKey: parsed["listenKey"] } : {}),
230
243
  source: String(parsed["source"]),
231
244
  ...(Array.isArray(urls) ? { urls: urls } : {}),
232
245
  ...(typeof parsed["firewall"] === "string" ? { firewall: parsed["firewall"] } : {}),
package/dist/server.js CHANGED
@@ -2200,7 +2200,7 @@ export function createHandler(engine, options) {
2200
2200
  return;
2201
2201
  }
2202
2202
  watch(request, response, "stream", current.tracks?.[current.index]?.title ?? "live");
2203
- liveAudio(request, response, engine, options.ffmpeg ?? ["ffmpeg"]);
2203
+ await liveAudio(request, response, engine, options.ffmpeg ?? ["ffmpeg"], options.ffprobe ?? ["ffprobe"]);
2204
2204
  return;
2205
2205
  }
2206
2206
  if (path.startsWith("/api/stream/")) {
@@ -2276,8 +2276,33 @@ const LIVE_IDLE_MS = 2000;
2276
2276
  * seconds and then sit waiting for the player to catch up, so the thing that
2277
2277
  * decides what plays next would be minutes behind what the listener hears.
2278
2278
  */
2279
- function liveAudio(request, response, engine, ffmpeg) {
2279
+ /**
2280
+ * The server's own output, as one address that keeps playing.
2281
+ *
2282
+ * This is the watch party: everybody pointed at it hears and sees whatever the
2283
+ * server is playing, and somebody joining halfway through joins halfway
2284
+ * through rather than starting the film again on their own.
2285
+ *
2286
+ * A film comes with its picture. It used to be `-vn` and MP3 whatever it was,
2287
+ * so inviting people to watch a film got them its soundtrack -- which is not
2288
+ * an invitation anybody wants. The container is decided when the connection
2289
+ * opens, because a response has one content type and MP4 and MP3 cannot be
2290
+ * spliced; going from a film to a song ends the stream, and a client that
2291
+ * wants to keep listening asks again and gets the right one.
2292
+ */
2293
+ async function liveAudio(request, response, engine, ffmpeg, ffprobe) {
2280
2294
  const [command, ...prefix] = ffmpeg;
2295
+ /** Whether this track is something to watch rather than only to hear. */
2296
+ const looksLikeVideo = async (source) => {
2297
+ if (hasPicture(source))
2298
+ return true;
2299
+ if (!nameSaysNothing(source))
2300
+ return false;
2301
+ const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source);
2302
+ return codecs.video !== "";
2303
+ };
2304
+ const first = engine.trackPath(engine.snapshot().index);
2305
+ const asVideo = first === undefined ? false : await looksLikeVideo(first);
2281
2306
  let child = null;
2282
2307
  let waiting = null;
2283
2308
  let closed = false;
@@ -2291,7 +2316,7 @@ function liveAudio(request, response, engine, ffmpeg) {
2291
2316
  started = true;
2292
2317
  response.writeHead(200, {
2293
2318
  ...CORS,
2294
- "content-type": "audio/mpeg",
2319
+ "content-type": asVideo ? "video/mp4" : "audio/mpeg",
2295
2320
  "cache-control": "no-store",
2296
2321
  "transfer-encoding": "chunked",
2297
2322
  });
@@ -2331,16 +2356,28 @@ function liveAudio(request, response, engine, ffmpeg) {
2331
2356
  return;
2332
2357
  }
2333
2358
  playing = snapshot.index;
2359
+ // Joined where the server is, not where the track begins. Somebody
2360
+ // arriving forty minutes into a film should arrive forty minutes in;
2361
+ // starting it again for them is not a watch party, it is two people
2362
+ // watching the same film separately.
2363
+ //
2364
+ // Before -i, so ffmpeg seeks rather than decoding its way there.
2365
+ const from = Math.max(0, Math.floor(snapshot.position));
2334
2366
  const spawned = spawn(command, [
2335
2367
  ...prefix,
2336
2368
  "-hide_banner",
2337
2369
  "-loglevel", "error",
2338
2370
  ...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
2371
+ // A live source has no beginning to seek from.
2372
+ ...(from > 1 && !isRemote(source) ? ["-ss", String(from)] : []),
2339
2373
  "-re",
2340
2374
  "-i", source,
2341
- "-vn",
2342
- "-f", "mp3",
2343
- "-b:a", "192k",
2375
+ ...(asVideo
2376
+ // Copied where it can be, because a room full of viewers is a room
2377
+ // full of encoders otherwise.
2378
+ ? ["-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-ac", "2",
2379
+ "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"]
2380
+ : ["-vn", "-f", "mp3", "-b:a", "192k"]),
2344
2381
  "-",
2345
2382
  ], { stdio: ["ignore", "pipe", "pipe"] });
2346
2383
  child = spawned;
@@ -2366,8 +2403,26 @@ function liveAudio(request, response, engine, ffmpeg) {
2366
2403
  // A track change that lands while we are between songs is the signal to go
2367
2404
  // now rather than wait out the poll.
2368
2405
  const unsubscribe = engine.subscribe(() => {
2369
- if (child === null && !closed && engine.snapshot().index !== playing)
2370
- next();
2406
+ if (closed || child !== null)
2407
+ return;
2408
+ if (engine.snapshot().index === playing)
2409
+ return;
2410
+ // The kind changed under us -- a film after a song, or the other way --
2411
+ // and one response cannot carry both. Ending it is how the client is told
2412
+ // to ask again, which it does.
2413
+ const source = engine.trackPath(engine.snapshot().index);
2414
+ if (source !== undefined) {
2415
+ void looksLikeVideo(source).then((wants) => {
2416
+ if (closed)
2417
+ return;
2418
+ if (wants !== asVideo)
2419
+ stop();
2420
+ else
2421
+ next();
2422
+ });
2423
+ return;
2424
+ }
2425
+ next();
2371
2426
  });
2372
2427
  response.on("close", stop);
2373
2428
  response.on("error", stop);
@@ -2907,6 +2962,10 @@ export async function serve(argv, version = "0.1.0") {
2907
2962
  host: options.host,
2908
2963
  port,
2909
2964
  key,
2965
+ // The other half of the pair. Without it `nixamp admin` can only draw
2966
+ // the links that administer, so the operator has nothing to hand
2967
+ // somebody who should be able to watch and not to drive.
2968
+ listenKey,
2910
2969
  source: root,
2911
2970
  urls: addresses,
2912
2971
  firewall,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.24",
3
+ "version": "0.7.26",
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/admin.ts CHANGED
@@ -38,6 +38,14 @@ export interface AdminOptions {
38
38
  * anybody. These are the ones worth reading off the screen.
39
39
  */
40
40
  links: { label: string; url: string }[];
41
+ /**
42
+ * The key that only watches, when this server has one.
43
+ *
44
+ * The links built from it are the ones to hand other people. Absent when
45
+ * pointed at a server by hand, since a listening key is not something you
46
+ * can work out from a control one.
47
+ */
48
+ listenKey?: string | null;
41
49
  /** What it is serving, so the admin view says so without being asked. */
42
50
  source: string;
43
51
  }
@@ -76,6 +84,7 @@ export function resolveTarget(argv: string[]): AdminOptions {
76
84
  return {
77
85
  url: url_,
78
86
  key: key ?? state.key,
87
+ ...(state.listenKey ? { listenKey: state.listenKey } : {}),
79
88
  // A state file written before 0.5.3 has no list; loopback stands in.
80
89
  links: state.urls ?? [{ label: "here", url: daemonUrl(state) }],
81
90
  source: state.source,
@@ -198,7 +207,7 @@ export async function admin(argv: string[]): Promise<void> {
198
207
  app.on("exit", () => clearInterval(timer));
199
208
  app.render(({ ui, theme }) => draw(ui, theme, {
200
209
  url: target.url, report, snapshot, error, typing, restreaming, replacing,
201
- links: target.links, key: target.key, source: target.source,
210
+ links: target.links, key: target.key, listenKey: target.listenKey, source: target.source,
202
211
  }));
203
212
 
204
213
  await app.start();
@@ -242,6 +251,8 @@ export interface View {
242
251
  /** Labelled addresses, and the key that makes them work. */
243
252
  links: { label: string; url: string }[];
244
253
  key: string | null;
254
+ /** The key that only watches, for the links you hand to other people. */
255
+ listenKey?: string | null;
245
256
  source: string;
246
257
  }
247
258
 
@@ -276,13 +287,32 @@ export function draw(ui: Container, theme: Theme, view: View): void {
276
287
  // reach it. The key is on them: without it every address is a 401.
277
288
  if (view.links.length > 0) {
278
289
  const width = Math.max(...view.links.map((link) => link.label.length));
279
- ui.panel({ title: "Share links", size: view.links.length + (view.source ? 3 : 2) }, (p) => {
290
+ // Both kinds, labelled, because they are not interchangeable and the
291
+ // operator had only ever been shown the one that drives. Handing that to
292
+ // somebody who wanted to watch hands them the controls with it, and
293
+ // there was nothing else to hand them.
294
+ // Counted rather than guessed: a heading of its own, the links, the second
295
+ // heading and its links when there is a viewing key, the source, and the
296
+ // two borders. A panel one line short silently drops the last row.
297
+ const rows =
298
+ 1 + view.links.length
299
+ + (view.listenKey ? 1 + view.links.length : 0)
300
+ + (view.source ? 1 : 0);
301
+ ui.panel({ title: "Your links, and the ones to share", size: rows + 2 }, (p) => {
302
+ p.label("Yours -- these administer this server:");
280
303
  for (const link of view.links) {
281
- const full = shareLink(link.url, view.key);
282
- p.text(`${link.label.padEnd(width)} ${full}`, {
304
+ p.text(`${link.label.padEnd(width)} ${shareLink(link.url, view.key)}`, {
283
305
  fg: link.label === "on the internet" ? theme.accent : theme.foreground,
284
306
  });
285
307
  }
308
+ if (view.listenKey) {
309
+ p.label("To share -- these watch and listen, and change nothing:");
310
+ for (const link of view.links) {
311
+ p.text(`${link.label.padEnd(width)} ${shareLink(link.url, view.listenKey, false)}`, {
312
+ fg: link.label === "on the internet" ? theme.accent : theme.foreground,
313
+ });
314
+ }
315
+ }
286
316
  if (view.source) p.label(view.source);
287
317
  });
288
318
  }
package/src/daemon.ts CHANGED
@@ -18,6 +18,13 @@ export interface DaemonState {
18
18
  port: number;
19
19
  /** The share key, so `nixamp admin` can talk to it without being told. */
20
20
  key: string | null;
21
+ /**
22
+ * The key that only views, for handing to somebody who should watch.
23
+ *
24
+ * Absent on a state file written by an older nixamp, and absent on a server
25
+ * running with no key at all.
26
+ */
27
+ listenKey?: string | null;
21
28
  source: string;
22
29
  startedAt: number;
23
30
  /** Where its output went, for when it died and you want to know why. */
@@ -164,8 +171,11 @@ function spell(ms: number): string {
164
171
  * typed the command.
165
172
  */
166
173
  export function daemonLines(state: DaemonState, uptimeMs?: number): string[] {
167
- // The daemon's own key is the one that administers, so it is an /a/ link.
174
+ // Two links per address: the one that drives this server and the one that
175
+ // only watches it. Printing just the first left the operator with nothing to
176
+ // hand anybody -- and handing over the wrong one hands over the controls.
168
177
  const link = (url: string): string => shareLink(url, state.key ?? null);
178
+ const viewLink = (url: string): string => shareLink(url, state.listenKey ?? null, false);
169
179
  // A state file written by an older nixamp has no list, so host and port
170
180
  // still stand in rather than printing nothing at all.
171
181
  const addresses = state.urls ?? [{ label: "here", url: daemonUrl(state) }];
@@ -174,6 +184,16 @@ export function daemonLines(state: DaemonState, uptimeMs?: number): string[] {
174
184
  const lines = [`nixamp daemon running (pid ${state.pid})`];
175
185
  for (const { label, url } of addresses) lines.push(` ${label.padEnd(width)} ${link(url)}`);
176
186
  lines.push(` ${"source".padEnd(width)} ${state.source}`);
187
+
188
+ // The viewing links, said separately and said as what they are. Somebody who
189
+ // should be able to watch your library should not be handed the link that
190
+ // re-streams it, and the operator had nothing else to give them.
191
+ if (state.listenKey) {
192
+ lines.push("", " To share -- these watch and listen, and change nothing:");
193
+ for (const { label, url } of addresses) {
194
+ lines.push(` ${label.padEnd(width)} ${viewLink(url)}`);
195
+ }
196
+ }
177
197
  if (uptimeMs !== undefined) lines.push(` ${"up".padEnd(width)} ${spell(uptimeMs)}`);
178
198
 
179
199
  if (state.guessedPublic) {
@@ -300,6 +320,7 @@ async function waitForAnnounce(
300
320
  host: String(parsed["host"]),
301
321
  port: Number(parsed["port"]),
302
322
  key: (parsed["key"] as string | null) ?? null,
323
+ ...(typeof parsed["listenKey"] === "string" ? { listenKey: parsed["listenKey"] } : {}),
303
324
  source: String(parsed["source"]),
304
325
  ...(Array.isArray(urls) ? { urls: urls as { label: string; url: string }[] } : {}),
305
326
  ...(typeof parsed["firewall"] === "string" ? { firewall: parsed["firewall"] } : {}),
package/src/server.ts CHANGED
@@ -2611,7 +2611,13 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2611
2611
  return;
2612
2612
  }
2613
2613
  watch(request, response, "stream", current.tracks?.[current.index]?.title ?? "live");
2614
- liveAudio(request, response, engine, options.ffmpeg ?? ["ffmpeg"]);
2614
+ await liveAudio(
2615
+ request,
2616
+ response,
2617
+ engine,
2618
+ options.ffmpeg ?? ["ffmpeg"],
2619
+ options.ffprobe ?? ["ffprobe"],
2620
+ );
2615
2621
  return;
2616
2622
  }
2617
2623
 
@@ -2690,13 +2696,39 @@ const LIVE_IDLE_MS = 2000;
2690
2696
  * seconds and then sit waiting for the player to catch up, so the thing that
2691
2697
  * decides what plays next would be minutes behind what the listener hears.
2692
2698
  */
2693
- function liveAudio(
2699
+ /**
2700
+ * The server's own output, as one address that keeps playing.
2701
+ *
2702
+ * This is the watch party: everybody pointed at it hears and sees whatever the
2703
+ * server is playing, and somebody joining halfway through joins halfway
2704
+ * through rather than starting the film again on their own.
2705
+ *
2706
+ * A film comes with its picture. It used to be `-vn` and MP3 whatever it was,
2707
+ * so inviting people to watch a film got them its soundtrack -- which is not
2708
+ * an invitation anybody wants. The container is decided when the connection
2709
+ * opens, because a response has one content type and MP4 and MP3 cannot be
2710
+ * spliced; going from a film to a song ends the stream, and a client that
2711
+ * wants to keep listening asks again and gets the right one.
2712
+ */
2713
+ async function liveAudio(
2694
2714
  request: IncomingMessage,
2695
2715
  response: ServerResponse,
2696
2716
  engine: Engine,
2697
2717
  ffmpeg: string[],
2698
- ): void {
2718
+ ffprobe: string[],
2719
+ ): Promise<void> {
2699
2720
  const [command, ...prefix] = ffmpeg as [string, ...string[]];
2721
+
2722
+ /** Whether this track is something to watch rather than only to hear. */
2723
+ const looksLikeVideo = async (source: string): Promise<boolean> => {
2724
+ if (hasPicture(source)) return true;
2725
+ if (!nameSaysNothing(source)) return false;
2726
+ const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source);
2727
+ return codecs.video !== "";
2728
+ };
2729
+
2730
+ const first = engine.trackPath(engine.snapshot().index);
2731
+ const asVideo = first === undefined ? false : await looksLikeVideo(first);
2700
2732
  let child: ReturnType<typeof spawn> | null = null;
2701
2733
  let waiting: ReturnType<typeof setTimeout> | null = null;
2702
2734
  let closed = false;
@@ -2710,7 +2742,7 @@ function liveAudio(
2710
2742
  started = true;
2711
2743
  response.writeHead(200, {
2712
2744
  ...CORS,
2713
- "content-type": "audio/mpeg",
2745
+ "content-type": asVideo ? "video/mp4" : "audio/mpeg",
2714
2746
  "cache-control": "no-store",
2715
2747
  "transfer-encoding": "chunked",
2716
2748
  });
@@ -2749,6 +2781,13 @@ function liveAudio(
2749
2781
  }
2750
2782
 
2751
2783
  playing = snapshot.index;
2784
+ // Joined where the server is, not where the track begins. Somebody
2785
+ // arriving forty minutes into a film should arrive forty minutes in;
2786
+ // starting it again for them is not a watch party, it is two people
2787
+ // watching the same film separately.
2788
+ //
2789
+ // Before -i, so ffmpeg seeks rather than decoding its way there.
2790
+ const from = Math.max(0, Math.floor(snapshot.position));
2752
2791
  const spawned = spawn(
2753
2792
  command,
2754
2793
  [
@@ -2756,11 +2795,16 @@ function liveAudio(
2756
2795
  "-hide_banner",
2757
2796
  "-loglevel", "error",
2758
2797
  ...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
2798
+ // A live source has no beginning to seek from.
2799
+ ...(from > 1 && !isRemote(source) ? ["-ss", String(from)] : []),
2759
2800
  "-re",
2760
2801
  "-i", source,
2761
- "-vn",
2762
- "-f", "mp3",
2763
- "-b:a", "192k",
2802
+ ...(asVideo
2803
+ // Copied where it can be, because a room full of viewers is a room
2804
+ // full of encoders otherwise.
2805
+ ? ["-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-ac", "2",
2806
+ "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"]
2807
+ : ["-vn", "-f", "mp3", "-b:a", "192k"]),
2764
2808
  "-",
2765
2809
  ],
2766
2810
  { stdio: ["ignore", "pipe", "pipe"] },
@@ -2789,7 +2833,21 @@ function liveAudio(
2789
2833
  // A track change that lands while we are between songs is the signal to go
2790
2834
  // now rather than wait out the poll.
2791
2835
  const unsubscribe = engine.subscribe(() => {
2792
- if (child === null && !closed && engine.snapshot().index !== playing) next();
2836
+ if (closed || child !== null) return;
2837
+ if (engine.snapshot().index === playing) return;
2838
+ // The kind changed under us -- a film after a song, or the other way --
2839
+ // and one response cannot carry both. Ending it is how the client is told
2840
+ // to ask again, which it does.
2841
+ const source = engine.trackPath(engine.snapshot().index);
2842
+ if (source !== undefined) {
2843
+ void looksLikeVideo(source).then((wants) => {
2844
+ if (closed) return;
2845
+ if (wants !== asVideo) stop();
2846
+ else next();
2847
+ });
2848
+ return;
2849
+ }
2850
+ next();
2793
2851
  });
2794
2852
 
2795
2853
  response.on("close", stop);
@@ -3389,6 +3447,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3389
3447
  host: options.host,
3390
3448
  port,
3391
3449
  key,
3450
+ // The other half of the pair. Without it `nixamp admin` can only draw
3451
+ // the links that administer, so the operator has nothing to hand
3452
+ // somebody who should be able to watch and not to drive.
3453
+ listenKey,
3392
3454
  source: root,
3393
3455
  urls: addresses,
3394
3456
  firewall,
@@ -1 +1 @@
1
- import{t as e}from"./index-CuPGZuSo.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
1
+ import{t as e}from"./index-Z0E29INh.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};