nixamp 0.7.5 → 0.7.7

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/src/partyline.ts CHANGED
@@ -239,15 +239,6 @@ export class PartyLine {
239
239
  this.reminders.set(code, set);
240
240
  }
241
241
  }
242
- /**
243
- * Legs listening to a stream, by its code.
244
- *
245
- * Separate from the rooms because a stream listener is not in a conference:
246
- * they are a leg with an MP3 playing into it. Nothing else was counting
247
- * them, so the directory had no way to say how many people were on the
248
- * phone for a broadcast.
249
- */
250
- private readonly streamLegs = new Map<string, Set<string>>();
251
242
  private readonly key: ReturnType<typeof createPublicKey> | null;
252
243
  private readonly fetch: typeof globalThis.fetch;
253
244
  private readonly now: () => number;
@@ -414,6 +405,14 @@ export class PartyLine {
414
405
  * room code still works: this line was a party line before it was a way into
415
406
  * a broadcast, and a code that means nothing to the directory should still
416
407
  * mean a room.
408
+ *
409
+ * Keying a stream's code puts you in a room with the other people watching
410
+ * it. It does not play the stream at you, which is what it used to do: this
411
+ * is the phone line beside a broadcast, the way a podcast has an 800 number
412
+ * -- the show is on your screen and the phone is where you talk about it.
413
+ * Playing the audio down the phone was both the worse half of the idea and
414
+ * the one that kept failing, because a share link answers a 302 and a cookie
415
+ * rather than an MP3.
417
416
  */
418
417
  private async stream(leg: string, code: string): Promise<boolean> {
419
418
  const streams = this.options.streams;
@@ -422,42 +421,13 @@ export class PartyLine {
422
421
  const live = streams.liveByCode(code);
423
422
  if (live !== undefined) {
424
423
  const what = live.nowPlaying ? ` of ${live.nowPlaying}` : "";
425
-
426
- // The share link is not playable. It answers 302 with a cookie and sends
427
- // a browser to the player page; Telnyx fetches once with no cookie jar
428
- // and gets a 401 in JSON. Playing it means a caller who is told "here it
429
- // is" and then hears nothing at all, which is how this was found. Say
430
- // what is true instead, and hang up rather than bill for silence.
431
- if (!live.audio) {
432
- await this.command(leg, "speak", {
433
- payload:
434
- `${live.name} is live right now${what}, but this stream cannot be played over the phone. ` +
435
- "You can listen to it at nixamp dot com slash directory. Goodbye.",
436
- voice: this.voice,
437
- });
438
- await this.command(leg, "hangup", {});
439
- this.options.onEvent?.(` ${code} is live but announced no audio address; nothing to play.`);
440
- return true;
441
- }
442
-
443
424
  await this.command(leg, "speak", {
444
- payload: `Welcome to ${live.name}'s live stream${what}. It started at ${pacificTime(live.startedAt)}. Here it is.`,
425
+ payload:
426
+ `You're on the line for ${live.name}${what}. ` +
427
+ "Everyone here is watching it too. Say hello.",
445
428
  voice: this.voice,
446
429
  });
447
- // A nixamp stream is an MP3 over HTTP and Telnyx will play a URL into a
448
- // call, so listening by phone costs no audio handling here at all.
449
- const playing = await this.command(leg, "playback_start", {
450
- audio_url: live.audio,
451
- loop: "infinity",
452
- });
453
- // Counted only once the audio is actually going. A leg we failed to
454
- // start is not somebody listening, and the directory would be saying so.
455
- if (playing) {
456
- const legs = this.streamLegs.get(code) ?? new Set<string>();
457
- legs.add(leg);
458
- this.streamLegs.set(code, legs);
459
- this.options.onEvent?.(` a caller is listening to ${code} (${legs.size} on the phone).`);
460
- }
430
+ await this.join(leg, code);
461
431
  return true;
462
432
  }
463
433
 
@@ -608,16 +578,19 @@ export class PartyLine {
608
578
  this.options.onEvent?.(` a caller joined a room (${room.callers} on the line).`);
609
579
  }
610
580
 
611
- /** How many people are listening to a stream by phone. */
581
+ /**
582
+ * How many people are on the phone for a stream.
583
+ *
584
+ * The room's own count, now that a stream's code is a room like any other.
585
+ * It used to count legs with an MP3 playing into them, which is a thing that
586
+ * no longer happens.
587
+ */
612
588
  listenersOn(code: string): number {
613
- return this.streamLegs.get(code)?.size ?? 0;
589
+ return this.rooms.get(code)?.callers ?? 0;
614
590
  }
615
591
 
616
592
  /** A leg that hung up or was dropped, wherever it was. */
617
593
  private release(leg: string): void {
618
- for (const [code, legs] of this.streamLegs) {
619
- if (legs.delete(leg) && legs.size === 0) this.streamLegs.delete(code);
620
- }
621
594
  const code = this.legRoom.get(leg);
622
595
  this.legRoom.delete(leg);
623
596
  if (code === undefined) return;
package/src/server.ts CHANGED
@@ -397,7 +397,19 @@ export function safeJoin(rootDir: string, urlPath: string): string | null {
397
397
  * album it came from, which is what lets a client draw the two apart instead
398
398
  * of running them together.
399
399
  */
400
- export type Loaded = Track & { group?: string };
400
+ export type Loaded = Track & {
401
+ group?: string;
402
+ /**
403
+ * Whether this has a picture, when the name could not say.
404
+ *
405
+ * A file on disk is named `film.mkv` and that is answer enough. A live
406
+ * stream is `http://host/tipoffsport/KEY/301`, which says nothing at all --
407
+ * so it was treated as audio, transcoded with `-vn`, and arrived as a
408
+ * football match somebody could only listen to. Asked of ffprobe once, when
409
+ * the source is added, rather than guessed from a URL that has no opinion.
410
+ */
411
+ picture?: boolean;
412
+ };
401
413
 
402
414
  /** What the HTTP layer needs from a player. Tests hand it a fake. */
403
415
  export interface Engine {
@@ -414,6 +426,8 @@ export interface Engine {
414
426
  * this server somewhere else", which throws the library away on purpose.
415
427
  */
416
428
  replace(tracks: Track[], root: string): void;
429
+ /** The library, arriving after the server was already listening. */
430
+ fill(tracks: Track[], root: string): void;
417
431
  /**
418
432
  * Play something as well as everything here.
419
433
  *
@@ -453,7 +467,8 @@ export function toRemoteTracks(tracks: Loaded[]): RemoteTrack[] {
453
467
  // Said out loud, because a remote cannot see the path and had been sending
454
468
  // every track to the audio element -- a film's soundtrack over a blank
455
469
  // panel, which is exactly what it looked like.
456
- ...(hasPicture(t.path) ? { video: true } : {}),
470
+ // The name when it says something, what ffprobe found when it does not.
471
+ ...(t.picture ?? hasPicture(t.path) ? { video: true } : {}),
457
472
  // Only for what was added; the library's own tracks say nothing, which is
458
473
  // how a client knows they are the library.
459
474
  ...(t.group ? { group: t.group } : {}),
@@ -468,6 +483,23 @@ export function hasPicture(path: string): boolean {
468
483
  return dot > 0 && PICTURE.has(path.slice(dot).toLowerCase());
469
484
  }
470
485
 
486
+ /**
487
+ * Whether the name of a source tells us anything about what is inside it.
488
+ *
489
+ * A remote address with no extension -- an IPTV channel, a stream key, a
490
+ * redirect -- is the case where it does not, and the only way to find out is
491
+ * to look.
492
+ */
493
+ export function nameSaysNothing(path: string): boolean {
494
+ if (!isRemote(path)) return false;
495
+ try {
496
+ const last = new URL(path).pathname.split("/").pop() ?? "";
497
+ return !last.includes(".");
498
+ } catch {
499
+ return false;
500
+ }
501
+ }
502
+
471
503
  /**
472
504
  * The headless player: the terminal app's engine without the terminal.
473
505
  * One ffmpeg decodes, ffplay makes the sound, and every sample is measured on
@@ -727,6 +759,30 @@ export class PlayerEngine implements Engine {
727
759
  return seen;
728
760
  }
729
761
 
762
+ /**
763
+ * The library, arriving after the server was already listening.
764
+ *
765
+ * Walking a directory is the slow part of starting -- eighty thousand files
766
+ * under a home directory takes far longer than the fifteen seconds
767
+ * `nixamp daemon start` waits for the server to say it is up, so starting a
768
+ * daemon with no source given looked exactly like hanging and then failed
769
+ * about a server that was working. The port opens first now and this puts
770
+ * the library in behind it.
771
+ *
772
+ * Unlike `replace` it stops nothing and clears no listeners: whoever
773
+ * connected in the first second is still connected, and simply sees the
774
+ * playlist appear.
775
+ */
776
+ fill(tracks: Loaded[], root: string): void {
777
+ // Something is already loaded, so this is a scan that finished after
778
+ // somebody pointed the server elsewhere. Theirs wins.
779
+ if (this.tracks.length > 0) return;
780
+ this.tracks = tracks;
781
+ this.root = root;
782
+ this.state.note = tracks.length === 0 ? `No audio files under ${root}.` : "";
783
+ this.push(true);
784
+ }
785
+
730
786
  retag(tracks: Track[], root: string): void {
731
787
  // Matched by path rather than by position, because the list is no longer
732
788
  // required to be the one that was sent for tagging: somebody can add an
@@ -770,6 +826,7 @@ export class EmptyEngine implements Engine {
770
826
  return undefined;
771
827
  }
772
828
  replace(): void {}
829
+ fill(): void {}
773
830
  add(): number {
774
831
  return 0;
775
832
  }
@@ -793,6 +850,15 @@ const CORS: Record<string, string> = {
793
850
  "access-control-max-age": "86400",
794
851
  };
795
852
 
853
+ /**
854
+ * How many nameless addresses are worth an ffprobe when a source is added.
855
+ *
856
+ * One is the ordinary case -- somebody pasting a channel -- and a directory
857
+ * listing of thousands must not turn into thousands of probes for an answer
858
+ * that only changes which element a browser uses.
859
+ */
860
+ const PROBE_BY_HAND = 8;
861
+
796
862
  /** /api/v1/<provider>/oauth/start and .../callback, the house callback shape. */
797
863
  const OAUTH_ROUTE = /^\/api\/v1\/([a-z0-9-]+)\/oauth\/(start|callback)$/;
798
864
 
@@ -891,6 +957,15 @@ export interface HandlerOptions {
891
957
  broadcaster?: Broadcaster;
892
958
  /** Where a broadcast should send, and what it should look like. */
893
959
  broadcast?: () => { destinations: Destination[]; settings: EncoderSettings };
960
+ /**
961
+ * Where OBS should point, one entry per stream this server will accept.
962
+ *
963
+ * There is deliberately no single link. ffmpeg's RTMP listener serves one
964
+ * connection per process, so three people going live at once is three ports
965
+ * and three URLs -- and a panel offering one address for all of them would
966
+ * be offering an address that works exactly once.
967
+ */
968
+ publishUrls?: () => { id: string; url: string }[];
894
969
  /** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
895
970
  accounts?: Accounts;
896
971
  /** Providers to sign in with, and the terminals waiting to be connected. */
@@ -2102,6 +2177,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2102
2177
  active: tracker.active,
2103
2178
  startedAt: started,
2104
2179
  now: Date.now(),
2180
+ // Where to point OBS. Printed at startup since RTMP was added, which
2181
+ // is no use at all to somebody looking at the admin panel a day later.
2182
+ publish: options.publishUrls?.() ?? [],
2105
2183
  });
2106
2184
  return;
2107
2185
  }
@@ -2207,11 +2285,28 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2207
2285
  return;
2208
2286
  }
2209
2287
  try {
2210
- const tracks = await options.load(source);
2288
+ let tracks: Loaded[] = await options.load(source);
2211
2289
  if (tracks.length === 0) {
2212
2290
  json(response, 422, { error: `nothing to play at ${source}` });
2213
2291
  return;
2214
2292
  }
2293
+ // A handful of addresses whose names say nothing get asked what they
2294
+ // are, so a live channel arrives as a picture rather than as its own
2295
+ // soundtrack. Capped, because a playlist of five thousand of them is
2296
+ // five thousand ffprobes and the answer only matters for the few a
2297
+ // person adds by hand.
2298
+ const looked = await Promise.all(
2299
+ tracks.map(async (track, at) => {
2300
+ if (at >= PROBE_BY_HAND || !nameSaysNothing(track.path)) return track;
2301
+ const codecs = await codecsOf(
2302
+ { ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null },
2303
+ track.path,
2304
+ );
2305
+ return codecs.video === "" ? track : { ...track, picture: true };
2306
+ }),
2307
+ );
2308
+ tracks = looked;
2309
+
2215
2310
  let added = tracks.length;
2216
2311
  if (replacing) {
2217
2312
  engine.replace(tracks, source);
@@ -2265,15 +2360,21 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2265
2360
 
2266
2361
  if (playsInBrowser(file) && capKbps === 0) {
2267
2362
  sendFile(request, response, file);
2268
- } else if (hasPicture(file)) {
2269
- // A film. It used to arrive as MP3 with `-vn`, which is to say as a
2270
- // soundtrack over a blank panel; what ffprobe finds inside decides how
2271
- // little work it takes to keep the picture.
2363
+ return;
2364
+ }
2365
+ // A film, or something whose name refuses to say. A live channel at
2366
+ // .../301 used to fall through to the audio branch and arrive as MP3
2367
+ // with `-vn` -- a match you could only listen to.
2368
+ if (hasPicture(file) || nameSaysNothing(file)) {
2369
+ // What ffprobe finds inside decides how little work it takes to keep
2370
+ // the picture, and whether there is a picture to keep at all.
2272
2371
  const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
2273
- pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps), "video/mp4");
2274
- } else {
2275
- transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
2372
+ if (codecs.video !== "") {
2373
+ pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps), "video/mp4");
2374
+ return;
2375
+ }
2276
2376
  }
2377
+ transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
2277
2378
  return;
2278
2379
  }
2279
2380
 
@@ -2664,10 +2765,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2664
2765
  //
2665
2766
  // So the filenames are enough to start: the server is up and answering in the
2666
2767
  // time it takes to walk the directory, and the titles fill in behind it.
2667
- const tracks = await loadSource(tools, root, false);
2668
- const engine: Engine = tracks.length > 0
2669
- ? new PlayerEngine(tracks, root, tools)
2670
- : new EmptyEngine(`No audio files under ${root}.`);
2768
+ // Empty on purpose. The walk happens below, once the port is open: it is
2769
+ // the slowest part of starting and nothing about it needs to happen first.
2770
+ const engine = new PlayerEngine([], root, tools);
2671
2771
 
2672
2772
  const web = options.web !== null ? resolve(options.web) : defaultWebDir();
2673
2773
  const key = options.key ? newKey() : null;
@@ -2885,11 +2985,17 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2885
2985
  })()
