nixamp 0.7.22 → 0.7.25

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.
@@ -28,6 +28,16 @@ export interface RemoteTrack {
28
28
  * a client puts at the top of that block so the two are not one soup.
29
29
  */
30
30
  group?: string;
31
+ /**
32
+ * Which folder it sits in, under whatever it was loaded from.
33
+ *
34
+ * Empty for a track at the top. A library is a shelf of albums and seasons,
35
+ * and five thousand files in one flat list is a list nobody can find
36
+ * anything in -- so the shape of the folders comes across and a player can
37
+ * offer them as folders. Relative, always: where the library sits on
38
+ * somebody's disk is their business.
39
+ */
40
+ folder?: string;
31
41
  }
32
42
  /** Everything a remote needs to draw the player. */
33
43
  export interface Snapshot {
package/dist/server.d.ts CHANGED
@@ -136,6 +136,8 @@ export declare function safeJoin(rootDir: string, urlPath: string): string | nul
136
136
  */
137
137
  export type Loaded = Track & {
138
138
  group?: string;
139
+ /** The folder it sits in, relative to what it was loaded from. */
140
+ folder?: string;
139
141
  /**
140
142
  * Whether this has a picture, when the name could not say.
141
143
  *
@@ -205,6 +207,17 @@ export interface Engine {
205
207
  }
206
208
  export declare function toRemoteTracks(tracks: Loaded[]): RemoteTrack[];
207
209
  export declare function hasPicture(path: string): boolean;
210
+ /**
211
+ * Where a track sits, relative to the thing it was loaded from.
212
+ *
213
+ * A library is a shelf of albums and seasons, and a flat list of five thousand
214
+ * files is one nobody can find anything in. This is what lets a player offer
215
+ * the folders as folders.
216
+ *
217
+ * Relative and never absolute: the shape of somebody's library is what a
218
+ * listener needs, and where it lives on their disk is not.
219
+ */
220
+ export declare function folderOf(path: string, from: string): string;
208
221
  /**
209
222
  * Whether the name of a source tells us anything about what is inside it.
210
223
  *
@@ -373,6 +386,18 @@ export interface HandlerOptions {
373
386
  };
374
387
  /** What this server calls itself, for the list of what is live on it. */
375
388
  serverName?: string;
389
+ /**
390
+ * The source this server was started on -- its own library.
391
+ *
392
+ * Replacing the playlist with a stream leaves no way back to it: the address
393
+ * is a path on somebody else's machine, and a person looking at a player has
394
+ * no reason to know it. Reported to an administrator so there can be a
395
+ * button rather than a thing you have to remember and retype.
396
+ *
397
+ * Admin-only, because it is a filesystem path and a viewer has no business
398
+ * with it.
399
+ */
400
+ homeSource?: string;
376
401
  /**
377
402
  * Going live: whether this server is listed, and how to change that.
378
403
  *
package/dist/server.js CHANGED
@@ -302,6 +302,7 @@ export function toRemoteTracks(tracks) {
302
302
  // Only for what was added; the library's own tracks say nothing, which is
303
303
  // how a client knows they are the library.
304
304
  ...(t.group ? { group: t.group } : {}),
305
+ ...(t.folder ? { folder: t.folder } : {}),
305
306
  }));
306
307
  }
307
308
  /** Video containers, as opposed to the songs that are most of a library. */
@@ -310,6 +311,34 @@ export function hasPicture(path) {
310
311
  const dot = path.lastIndexOf(".");
311
312
  return dot > 0 && PICTURE.has(path.slice(dot).toLowerCase());
312
313
  }
314
+ /**
315
+ * Where a track sits, relative to the thing it was loaded from.
316
+ *
317
+ * A library is a shelf of albums and seasons, and a flat list of five thousand
318
+ * files is one nobody can find anything in. This is what lets a player offer
319
+ * the folders as folders.
320
+ *
321
+ * Relative and never absolute: the shape of somebody's library is what a
322
+ * listener needs, and where it lives on their disk is not.
323
+ */
324
+ export function folderOf(path, from) {
325
+ const strip = (value) => value.replace(/\/+$/, "");
326
+ const base = strip(from);
327
+ if (base === "" || !path.startsWith(base + "/"))
328
+ return "";
329
+ const rest = path.slice(base.length + 1);
330
+ const at = rest.lastIndexOf("/");
331
+ if (at === -1)
332
+ return "";
333
+ const folder = rest.slice(0, at);
334
+ // A URL's path is percent-encoded and a person reading a folder name is not.
335
+ try {
336
+ return isRemote(path) ? decodeURIComponent(folder) : folder;
337
+ }
338
+ catch {
339
+ return folder;
340
+ }
341
+ }
313
342
  /**
314
343
  * Whether the name of a source tells us anything about what is inside it.
315
344
  *
@@ -521,7 +550,7 @@ export class PlayerEngine {
521
550
  }
522
551
  replace(tracks, root) {
523
552
  this.stop();
524
- this.tracks = tracks;
553
+ this.tracks = tracks.map((track) => ({ ...track, folder: folderOf(track.path, root) }));
525
554
  this.root = root;
526
555
  this.state.index = 0;
527
556
  this.state.position = 0;
@@ -544,7 +573,9 @@ export class PlayerEngine {
544
573
  add(tracks, from) {
545
574
  const group = sourceLabel(from);
546
575
  const known = new Set(this.tracks.map((track) => track.path));
547
- const fresh = tracks.filter((track) => !known.has(track.path)).map((track) => ({ ...track, group }));
576
+ const fresh = tracks
577
+ .filter((track) => !known.has(track.path))
578
+ .map((track) => ({ ...track, group, folder: folderOf(track.path, from) }));
548
579
  if (fresh.length === 0)
549
580
  return 0;
550
581
  this.tracks = [...this.tracks, ...fresh];
@@ -617,7 +648,7 @@ export class PlayerEngine {
617
648
  // somebody pointed the server elsewhere. Theirs wins.
618
649
  if (this.tracks.length > 0)
619
650
  return;
620
- this.tracks = tracks;
651
+ this.tracks = tracks.map((track) => ({ ...track, folder: folderOf(track.path, root) }));
621
652
  this.root = root;
622
653
  this.state.note = tracks.length === 0 ? `No audio files under ${root}.` : "";
623
654
  this.push(true);
@@ -639,7 +670,11 @@ export class PlayerEngine {
639
670
  changed = true;
640
671
  // The group is ours, not the tagger's: it knows what a track is called,
641
672
  // not which pile it is in.
642
- return { ...tagged, ...(track.group ? { group: track.group } : {}) };
673
+ return {
674
+ ...tagged,
675
+ ...(track.group ? { group: track.group } : {}),
676
+ ...(track.folder ? { folder: track.folder } : {}),
677
+ };
643
678
  });
644
679
  if (!changed)
645
680
  return;
@@ -1692,6 +1727,16 @@ export function createHandler(engine, options) {
1692
1727
  listeners: one.listeners,
1693
1728
  startedAt: one.startedAt,
1694
1729
  })),
1730
+ // Anything re-streamed into this server is a live stream too, and was
1731
+ // sitting in the middle of the playlist among the files -- which is
1732
+ // what made moving between a channel and an album so confusing. Named
1733
+ // here with the first track it owns, so it can be played from the list
1734
+ // of what is live rather than hunted for among five thousand files.
1735
+ restreams: engine.groups().map((name) => ({
1736
+ name,
1737
+ at: (engine.snapshot().tracks ?? []).findIndex((track) => track.group === name),
1738
+ tracks: (engine.snapshot().tracks ?? []).filter((track) => track.group === name).length,
1739
+ })),
1695
1740
  });
1696
1741
  return;
1697
1742
  }
@@ -1938,6 +1983,10 @@ export function createHandler(engine, options) {
1938
1983
  // And which of those slots somebody is already on, because the
1939
1984
  // question you have in front of three addresses is which one is free.
1940
1985
  channels: options.channels?.list().map(({ id, name, via }) => ({ id, name, via })) ?? [],
1986
+ // What this server's own library is, and whether it is loaded, so
1987
+ // there can be a way back to it that is not retyping a path.
1988
+ home: options.homeSource ?? "",
1989
+ root: engine.snapshot(false).root,
1941
1990
  });
1942
1991
  return;
1943
1992
  }
@@ -2151,7 +2200,7 @@ export function createHandler(engine, options) {
2151
2200
  return;
2152
2201
  }
2153
2202
  watch(request, response, "stream", current.tracks?.[current.index]?.title ?? "live");
2154
- liveAudio(request, response, engine, options.ffmpeg ?? ["ffmpeg"]);
2203
+ await liveAudio(request, response, engine, options.ffmpeg ?? ["ffmpeg"], options.ffprobe ?? ["ffprobe"]);
2155
2204
  return;
2156
2205
  }
2157
2206
  if (path.startsWith("/api/stream/")) {
@@ -2227,8 +2276,33 @@ const LIVE_IDLE_MS = 2000;
2227
2276
  * seconds and then sit waiting for the player to catch up, so the thing that
2228
2277
  * decides what plays next would be minutes behind what the listener hears.
2229
2278
  */
2230
- 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) {
2231
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);
2232
2306
  let child = null;
2233
2307
  let waiting = null;
2234
2308
  let closed = false;
@@ -2242,7 +2316,7 @@ function liveAudio(request, response, engine, ffmpeg) {
2242
2316
  started = true;
2243
2317
  response.writeHead(200, {
2244
2318
  ...CORS,
2245
- "content-type": "audio/mpeg",
2319
+ "content-type": asVideo ? "video/mp4" : "audio/mpeg",
2246
2320
  "cache-control": "no-store",
2247
2321
  "transfer-encoding": "chunked",
2248
2322
  });
@@ -2282,16 +2356,28 @@ function liveAudio(request, response, engine, ffmpeg) {
2282
2356
  return;
2283
2357
  }
2284
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));
2285
2366
  const spawned = spawn(command, [
2286
2367
  ...prefix,
2287
2368
  "-hide_banner",
2288
2369
  "-loglevel", "error",
2289
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)] : []),
2290
2373
  "-re",
