nixamp 0.7.34 → 0.7.35

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/channels.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  import { spawn, type ChildProcess } from "node:child_process";
18
18
  import { randomBytes } from "node:crypto";
19
19
  import type { Readable } from "node:stream";
20
+ import { Fragments } from "./fragments.ts";
20
21
 
21
22
  /** Somewhere for a channel's audio to go. A response, in practice. */
22
23
  export interface Listener {
@@ -31,12 +32,25 @@ export interface ChannelInfo {
31
32
  /** The container it is sending, e.g. webm from a browser, flv over RTMP. */
32
33
  format: string;
33
34
  /** How it arrived. */
34
- via: "http" | "rtmp";
35
+ via: "http" | "rtmp" | "pull";
35
36
  startedAt: number;
36
37
  bytes: number;
37
38
  listeners: number;
39
+ /**
40
+ * Whether there is a picture, which decides what a listener is sent and
41
+ * what the response calls it. A channel that says audio/mpeg while sending
42
+ * MP4 plays as nothing at all.
43
+ */
44
+ kind?: "audio" | "video";
45
+ /** For a channel we pull ourselves: where from. Never shown to a listener. */
46
+ source?: string;
38
47
  }
39
48
 
49
+ /** How long to wait before dialling a dropped source again. */
50
+ export const REDIAL = 2000;
51
+ /** How many times in a row a source may fail without ever sending anything. */
52
+ export const GIVE_UP = 5;
53
+
40
54
  /** A name that can sit in a URL and be read back in a list. */
41
55
  export function cleanId(value: unknown, fallback = "main"): string {
42
56
  if (typeof value !== "string") return fallback;
@@ -60,6 +74,12 @@ export class Channel {
60
74
  readonly listeners = new Set<Listener>();
61
75
  private child: ChildProcess | null = null;
62
76
  private closing = false;
77
+ /** Set for a channel that carries pictures, which cannot be joined blind. */
78
+ private fragments: Fragments | null = null;
79
+ /** For a pulled channel: what to run, and how many times it has failed. */
80
+ private redial: (() => void) | null = null;
81
+ private failures = 0;
82
+ private timer: ReturnType<typeof setTimeout> | null = null;
63
83
 
64
84
  constructor(
65
85
  readonly info: ChannelInfo,
@@ -103,6 +123,103 @@ export class Channel {
103
123
  this.options.onStart?.(this.info);
104
124
  }
105
125
 
126
+ /**
127
+ * Fetch a source ourselves, rather than waiting to be sent one.
128
+ *
129
+ * This is what makes a re-stream a channel instead of a track. A track is
130
+ * played by the one player a server has, so a second one is a second thing
131
+ * that server cannot do at the same time; a channel is its own process with
132
+ * its own audience, and a server can carry as many as it can decode. Two
133
+ * channels means two tabs, or two panels of a multiview.
134
+ *
135
+ * It keeps running with nobody listening. Live television does not pause
136
+ * because you looked away, and a room where the picture depends on who is
137
+ * in it is not a room anybody can be invited to.
138
+ */
139
+ pull(source: string, encode: string[], paced = true): void {
140
+ if (this.info.kind === "video") this.fragments = new Fragments();
141
+ const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
142
+ const remote = /^https?:\/\//i.test(source);
143
+
144
+ const dial = (): void => {
145
+ if (this.closing) return;
146
+ const child = spawn(
147
+ command,
148
+ [
149
+ ...prefix,
150
+ "-hide_banner",
151
+ "-loglevel", "error",
152
+ // A dropped source is normal over hours, and a channel that dies
153
+ // the first time a CDN hiccups is not a channel anybody can rely
154
+ // on. ffmpeg redials on its own before we have to.
155
+ ...(remote ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
156
+ // Real time, always. A file read as fast as the disk allows is an
157
+ // hour of film in ninety seconds and a room that cannot be in it
158
+ // together; a live source is already paced and loses nothing.
159
+ ...(paced ? ["-re"] : []),
160
+ "-i", source,
161
+ ...encode,
162
+ "pipe:1",
163
+ ],
164
+ { stdio: ["ignore", "pipe", "pipe"] },
165
+ );
166
+
167
+ let sent = false;
168
+ child.stdout?.on("data", (chunk: Buffer) => {
169
+ sent = true;
170
+ this.info.bytes += chunk.byteLength;
171
+ this.emit(chunk);
172
+ });
173
+ child.stdout?.on("error", () => undefined);
174
+ child.on("error", () => this.dropped(sent));
175
+ child.on("close", () => this.dropped(sent));
176
+ this.child = child;
177
+ };
178
+
179
+ this.redial = dial;
180
+ dial();
181
+ this.options.onStart?.(this.info);
182
+ }
183
+
184
+ /**
185
+ * A source that stopped. Try it again, unless it never worked at all.
186
+ *
187
+ * The difference matters: a channel that ran for six hours and dropped is
188
+ * worth dialling again, and a URL that has never once produced a byte is a
189
+ * mistake somebody made, and retrying it for ever helps nobody.
190
+ */
191
+ private dropped(sent: boolean): void {
192
+ if (this.closing || !this.redial) return;
193
+ this.child = null;
194
+ this.failures = sent ? 0 : this.failures + 1;
195
+ if (this.failures >= GIVE_UP) {
196
+ this.close();
197
+ return;
198
+ }
199
+ const dial = this.redial;
200
+ this.timer = setTimeout(() => {
201
+ this.timer = null;
202
+ dial();
203
+ }, REDIAL);
204
+ // A redial is not a reason to keep the process alive at exit.
205
+ this.timer.unref?.();
206
+ }
207
+
208
+ /**
209
+ * Out to the audience, whole boxes at a time when there are boxes.
210
+ *
211
+ * Video listeners are only ever sent complete boxes, so that a new one can
212
+ * be given the opening boxes and then join at the next fragment and have it
213
+ * make sense.
214
+ */
215
+ private emit(chunk: Buffer): void {
216
+ if (!this.fragments) {
217
+ this.send(chunk);
218
+ return;
219
+ }
220
+ for (const box of this.fragments.push(chunk)) this.send(box);
221
+ }
222
+
106
223
  /** Feed the source. */
107
224
  write(chunk: Buffer): boolean {
108
225
  return this.child?.stdin?.write(chunk) ?? false;
@@ -141,6 +258,16 @@ export class Channel {
141
258
  }
142
259
 
143
260
  listen(listener: Listener): () => void {
261
+ // What the stream is, before any of what it is currently saying. Without
262
+ // this a listener who arrives after the first second gets fragments that
263
+ // reference tracks they were never told about: a blank panel, no error.
264
+ if (this.fragments?.ready) {
265
+ try {
266
+ listener.write(this.fragments.header);
267
+ } catch {
268
+ // Gone before it began; the detach below still tidies up.
269
+ }
270
+ }
144
271
  this.listeners.add(listener);
145
272
  this.info.listeners = this.listeners.size;
146
273
  return () => {
@@ -152,6 +279,9 @@ export class Channel {
152
279
  close(): void {
153
280
  if (this.closing) return;
154
281
  this.closing = true;
282
+ this.redial = null;
283
+ if (this.timer) clearTimeout(this.timer);
284
+ this.timer = null;
155
285
  const child = this.child;
156
286
  this.child = null;
157
287
  try {
@@ -234,6 +364,47 @@ export class Channels {
234
364
  return channel;
235
365
  }
236
366
 
367
+ /**
368
+ * Carry a source of our own: a re-stream, or a film on this disk shown live.
369
+ *
370
+ * Null when that channel is taken, the same as publishing. Everything else
371
+ * about it is the same too, which is the point -- a re-stream stops being a
372
+ * special case and becomes one more thing that is on.
373
+ */
374
+ pull(
375
+ id: string,
376
+ name: string,
377
+ source: string,
378
+ encode: string[],
379
+ kind: "audio" | "video",
380
+ paced = true,
381
+ ): Channel | null {
382
+ if (this.open.has(id)) return null;
383
+ const channel = new Channel(
384
+ {
385
+ id,
386
+ name: name || source,
387
+ format: kind === "video" ? "mp4" : "mp3",
388
+ via: "pull",
389
+ startedAt: Date.now(),
390
+ bytes: 0,
391
+ listeners: 0,
392
+ kind,
393
+ source,
394
+ },
395
+ this.options,
396
+ (gone) => this.open.delete(gone),
397
+ );
398
+ this.open.set(id, channel);
399
+ channel.pull(source, encode, paced);
400
+ return channel;
401
+ }
402
+
403
+ /** What a listener should be told this channel is. */
404
+ contentType(id: string): string {
405
+ return this.open.get(id)?.info.kind === "video" ? "video/mp4" : "audio/mpeg";
406
+ }
407
+
237
408
  /** Attach a listener, or null when nothing is playing on that channel. */
238
409
  listen(id: string, listener: Listener): (() => void) | null {
239
410
  const channel = this.open.get(id);
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Splitting a fragmented MP4 into the pieces a late arrival needs.
3
+ *
4
+ * MP3 can be joined halfway through because every frame says what it is: a
5
+ * player finds the next frame boundary and carries on. Fragmented MP4 cannot.
6
+ * It opens with an `ftyp` and a `moov` that describe the tracks -- how many,
7
+ * which codecs, what timescale -- and everything after that is a `moof` and an
8
+ * `mdat` that mean nothing without them. Hand somebody the middle of that
9
+ * stream and their browser has no idea what it is holding, which is a black
10
+ * panel and no error.
11
+ *
12
+ * So the opening boxes are kept, and a listener who arrives an hour late is
13
+ * given them before the live bytes. Fragments written with `frag_keyframe`
14
+ * each begin at a keyframe, so the picture starts at the first one rather than
15
+ * with a screen of blocks catching up.
16
+ *
17
+ * This also means listeners are only ever written whole boxes. A chunk from a
18
+ * pipe ends wherever the pipe felt like ending it, and half a `moof` is not
19
+ * something to send anybody.
20
+ */
21
+
22
+ /** The header of an MP4 box: four bytes of length, four of name. */
23
+ const HEADER = 8;
24
+ /** A length of 1 means the real one is the eight bytes that follow. */
25
+ const BIG = 16;
26
+
27
+ export interface Box {
28
+ type: string;
29
+ bytes: Buffer;
30
+ }
31
+
32
+ /**
33
+ * The first whole box in a buffer, or null when it has not all arrived.
34
+ *
35
+ * Null is also the answer for anything malformed, because the difference does
36
+ * not matter to a caller who can only wait or give up, and guessing at a
37
+ * broken length walks off the end of the stream.
38
+ */
39
+ export function firstBox(buffer: Buffer): { box: Box; rest: Buffer } | null {
40
+ if (buffer.length < HEADER) return null;
41
+ const stated = buffer.readUInt32BE(0);
42
+ const type = buffer.toString("latin1", 4, HEADER);
43
+ // A name is four printable characters. Anything else means we are not
44
+ // looking at a box header, and no length read from here can be trusted.
45
+ if (!/^[\x20-\x7e]{4}$/.test(type)) return null;
46
+
47
+ let size = stated;
48
+ let header = HEADER;
49
+ if (stated === 1) {
50
+ if (buffer.length < BIG) return null;
51
+ const large = buffer.readBigUInt64BE(HEADER);
52
+ if (large > BigInt(Number.MAX_SAFE_INTEGER)) return null;
53
+ size = Number(large);
54
+ header = BIG;
55
+ }
56
+ // Zero means "to the end of the file", which a live stream does not have.
57
+ if (size < header) return null;
58
+ if (buffer.length < size) return null;
59
+ return { box: { type, bytes: buffer.subarray(0, size) }, rest: buffer.subarray(size) };
60
+ }
61
+
62
+ /** The boxes that describe the stream rather than carry it. */
63
+ export function isOpening(type: string): boolean {
64
+ return type === "ftyp" || type === "moov";
65
+ }
66
+
67
+ /**
68
+ * A fragmented MP4 arriving in pieces, handed back a box at a time.
69
+ *
70
+ * Keeps the opening boxes so they can be replayed to whoever turns up later.
71
+ * If the bytes turn out not to be an MP4 at all -- a source that failed, a
72
+ * format nobody expected -- it stops trying to parse and passes them through,
73
+ * on the grounds that a stream somebody might be able to play beats a stream
74
+ * nobody can.
75
+ */
76
+ export class Fragments {
77
+ private held: Buffer = Buffer.alloc(0);
78
+ private opening: Buffer[] = [];
79
+ private confused = false;
80
+
81
+ /** The `ftyp` and `moov` seen so far, ready to send to a new listener. */
82
+ get header(): Buffer {
83
+ return this.opening.length === 0 ? Buffer.alloc(0) : Buffer.concat(this.opening);
84
+ }
85
+
86
+ /** Whether enough has arrived to describe the stream to somebody new. */
87
+ get ready(): boolean {
88
+ return this.opening.length > 0;
89
+ }
90
+
91
+ /** Feed bytes in; get whole boxes out, in order. */
92
+ push(chunk: Buffer): Buffer[] {
93
+ if (this.confused) return [chunk];
94
+ this.held = this.held.length === 0 ? chunk : Buffer.concat([this.held, chunk]);
95
+
96
+ const out: Buffer[] = [];
97
+ for (;;) {
98
+ const next = firstBox(this.held);
99
+ if (!next) break;
100
+ this.held = next.rest;
101
+ if (isOpening(next.box.type)) this.opening.push(next.box.bytes);
102
+ out.push(next.box.bytes);
103
+ }
104
+
105
+ // Nothing parses and the buffer keeps growing: this is not an MP4. Let it
106
+ // through rather than swallowing a stream into memory for ever.
107
+ if (this.opening.length === 0 && this.held.length > 4 * 1024 * 1024) {
108
+ this.confused = true;
109
+ const everything = this.held;
110
+ this.held = Buffer.alloc(0);
111
+ return [everything];
112
+ }
113
+ return out;
114
+ }
115
+ }
package/src/server.ts CHANGED
@@ -26,7 +26,7 @@ import {
26
26
  redact,
27
27
  } from "./broadcast.ts";
28
28
  import { Ingest, normaliseFormat } from "./ingest.ts";
29
- import { Channels, cleanId } from "./channels.ts";
29
+ import { Channels, cleanId, generatedId } from "./channels.ts";
30
30
  import { RtmpListeners } from "./rtmp-in.ts";
31
31
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
32
32
  import { anonymousHandle, Handles } from "./handles.ts";
@@ -2212,17 +2212,32 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2212
2212
  if (action === undefined && request.method === "GET") {
2213
2213
  // Listening. The response is the fan-out target: whatever ffmpeg
2214
2214
  // produces for this channel is written to it until one end goes away.
2215
- const detach = channels.listen(id, response);
2216
- if (detach === null) {
2215
+ if (!channels.has(id)) {
2217
2216
  json(response, 404, { error: "nothing is playing on that channel" });
2218
2217
  return;
2219
2218
  }
2220
2219
  watch(request, response, "stream", id);
2220
+ // Headers first, and then the listener.
2221
+ //
2222
+ // Attaching first was fine while a channel only ever wrote future
2223
+ // bytes. A video channel writes the opening boxes to a new listener
2224
+ // the moment it joins, and those went out before this response had
2225
+ // any headers at all -- so it committed as a bare 200 with no
2226
+ // content-type, ended immediately, and the picture was one kilobyte
2227
+ // long. Whether it happened depended on whether ffmpeg had produced
2228
+ // its header yet, which is why it looked intermittent.
2221
2229
  response.writeHead(200, {
2222
2230
  ...CORS,
2223
- "content-type": "audio/mpeg",
2231
+ // Asked of the channel rather than assumed: a channel carrying
2232
+ // pictures that calls itself audio/mpeg plays as nothing at all.
2233
+ "content-type": channels.contentType(id),
2224
2234
  "cache-control": "no-store",
2225
2235
  });
2236
+ const detach = channels.listen(id, response);
2237
+ if (detach === null) {
2238
+ response.end();
2239
+ return;
2240
+ }
2226
2241
  const leave = (): void => detach();
2227
2242
  request.on("close", leave);
2228
2243
  response.on("close", leave);
@@ -2242,6 +2257,68 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2242
2257
  return;
2243
2258
  }
2244
2259
 
2260
+ /**
2261
+ * Carry a source of our own, rather than waiting to be sent one.
2262
+ *
2263
+ * A re-stream used to be added to the playlist, where it became one
2264
+ * more track -- and a server plays one track at a time, so the second
2265
+ * channel you added sat there saying "stopped". Two channels are two
2266
+ * processes with two audiences and two addresses, which is what lets
2267
+ * one person watch the baseball while another watches the news, in two
2268
+ * tabs or in two panels of the same multiview.
2269
+ */
2270
+ if (action === "pull") {
2271
+ let source = "";
2272
+ let called = "";
2273
+ try {
2274
+ const body = JSON.parse(await readBody(request)) as {
2275
+ source?: unknown; name?: unknown; at?: unknown;
2276
+ };
2277
+ called = String(body.name ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 80);
2278
+ // A track number rather than a path: it is the server's own library
2279
+ // either way, and a number cannot name a file outside it.
2280
+ if (typeof body.at === "number" && Number.isInteger(body.at) && body.at >= 0) {
2281
+ source = engine.trackPath(body.at) ?? "";
2282
+ if (source === "") {
2283
+ json(response, 404, { error: "no track there" });
2284
+ return;
2285
+ }
2286
+ } else {
2287
+ source = String(body.source ?? "").trim();
2288
+ }
2289
+ } catch {
2290
+ json(response, 400, { error: "bad JSON" });
2291
+ return;
2292
+ }
2293
+ if (source === "") {
2294
+ json(response, 400, { error: "give a URL to carry, or a track to show" });
2295
+ return;
2296
+ }
2297
+
2298
+ const wanted = cleanId(rawId, generatedId());
2299
+ if (channels.has(wanted)) {
2300
+ json(response, 409, { error: "that channel is already on" });
2301
+ return;
2302
+ }
2303
+
2304
+ const probe = options.ffprobe ?? ["ffprobe"];
2305
+ const codecs = await codecsOf({ ffmpeg: [], ffprobe: probe, play: null }, source);
2306
+ const kind = codecs.video === "" ? "audio" : "video";
2307
+ const encode = kind === "video"
2308
+ ? videoArgs(codecs)
2309
+ // No picture in it, so none is invented: MP3 is the thing every
2310
+ // browser plays and the thing a listener can join halfway through.
2311
+ : ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
2312
+
2313
+ const channel = channels.pull(wanted, called, source, encode, kind);
2314
+ if (!channel) {
2315
+ json(response, 409, { error: "that channel is already on" });
2316
+ return;
2317
+ }
2318
+ json(response, 200, { ok: true, channel: channel.info });
2319
+ return;
2320
+ }
2321
+
2245
2322
  const format = normaliseFormat(url.searchParams.get("format") ?? request.headers["content-type"]);
2246
2323
  if (format === null) {
2247
2324
  json(response, 415, { error: "give a container ffmpeg knows: webm, ogg, mp4, mp3, wav" });
@@ -1 +1 @@
1
- import{t as e}from"./index-DFWY01QV.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-C_j8Onbv.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
+ (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-DL_mIqWp.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-UN1L-F7w.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 ee(e){return e.filter(e=>_(e.name,e.type)).sort((e,t)=>v(y(e),y(t))).map(e=>({title:n(e.name),artist:``,album:b(y(e)),duration:0,url:URL.createObjectURL(e),video:i(e.name,e.type),objectUrl:!0}))}function y(e){return e.webkitRelativePath||e.name}function b(e){let t=e.split(`/`);return t.length>1?t[t.length-2]:``}function te(e){for(let t of e)t.objectUrl&&URL.revokeObjectURL(t.url)}var x=2048;function S(e,t){return e||t===`hls`||t===`mpegts`}var ne=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(C(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=x,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=S(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 C(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 re(){return{revision:0,tracks:[],trackCount:0,index:0,playing:!1,position:0,bars:[],levels:[0,0],silent:!0,note:``,root:``}}function ie(e,t){return{...t,tracks:t.tracks??e.tracks}}function w(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 T(e,t,n=``){let r=`${e===``?``:w(e)}${t.startsWith(`/`)?t:`/${t}`}`;return n?`${r}${r.includes(`?`)?`&`:`?`}k=${encodeURIComponent(n)}`:r}function ae(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=/^\/(?:admin|view|a|v)\/([^/]+)\/?$/.exec(n.pathname),i=r?.[1]??n.searchParams.get(`k`)??``;return r&&(n.pathname=`/`),n.searchParams.delete(`k`),{base:w(`${n.origin}${n.pathname}`),key:decodeURIComponent(i)}}function E(e,t,n=0,r=``){return T(e,n>0?`/api/media/${t}?kbps=${Math.round(n)}`:`/api/media/${t}`,r)}function D(e){if(typeof e!=`object`||!e)return null;let t=e,n=re(),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}:{},...typeof t.folder==`string`&&t.folder!==``?{folder:t.folder}:{},...t.remote===!0?{remote:!0}:{}}}):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 oe=class{handlers;source=null;base=``;key=``;shape=`/admin/`;lastRevision=-1;constructor(e){this.handlers=e}get address(){return this.base}url(e){return T(this.base,e,this.key)}get shareLink(){return this.base===``?``:this.key===``?this.base:`${this.base}${this.shape}${this.key}`}get connected(){return this.source!==null}connect(e){let{base:t,key:n}=ae(e);this.close(),this.base=t,this.key=n,this.shape=/\/(?:view|v)\/[^/]+\/?$/.test(e.trim())?`/view/`:`/admin/`,this.lastRevision=-1,this.handlers.onStatus(`connecting`);let r=new EventSource(T(t,`/api/events`,n));this.source=r,r.onopen=()=>this.handlers.onStatus(`live`),r.onmessage=e=>{let t=D(O(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(T(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=D(await t.json());n&&this.handlers.onSnapshot(n)}media(e,t=0){return E(this.base,e,t,this.key)}close(){this.source?.close(),this.source=null}};function O(e){try{return JSON.parse(e)}catch{return null}}async function se(e,t,n=``){try{let r=await fetch(T(e,`/api/state`,n),{signal:t});return r.ok?D(await r.json()):null}catch{return null}}function ce(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 le(e,t=``,n){let r;try{r=await fetch(T(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 with /admin/ or /view/ in it — 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 k(e,t,n=``){try{let r=await fetch(T(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 ue(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 A=.14,j=.02;function de(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 fe(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 pe(e,t,n=A){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function M(e,t,n=j){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function me(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 N=`nixamp.remote`,he=`nixamp.volume`,ge=`nixamp.listenHere`,_e=!1;function P(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function F(){let n={status:P(`status`),source:P(`source`),install:P(`install`),video:P(`video`),audio:P(`audio`),title:P(`title-line`),album:P(`album-line`),elapsed:P(`elapsed`),total:P(`total`),seek:P(`seek`),fullscreen:P(`fullscreen`),canvas:P(`spectrum`),glyphs:P(`glyphs`),levels:P(`levels`),playlist:P(`playlist`),crumbs:P(`crumbs`),filter:P(`filter`),playlistTitle:P(`playlist-panel`),note:P(`note`),files:P(`files`),folder:P(`folder`),remoteUrl:P(`remote-url`),remoteForm:P(`remote-form`),remoteState:P(`remote-state`),disconnect:P(`disconnect`),browse:P(`browse`),accountForm:P(`account-form`),accountEmail:P(`account-email`),accountPassword:P(`account-password`),accountSubmit:P(`account-submit`),accountToggle:P(`account-toggle`),accountProviders:P(`account-providers`),accountPanel:P(`account-panel`),accountElsewhere:P(`account-elsewhere`),accountSignOut:P(`account-signout`),accountNote:P(`account-note`),adminPanel:P(`admin-panel`),adminNote:P(`admin-note`),adminSaid:P(`admin-said`),adminConnections:P(`admin-connections`),publishPanel:P(`publish-panel`),publishNote:P(`publish-note`),publishList:P(`publish-list`),adminRestream:P(`admin-restream`),adminReplace:P(`admin-replace`),adminSource:P(`admin-source`),adminName:P(`admin-name`),adminAdd:P(`admin-add`),homeNote:P(`home-note`),loadHome:P(`load-home`),directory:P(`directory`),recentNote:P(`recent-note`),recentList:P(`recent-list`),followingNote:P(`following-note`),followingList:P(`following-list`),serversPanel:P(`servers-panel`),serversNote:P(`servers-note`),serversList:P(`servers-list`),notifyPanel:P(`notify-panel`),notifyNote:P(`notify-note`),notifyWeb:P(`notify-web`),notifyEmail:P(`notify-email`),notifySms:P(`notify-sms`),notifyPhone:P(`notify-phone`),notifyPhoneForm:P(`notify-phone-form`),notifyPhoneNote:P(`notify-phone-note`),directoryNote:P(`directory-note`),directoryList:P(`directory-list`),onairPanel:P(`onair-panel`),onairNote:P(`onair-note`),onairList:P(`onair-list`),sharePanel:P(`share-panel`),shareNote:P(`share-note`),shareLink:P(`share-link`),shareCopy:P(`share-copy`),sharePhone:P(`share-phone`),shareSend:P(`share-send`),liveControls:P(`live-controls`),goLive:P(`go-live`),stopLive:P(`stop-live`),shareTo:P(`share-to`),listenOnly:P(`listen-only`),listenHere:P(`listen-here`),volume:P(`volume`),prev:P(`prev`),playPause:P(`play-pause`),stop:P(`stop`),next:P(`next`)},r=`local`,i=[],a=0,o=re(),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 ne({audio:n.audio,video:n.video},{onTime:(e,t)=>{let n=i[a];r===`local`&&n&&t>0&&n.duration!==t&&(n.duration=t),I()},onEnded:()=>A(1),onState:()=>I(),onError:e=>{l=e,I(),Je()}}),v=new oe({onSnapshot:e=>{o=ie(o,e),g()&&(p=e.bars.length>0?e.bars:p,m=M(m,p)),I()},onStatus:(e,t)=>{s=e,c=t??``,I()}}),y=()=>r===`remote`?o.tracks.length:i.length,b=()=>r===`remote`?g()||d<0?o.index:Math.min(d,Math.max(0,o.tracks.length-1)):a,x=()=>{let e=r===`remote`?o.tracks[b()]:i[b()];return e?t(e):`Nothing loaded.`},S=()=>(r===`remote`?o.tracks[b()]:i[b()])?.album||`—`,C=()=>g()?o.tracks[b()]?.duration??0:_.duration,w=()=>g()?o.position:_.position,T=()=>g()?o.playing:_.playing;async function E(e){if(r===`remote`){if(g()){await v.send({type:`play`,index:e});return}await D(e);return}let t=i[e];t&&(a=e,await _.load(t,!0),B(t.video),we(),I())}async function D(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),B(t.video===!0),we())}async function O(){if(g()){await v.send({type:`toggle`});return}y()!==0&&(_.playing?_.pause():_.position>0?await _.play():await E(b()),I())}async function A(e){let t=y();if(t!==0){if(g()){await v.send({type:e>0?`next`:`prev`});return}await E((b()+e+t)%t)}}async function j(){if(g()){await v.send({type:`stop`});return}_.stop(),p=Array(24).fill(0),m=[...p],I()}let F=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))];function I(){let t=y(),a=T();n.status.textContent=a?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=String(a),n.title.textContent=x(),n.album.textContent=S();let d=w(),f=C();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===``,z(),n.glyphs.textContent=p.map(F).join(``);let[h,ee]=g()?o.levels:_.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(h*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(ee*6)).padEnd(6,`·`)}`}let L=``,ve=-1,R=``;function ye(e){if(n.crumbs.hidden=!e,!e)return;let t=R===``?[]:R.split(`/`),r=(e,t,n)=>{if(n){let t=document.createElement(`span`);return t.className=`here`,t.textContent=e,t}let r=document.createElement(`button`);return r.type=`button`,r.textContent=e,r.addEventListener(`click`,()=>{R=t,L=``,z()}),r},i=[r(`All files`,``,t.length===0)],a=``;t.forEach((e,n)=>{a=a===``?e:`${a}/${e}`;let o=document.createElement(`span`);o.textContent=`/`,i.push(o,r(e,a,n===t.length-1))}),n.crumbs.replaceChildren(...i)}function be(e,t){let n=document.createElement(`li`);n.className=`folder`;let r=document.createElement(`span`);r.className=`name`,r.textContent=`${e}/`;let i=document.createElement(`span`);return i.className=`count`,i.textContent=`${t} file${t===1?``:`s`}`,n.append(r,i),n.addEventListener(`click`,()=>{R=R===``?e:`${R}/${e}`,L=``,z()}),n}function z(){let a=r===`remote`?o.tracks.map(e=>({name:t(e),seconds:e.duration,group:e.group??``,folder:e.folder??``,remote:e.remote===!0})):i.map(e=>({name:t(e),seconds:e.duration,group:``,folder:``,remote:!1})),s=n.filter.value.trim().toLowerCase(),c=a.map((e,t)=>({...e,index:t})).filter(e=>!e.remote).filter(e=>s===``||`${e.folder}/${e.name}`.toLowerCase().includes(s)),l=e=>s!==``||R===``||e===R||e.startsWith(`${R}/`),u=e=>s!==``||e===R,d=e=>{let t=R===``?e:e.slice(R.length+1),n=t.indexOf(`/`);return n===-1?t:t.slice(0,n)},f=new Map;for(let e of c){if(!l(e.folder)||u(e.folder))continue;let t=d(e.folder);t!==``&&f.set(t,(f.get(t)??0)+1)}let p=c.filter(e=>u(e.folder)&&l(e.folder)),m=`${r}:${R}:${s}:${[...f].join(`,`)}:${p.map(e=>`${e.index}@${e.name}@${e.seconds}@${e.group}`).join(`|`)}`;if(m!==L){L=m,ye(s===``&&([...f.keys()].length>0||R!==``));let t=[];for(let[e,n]of[...f].sort((e,t)=>e[0].localeCompare(t[0],void 0,{numeric:!0})))t.push(be(e,n));let r=``,i=p.some(e=>e.group!==``);for(let n of p){n.group!==r&&(i||n.group!==``)&&(r=n.group,t.push(xe(n.group)));let a=document.createElement(`li`);a.className=`row`,a.dataset.index=String(n.index);let o=document.createElement(`span`);o.className=`n`,o.textContent=String(n.index+1).padStart(2,` `);let s=document.createElement(`span`);s.className=`name`,s.textContent=n.name;let c=document.createElement(`span`);c.className=`time`,c.textContent=n.seconds>0?e(n.seconds):`--:--`,a.append(o,s,c),t.push(a)}n.playlist.replaceChildren(...t)}let h=b(),g=T(),_;for(let e of Array.from(n.playlist.children)){let t=e,n=Number(t.dataset.index),r=Number.isInteger(n)&&n===h;t.classList.toggle(`selected`,r),t.classList.toggle(`playing`,r&&g),r&&(_=t)}h!==ve&&(ve=h,_?.scrollIntoView({block:`nearest`}))}function xe(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(),Se(e)}),t.append(n)}return t}async function Se(e){try{let t=await fetch(v.url(`/api/source/remove`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({group:e})}),n=await t.json();U(t.ok?`Removed ${n.removed??0} tracks from ${e}.`:n.error??`that did not work`)}catch{U(`could not reach the server`)}}function Ce(){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=M(m,p);else{let e=_.read();e.length>0&&(h.length!==25&&(h=de(24,e.length)),p=pe(p,fe(e,h)),m=M(m,p))}if(s){let e=getComputedStyle(document.documentElement);me(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(T()){n.glyphs.textContent=p.map(F).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(w());let i=C();!u&&i>0&&(n.seek.value=String(Math.round(w()/i*1e3)))}requestAnimationFrame(Ce)}function B(e){n.video.hidden=!e,n.fullscreen.hidden=!e}function we(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:x(),album:S(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void O()),navigator.mediaSession.setActionHandler(`pause`,()=>void O()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void A(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void A(-1)))}n.filter.addEventListener(`input`,()=>{L=``,z()}),n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&E(n)}),n.fullscreen.addEventListener(`click`,()=>{let e=n.video;if(document.fullscreenElement){document.exitFullscreen().catch(()=>{});return}if(typeof e.requestFullscreen==`function`){e.requestFullscreen().catch(()=>{e.webkitEnterFullscreen?.()});return}e.webkitEnterFullscreen?.()}),n.prev.addEventListener(`click`,()=>void A(-1)),n.next.addEventListener(`click`,()=>void A(1)),n.stop.addEventListener(`click`,()=>void j()),n.playPause.addEventListener(`click`,()=>void O()),n.seek.addEventListener(`input`,()=>{u=!0}),n.seek.addEventListener(`change`,()=>{let e=C();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(he,String(e))}catch{}});let Te=e=>{e.addEventListener(`change`,()=>{let t=ee(Array.from(e.files??[]));if(t.length===0){l=`Nothing playable in that selection.`,I();return}te(i),i=t,a=0,r=`local`,v.close(),l=``,E(0)})};Te(n.files),Te(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.remoteUrl.value,{base:i,key:a}=ae(t);if(i===``){l=`That is not an address.`,I();return}(async()=>{s=`connecting`,I();let e=ue(i);if(e){s=`error`,c=e,l=e,r=`local`,I();return}if(await k(i,void 0,a)===null){s=`error`;let e=ce(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`,I();return}let n=await le(i,a);if(n){s=`error`,c=n,l=n,r=`local`,I();return}r=`remote`,l=``;try{localStorage.setItem(N,t.trim())}catch{}v.connect(t),Q(),Z(!0),W(),Y(),I()})()});let V=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??[],Fe(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);if(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&&q&&t.ownerId!==q&&e.append(Re(t.ownerId,t.name)),t.ownerId&&q&&t.ownerId===q){let r=document.createElement(`button`);r.type=`button`,r.className=`ghost`,r.textContent=`Take off the list`,r.addEventListener(`click`,e=>{e.stopPropagation(),r.disabled=!0,(async()=>{try{let e=await fetch(`/api/directory?id=${encodeURIComponent(t.id)}`,{method:`DELETE`}),r=await e.json().catch(()=>({}));n.directoryNote.textContent=e.ok?`${t.name} is off the list.`:r.error??`that did not work`}catch{n.directoryNote.textContent=`could not reach the directory`}finally{await V()}})()}),e.append(r)}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),V()}let H=null,Ee=e=>e===`cgnat`?`mobile or tailscale`:e===`private`?`your network`:e===`local`?`this machine`:e===`public`?`the internet`:e,De=e=>e===`events`?`watching the panel`:e===`page`?`opened the page`:e===`media`?`playing a track`:e===`stream`?`listening live`:e,Oe=``,ke=``,Ae=e=>{let t=e.map(e=>`${e.address}|${e.kind}|${e.track}|${Math.round(e.bytes/4096)}|${e.endedAt}`).join(`~`);if(t===Oe)return;Oe=t,n.adminConnections.replaceChildren();let r=document.createElement(`tr`);for(let e of[`Where`,`Network`,`Kind`,`Client`,`Track`,`Sent`]){let t=document.createElement(`th`);t.textContent=e,r.append(t)}n.adminConnections.append(r);for(let t of e.slice(0,40)){let e=document.createElement(`tr`);t.endedAt!==null&&(e.className=`ended`);let r=[[t.address,``],[Ee(t.network),`network-${t.network}`],[De(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)}};function U(e){n.adminSaid.textContent=e,n.adminSaid.hidden=e===``}let je=async()=>{try{let e=await fetch(v.url(`/api/connections`));if(!e.ok)return;let t=await e.json(),r=(t.connections??[]).filter(e=>e.endedAt===null&&e.kind!==`media`&&e.kind!==`stream`).length,i=t.active??0;n.adminNote.textContent=r===0?`${i} listening now.`:`${i} listening now, and ${r} with the page open.`,Ae(t.connections??[]),Me(t.publish??[],(t.channels??[]).map(e=>e.id)),Qe(t.home??``,t.root??``),Q()}catch{n.adminNote.textContent=`lost touch with the server`}};function Me(e,t){n.publishPanel.hidden=e.length===0;let r=`${e.map(e=>`${e.id}=${e.url}`).join(`~`)}::${t.join(`,`)}`;if(r===ke)return;if(ke=r,e.length===0){n.publishList.replaceChildren();return}let i=e.length-t.length;n.publishNote.textContent=`Point OBS, Larix or ffmpeg at one of these. One publisher per URL — ${e.length} at once, ${i} free right now.`,n.publishList.replaceChildren(...e.map(e=>{let n=t.includes(e.id),r=document.createElement(`li`);n&&(r.className=`in-use`);let i=document.createElement(`span`);i.className=`slot`,i.textContent=n?`${e.id} · live`:e.id;let a=document.createElement(`input`);a.type=`text`,a.readOnly=!0,a.value=e.url,a.setAttribute(`aria-label`,`RTMP URL for ${e.id}`);let o=document.createElement(`button`);return o.type=`button`,o.className=`ghost`,o.textContent=`Copy`,o.addEventListener(`click`,()=>{a.select(),navigator.clipboard?.writeText(e.url).catch(()=>{})}),r.append(i,a,o),r}))}let W=async()=>{if(r!==`remote`){n.adminPanel.hidden=!0,n.publishPanel.hidden=!0,H&&clearInterval(H),H=null;return}let e=!1,t=null,i=!1;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,i=r.claimed===!0}}catch{e=!1}if(n.adminPanel.hidden=!e,H&&clearInterval(H),H=null,Y(),n.listenOnly.hidden=e,!e){n.listenOnly.textContent=i?`This is a listen-only link: you can hear this server but not change what it plays. Use its control link — the first one it printed — or sign in as its owner.`:`This is a listen-only link: you can hear this server but not change what it plays. Use its control link, the first one it printed.`;return}n.adminNote.textContent=t===`owner`?`You own this server.`:`You hold this server's control link.`,je(),H=setInterval(()=>void je(),2e3)};function Ne(e,t,r){let i=(t||e).toLowerCase().replace(/[^a-z0-9_-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,40)||`s${Math.random().toString(16).slice(2,8)}`;U(`Starting ${t||e}…`),(async()=>{try{let a=await fetch(v.url(`/api/channels/${encodeURIComponent(i)}/pull`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({...r===void 0?{source:e}:{at:r},...t?{name:t}:{}})}),o=await a.json();if(!a.ok){U(o.error??`that did not work`);return}U(`${o.channel?.name||t||e} is on the air.`),n.adminSource.value=``,n.adminName.value=``,Y(),Q()}catch{U(`could not reach the server`)}})()}n.adminRestream.addEventListener(`submit`,e=>{e.preventDefault();let t=n.adminSource.value.trim();t&&Ne(t,n.adminName.value.trim())}),n.adminAdd.addEventListener(`click`,()=>{let e=n.adminSource.value.trim();if(!e)return;U(`Reading ${e}…`);let t=n.adminReplace.checked,r=n.adminName.value.trim();(async()=>{try{let i=await fetch(v.url(`/api/source`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({source:e,...r?{name:r}:{},...t?{replace:!0}:{}})}),a=await i.json();U(i.ok?t?`Now serving ${e}.`:a.added===0?`Everything there was already in the playlist.`:`Added ${a.added??0} tracks from ${e}.`:a.error??`that did not work`),i.ok&&(n.adminSource.value=``,n.adminName.value=``,Y(),Q())}catch{U(`could not reach the server`)}})()});let Pe=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`},Fe=e=>{n.recentList.replaceChildren();let t=q?e.filter(e=>e.ownerId&&e.ownerId!==q):[];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 ${Pe(e.endedAt)}`:`ended ${Pe(e.endedAt)}`,r.append(i,a),t.append(r,Re(e.ownerId,e.name)),n.recentList.append(t)}},Ie=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}/admin/${e.key}`:e.url,n.remoteForm.requestSubmit()}),k(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 Ie()}catch{s.disabled=!1}})()}),t.append(r,o,s),n.serversList.append(t)}}catch{n.serversPanel.hidden=!0}},Le=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}},Re=(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),Le())}catch{}finally{n.disabled=!1}})()}),n},ze=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},Be=()=>`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window,Ve=async()=>{if(!Be())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:ze(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}},He=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{}},G=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`}},Ue=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=Be()&&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 Ve();n.notifyWeb.checked=e,await G({wantsWeb:e});return}await He(),await G({wantsWeb:!1}),n.notifyNote.textContent=`Turn on “On this device” to be told here.`})()}),n.notifyEmail.addEventListener(`change`,()=>{G({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 G({wantsSms:n.notifySms.checked})})()}),n.notifyPhoneForm.addEventListener(`submit`,e=>{e.preventDefault(),G({phone:n.notifyPhone.value.trim()})});let K=!1,q=``,J=e=>{let t=e!==null;n.notifyPanel.hidden=!t,t?(Ue(),Le(),Ie()):(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}.`:K?`Create an account on nixamp.com.`:`Sign in to nixamp.com to publish and get paid.`,n.accountSubmit.textContent=K?`Create account`:`Sign in`,n.accountToggle.textContent=K?`I have one`:`Create one`,n.accountPassword.autocomplete=K?`new-password`:`current-password`},We=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)}},Ge=async()=>{try{let e=await fetch(`/api/v1/auth/me`),t=await e.json();q=e.ok?t.account?.id??``:``,J(e.ok?t.account?.email??`you`:null)}catch{q=``,J(null)}Ke()};function Ke(){if(f===``)return;let e=f;f=``,n.remoteUrl.value=e,n.remoteForm.requestSubmit()}n.accountToggle.addEventListener(`click`,()=>{K=!K,J(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/${K?`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}q=i.account?.id??``,n.accountPassword.value=``,J(i.account?.email??t),W(),Ke()}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{}q=``,J(null),v.close(),d=-1,r=`local`,s=`idle`,c=``,n.remoteUrl.value=``,n.sharePanel.hidden=!0,n.publishPanel.hidden=!0,n.adminPanel.hidden=!0,n.onairPanel.hidden=!0,n.listenOnly.hidden=!0,Z(!1);try{localStorage.removeItem(N)}catch{}l=`Signed out, and disconnected from the server.`,W(),I()})()});try{let e=new URL(globalThis.location.href).searchParams.get(`url`)??``;e!==``&&(f=e,n.remoteUrl.value=e,l=`Opening the stream you were sent…`,globalThis.history?.replaceState(null,``,globalThis.location.pathname))}catch{}We(),Ge(),W(),n.browse.addEventListener(`click`,()=>{if(!n.directory.hidden){n.directory.hidden=!0;return}V(),n.directory.scrollIntoView({behavior:`smooth`,block:`nearest`})}),n.disconnect.addEventListener(`click`,()=>{v.close(),n.listenOnly.hidden=!0,d=-1,n.sharePanel.hidden=!0,n.publishPanel.hidden=!0,n.adminPanel.hidden=!0,n.onairPanel.hidden=!0,Z(!1),r=`local`,s=`idle`,c=``,I()});async function Y(){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,n.liveControls.hidden=!0;let i=``;try{let e=await fetch(`/api/directory`);e.ok&&(i=(await e.json()).callIn??``)}catch{}let a=null;try{let r=await fetch(v.url(`/api/live/state`));r.ok&&(a=await r.json()),a?.url&&(e=a.url,n.shareLink.value=e.startsWith(`https://`)?`${t}/?url=${encodeURIComponent(e)}`:e)}catch{}if(a){if(n.liveControls.hidden=n.adminPanel.hidden||!a.possible,n.goLive.hidden=a.live,n.stopLive.hidden=!a.live,n.sharePhone.hidden=!1,!a.live){n.sharePhone.textContent=a.possible?`Not listed, so nobody can find this in the directory. Go live to list it, with a phone number and a code anyone can call.`:`This machine has no address the world can reach, so it cannot be listed.`;return}if(!i){n.sharePhone.textContent=`Listed. The code for the phone line is ${a.code}.`,n.shareSend.hidden=!1;return}n.sharePhone.replaceChildren(document.createTextNode(`To talk about it, call `),et(i),document.createTextNode(` and key `),et(a.code),document.createTextNode(`. That is a room with everyone else watching — not the stream itself.`)),n.shareSend.hidden=!1}}let qe=``,X=null,Z=e=>{X&&clearInterval(X),X=null,e&&(X=setInterval(()=>void Q(),6e3))};async function Q(){if(r!==`remote`){n.onairPanel.hidden=!0;return}let e;try{let t=await fetch(v.url(`/api/streams`));if(!t.ok){n.onairPanel.hidden=!0;return}e=await t.json()}catch{n.onairPanel.hidden=!0;return}n.onairPanel.hidden=!1;let t=JSON.stringify(e);if(t===qe)return;qe=t;let i=e.restreams??[],a=e.channels.length+i.length;n.onairNote.textContent=a===0?`One stream, from this server's own files.`:`${a+1} streams: this server's own files, and ${a} more on it.`;let o=[],s=e.server.playing,c=!n.adminPanel.hidden;o.push($({title:e.server.name,detail:[s?`playing ${e.server.nowPlaying}`:e.server.nowPlaying?`stopped on ${e.server.nowPlaying}`:`nothing loaded`,`${e.server.tracks} track${e.server.tracks===1?``:`s`}`,e.server.code?`☎ ${e.server.code}`:`not listed`].join(` · `),playLabel:s?`Join live`:c?`Start the stream`:`Nothing playing`,onPlay:()=>{if(s){Xe(e.server.nowPlaying);return}c&&Ye()},link:e.server.live?e.server.url:``}));for(let e of i)o.push($({title:e.name,detail:e.tracks===1?`re-streamed from the web`:`re-streamed from the web · ${e.tracks} tracks`,onPlay:()=>{E(e.at)},link:``}));for(let t of e.channels){let e=t.kind!==`audio`;o.push($({title:t.name,detail:t.via===`pull`?`on the air · ${t.listeners} watching`:`live over ${t.via} · ${t.listeners} listening`,onPlay:()=>{_.load({title:t.name,artist:``,album:``,duration:0,url:v.url(`/api/channels/${encodeURIComponent(t.id)}`),video:e,objectUrl:!1},!0),B(e)},link:v.url(`/api/channels/${encodeURIComponent(t.id)}`),onStop:n.adminPanel.hidden?void 0:()=>{(async()=>{await fetch(v.url(`/api/channels/${encodeURIComponent(t.id)}`),{method:`DELETE`}).catch(()=>void 0),Q()})()}}))}n.onairList.replaceChildren(...o)}async function Je(){if(r===`remote`)try{if((await fetch(v.media(b(),0),{method:`GET`,headers:{range:`bytes=0-1`}})).status!==402)return;l=q===``?`This stream is busy enough to be charging for. Sign in to nixamp.com to pay for a pass.`:`This stream is charging for a pass. Follow the payment prompt to keep listening.`,q===``&&n.accountPanel.scrollIntoView({behavior:`smooth`,block:`center`}),I()}catch{}}async function Ye(){U(`Starting the stream on the server…`);try{await v.send({type:`play`,index:Math.max(0,b())})}catch{U(`could not reach the server`);return}await Xe(o.tracks[b()]?.title??``),U(`Playing to the room. Anybody with the view link sees this.`),await Q()}async function Xe(e){d=-1,await _.load({title:e||`Live`,artist:``,album:``,duration:0,url:v.url(`/api/live`),video:!0,objectUrl:!1},!0),B(!0),l=`Watching what this server is playing. Everyone here sees the same thing.`,I()}function $(e){let t=document.createElement(`li`),n=document.createElement(`span`);n.className=`recent-label`;let r=document.createElement(`span`);r.className=`name`,r.textContent=e.title;let i=document.createElement(`span`);i.className=`detail`,i.textContent=e.detail,n.append(r,i);let a=document.createElement(`button`);if(a.type=`button`,a.className=`button`,a.textContent=e.playLabel??`Play`,a.addEventListener(`click`,e.onPlay),t.append(n,a),e.link){let n=document.createElement(`button`);n.type=`button`,n.className=`ghost`,n.textContent=`Copy link`,n.addEventListener(`click`,()=>{let t=globalThis.location.origin,n=e.link.startsWith(`https://`)?`${t}/?url=${encodeURIComponent(e.link)}`:e.link;navigator.clipboard?.writeText(n).catch(()=>{})}),t.append(n)}if(e.onStop){let n=document.createElement(`button`);n.type=`button`,n.className=`ghost`,n.textContent=`Stop`,n.addEventListener(`click`,e.onStop),t.append(n)}return t}let Ze=``;function Qe(e,t){Ze=e;let r=e!==``&&t===e;n.loadHome.hidden=e===``,n.homeNote.hidden=e===``,e!==``&&(n.homeNote.textContent=r?`This server's own files: ${e}`:`This server's own files are ${e}, and are not in the playlist.`,n.loadHome.disabled=!1)}n.loadHome.addEventListener(`click`,()=>{Ze!==``&&(n.loadHome.disabled=!0,U(`Reading this server's files…`),(async()=>{try{let e=await fetch(v.url(`/api/source`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({source:Ze})}),t=await e.json();U(e.ok?t.added===0?`This server's files are already in the playlist.`:`Loaded ${t.added??0} of this server's own files.`:t.error??`that did not work`)}catch{U(`could not reach the server`)}finally{n.loadHome.disabled=!1}})())});let $e=async e=>{n.goLive.disabled=!0,n.stopLive.disabled=!0,n.shareNote.textContent=e?`Going live…`:`Taking it off the list…`;try{let t=await fetch(v.url(e?`/api/live/start`:`/api/live/stop`),{method:`POST`}),r=await t.json();n.shareNote.textContent=t.ok?e?`Live. Anyone can call and key ${r.code??``} to talk about it.`:`Taken off the list. The link still works for anybody who has it.`:r.error??`that did not work`}catch{n.shareNote.textContent=`could not reach the server`}finally{n.goLive.disabled=!1,n.stopLive.disabled=!1,await Y()}};n.goLive.addEventListener(`click`,()=>void $e(!0)),n.stopLive.addEventListener(`click`,()=>void $e(!1));function et(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(ge,n.listenHere.checked?`1`:`0`)}catch{}r===`remote`&&(async()=>{n.listenHere.checked?(await v.send({type:`stop`}),await D(o.index)):(_.stop(),d=-1),I()})()}),document.addEventListener(`keydown`,e=>{let t=e.target;if(!(t&&/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))switch(e.key){case` `:e.preventDefault(),O();return;case`s`:j();return;case`n`:case`ArrowRight`:A(1);return;case`p`:case`ArrowLeft`:A(-1);return;case`ArrowDown`:e.preventDefault(),E(Math.min(y()-1,b()+1));return;case`ArrowUp`:e.preventDefault(),E(Math.max(0,b()-1));return}});let tt=null;globalThis.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),tt=e,n.install.hidden=!1}),n.install.addEventListener(`click`,()=>{tt?.prompt(),tt=null,n.install.hidden=!0});try{let e=localStorage.getItem(he);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),_.volume=Number(e));let t=localStorage.getItem(N);t&&(n.remoteUrl.value=t),localStorage.getItem(ge)===`0`&&(n.listenHere.checked=!1)}catch{}(async()=>{if(n.remoteUrl.value!==``)return;let e=globalThis.location.origin;if(await k(e)===null)return;let t=await se(e);t&&t.trackCount!==0&&(n.remoteUrl.value=e,r=`remote`,l=``,v.connect(e),I())})(),(()=>{if(_e)return;let e=async()=>{try{let e=await(await fetch(`/jingles/index.json`)).json();if(Array.isArray(e)&&e.length>0){let t=e[Math.floor(Math.random()*e.length)];if(typeof t==`string`)return`/jingles/${t}`}}catch{}return``},t=new Audio;t.volume=.7;let n=()=>{_e=!0},r=()=>{document.removeEventListener(`pointerdown`,r),document.removeEventListener(`keydown`,r),n(),t.play().catch(()=>{})};e().then(e=>{if(e!==``)return t.src=e,t.play().then(n,()=>{document.addEventListener(`pointerdown`,r,{once:!0}),document.addEventListener(`keydown`,r,{once:!0})})})})(),I(),requestAnimationFrame(Ce)}F(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});export{c as t};