2886
2986
  : undefined;
2887
2987
 
2988
+ // Filled in below, when the RTMP listeners are opened. Read through a
2989
+ // function so the handler sees the list rather than the empty array it was
2990
+ // built with.
2991
+ let publishUrls: { id: string; url: string }[] = [];
2992
+
2888
2993
  const server = createServer(engine, {
2889
2994
  web,
2890
2995
  media: options.media,
2891
2996
  owner,
2892
2997
  channels,
2998
+ publishUrls: () => publishUrls,
2893
2999
  ...(ingest ? { ingest } : {}),
2894
3000
  broadcaster,
2895
3001
  broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
@@ -3015,20 +3121,30 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3015
3121
  );
3016
3122
  }
3017
3123
 
3018
- // The other half of "names now, tags later". It runs while the banner is
3019
- // printed and while the publish prompt waits, and it is deliberately not
3020
- // awaited: nothing downstream needs it, and a library that takes a minute to
3021
- // read should cost nobody a minute of silence.
3022
- if (tracks.length > 0 && !isRemote(root)) {
3023
- void loadTagged(tools, root)
3024
- .then((tagged) => engine.retag(tagged, root))
3025
- .catch(() => {
3026
- // Filenames are a working player. A failure here is worth nothing but
3027
- // titles that stay as they are.
3028
- });
3029
- }
3124
+ // Names now, tags later, and both after the door is open. Not awaited:
3125
+ // nothing below needs the library, and a directory that takes a minute to
3126
+ // walk should cost nobody a minute of not being able to connect.
3127
+ void loadSource(tools, root, false)
3128
+ .then((found) => {
3129
+ engine.fill(found, root);
3130
+ if (found.length === 0) {
3131
+ console.log(`nixamp serve — no audio files under ${root}`);
3132
+ return;
3133
+ }
3134
+ console.log(`nixamp serve — ${found.length} tracks under ${root}`);
3135
+ if (isRemote(root)) return;
3136
+ return loadTagged(tools, root)
3137
+ .then((tagged) => engine.retag(tagged, root))
3138
+ .catch(() => {
3139
+ // Filenames are a working player. A failure here is worth nothing
3140
+ // but titles that stay as they are.
3141
+ });
3142
+ })
3143
+ .catch((error: unknown) => {
3144
+ console.log(`nixamp: could not read ${root}: ${(error as Error).message}`);
3145
+ });
3030
3146
 