2291
2374
  "-i", source,
2292
- "-vn",
2293
- "-f", "mp3",
2294
- "-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"]),
2295
2381
  "-",
2296
2382
  ], { stdio: ["ignore", "pipe", "pipe"] });
2297
2383
  child = spawned;
@@ -2317,8 +2403,26 @@ function liveAudio(request, response, engine, ffmpeg) {
2317
2403
  // A track change that lands while we are between songs is the signal to go
2318
2404
  // now rather than wait out the poll.
2319
2405
  const unsubscribe = engine.subscribe(() => {
2320
- if (child === null && !closed && engine.snapshot().index !== playing)
2321
- 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();
2322
2426
  });
2323
2427
  response.on("close", stop);
2324
2428
  response.on("error", stop);
@@ -2723,6 +2827,7 @@ export async function serve(argv, version = "0.1.0") {
2723
2827
  channels,
2724
2828
  publishUrls: () => publishUrls,
2725
2829
  serverName: options.name || hostname(),
2830
+ homeSource: root,
2726
2831
  live: {
2727
2832
  status: () => ({
2728
2833
  live: publisher !== null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.7.22",
3
+ "version": "0.7.25",
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/protocol.ts CHANGED
@@ -29,6 +29,16 @@ export interface RemoteTrack {
29
29
  * a client puts at the top of that block so the two are not one soup.
30
30
  */
31
31
  group?: string;
32
+ /**
33
+ * Which folder it sits in, under whatever it was loaded from.
34
+ *
35
+ * Empty for a track at the top. A library is a shelf of albums and seasons,
36
+ * and five thousand files in one flat list is a list nobody can find
37
+ * anything in -- so the shape of the folders comes across and a player can
38
+ * offer them as folders. Relative, always: where the library sits on
39
+ * somebody's disk is their business.
40
+ */
41
+ folder?: string;
32
42
  }
33
43
 
34
44
  /** Everything a remote needs to draw the player. */
package/src/server.ts CHANGED
@@ -408,6 +408,8 @@ export function safeJoin(rootDir: string, urlPath: string): string | null {
408
408
  */
409
409
  export type Loaded = Track & {
410
410
  group?: string;
411
+ /** The folder it sits in, relative to what it was loaded from. */
412
+ folder?: string;
411
413
  /**
412
414
  * Whether this has a picture, when the name could not say.
413
415
  *
@@ -491,6 +493,7 @@ export function toRemoteTracks(tracks: Loaded[]): RemoteTrack[] {
491
493
  // Only for what was added; the library's own tracks say nothing, which is
492
494
  // how a client knows they are the library.
493
495
  ...(t.group ? { group: t.group } : {}),
496
+ ...(t.folder ? { folder: t.folder } : {}),
494
497
  }));
495
498
  }
496
499
 
@@ -502,6 +505,32 @@ export function hasPicture(path: string): boolean {
502
505
  return dot > 0 && PICTURE.has(path.slice(dot).toLowerCase());
503
506
  }
504
507
 
508
+ /**
509
+ * Where a track sits, relative to the thing it was loaded from.
510
+ *
511
+ * A library is a shelf of albums and seasons, and a flat list of five thousand
512
+ * files is one nobody can find anything in. This is what lets a player offer
513
+ * the folders as folders.
514
+ *
515
+ * Relative and never absolute: the shape of somebody's library is what a
516
+ * listener needs, and where it lives on their disk is not.
517
+ */
518
+ export function folderOf(path: string, from: string): string {
519
+ const strip = (value: string): string => value.replace(/\/+$/, "");
520
+ const base = strip(from);
521
+ if (base === "" || !path.startsWith(base + "/")) return "";
522
+ const rest = path.slice(base.length + 1);
523
+ const at = rest.lastIndexOf("/");
524
+ if (at === -1) return "";
525
+ const folder = rest.slice(0, at);
526
+ // A URL's path is percent-encoded and a person reading a folder name is not.
527
+ try {
528
+ return isRemote(path) ? decodeURIComponent(folder) : folder;
529
+ } catch {
530
+ return folder;
531
+ }
532
+ }
533
+
505
534
  /**
506
535
  * Whether the name of a source tells us anything about what is inside it.
507
536
  *
@@ -711,7 +740,7 @@ export class PlayerEngine implements Engine {
711
740
 
712
741
  replace(tracks: Track[], root: string): void {
713
742
  this.stop();
714
- this.tracks = tracks;
743
+ this.tracks = tracks.map((track) => ({ ...track, folder: folderOf(track.path, root) }));
715
744
  this.root = root;
716
745
  this.state.index = 0;
717
746
  this.state.position = 0;
@@ -735,7 +764,9 @@ export class PlayerEngine implements Engine {
735
764
  add(tracks: Track[], from: string): number {
736
765
  const group = sourceLabel(from);
737
766
  const known = new Set(this.tracks.map((track) => track.path));
738
- const fresh = tracks.filter((track) => !known.has(track.path)).map((track) => ({ ...track, group }));
767
+ const fresh = tracks
768
+ .filter((track) => !known.has(track.path))
769
+ .map((track) => ({ ...track, group, folder: folderOf(track.path, from) }));
739
770
  if (fresh.length === 0) return 0;
740
771
  this.tracks = [...this.tracks, ...fresh];
741
772
  // The list itself changed, so it has to ride this frame; a count nobody
@@ -805,7 +836,7 @@ export class PlayerEngine implements Engine {
805
836
  // Something is already loaded, so this is a scan that finished after
806
837
  // somebody pointed the server elsewhere. Theirs wins.
807
838
  if (this.tracks.length > 0) return;
808
- this.tracks = tracks;
839
+ this.tracks = tracks.map((track) => ({ ...track, folder: folderOf(track.path, root) }));
809
840
  this.root = root;
810
841
  this.state.note = tracks.length === 0 ? `No audio files under ${root}.` : "";
811
842
  this.push(true);
@@ -827,7 +858,11 @@ export class PlayerEngine implements Engine {
827
858
  changed = true;
828
859
  // The group is ours, not the tagger's: it knows what a track is called,
829
860
  // not which pile it is in.
830
- return { ...tagged, ...(track.group ? { group: track.group } : {}) };
861
+ return {
862
+ ...tagged,
863
+ ...(track.group ? { group: track.group } : {}),
864
+ ...(track.folder ? { folder: track.folder } : {}),
865
+ };
831
866
  });
832
867
  if (!changed) return;
833
868
  this.tracks = merged;
@@ -988,6 +1023,18 @@ export interface HandlerOptions {
988
1023
  broadcast?: () => { destinations: Destination[]; settings: EncoderSettings };
989
1024
  /** What this server calls itself, for the list of what is live on it. */
990
1025
  serverName?: string;
1026
+ /**
1027
+ * The source this server was started on -- its own library.
1028
+ *
1029
+ * Replacing the playlist with a stream leaves no way back to it: the address
1030
+ * is a path on somebody else's machine, and a person looking at a player has
1031
+ * no reason to know it. Reported to an administrator so there can be a
1032
+ * button rather than a thing you have to remember and retype.
1033
+ *
1034
+ * Admin-only, because it is a filesystem path and a viewer has no business
1035
+ * with it.
1036
+ */
1037
+ homeSource?: string;
991
1038
  /**
992
1039
  * Going live: whether this server is listed, and how to change that.
993
1040
  *
@@ -2068,6 +2115,16 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2068
2115
  listeners: one.listeners,
2069
2116
  startedAt: one.startedAt,
2070
2117
  })),
2118
+ // Anything re-streamed into this server is a live stream too, and was
2119
+ // sitting in the middle of the playlist among the files -- which is
2120
+ // what made moving between a channel and an album so confusing. Named
2121
+ // here with the first track it owns, so it can be played from the list
2122
+ // of what is live rather than hunted for among five thousand files.
2123
+ restreams: engine.groups().map((name) => ({
2124
+ name,
2125
+ at: (engine.snapshot().tracks ?? []).findIndex((track) => track.group === name),
2126
+ tracks: (engine.snapshot().tracks ?? []).filter((track) => track.group === name).length,
2127
+ })),
2071
2128
  });
2072
2129
  return;
2073
2130
  }
@@ -2330,6 +2387,10 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2330
2387
  // And which of those slots somebody is already on, because the
2331
2388
  // question you have in front of three addresses is which one is free.
2332
2389
  channels: options.channels?.list().map(({ id, name, via }) => ({ id, name, via })) ?? [],
2390
+ // What this server's own library is, and whether it is loaded, so
2391
+ // there can be a way back to it that is not retyping a path.
2392
+ home: options.homeSource ?? "",
2393
+ root: engine.snapshot(false).root,
2333
2394
  });
2334
2395
  return;
2335
2396
  }
@@ -2550,7 +2611,13 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2550
2611
  return;
2551
2612
  }
2552
2613
  watch(request, response, "stream", current.tracks?.[current.index]?.title ?? "live");
2553
- 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
+ );
2554
2621
  return;
2555
2622
  }
2556
2623
 
@@ -2629,13 +2696,39 @@ const LIVE_IDLE_MS = 2000;
2629
2696
  * seconds and then sit waiting for the player to catch up, so the thing that
2630
2697
  * decides what plays next would be minutes behind what the listener hears.
2631
2698
  */
2632
- 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(
2633
2714
  request: IncomingMessage,
2634
2715
  response: ServerResponse,
2635
2716
  engine: Engine,
2636
2717
  ffmpeg: string[],
2637
- ): void {
2718
+ ffprobe: string[],
2719
+ ): Promise<void> {
2638
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);
2639
2732
  let child: ReturnType<typeof spawn> | null = null;
2640
2733
  let waiting: ReturnType<typeof setTimeout> | null = null;
2641
2734
  let closed = false;
@@ -2649,7 +2742,7 @@ function liveAudio(
2649
2742
  started = true;
2650
2743
  response.writeHead(200, {
2651
2744
  ...CORS,
2652
- "content-type": "audio/mpeg",
2745
+ "content-type": asVideo ? "video/mp4" : "audio/mpeg",
2653
2746
  "cache-control": "no-store",
2654
2747
  "transfer-encoding": "chunked",
2655
2748
  });
@@ -2688,6 +2781,13 @@ function liveAudio(
2688
2781
  }
2689
2782
 
2690
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));
2691
2791
  const spawned = spawn(
2692
2792
  command,
2693
2793
  [
@@ -2695,11 +2795,16 @@ function liveAudio(
2695
2795
  "-hide_banner",
2696
2796
  "-loglevel", "error",
2697
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)] : []),
2698
2800
  "-re",
2699
2801
  "-i", source,
2700
- "-vn",
2701
- "-f", "mp3",
2702
- "-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"]),
2703
2808
  "-",
2704
2809
  ],
2705
2810
  { stdio: ["ignore", "pipe", "pipe"] },
@@ -2728,7 +2833,21 @@ function liveAudio(
2728
2833
  // A track change that lands while we are between songs is the signal to go
2729
2834
  // now rather than wait out the poll.
2730
2835
  const unsubscribe = engine.subscribe(() => {
2731
- 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();
2732
2851
  });
2733
2852
 
2734
2853
  response.on("close", stop);
@@ -3174,6 +3293,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3174
3293
  channels,
3175
3294
  publishUrls: () => publishUrls,
3176
3295
  serverName: options.name || hostname(),
3296
+ homeSource: root,
3177
3297
  live: {
3178
3298
  status: () => ({
3179
3299
  live: publisher !== null,
@@ -1 +1 @@
1
- import{t as e}from"./index-BjUE4krU.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};
@@ -0,0 +1 @@
1
+ :root{--bg:#080c09;--panel:#0c120e;--edge:#1d2c22;--green:#4af689;--green-dim:#227a4a;--fg:#cfe8d8;--muted:#6d8a79;--warn:#e8c35a;--accent:#7ef0c4;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}[hidden]{display:none!important}html,body{background:var(--bg);min-height:100%;color:var(--fg);margin:0;font:14px/1.45 ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace}body{background-image:repeating-linear-gradient(#4af68906 0 1px,#0000 1px 3px)}#app{max-width:1100px;padding:calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left));flex-direction:column;gap:10px;margin:0 auto;display:flex}.bar{flex-wrap:wrap;align-items:center;gap:10px;display:flex}.brand{color:var(--green);letter-spacing:.14em;font-weight:700}.status{color:var(--muted)}.status[data-playing=true]{color:var(--green)}.chip{color:var(--muted);border:1px solid var(--edge);text-overflow:ellipsis;white-space:nowrap;border-radius:999px;max-width:46vw;margin-left:auto;padding:1px 10px;font-size:12px;overflow:hidden}.panel{border:1px solid var(--edge);background:var(--panel);border-radius:6px;min-width:0;padding:14px 12px 12px;position:relative}.panel:before{content:attr(data-title);background:var(--panel);color:var(--green-dim);letter-spacing:.06em;padding:0 6px;font-size:12px;position:absolute;top:-.72em;left:10px}.split{grid-template-columns:1.3fr 1fr;gap:10px;display:grid}.split>.col-a{grid-column:1}.split>.col-b{grid-column:2}@media (max-width:720px){.split{grid-template-columns:1fr}.split>.col-a,.split>.col-b{grid-column:auto}}.track-title{color:var(--accent);text-overflow:ellipsis;white-space:nowrap;font-weight:700;overflow:hidden}.track-sub{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}#video{border:1px solid var(--edge);background:#000;border-radius:4px;width:100%;max-height:46vh;margin-bottom:8px}.scrub{align-items:center;gap:10px;margin-top:6px;display:flex}.time{color:var(--fg);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}input[type=range]{appearance:none;cursor:pointer;background:0 0;flex:1;min-width:0;height:14px}input[type=range]::-webkit-slider-runnable-track{background:linear-gradient(var(--edge), var(--edge));border:1px solid var(--edge);border-radius:3px;height:6px}input[type=range]::-moz-range-track{background:var(--edge);border-radius:3px;height:6px}input[type=range]::-webkit-slider-thumb{appearance:none;background:var(--green);border-radius:2px;width:10px;height:16px;margin-top:-6px}input[type=range]::-moz-range-thumb{background:var(--green);border:0;border-radius:2px;width:10px;height:16px}input[type=range]:disabled{opacity:.45;cursor:default}#spectrum{border:1px solid var(--edge);background:#060a07;border-radius:4px;width:100%;height:190px;display:block}.meters{align-items:baseline;gap:10px;margin-top:6px;display:flex;overflow:hidden}.glyphs{color:var(--green);letter-spacing:1px;white-space:nowrap;min-height:1.4em;overflow:hidden}.levelmeter{color:var(--accent);white-space:nowrap;margin-left:auto}.filter{border:1px solid var(--edge);width:100%;color:var(--fg);font:inherit;background:#0a100c;border-radius:4px;margin-bottom:6px;padding:4px 8px}.filter:focus{border-color:var(--accent);outline:none}.crumbs{color:var(--muted);flex-wrap:wrap;align-items:center;gap:4px;margin-bottom:6px;font-size:12px;display:flex}.crumbs button{font:inherit;color:var(--accent);cursor:pointer;background:0 0;border:0;padding:0 2px}.crumbs button:hover{text-decoration:underline}.crumbs .here{color:var(--fg)}.folder{cursor:pointer;white-space:nowrap;color:var(--accent);border-radius:3px;gap:8px;padding:2px 6px;display:flex}.folder:hover{background:#142019}.folder .name{text-overflow:ellipsis;flex:1;overflow:hidden}.folder .count{color:var(--muted);flex:none}.playlist{scrollbar-color:var(--green-dim) transparent;max-height:214px;margin:0;padding:0;list-style:none;overflow-y:auto}.row{cursor:pointer;white-space:nowrap;border-radius:3px;gap:8px;padding:2px 6px;display:flex}.row:hover{background:#142019}.row.selected{background:#16241c}.row.selected .name{color:var(--accent)}.row.playing .name{color:var(--green)}.group{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;border-top:1px solid var(--edge);align-items:center;gap:8px;margin-top:4px;padding:6px 6px 2px;font-size:11px;display:flex}.group:first-child{border-top:0;margin-top:0}.group-name{text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}.group-remove{border:1px solid var(--edge);color:var(--muted);cursor:pointer;background:0 0;border-radius:3px;flex:none;padding:0 6px;line-height:1.4}.group-remove:hover{color:var(--accent);border-color:var(--accent)}.row .n{color:var(--muted);text-align:right;flex:none;width:2.4em}.row .name{text-overflow:ellipsis;flex:1;overflow:hidden}.row .time{color:var(--muted);flex:none}.transport{border:1px solid var(--edge);background:var(--panel);border-radius:6px;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 10px;display:flex}button{font:inherit;color:var(--fg);border:1px solid var(--edge);cursor:pointer;background:#121a15;border-radius:4px;padding:6px 12px}button:hover{border-color:var(--green-dim);color:var(--green)}button:active{transform:translateY(1px)}button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid var(--green);outline-offset:2px}button.primary{color:var(--green);border-color:var(--green-dim);min-width:64px}button.ghost{color:var(--muted);background:0 0}.volume{flex:1;align-items:center;gap:8px;min-width:120px;max-width:220px;margin-left:auto;display:flex}.vol{color:var(--muted);letter-spacing:.08em;font-size:12px}.picker{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.button{border:1px solid var(--edge);cursor:pointer;color:var(--fg);background:#121a15;border-radius:4px;padding:6px 12px;display:inline-block}.button:hover{border-color:var(--green-dim);color:var(--green)}.button input[type=file]{display:none}#remote-url{font:inherit;min-width:12ch;color:var(--fg);border:1px solid var(--edge);background:#060a07;border-radius:4px;flex:1;padding:6px 10px}.check{color:var(--muted);align-items:center;gap:8px;margin-top:10px;display:flex}.check input{accent-color:var(--green)}.hint{color:var(--muted);margin:0 0 8px;font-size:13px}.hint code{color:var(--accent)}#remote-state[data-status=live]{color:var(--green)}#remote-state[data-status=connecting],#remote-state[data-status=error]{color:var(--warn)}.note{color:var(--warn);border-left:2px solid var(--warn);margin:0;padding-left:8px}.statusbar{color:var(--muted);border-top:1px solid var(--edge);flex-wrap:wrap;gap:14px;padding-top:8px;font-size:12px;display:flex}.statusbar b{color:var(--green);font-weight:700}.statusbar .spacer{flex:1}.statusbar a{color:var(--muted)}.directory{border-top:1px solid var(--line);margin-top:.6rem;padding-top:.6rem}.directory-list{max-height:12rem;margin:0;padding:0;list-style:none;overflow-y:auto}.directory-list li+li{margin-top:.3rem}.directory-list button{border:1px solid var(--line);color:inherit;font:inherit;cursor:pointer;background:0 0;border-radius:4px;padding:.4rem .5rem}.directory-list button:hover,.directory-list button:focus-visible{border-color:var(--accent);background:#ffffff0a}.directory-list .name{color:var(--accent);display:block}.directory-list .detail{opacity:.7;text-overflow:ellipsis;white-space:nowrap;font-size:.85em;display:block;overflow:hidden}.publish-list{margin:.3rem 0 0;padding:0;list-style:none}.publish-list li{align-items:center;gap:8px;margin-top:.3rem;display:flex}.publish-list li.in-use .slot{color:var(--green)}.publish-list .slot{color:var(--muted);flex:none;min-width:4.5em;font-size:12px}.publish-list input{flex:auto;min-width:0}.said{color:var(--accent);margin:.3rem 0 0}.directory-list li.offline .recent-label{opacity:.55}.directory-list li.offline .detail{color:var(--warn)}.share-line{align-items:center;gap:8px;margin:.4rem 0;display:flex}.share-what{color:var(--muted);letter-spacing:.06em;text-transform:uppercase;flex:none;font-size:12px}.share-line input{flex:auto;min-width:0}#share-phone{color:var(--fg);font-size:1.05em}#share-phone b{color:var(--accent)}.admin-table{border-collapse:collapse;width:100%;min-height:5.5em;max-height:14rem;margin:.4rem 0;font-size:.85em;display:block;overflow-y:auto}.admin-table th{text-align:left;opacity:.6;padding:.2rem .4rem .2rem 0;font-weight:400}.admin-table td{white-space:nowrap;text-overflow:ellipsis;max-width:12rem;padding:.2rem .4rem .2rem 0;overflow:hidden}.admin-table td.network-public{color:var(--warning,#e0b341)}.admin-table td.network-private{color:var(--success,#7fd18b)}.admin-table tr.ended{opacity:.45}body.route-directory .player-only{display:none}.directory-list li{align-items:stretch;gap:.4rem;display:flex}.directory-list li>:first-child{flex:auto;min-width:0}.directory-list li>button:first-child{text-align:left;width:100%}.directory-list li>.button,.directory-list li>.ghost{white-space:nowrap;flex:none;align-items:center;display:flex}.directory-list .follow{white-space:nowrap;width:auto;color:var(--muted);flex:none;padding-inline:.6rem}.directory-list .follow[data-following=yes]{border-color:var(--accent);color:var(--accent)}.toggle{color:var(--muted);cursor:pointer;align-items:center;gap:.35rem;display:inline-flex}.toggle input{accent-color:var(--accent)}.recent-label{border:1px solid var(--line);border-radius:4px;flex:auto;min-width:0;padding:.4rem .5rem}