3031
- console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
3147
+ console.log(`nixamp serve — reading ${root}`);
3032
3148
  console.log("");
3033
3149
 
3034
3150
  const width = Math.max(...addresses.map((a) => a.label.length));
@@ -3083,9 +3199,14 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3083
3199
  rtmp = new RtmpListeners(channels, tools.ffmpeg, listenKey ?? "live");
3084
3200
  rtmp.listen(slots);
3085
3201
 
3202
+ publishUrls = slots.map((slot) => ({
3203
+ id: slot.id,
3204
+ url: `rtmp://${host}:${slot.port}/live/${listenKey ?? "live"}`,
3205
+ }));
3206
+
3086
3207
  console.log(" Or publish from OBS, Larix or ffmpeg, one per URL:");
3087
- for (const slot of slots) {
3088
- console.log(` rtmp://${host}:${slot.port}/live/${listenKey ?? "live"} -> "${slot.id}"`);
3208
+ for (const entry of publishUrls) {
3209
+ console.log(` ${entry.url} -> "${entry.id}"`);
3089
3210
  }
3090
3211
  }
3091
3212
  if (destinations.length > 0) {
@@ -3149,7 +3270,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3149
3270
  name: options.name || hostname(),
3150
3271
  url: listen,
3151
3272
  audio,
3152
- tracks: tracks.length,
3273
+ // Asked of the engine rather than a variable, because the library is
3274
+ // now read after the port opens and may still be arriving.
3275
+ tracks: engine.snapshot(false).trackCount,
3153
3276
  // From `nixamp login`. The directory will not list a stream it cannot
3154
3277
  // attribute to somebody, because a listing is now a phone code that
3155
3278
  // costs money to answer.
package/src/session.ts CHANGED
@@ -582,7 +582,11 @@ export async function servers(argv: string[], fetcher: typeof fetch = fetch): Pr
582
582
  const daemon = await import("./daemon.ts");
583
583
  const state = daemon.readState();
584
584
  if (state === null) {
585
- console.error("nixamp: no daemon is running here. Start one, or pass a URL.");
585
+ console.error(
586
+ "nixamp: no daemon is running here.\n" +
587
+ " Start one: nixamp daemon start ~/Music\n" +
588
+ " Or give the share link of the one you mean: https://host:4321/s/KEY",
589
+ );
586
590
  return 1;
587
591
  }
588
592
  // The address worth remembering is the one somebody else can open.
package/src/share.ts CHANGED
@@ -128,6 +128,24 @@ export async function lookupPublicIp(send: typeof fetch = fetch, timeoutMs = 250
128
128
  * somewhere else can open. It is labelled for what it is, because the key in
129
129
  * the link is then the only thing between a stranger and the library.
130
130
  */
131
+ /**
132
+ * Whether a browser could ever verify a certificate for this address.
133
+ *
134
+ * A certificate is issued for a name. Handed an https link to a bare IP, a
135
+ * browser has nothing to match it against and refuses the connection before it
136
+ * asks anything -- and from the page it looks identical to a machine that is
137
+ * switched off. Offering such a link is offering one that cannot work, so the
138
+ * links are labelled and the ones that can work go first.
139
+ */
140
+ export function certifiable(url: string): boolean {
141
+ if (!url.startsWith("https://")) return true;
142
+ try {
143
+ return !isIpAddress(new URL(url).hostname.replace(/^\[|\]$/g, ""));
144
+ } catch {
145
+ return true;
146
+ }
147
+ }
148
+
131
149
  export function reachableAddresses(
132
150
  host: string,
133
151
  port: number,
@@ -161,11 +179,21 @@ export function reachableAddresses(
161
179
  }
162
180
  const order = { private: 0, cgnat: 1, public: 2 } as const;
163
181
  found.sort((x, y) => order[x.kind] - order[y.kind]);
164
- return [
182
+ const all = [
165
183
  ...told,
166
184
  { label: "here", url: `${scheme}://localhost:${port}` },
167
185
  ...found.map(({ label, url }) => ({ label, url })),
168
186
  ];
187
+ // Serving https, an address that is a bare IP cannot be verified by anyone,
188
+ // so it is said last and said differently rather than handed out as though
189
+ // it were a link somebody could use.
190
+ if (scheme !== "https") return all;
191
+ return [
192
+ ...all.filter((entry) => certifiable(entry.url)),
193
+ ...all
194
+ .filter((entry) => !certifiable(entry.url))
195
+ .map((entry) => ({ label: `${entry.label} (no certificate for an IP)`, url: entry.url })),
196
+ ];
169
197
  }
170
198
 
171
199
  /** The full link, key and all. */
@@ -1 +1 @@
1
- import{t as e}from"./index-a1aekKkP.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-CGXkk7_z.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}.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 .slot{color:var(--muted);flex:none;min-width:4.5em;font-size:12px}.publish-list input{flex:auto;min-width: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%;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}
@@ -0,0 +1 @@
1
+ (function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();function e(e){if(!Number.isFinite(e)||e<0)return`--:--`;let t=Math.floor(e),n=Math.floor(t/60),r=t%60;return`${String(n).padStart(2,`0`)}:${String(r).padStart(2,`0`)}`}function t(e){return e.artist?`${e.artist} — ${e.title}`:e.title}function n(e){let t=e.split(`/`).pop()??e,n=t.lastIndexOf(`.`);return n>0?t.slice(0,n):t}var r=new Set([`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`,`avi`]);function i(e,t=``){if(t.startsWith(`video/`))return!0;if(t.startsWith(`audio/`))return!1;let n=e.lastIndexOf(`.`);return n>0&&r.has(e.slice(n+1).toLowerCase())}var a=`modulepreload`,o=function(e){return`/`+e},s={},c=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),c=i?.nonce||i?.getAttribute(`nonce`);function l(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function u(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=l(t.map(t=>{if(t=o(t,n),t=u(t),t in s)return;s[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:a,r||(i.as=`script`),i.crossOrigin=``,i.href=t,c&&i.setAttribute(`nonce`,c),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},l=[[/\.m3u8$/i,`hls`],[/\.(ts|mts|m2ts|mpegts)$/i,`mpegts`],[/\.(mp3|m4a|aac|oga|ogg|opus|wav|flac)$/i,`audio`],[/\.(mp4|m4v|webm|mov|ogv)$/i,`mp4`]],u=[[/mpegurl/i,`hls`],[/mp2t|mpeg-?ts/i,`mpegts`],[/^audio\//i,`audio`],[/^video\//i,`mp4`]];function d(e){if(e.kind)return e.kind;if(e.mimeType){for(let[t,n]of u)if(t.test(e.mimeType))return n}let t=f(e.src);for(let[e,n]of l)if(e.test(t))return n;return`unknown`}function f(e){try{return new URL(e,`https://placeholder.invalid`).pathname}catch{return e.split(/[?#]/)[0]??e}}function p(e=globalThis,t=null){let n=e.MediaSource!==void 0,r=!1;try{let e=t??(typeof document>`u`?null:document.createElement(`video`));r=e?e.canPlayType(`application/vnd.apple.mpegurl`)!==``||e.canPlayType(`application/x-mpegURL`)!==``:!1}catch{r=!1}return{mediaSource:n,nativeHls:r}}function m(e,t){let n=d(e);switch(n){case`hls`:return t.mediaSource?{engine:`hls`,kind:n}:t.nativeHls?{engine:`native`,kind:n}:{engine:`native`,kind:n,unplayable:`This browser cannot play HLS streams.`};case`mpegts`:return t.mediaSource?{engine:`mpegts`,kind:n}:{engine:`mpegts`,kind:n,unplayable:`This browser cannot play transport streams.`};default:return{engine:`native`,kind:n}}}async function h(e,t){let n=t.capabilities??p(),r=m({src:t.src,...t.kind?{kind:t.kind}:{},...t.mimeType?{mimeType:t.mimeType}:{}},n),i=()=>void 0,a={media:e,src:t.src,isTv:t.isTv??!1,live:t.live??r.kind===`mpegts`,onError:t.onError??i,onNotice:t.onNotice??i,...t.onReady?{onReady:t.onReady}:{}};if(r.unplayable)return t.onError?.(r.unplayable),{destroy:i,engine:r.engine,kind:r.kind,levels:()=>[],unplayable:r.unplayable};let o,s=t.engines?.[r.engine];if(s)o=await s(a);else if(r.engine===`hls`){let{createHlsEngine:e}=await c(async()=>{let{createHlsEngine:e}=await import(`./hls-3VKVEQE3-BM7Le4NN.js`);return{createHlsEngine:e}},[]);o=await e(a)}else if(r.engine===`mpegts`){let{createMpegtsEngine:e}=await c(async()=>{let{createMpegtsEngine:e}=await import(`./mpegts-LO6RVLD6-B_K_M921.js`);return{createMpegtsEngine:e}},[]);o=await e(a,{withCredentials:t.withCredentials??!1,unplayableAdvice:t.unplayableAdvice??``})}else{let{createNativeEngine:e}=await c(async()=>{let{createNativeEngine:e}=await import(`./native-C7JTKWJH-BUyIoj0P.js`);return{createNativeEngine:e}},[]);o=await e(a)}return{destroy:()=>{o.destroy()},engine:r.engine,kind:r.kind,levels:o.levels,...o.setLevel?{setLevel:o.setLevel}:{},...o.currentLevel?{currentLevel:o.currentLevel}:{}}}var g=new Set([`mp3`,`flac`,`ogg`,`oga`,`opus`,`m4a`,`aac`,`wav`,`wma`,`aiff`,`aif`,`alac`,`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`]);function _(e,t=``){if(t.startsWith(`audio/`)||t.startsWith(`video/`))return!0;let n=e.lastIndexOf(`.`);return n>0&&g.has(e.slice(n+1).toLowerCase())}function v(e,t){return e.localeCompare(t,void 0,{numeric:!0,sensitivity:`base`})}function y(e){return e.filter(e=>_(e.name,e.type)).sort((e,t)=>v(b(e),b(t))).map(e=>({title:n(e.name),artist:``,album:x(b(e)),duration:0,url:URL.createObjectURL(e),video:i(e.name,e.type),objectUrl:!0}))}function b(e){return e.webkitRelativePath||e.name}function x(e){let t=e.split(`/`);return t.length>1?t[t.length-2]:``}function ee(e){for(let t of e)t.objectUrl&&URL.revokeObjectURL(t.url)}var S=2048;function C(e,t){return e||t===`hls`||t===`mpegts`}var te=class{elements;handlers;attached=null;context=null;analyser=null;wired=new WeakSet;active;frequencies=new Uint8Array;constructor(e,t){this.elements=e,this.handlers=t,this.active=e.audio;for(let t of[e.audio,e.video])t.crossOrigin=`anonymous`,t.addEventListener(`timeupdate`,()=>{t===this.active&&this.handlers.onTime(t.currentTime,Number.isFinite(t.duration)?t.duration:0)}),t.addEventListener(`loadedmetadata`,()=>{t===this.active&&this.handlers.onTime(t.currentTime,Number.isFinite(t.duration)?t.duration:0)}),t.addEventListener(`ended`,()=>{t===this.active&&this.handlers.onEnded()}),t.addEventListener(`play`,()=>{t===this.active&&this.handlers.onState(!0)}),t.addEventListener(`pause`,()=>{t===this.active&&this.handlers.onState(!1)}),t.addEventListener(`error`,()=>{t===this.active&&this.handlers.onError(w(t))})}get playing(){return!this.active.paused&&!this.active.ended}get position(){return this.active.currentTime}get duration(){return Number.isFinite(this.active.duration)?this.active.duration:0}get showingVideo(){return this.active===this.elements.video}ensureGraph(e){let t=globalThis.AudioContext??globalThis.webkitAudioContext;if(t){if(this.context??=new t,this.analyser||(this.analyser=this.context.createAnalyser(),this.analyser.fftSize=S,this.analyser.smoothingTimeConstant=.6,this.analyser.connect(this.context.destination),this.frequencies=new Uint8Array(this.analyser.frequencyBinCount)),!this.wired.has(e))try{this.context.createMediaElementSource(e).connect(this.analyser),this.wired.add(e)}catch{this.wired.add(e)}this.context.resume()}}read(){return this.analyser&&this.analyser.getByteFrequencyData(this.frequencies),this.frequencies}levels(){if(!this.analyser)return[0,0];let e=this.read(),t=0;for(let n of e)t+=n;let n=e.length===0?0:t/e.length/255;return[Math.min(1,n*2.2),Math.min(1,n*2.2)]}async load(e,t){let n=e.objectUrl?e.video?`mp4`:`audio`:d({src:e.url}),r=C(e.video,n)?this.elements.video:this.elements.audio;r!==this.active&&(this.active.pause(),this.active.removeAttribute(`src`),this.active.load(),this.active=r),this.attached?.destroy(),this.attached=null;try{this.attached=await h(this.active,{src:e.url,kind:n,unplayableAdvice:`VLC or mpv will play it; nixamp can only hand it to your browser.`,onError:e=>this.handlers.onError(e),onNotice:e=>{e&&this.handlers.onError(e)}})}catch(e){this.handlers.onError(e instanceof Error?e.message:`that would not play`);return}t&&await this.play()}async play(){this.ensureGraph(this.active);try{await this.active.play()}catch(e){this.handlers.onError(e instanceof Error?e.message:`playback was refused`)}}pause(){this.active.pause()}stop(){this.active.pause(),this.active.currentTime=0,this.attached?.destroy(),this.attached=null}seek(e){Number.isFinite(e)&&(this.active.currentTime=Math.max(0,e))}set volume(e){this.elements.audio.volume=e,this.elements.video.volume=e}get volume(){return this.active.volume}};function w(e){switch(e.error?.code){case MediaError.MEDIA_ERR_ABORTED:return`playback was aborted`;case MediaError.MEDIA_ERR_NETWORK:return`the network dropped mid-track`;case MediaError.MEDIA_ERR_DECODE:return`this browser could not decode that`;case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:return`this browser cannot play that format`;default:return`playback failed`}}function T(){return{revision:0,tracks:[],trackCount:0,index:0,playing:!1,position:0,bars:[],levels:[0,0],silent:!0,note:``,root:``}}function ne(e,t){return{...t,tracks:t.tracks??e.tracks}}function E(e){let t=e.trim();if(t===``)return``;/^https?:\/\//i.test(t)||(t=`http://${t}`);let n;try{n=new URL(t)}catch{return``}let r=n.pathname.replace(/\/+$/,``);return r=r.replace(/\/api(\/.*)?$/,``),`${n.origin}${r}`}function D(e,t,n=``){let r=`${e===``?``:E(e)}${t.startsWith(`/`)?t:`/${t}`}`;return n?`${r}${r.includes(`?`)?`&`:`?`}k=${encodeURIComponent(n)}`:r}function re(e){let t=e.trim();if(t===``)return{base:``,key:``};let n;try{n=new URL(/^https?:\/\//i.test(t)?t:`http://${t}`)}catch{return{base:``,key:``}}let r=/^\/s\/([^/]+)\/?$/.exec(n.pathname),i=r?.[1]??n.searchParams.get(`k`)??``;return r&&(n.pathname=`/`),n.searchParams.delete(`k`),{base:E(`${n.origin}${n.pathname}`),key:decodeURIComponent(i)}}function O(e,t,n=0,r=``){return D(e,n>0?`/api/media/${t}?kbps=${Math.round(n)}`:`/api/media/${t}`,r)}function k(e){if(typeof e!=`object`||!e)return null;let t=e,n=T(),r=(e,t)=>typeof e==`number`&&Number.isFinite(e)?e:t,i=Array.isArray(t.levels)?t.levels:[],a=Array.isArray(t.tracks)?t.tracks.map(e=>{let t=typeof e==`object`&&e?e:{};return{title:typeof t.title==`string`?t.title:`Untitled`,artist:typeof t.artist==`string`?t.artist:``,album:typeof t.album==`string`?t.album:``,duration:r(t.duration,0),...t.video===!0?{video:!0}:{},...typeof t.group==`string`&&t.group!==``?{group:t.group}:{}}}):void 0;return{revision:r(t.revision,0),...a?{tracks:a}:{},trackCount:r(t.trackCount,a?.length??0),index:r(t.index,0),playing:t.playing===!0,position:r(t.position,0),bars:Array.isArray(t.bars)?t.bars.map(e=>r(e,0)):[],levels:[r(i[0],0),r(i[1],0)],silent:t.silent===!0,note:typeof t.note==`string`?t.note:``,root:typeof t.root==`string`?t.root:n.root}}var ie=class{handlers;source=null;base=``;key=``;lastRevision=-1;constructor(e){this.handlers=e}get address(){return this.base}url(e){return D(this.base,e,this.key)}get shareLink(){return this.base===``?``:this.key===``?this.base:`${this.base}/s/${this.key}`}get connected(){return this.source!==null}connect(e){let{base:t,key:n}=re(e);this.close(),this.base=t,this.key=n,this.lastRevision=-1,this.handlers.onStatus(`connecting`);let r=new EventSource(D(t,`/api/events`,n));this.source=r,r.onopen=()=>this.handlers.onStatus(`live`),r.onmessage=e=>{let t=k(A(e.data));t&&(t.revision<this.lastRevision||(this.lastRevision=t.revision,this.handlers.onStatus(`live`),this.handlers.onSnapshot(t)))},r.onerror=()=>{this.handlers.onStatus(`error`,`reconnecting…`)}}async send(e){if(this.base===``&&!this.connected)return;let t=await fetch(D(this.base,`/api/command`,this.key),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify(e)});if(!t.ok){this.handlers.onStatus(`error`,`command refused (${t.status})`);return}let n=k(await t.json());n&&this.handlers.onSnapshot(n)}media(e,t=0){return O(this.base,e,t,this.key)}close(){this.source?.close(),this.source=null}};function A(e){try{return JSON.parse(e)}catch{return null}}async function ae(e,t,n=``){try{let r=await fetch(D(e,`/api/state`,n),{signal:t});return r.ok?k(await r.json()):null}catch{return null}}function oe(e){if(!/^https:\/\//i.test(e))return``;let t;try{t=new URL(e).hostname.replace(/^\[|\]$/g,``)}catch{return``}return/^\d{1,3}(\.\d{1,3}){3}$/.test(t)||t.includes(`:`)?`That is an https address for a bare IP, and a certificate is issued for a name — a browser refuses it before it asks anything. Use the server's name instead (the address it printed first), or connect over http.`:``}async function se(e,t=``,n){let r;try{r=await fetch(D(e,`/api/state`,t),{signal:n})}catch{return``}return r.ok?``:r.status===401?t===``?`That server needs its share link. Paste the whole link — the one ending in /s/… — or sign in as its owner.`:`That share link is not accepted by that server. It may have been restarted, which gives it a new one.`:r.status===403?`That link can listen but not drive this server.`:r.status===429?`That server is asking us to slow down. Try again in a moment.`:``}async function j(e,t,n=``){try{let r=await fetch(D(e,`/api/health`,n),{signal:t});if(!r.ok)return null;let i=await r.json();return i.name===`nixamp`?i.version??`unknown`:null}catch{return null}}function ce(e,t=globalThis.location?.protocol){return t!==`https:`||!/^http:\/\//i.test(e.trim())?``:`This page is https, and a browser refuses every request from an https page to an http one. Open that address directly, or give the server a certificate: nixamp serve --tls-cert cert.pem --tls-key key.pem.`}var M=.14,N=.02;function le(e,t){let n=[];for(let r=0;r<=e;r++){let i=r/e,a=Math.round(1*(t/1)**i),o=n[n.length-1];n.push(o===void 0?a:Math.max(a,o+1))}return n}function ue(e,t){let n=[];for(let r=0;r+1<t.length;r++){let i=Math.min(t[r],e.length),a=Math.min(Math.max(t[r+1],i+1),e.length),o=0,s=0;for(let t=i;t<a;t++)o+=e[t],s++;n.push(s===0?0:o/s/255)}return n}function de(e,t,n=M){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function P(e,t,n=N){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function fe(e,t,n,r,i){let{width:a,height:o}=t;if(a<=0||o<=0||n.length===0)return;e.clearRect(0,0,a,o);let s=a/n.length,c=Math.max(1,s*.72),l=Math.max(2,o*.012);e.fillStyle=i.bar,n.forEach((t,n)=>{let r=Math.max(1,t*(o-l*2));e.fillRect(n*s+(s-c)/2,o-r,c,r)}),e.fillStyle=i.peak,r.forEach((t,n)=>{let r=o-Math.max(1,t*(o-l*2))-l*2;e.fillRect(n*s+(s-c)/2,Math.max(0,r),c,l)})}var F=`nixamp.remote`,I=`nixamp.volume`,L=`nixamp.listenHere`;function R(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function z(){let n={status:R(`status`),source:R(`source`),install:R(`install`),video:R(`video`),audio:R(`audio`),title:R(`title-line`),album:R(`album-line`),elapsed:R(`elapsed`),total:R(`total`),seek:R(`seek`),canvas:R(`spectrum`),glyphs:R(`glyphs`),levels:R(`levels`),playlist:R(`playlist`),playlistTitle:R(`playlist-panel`),note:R(`note`),files:R(`files`),folder:R(`folder`),remoteUrl:R(`remote-url`),remoteForm:R(`remote-form`),remoteState:R(`remote-state`),disconnect:R(`disconnect`),browse:R(`browse`),accountForm:R(`account-form`),accountEmail:R(`account-email`),accountPassword:R(`account-password`),accountSubmit:R(`account-submit`),accountToggle:R(`account-toggle`),accountProviders:R(`account-providers`),accountPanel:R(`account-panel`),accountElsewhere:R(`account-elsewhere`),accountSignOut:R(`account-signout`),accountNote:R(`account-note`),adminPanel:R(`admin-panel`),adminNote:R(`admin-note`),adminConnections:R(`admin-connections`),publishNote:R(`publish-note`),publishList:R(`publish-list`),adminRestream:R(`admin-restream`),adminReplace:R(`admin-replace`),adminSource:R(`admin-source`),directory:R(`directory`),recentNote:R(`recent-note`),recentList:R(`recent-list`),followingNote:R(`following-note`),followingList:R(`following-list`),serversPanel:R(`servers-panel`),serversNote:R(`servers-note`),serversList:R(`servers-list`),notifyPanel:R(`notify-panel`),notifyNote:R(`notify-note`),notifyWeb:R(`notify-web`),notifyEmail:R(`notify-email`),notifySms:R(`notify-sms`),notifyPhone:R(`notify-phone`),notifyPhoneForm:R(`notify-phone-form`),notifyPhoneNote:R(`notify-phone-note`),directoryNote:R(`directory-note`),directoryList:R(`directory-list`),sharePanel:R(`share-panel`),shareNote:R(`share-note`),shareLink:R(`share-link`),shareCopy:R(`share-copy`),sharePhone:R(`share-phone`),shareSend:R(`share-send`),shareTo:R(`share-to`),listenHere:R(`listen-here`),volume:R(`volume`),prev:R(`prev`),playPause:R(`play-pause`),stop:R(`stop`),next:R(`next`)},r=`local`,i=[],a=0,o=T(),s=`idle`,c=``,l=`Pick files, or connect to a nixamp running somewhere else.`,u=!1,d=-1,f=``,p=Array(24).fill(0),m=Array(24).fill(0),h=[],g=()=>r===`remote`&&!n.listenHere.checked,_=new te({audio:n.audio,video:n.video},{onTime:(e,t)=>{let n=i[a];r===`local`&&n&&t>0&&n.duration!==t&&(n.duration=t),B()},onEnded:()=>M(1),onState:()=>B(),onError:e=>{l=e,B()}}),v=new ie({onSnapshot:e=>{o=ne(o,e),g()&&(p=e.bars.length>0?e.bars:p,m=P(m,p)),B()},onStatus:(e,t)=>{s=e,c=t??``,B()}}),b=()=>r===`remote`?o.tracks.length:i.length,x=()=>r===`remote`?g()||d<0?o.index:Math.min(d,Math.max(0,o.tracks.length-1)):a,S=()=>{let e=r===`remote`?o.tracks[x()]:i[x()];return e?t(e):`Nothing loaded.`},C=()=>(r===`remote`?o.tracks[x()]:i[x()])?.album||`—`,w=()=>g()?o.tracks[x()]?.duration??0:_.duration,E=()=>g()?o.position:_.position,D=()=>g()?o.playing:_.playing;async function O(e){if(r===`remote`){if(g()){await v.send({type:`play`,index:e});return}await k(e);return}let t=i[e];t&&(a=e,await _.load(t,!0),W(t.video),G(),B())}async function k(e){let t=o.tracks[e];t&&(d=e,await _.load({title:t.title,artist:t.artist,album:t.album,duration:t.duration,url:v.media(e,0),video:t.video===!0,objectUrl:!1},!0),W(t.video===!0),G())}async function A(){if(g()){await v.send({type:`toggle`});return}b()!==0&&(_.playing?_.pause():_.position>0?await _.play():await O(x()),B())}async function M(e){let t=b();if(t!==0){if(g()){await v.send({type:e>0?`next`:`prev`});return}await O((x()+e+t)%t)}}async function N(){if(g()){await v.send({type:`stop`});return}_.stop(),p=Array(24).fill(0),m=[...p],B()}let z=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))];function B(){let t=b(),a=D();n.status.textContent=a?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=String(a),n.title.textContent=S(),n.album.textContent=C();let d=E(),f=w();n.elapsed.textContent=e(d),n.total.textContent=f>0?e(f):`--:--`,u||(n.seek.value=String(f>0?Math.round(d/f*1e3):0),n.seek.disabled=f<=0||g()),n.playPause.textContent=a?`❚❚`:`▶`,n.playPause.setAttribute(`aria-label`,a?`Pause`:`Play`),n.playlistTitle.dataset.title=`Playlist (${t})`,n.source.textContent=r===`remote`?`remote · ${v.address.replace(/^https?:\/\//,``)||`—`}`:i.length>0?`local · ${i.length} files`:`no source`,n.remoteState.textContent=r===`remote`?`${s}${c?` — ${c}`:``}`:`not connected`,n.remoteState.dataset.status=r===`remote`?s:`idle`,n.disconnect.hidden=r!==`remote`;let m=r===`remote`&&o.note!==``?o.note:l;n.note.textContent=m,n.note.hidden=m===``,pe(),n.glyphs.textContent=p.map(z).join(``);let[h,y]=g()?o.levels:_.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(h*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(y*6)).padEnd(6,`·`)}`}let V=``,H=-1;function pe(){let a=r===`remote`?o.tracks.map(e=>({name:t(e),seconds:e.duration,group:e.group??``})):i.map(e=>({name:t(e),seconds:e.duration,group:``})),s=`${r}:${a.map(e=>`${e.name}@${e.seconds}@${e.group}`).join(`|`)}`;if(s!==V){V=s;let t=[],r=``,i=a.some(e=>e.group!==``);a.forEach((n,a)=>{n.group!==r&&(i||n.group!==``)&&(r=n.group,t.push(me(n.group)));let o=document.createElement(`li`);o.className=`row`,o.dataset.index=String(a);let s=document.createElement(`span`);s.className=`n`,s.textContent=String(a+1).padStart(2,` `);let c=document.createElement(`span`);c.className=`name`,c.textContent=n.name;let l=document.createElement(`span`);l.className=`time`,l.textContent=n.seconds>0?e(n.seconds):`--:--`,o.append(s,c,l),t.push(o)}),n.playlist.replaceChildren(...t)}let c=x(),l=D(),u;for(let e of Array.from(n.playlist.children)){let t=e,n=Number(t.dataset.index),r=Number.isInteger(n)&&n===c;t.classList.toggle(`selected`,r),t.classList.toggle(`playing`,r&&l),r&&(u=t)}c!==H&&(H=c,u?.scrollIntoView({block:`nearest`}))}function me(e){let t=document.createElement(`li`);t.className=`group`;let r=document.createElement(`span`);if(r.className=`group-name`,r.textContent=e===``?`This server's library`:e,t.append(r),e!==``&&!n.adminPanel.hidden){let n=document.createElement(`button`);n.type=`button`,n.className=`group-remove`,n.textContent=`×`,n.title=`Remove ${e} from the playlist`,n.setAttribute(`aria-label`,`Remove ${e} from the playlist`),n.addEventListener(`click`,t=>{t.stopPropagation(),he(e)}),t.append(n)}return t}async function he(e){try{let t=await fetch(v.url(`/api/source/remove`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({group:e})}),r=await t.json();n.adminNote.textContent=t.ok?`Removed ${r.removed??0} tracks from ${e}.`:r.error??`that did not work`}catch{n.adminNote.textContent=`could not reach the server`}}function U(){let t=n.canvas,r=Math.min(2,globalThis.devicePixelRatio||1),i=Math.round(t.clientWidth*r),a=Math.round(t.clientHeight*r);i>0&&a>0&&(t.width!==i||t.height!==a)&&(t.width=i,t.height=a);let s=t.getContext(`2d`);if(g())m=P(m,p);else{let e=_.read();e.length>0&&(h.length!==25&&(h=le(24,e.length)),p=de(p,ue(e,h)),m=P(m,p))}if(s){let e=getComputedStyle(document.documentElement);fe(s,{width:t.width,height:t.height},p,m,{bar:e.getPropertyValue(`--green`).trim()||`#4af689`,peak:e.getPropertyValue(`--green-dim`).trim()||`#227a4a`,background:`transparent`})}if(D()){n.glyphs.textContent=p.map(z).join(``);let[t,r]=g()?o.levels:_.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(t*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(r*6)).padEnd(6,`·`)}`,n.elapsed.textContent=e(E());let i=w();!u&&i>0&&(n.seek.value=String(Math.round(E()/i*1e3)))}requestAnimationFrame(U)}function W(e){n.video.hidden=!e}function G(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:S(),album:C(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void A()),navigator.mediaSession.setActionHandler(`pause`,()=>void A()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void M(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void M(-1)))}n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&O(n)}),n.prev.addEventListener(`click`,()=>void M(-1)),n.next.addEventListener(`click`,()=>void M(1)),n.stop.addEventListener(`click`,()=>void N()),n.playPause.addEventListener(`click`,()=>void A()),n.seek.addEventListener(`input`,()=>{u=!0}),n.seek.addEventListener(`change`,()=>{let e=w();e>0&&_.seek(Number(n.seek.value)/1e3*e),u=!1}),n.volume.addEventListener(`input`,()=>{let e=Number(n.volume.value)/100;_.volume=e;try{localStorage.setItem(I,String(e))}catch{}});let ge=e=>{e.addEventListener(`change`,()=>{let t=y(Array.from(e.files??[]));if(t.length===0){l=`Nothing playable in that selection.`,B();return}ee(i),i=t,a=0,r=`local`,v.close(),l=``,O(0)})};ge(n.files),ge(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.remoteUrl.value,{base:i,key:a}=re(t);if(i===``){l=`That is not an address.`,B();return}(async()=>{s=`connecting`,B();let e=ce(i);if(e){s=`error`,c=e,l=e,r=`local`,B();return}if(await j(i,void 0,a)===null){s=`error`;let e=oe(i);c=e?`needs the server's name`:`not answering`,l=e||`Nothing answered at ${i}. If that is your machine, it is off or nixamp is not running on it; otherwise check the address.`,r=`local`,B();return}let n=await se(i,a);if(n){s=`error`,c=n,l=n,r=`local`,B();return}r=`remote`,l=``;try{localStorage.setItem(F,t.trim())}catch{}v.connect(t),Pe(),B()})()});let _e=async()=>{n.directory.hidden=!1,n.directoryNote.textContent=`Looking for live streams…`,n.directoryList.replaceChildren();let e;try{let t=await fetch(`/api/directory`);if(!t.ok)throw Error(String(t.status));let n=await t.json();e=n.streams??[],Se(n.recent??[])}catch{n.directoryNote.textContent=`The directory is not answering. Type an address instead.`;return}if(e.length===0){n.directoryNote.textContent=`Nobody is streaming right now.`;return}n.directoryNote.textContent=`${e.length} live ${e.length===1?`stream`:`streams`}:`;for(let t of e){let e=document.createElement(`li`),r=document.createElement(`button`);r.type=`button`;let i=document.createElement(`span`);i.className=`name`,i.textContent=t.name;let a=document.createElement(`span`);a.className=`detail`;let o=[t.nowPlaying,`${t.tracks} tracks`].filter(Boolean);t.code&&o.push(t.callers?`☎ ${t.code} · ${t.callers} on the phone`:`☎ ${t.code}`),a.textContent=o.join(` · `),r.append(i,a),r.addEventListener(`click`,()=>{n.remoteUrl.value=t.url,n.directory.hidden=!0,n.remoteForm.requestSubmit()}),e.append(r),t.ownerId&&X&&t.ownerId!==X&&e.append(Te(t.ownerId,t.name)),n.directoryList.append(e)}};if(location.pathname.replace(/\/+$/,``)===`/directory`){document.body.classList.add(`route-directory`);let e=document.getElementById(`directory-back`);e&&(e.hidden=!1),_e()}let K=null,ve=e=>{n.adminConnections.replaceChildren();let t=document.createElement(`tr`);for(let e of[`Where`,`Network`,`Kind`,`Client`,`Track`,`Sent`]){let n=document.createElement(`th`);n.textContent=e,t.append(n)}n.adminConnections.append(t);for(let t of e.slice(0,40)){let e=document.createElement(`tr`);t.endedAt!==null&&(e.className=`ended`);let r=[[t.address,``],[t.network,`network-${t.network}`],[t.kind,``],[t.agent,``],[t.track||`—`,``],[`${Math.round(t.bytes/1024)} KiB`,``]];for(let[t,n]of r){let r=document.createElement(`td`);r.textContent=t,n&&(r.className=n),e.append(r)}n.adminConnections.append(e)}},ye=async()=>{try{let e=await fetch(v.url(`/api/connections`));if(!e.ok)return;let t=await e.json();n.adminNote.textContent=`${t.active??0} listening now.`,ve(t.connections??[]),be(t.publish??[])}catch{n.adminNote.textContent=`lost touch with the server`}};function be(e){if(n.publishNote.hidden=e.length===0,e.length===0){n.publishList.replaceChildren(),n.publishNote.hidden=!1,n.publishNote.textContent=`This server takes no RTMP. Start it with --rtmp-in 1935 to publish into it from OBS.`;return}n.publishNote.textContent=e.length===1?`Publish into this server from OBS, Larix or ffmpeg:`:`Publish into this server from OBS, Larix or ffmpeg. One URL per stream — ${e.length} at once:`,n.publishList.replaceChildren(...e.map(e=>{let t=document.createElement(`li`),n=document.createElement(`span`);n.className=`slot`,n.textContent=e.id;let r=document.createElement(`input`);r.type=`text`,r.readOnly=!0,r.value=e.url,r.setAttribute(`aria-label`,`RTMP URL for ${e.id}`);let i=document.createElement(`button`);return i.type=`button`,i.className=`ghost`,i.textContent=`Copy`,i.addEventListener(`click`,()=>{r.select(),navigator.clipboard?.writeText(e.url).catch(()=>{})}),t.append(n,r,i),t}))}let q=async()=>{let e=!1,t=null;try{let n=await fetch(v.url(`/api/admin`));if(n.ok){let r=await n.json();e=r.allowed===!0,t=r.as??null}}catch{e=!1}n.adminPanel.hidden=!e,K&&clearInterval(K),K=null,e&&(n.adminNote.textContent=t===`owner`?`You own this server.`:`You hold this server's control link.`,ye(),K=setInterval(()=>void ye(),2e3))};n.adminRestream.addEventListener(`submit`,e=>{e.preventDefault();let t=n.adminSource.value.trim();if(!t)return;let r=n.adminReplace.checked;(async()=>{try{let e=await fetch(v.url(`/api/source`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({source:t,...r?{replace:!0}:{}})}),i=await e.json();n.adminNote.textContent=e.ok?r?`Now serving ${t}.`:i.added===0?`Everything there was already in the playlist.`:`Added ${i.added??0} tracks from ${t}.`:i.error??`that did not work`,e.ok&&(n.adminSource.value=``)}catch{n.adminNote.textContent=`could not reach the server`}})()});let xe=e=>{let t=Math.max(1,Math.round((Date.now()-e)/6e4));if(t<60)return`${t} minute${t===1?``:`s`} ago`;let n=Math.round(t/60);return`${n} hour${n===1?``:`s`} ago`},Se=e=>{n.recentList.replaceChildren();let t=X?e.filter(e=>e.ownerId&&e.ownerId!==X):[];if(n.recentNote.hidden=t.length===0,t.length!==0)for(let e of t){let t=document.createElement(`li`),r=document.createElement(`span`);r.className=`recent-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name;let a=document.createElement(`span`);a.className=`detail`,a.textContent=e.nowPlaying?`${e.nowPlaying} · ended ${xe(e.endedAt)}`:`ended ${xe(e.endedAt)}`,r.append(i,a),t.append(r,Te(e.ownerId,e.name)),n.recentList.append(t)}},Ce=async()=>{n.serversList.replaceChildren();try{let e=await fetch(`/api/v1/servers`);if(!e.ok){n.serversPanel.hidden=!0;return}let t=(await e.json()).servers??[];n.serversPanel.hidden=!1,n.serversNote.textContent=t.length===0?"No servers yet. `nixamp server add --here` remembers the one you are running.":`The machines on your account. Open one, or forget it.`;for(let e of t){let t=document.createElement(`li`),r=document.createElement(`span`);r.className=`recent-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name;let a=document.createElement(`span`);a.className=`detail`,a.textContent=e.url,r.append(i,a);let o=document.createElement(`button`);o.type=`button`,o.className=`button`,o.textContent=`Open`,o.addEventListener(`click`,()=>{n.remoteUrl.value=e.key?`${e.url}/s/${e.key}`:e.url,n.remoteForm.requestSubmit()}),j(e.url).then(n=>{if(n!==null){a.textContent=`${e.url} · ${n}`;return}a.textContent=`${e.url} · not answering`,t.classList.add(`offline`),o.disabled=!0,o.title=`That machine is not answering. Start nixamp on it.`});let s=document.createElement(`button`);s.type=`button`,s.className=`ghost`,s.textContent=`Forget`,s.addEventListener(`click`,()=>{(async()=>{s.disabled=!0;try{await fetch(`/api/v1/servers/${encodeURIComponent(e.id)}`,{method:`DELETE`}),await Ce()}catch{s.disabled=!1}})()}),t.append(r,o,s),n.serversList.append(t)}}catch{n.serversPanel.hidden=!0}},we=async()=>{n.followingList.replaceChildren();try{let e=await fetch(`/api/v1/follows`);if(!e.ok){n.followingNote.hidden=!0;return}let t=(await e.json()).following??[];n.followingNote.hidden=t.length===0;for(let e of t){let t=document.createElement(`li`),r=document.createElement(`span`);r.className=`recent-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name||`a nixamp`;let a=document.createElement(`span`);a.className=`detail`,a.textContent=e.live?`live now`:`not streaming`,r.append(i,a);let o=document.createElement(`button`);o.type=`button`,o.className=`ghost follow`,o.textContent=`Unfollow`,o.addEventListener(`click`,()=>{(async()=>{o.disabled=!0;try{await fetch(`/api/v1/follows/${encodeURIComponent(e.id)}`,{method:`DELETE`}),t.remove(),n.followingList.children.length===0&&(n.followingNote.hidden=!0)}finally{o.disabled=!1}})()}),t.append(r,o),n.followingList.append(t)}}catch{n.followingNote.hidden=!0}},Te=(e,t)=>{let n=document.createElement(`button`);n.type=`button`,n.className=`ghost follow`,n.textContent=`Follow`,n.setAttribute(`aria-label`,`Follow ${t}`);let r=e=>{n.textContent=e?`Following`:`Follow`,n.dataset.following=e?`yes`:`no`};return(async()=>{try{let t=await fetch(`/api/v1/follows/${encodeURIComponent(e)}`);t.ok&&r((await t.json()).following===!0)}catch{}})(),n.addEventListener(`click`,()=>{(async()=>{let t=n.dataset.following===`yes`;n.disabled=!0;try{(await fetch(`/api/v1/follows/${encodeURIComponent(e)}`,{method:t?`DELETE`:`PUT`,headers:{"content-type":`application/json`},body:t?void 0:`{}`})).ok&&(r(!t),we())}catch{}finally{n.disabled=!1}})()}),n},Ee=e=>{let t=(e+`=`.repeat((4-e.length%4)%4)).replace(/-/g,`+`).replace(/_/g,`/`),n=atob(t),r=new Uint8Array(new ArrayBuffer(n.length));for(let e=0;e<n.length;e+=1)r[e]=n.charCodeAt(e);return r},De=()=>`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window,Oe=async()=>{if(!De())return n.notifyNote.textContent=`This browser cannot show notifications.`,!1;if(Notification.permission===`denied`)return n.notifyNote.textContent=`This browser is blocking notifications. Allow them in site settings first.`,!1;if(await Notification.requestPermission()!==`granted`)return n.notifyNote.textContent=`Not allowed, so nothing will be sent here.`,!1;try{let e=await navigator.serviceWorker.ready,{publicKey:t}=await(await fetch(`/api/v1/notify/key`)).json();if(!t)return n.notifyNote.textContent=`This server is not set up to send notifications.`,!1;let r=await e.pushManager.getSubscription()??await e.pushManager.subscribe({userVisibleOnly:!0,applicationServerKey:Ee(t)}),i=await fetch(`/api/v1/notify/subscribe`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify(r.toJSON())});if(!i.ok)throw Error(String(i.status));return n.notifyNote.textContent=`This device will be told.`,!0}catch{return n.notifyNote.textContent=`Could not set this device up.`,!1}},ke=async()=>{try{let e=await(await navigator.serviceWorker.ready).pushManager.getSubscription();if(!e)return;await fetch(`/api/v1/notify/subscribe?endpoint=${encodeURIComponent(e.endpoint)}`,{method:`DELETE`}),await e.unsubscribe()}catch{}},J=async e=>{try{let t=await fetch(`/api/v1/notify/prefs`,{method:`PUT`,headers:{"content-type":`application/json`},body:JSON.stringify(e)}),r=await t.json();n.notifyPhoneNote.textContent=t.ok?``:r.error??`that did not save`,t.ok&&typeof r.phone==`string`&&(n.notifyPhone.value=r.phone)}catch{n.notifyPhoneNote.textContent=`could not reach nixamp.com`}},Ae=async()=>{try{let e=await fetch(`/api/v1/notify/prefs`);if(!e.ok)return;let t=await e.json();n.notifyEmail.checked=t.wantsEmail!==!1,n.notifySms.checked=t.wantsSms===!0,n.notifyPhone.value=t.phone??``;let r=De()&&Notification.permission===`granted`?await(await navigator.serviceWorker.ready).pushManager.getSubscription()!==null:!1;n.notifyWeb.checked=t.wantsWeb!==!1&&r,n.notifyNote.textContent=r?`Get told when someone you follow goes live.`:`Turn on “On this device” to be told here.`}catch{}};n.notifyWeb.addEventListener(`change`,()=>{(async()=>{if(n.notifyWeb.checked){let e=await Oe();n.notifyWeb.checked=e,await J({wantsWeb:e});return}await ke(),await J({wantsWeb:!1}),n.notifyNote.textContent=`Turn on “On this device” to be told here.`})()}),n.notifyEmail.addEventListener(`change`,()=>{J({wantsEmail:n.notifyEmail.checked})}),n.notifySms.addEventListener(`change`,()=>{(async()=>{if(n.notifySms.checked&&!n.notifyPhone.value.trim()){n.notifyPhoneNote.textContent=`Add a phone number first.`,n.notifySms.checked=!1,n.notifyPhone.focus();return}await J({wantsSms:n.notifySms.checked})})()}),n.notifyPhoneForm.addEventListener(`submit`,e=>{e.preventDefault(),J({phone:n.notifyPhone.value.trim()})});let Y=!1,X=``,Z=e=>{let t=e!==null;n.notifyPanel.hidden=!t,t?(Ae(),we(),Ce()):(n.serversPanel.hidden=!0,n.followingNote.hidden=!0,n.followingList.replaceChildren(),n.recentNote.hidden=!0,n.recentList.replaceChildren()),n.accountForm.hidden=t,n.accountProviders.hidden=t||n.accountProviders.childElementCount===0,n.accountSignOut.hidden=!t,n.accountNote.textContent=t?`Signed in as ${e}.`:Y?`Create an account on nixamp.com.`:`Sign in to nixamp.com to publish and get paid.`,n.accountSubmit.textContent=Y?`Create account`:`Sign in`,n.accountToggle.textContent=Y?`I have one`:`Create one`,n.accountPassword.autocomplete=Y?`new-password`:`current-password`},je=async()=>{let e=[],t=!1;try{let n=await fetch(`/api/v1/auth/providers`);n.ok&&(t=!0,e=(await n.json()).providers??[])}catch{}n.accountProviders.replaceChildren(),n.accountProviders.hidden=e.length===0,n.accountPanel.hidden=!t,n.accountElsewhere.hidden=t;for(let t of e){let e=document.createElement(`a`);e.className=`button`,e.href=`/api/v1/${encodeURIComponent(t.id)}/oauth/start`,e.textContent=`Continue with ${t.name}`,n.accountProviders.append(e)}},Me=async()=>{try{let e=await fetch(`/api/v1/auth/me`),t=await e.json();X=e.ok?t.account?.id??``:``,Z(e.ok?t.account?.email??`you`:null)}catch{X=``,Z(null)}Ne()};function Ne(){if(f===``||X===``)return;let e=f;f=``,n.remoteUrl.value=e,n.remoteForm.requestSubmit()}n.accountToggle.addEventListener(`click`,()=>{Y=!Y,Z(null)}),n.accountForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.accountEmail.value.trim(),r=n.accountPassword.value;(async()=>{n.accountSubmit.disabled=!0;try{let e=await fetch(`/api/v1/auth/${Y?`signup`:`login`}`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({email:t,password:r})}),i=await e.json();if(!e.ok){n.accountNote.textContent=i.error??`that did not work`;return}X=i.account?.id??``,n.accountPassword.value=``,Z(i.account?.email??t),q()}catch{n.accountNote.textContent=`could not reach nixamp.com`}finally{n.accountSubmit.disabled=!1}})()}),n.accountSignOut.addEventListener(`click`,()=>{(async()=>{try{await fetch(`/api/v1/auth/logout`,{method:`POST`})}catch{}X=``,Z(null),q()})()});try{let e=new URL(globalThis.location.href).searchParams.get(`url`)??``;e!==``&&(f=e,n.remoteUrl.value=e,l=`Sign in to watch this stream.`,globalThis.history?.replaceState(null,``,globalThis.location.pathname))}catch{}je(),Me(),q(),n.browse.addEventListener(`click`,()=>{if(!n.directory.hidden){n.directory.hidden=!0;return}_e(),n.directory.scrollIntoView({behavior:`smooth`,block:`nearest`})}),n.disconnect.addEventListener(`click`,()=>{v.close(),d=-1,n.sharePanel.hidden=!0,r=`local`,s=`idle`,c=``,B()});let Pe=async()=>{if(r!==`remote`||v.shareLink===``){n.sharePanel.hidden=!0;return}n.sharePanel.hidden=!1;let e=v.shareLink,t=globalThis.location.origin;n.shareLink.value=e.startsWith(`https://`)?`${t}/?url=${encodeURIComponent(e)}`:e,n.shareNote.textContent=`Anyone with this link can watch. They sign in once, then it opens.`,n.sharePhone.hidden=!0,n.shareSend.hidden=!0;try{let t=await fetch(`/api/directory`);if(!t.ok)return;let r=await t.json(),i=new URL(e).origin,a=(r.streams??[]).find(e=>{try{return new URL(e.url).origin===i}catch{return!1}});if(!a||!r.callIn){n.sharePhone.hidden=!1,n.sharePhone.textContent=`Publish this stream (nixamp serve --announce) to get a phone number and a code for it.`;return}n.sharePhone.hidden=!1,n.sharePhone.innerHTML=``,n.sharePhone.append(document.createTextNode(`To talk about it, call `),Q(r.callIn),document.createTextNode(` and key `),Q(a.code),document.createTextNode(`. That is a room with everyone else watching — not the stream itself.`)),n.shareSend.hidden=!1}catch{}};function Q(e){let t=document.createElement(`b`);return t.textContent=e,t}n.shareCopy.addEventListener(`click`,()=>{n.shareLink.select(),navigator.clipboard?.writeText(n.shareLink.value).then(()=>{n.shareNote.textContent=`Copied. Send it to anybody.`},()=>{n.shareNote.textContent=`Copy it from the box above.`})}),n.shareSend.addEventListener(`submit`,e=>{e.preventDefault();let t=n.shareTo.value.trim();t!==``&&(async()=>{n.shareNote.textContent=`Sending…`;try{let e=await fetch(`/api/v1/invite`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({to:t,stream:v.shareLink})}),r=await e.json();n.shareNote.textContent=e.ok?`Sent to ${r.sent??t}.`:r.error??`that did not send`,e.ok&&(n.shareTo.value=``)}catch{n.shareNote.textContent=`could not send that`}})()}),n.listenHere.addEventListener(`change`,()=>{try{localStorage.setItem(L,n.listenHere.checked?`1`:`0`)}catch{}r===`remote`&&(async()=>{n.listenHere.checked?(await v.send({type:`stop`}),await k(o.index)):(_.stop(),d=-1),B()})()}),document.addEventListener(`keydown`,e=>{let t=e.target;if(!(t&&/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))switch(e.key){case` `:e.preventDefault(),A();return;case`s`:N();return;case`n`:case`ArrowRight`:M(1);return;case`p`:case`ArrowLeft`:M(-1);return;case`ArrowDown`:e.preventDefault(),O(Math.min(b()-1,x()+1));return;case`ArrowUp`:e.preventDefault(),O(Math.max(0,x()-1));return}});let $=null;globalThis.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),$=e,n.install.hidden=!1}),n.install.addEventListener(`click`,()=>{$?.prompt(),$=null,n.install.hidden=!0});try{let e=localStorage.getItem(I);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),_.volume=Number(e));let t=localStorage.getItem(F);t&&(n.remoteUrl.value=t),localStorage.getItem(L)===`0`&&(n.listenHere.checked=!1)}catch{}(async()=>{if(n.remoteUrl.value!==``)return;let e=globalThis.location.origin;if(await j(e)===null)return;let t=await ae(e);t&&t.trackCount!==0&&(n.remoteUrl.value=e,r=`remote`,l=``,v.connect(e),B())})(),B(),requestAnimationFrame(U)}z(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});export{c as t};