nixamp 0.9.12 → 0.10.0

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
@@ -219,7 +219,7 @@ export class Channel {
219
219
  * because you looked away, and a room where the picture depends on who is
220
220
  * in it is not a room anybody can be invited to.
221
221
  */
222
- pull(source: string, encode: string[], paced = true, stall = STALL): void {
222
+ pull(source: string, encode: string[], paced = true, stall = STALL, input: string[] = []): void {
223
223
  this.stall = stall;
224
224
  if (this.info.kind === "video") this.fragments = new Fragments();
225
225
  const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
@@ -247,6 +247,10 @@ export class Channel {
247
247
  // hour of film in ninety seconds and a room that cannot be in it
248
248
  // together; a live source is already paced and loses nothing.
249
249
  ...(paced ? ["-re"] : []),
250
+ // What the source's site expects on the request: a user agent, a
251
+ // referer, a cookie. A link resolved by yt-dlp comes with these,
252
+ // and a CDN that got them from yt-dlp and not from us answers 403.
253
+ ...input,
250
254
  "-i", source,
251
255
  ...encode,
252
256
  "pipe:1",
@@ -612,6 +616,7 @@ export class Channels {
612
616
  kind: "audio" | "video",
613
617
  paced = true,
614
618
  stall = STALL,
619
+ input: string[] = [],
615
620
  ): Channel | null {
616
621
  if (this.open.has(id)) return null;
617
622
  const channel = new Channel(
@@ -630,7 +635,7 @@ export class Channels {
630
635
  (gone) => this.open.delete(gone),
631
636
  );
632
637
  this.open.set(id, channel);
633
- channel.pull(source, encode, paced, stall);
638
+ channel.pull(source, encode, paced, stall, input);
634
639
  return channel;
635
640
  }
636
641
 
package/src/directory.ts CHANGED
@@ -73,6 +73,12 @@ export interface Listing {
73
73
  playing: boolean;
74
74
  /** The live channels on it, by name: what a visitor could actually watch. */
75
75
  channels: string[];
76
+ /**
77
+ * A phone code for each of those channels, by name: its own room, so the
78
+ * people calling about one live are not put in with the people calling
79
+ * about another on the same server.
80
+ */
81
+ channelCodes: Record<string, string>;
76
82
  /** Set by the directory from the request, never by the publisher. */
77
83
  updatedAt: number;
78
84
  /** When this stream first announced itself: the "started at" a caller hears. */
@@ -283,6 +289,11 @@ export class Directory {
283
289
  // listing always meant, and no channels is the honest empty list.
284
290
  playing: announcement.playing ?? true,
285
291
  channels: announcement.channels ?? [],
292
+ channelCodes: this.codesFor(
293
+ announcement.channels ?? [],
294
+ existing?.channelCodes ?? (previously && "channelCodes" in previously ? previously.channelCodes : undefined),
295
+ id,
296
+ ),
286
297
  updatedAt: this.now(),
287
298
  // A stream that never stopped keeps its original start. One that did
288
299
  // starts again now, because that is what a caller is being told about.
@@ -305,10 +316,55 @@ export class Directory {
305
316
  return [...this.items.values()].sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
306
317
  }
307
318
 
308
- /** The live stream on this code, if there is one. */
319
+ /**
320
+ * The live stream on this code, if there is one.
321
+ *
322
+ * A channel's code answers as the channel: the same listing, named for the
323
+ * channel and playing nothing else, so the phone line says "the live room
324
+ * for FIBA World Cup" rather than the server's name and whatever its own
325
+ * player has on.
326
+ */
309
327
  liveByCode(code: string): Listing | undefined {
310
328
  this.sweep();
311
- return [...this.items.values()].find((item) => item.code === code);
329
+ const own = [...this.items.values()].find((item) => item.code === code);
330
+ if (own !== undefined) return own;
331
+ for (const item of this.items.values()) {
332
+ const channel = Object.entries(item.channelCodes).find(([, one]) => one === code)?.[0];
333
+ if (channel !== undefined) return { ...item, code, name: channel, nowPlaying: "" };
334
+ }
335
+ return undefined;
336
+ }
337
+
338
+ /**
339
+ * A code for each channel, kept while the channel stays on.
340
+ *
341
+ * One code per server meant every live on it shared a room: somebody
342
+ * calling about the basketball landed with the people talking about the
343
+ * film. Each live is its own room now, and a channel that is still there at
344
+ * the next heartbeat keeps the code it was given.
345
+ */
346
+ private codesFor(channels: string[], had: Record<string, string> | undefined, own = ""): Record<string, string> {
347
+ const codes: Record<string, string> = {};
348
+ for (const name of channels) {
349
+ if (codes[name] !== undefined) continue;
350
+ const kept = had?.[name];
351
+ codes[name] = kept !== undefined && !this.taken(kept, codes, own) ? kept : this.freeCode(codes, own);
352
+ }
353
+ return codes;
354
+ }
355
+
356
+ /**
357
+ * Whether a code is somebody's already: a stream's own, a channel's on any
358
+ * stream, or a recently-ended stream's. A listing renewing itself is not
359
+ * "somebody else", or its channels would be re-coded every heartbeat.
360
+ */
361
+ private taken(code: string, besides: Record<string, string> = {}, own = ""): boolean {
362
+ if (Object.values(besides).includes(code)) return true;
363
+ for (const item of this.items.values()) {
364
+ if (item.code === code) return true;
365
+ if (item.id !== own && Object.values(item.channelCodes).includes(code)) return true;
366
+ }
367
+ return [...this.ended.values()].some((i) => i.code === code);
312
368
  }
313
369
 
314
370
  /**
@@ -372,15 +428,12 @@ export class Directory {
372
428
  this.mirror?.save(record);
373
429
  }
374
430
 
375
- /** A code no live and no recently-ended stream is using. */
376
- private freeCode(): string {
431
+ /** A code no live stream, no channel on one, and no recently-ended stream is using. */
432
+ private freeCode(besides: Record<string, string> = {}, own = ""): string {
377
433
  for (let tries = 0; tries < 40; tries += 1) {
378
434
  const code = this.randomCode();
379
435
  if (code.length !== 6) continue;
380
- const taken =
381
- [...this.items.values()].some((i) => i.code === code) ||
382
- [...this.ended.values()].some((i) => i.code === code);
383
- if (!taken) return code;
436
+ if (!this.taken(code, besides, own)) return code;
384
437
  }
385
438
  return "";
386
439
  }
package/src/links.ts ADDED
Binary file
package/src/server.ts CHANGED
@@ -51,6 +51,10 @@ import { stateDir } from "./daemon.ts";
51
51
  import { readSession } from "./session.ts";
52
52
  import { Directory, ENDED_TTL_MS, parseAnnouncement, type Listing } from "./directory.ts";
53
53
  import { PartyLine, telnyxSms } from "./partyline.ts";
54
+ import {
55
+ contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, playableLink, resolveLink, saveFormat,
56
+ type ResolvedLink,
57
+ } from "./links.ts";
54
58
  import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.ts";
55
59
  import pg from "pg";
56
60
  import { Follows, phoneFrom } from "./follows.ts";
@@ -978,15 +982,50 @@ export async function pullChannel(
978
982
  id: string,
979
983
  name: string,
980
984
  source: string,
985
+ input: string[] = [],
981
986
  ): Promise<Channel | null> {
982
- const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source);
987
+ const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source, input);
983
988
  const kind = codecs.video === "" ? "audio" : "video";
984
989
  const encode = kind === "video"
985
990
  ? videoArgs(codecs)
986
991
  // No picture in it, so none is invented: MP3 is the thing every browser
987
992
  // plays and the thing a listener can join halfway through.
988
993
  : ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
989
- return channels.pull(id, name, source, encode, kind);
994
+ return channels.pull(id, name, source, encode, kind, true, undefined, input);
995
+ }
996
+
997
+ /** What a link resolves to, kept so a download can be named without asking twice. */
998
+ const links = new Map<string, ResolvedLink>();
999
+
1000
+ /**
1001
+ * A Netscape cookies file beside the state, if the operator has put one there.
1002
+ *
1003
+ * YouTube and Vimeo refuse a datacenter without a signed-in cookie; the
1004
+ * person who runs the server can export one from their browser and drop it
1005
+ * at ~/.local/state/nixamp/cookies.txt, and every link is asked for with it.
1006
+ */
1007
+ export function cookiesFile(): string {
1008
+ const path = join(stateDir(), "cookies.txt");
1009
+ try {
1010
+ return statSync(path).isFile() ? path : "";
1011
+ } catch {
1012
+ return "";
1013
+ }
1014
+ }
1015
+
1016
+ /** What the page is told about a link it asked to play. */
1017
+ function shownLink(channelId: string, link: ResolvedLink) {
1018
+ return {
1019
+ kind: "live",
1020
+ channel: channelId,
1021
+ name: link.title,
1022
+ live: link.live,
1023
+ video: link.video,
1024
+ duration: link.duration,
1025
+ extractor: link.extractor,
1026
+ // A live has no whole to keep; a bare file can be fetched by the browser itself.
1027
+ download: !link.live && link.extractor !== "direct",
1028
+ };
990
1029
  }
991
1030
 
992
1031
  export function liveOnes(engine: Engine): { name: string; at: number; tracks: number }[] {
@@ -1122,6 +1161,10 @@ export interface HandlerOptions {
1122
1161
  ffmpeg?: string[];
1123
1162
  /** Where ffprobe is, for asking what is inside a file before re-encoding it. */
1124
1163
  ffprobe?: string[];
1164
+ /** yt-dlp, which turns a pasted page into a media address. Null when there is none. */
1165
+ ytdlp?: string[] | null;
1166
+ /** A Netscape cookies file for sites that want a signed-in browser, when there is one. */
1167
+ cookies?: string;
1125
1168
  /** Who is listening, for the admin view. */
1126
1169
  connections?: Connections;
1127
1170
  /**
@@ -1177,7 +1220,11 @@ export interface HandlerOptions {
1177
1220
  * and starting it. It is an action now, because that is what it is.
1178
1221
  */
1179
1222
  live?: {
1180
- status: () => { live: boolean; code: string; name: string; url: string; possible: boolean };
1223
+ status: () => {
1224
+ live: boolean; code: string; name: string; url: string; possible: boolean;
1225
+ /** A phone code per live channel, by name, as the directory assigned them. */
1226
+ channelCodes?: Record<string, string>;
1227
+ };
1181
1228
  start: () => Promise<{ live: boolean; code: string; name: string; url: string; error?: string }>;
1182
1229
  stop: () => Promise<void>;
1183
1230
  /**
@@ -2363,6 +2410,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2363
2410
  const streams = options.directory.list().map(({ admin, ...stream }) => ({
2364
2411
  ...stream,
2365
2412
  callers: onThePhone ? onThePhone.listenersOn(stream.code) : 0,
2413
+ // And on the phone for each live on it, by name: every live is its
2414
+ // own room, so each has its own count.
2415
+ channelCallers: Object.fromEntries(
2416
+ Object.entries(stream.channelCodes).map(([name, code]) => [name, onThePhone ? onThePhone.listenersOn(code) : 0]),
2417
+ ),
2366
2418
  ...(admin && me !== null && stream.ownerId === me.id ? { admin } : {}),
2367
2419
  }));
2368
2420
  // Recently ended too, because following exists to hear about
@@ -2533,6 +2585,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2533
2585
  // Whether it has a picture, so the page puts it in the element
2534
2586
  // that can show one. Never the source: that is the owner's.
2535
2587
  kind: one.kind ?? "audio",
2588
+ // Its own room on the phone line, as the directory assigned it;
2589
+ // empty until the next heartbeat has told the directory it is on.
2590
+ code: state?.channelCodes?.[one.name] ?? "",
2536
2591
  // How it has been going, for whoever may do something about it.
2537
2592
  redials: one.redials ?? 0,
2538
2593
  error: one.error ?? "",
@@ -2721,6 +2776,118 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2721
2776
  return;
2722
2777
  }
2723
2778
 
2779
+ // --- any link, played -------------------------------------------------
2780
+ //
2781
+ // Paste a page -- YouTube, a podcast, SoundCloud, a TikTok live -- and the
2782
+ // server works out where the media is and plays it as a channel of its
2783
+ // own, the way a catalog entry is played: started for whoever asked,
2784
+ // stopped a minute after the last viewer leaves. Open to anyone holding
2785
+ // the link, like picking something from a catalog.
2786
+ if (path === "/api/links/play" && request.method === "POST") {
2787
+ let body: { url?: unknown } = {};
2788
+ try {
2789
+ body = JSON.parse(await readBody(request)) as typeof body;
2790
+ } catch {
2791
+ json(response, 400, { error: "bad JSON" });
2792
+ return;
2793
+ }
2794
+ const link = playableLink(body.url);
2795
+ if (link === "") {
2796
+ json(response, 400, { error: "that is not a link this can play" });
2797
+ return;
2798
+ }
2799
+ if (!options.channels) {
2800
+ json(response, 503, { error: "this server cannot carry channels" });
2801
+ return;
2802
+ }
2803
+ const channelId = linkChannelId(link);
2804
+ const known = links.get(link);
2805
+ if (options.channels.has(channelId) && known) {
2806
+ json(response, 200, shownLink(channelId, known));
2807
+ return;
2808
+ }
2809
+ if (!options.channels.has(channelId) && options.channels.ephemeralCount >= MAX_ON_DEMAND) {
2810
+ json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
2811
+ return;
2812
+ }
2813
+ const resolved = known ?? await resolveLink(options.ytdlp ?? null, link, { cookies: options.cookies ?? "" });
2814
+ if ("error" in resolved) {
2815
+ json(response, 422, { error: resolved.error });
2816
+ return;
2817
+ }
2818
+ links.set(link, resolved);
2819
+ if (!options.channels.has(channelId)) {
2820
+ const started = await pullChannel(
2821
+ options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media,
2822
+ inputArgsFor(resolved.headers),
2823
+ );
2824
+ if (!started) {
2825
+ json(response, 409, { error: "that link is already starting" });
2826
+ return;
2827
+ }
2828
+ options.channels.ephemeral(channelId);
2829
+ }
2830
+ json(response, 200, shownLink(channelId, resolved));
2831
+ return;
2832
+ }
2833
+
2834
+ // The whole thing, to keep. The server fetches it through yt-dlp and
2835
+ // hands the bytes straight on as a download, so the person's own machine
2836
+ // ends up with the file and the server keeps nothing. A live stream has
2837
+ // no whole to hand over.
2838
+ if (path === "/api/links/download") {
2839
+ const link = playableLink(url.searchParams.get("url"));
2840
+ if (link === "") {
2841
+ json(response, 400, { error: "that is not a link this can fetch" });
2842
+ return;
2843
+ }
2844
+ if (!options.ytdlp || options.ytdlp.length === 0) {
2845
+ json(response, 503, { error: "this server has no yt-dlp to fetch with" });
2846
+ return;
2847
+ }
2848
+ const resolved = links.get(link) ?? await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "" });
2849
+ if ("error" in resolved) {
2850
+ json(response, 422, { error: resolved.error });
2851
+ return;
2852
+ }
2853
+ links.set(link, resolved);
2854
+ if (resolved.live) {
2855
+ json(response, 409, { error: "that is live; there is no whole file to download yet" });
2856
+ return;
2857
+ }
2858
+ const audioOnly = url.searchParams.get("audio") === "1" || !resolved.video;
2859
+ // Named for the format the download will actually take, which is not
2860
+ // always the one played: a track played from an HLS playlist is saved
2861
+ // as the plain MP3 the site also offers, and ".m4a" on an MP3 is a
2862
+ // file nothing will open.
2863
+ const saved = await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "", format: saveFormat(audioOnly) });
2864
+ const fileName = fileNameFor("error" in saved ? resolved : { ...resolved, ext: saved.ext || resolved.ext }, audioOnly);
2865
+ const [command, ...prefix] = options.ytdlp as [string, ...string[]];
2866
+ const child = spawn(command, [...prefix, ...downloadArgs(link, audioOnly, options.cookies ?? "")], {
2867
+ stdio: ["ignore", "pipe", "pipe"],
2868
+ });
2869
+ response.writeHead(200, {
2870
+ "content-type": contentTypeFor(fileName),
2871
+ "content-disposition": `attachment; filename="${fileName.replace(/["\\]/g, "")}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
2872
+ "cache-control": "no-store",
2873
+ });
2874
+ child.stdout?.pipe(response);
2875
+ let complaint = "";
2876
+ child.stderr?.on("data", (chunk: Buffer) => {
2877
+ if (complaint.length < 20_000) complaint += chunk.toString("utf8");
2878
+ });
2879
+ child.on("error", () => response.end());
2880
+ child.on("close", (code) => {
2881
+ if (code !== 0) console.log(` a download of ${link} failed: ${complaint.split("\n").filter((one) => one.startsWith("ERROR")).pop() ?? code}`);
2882
+ response.end();
2883
+ });
2884
+ // The person closed the tab: stop fetching what nobody will keep.
2885
+ response.on("close", () => {
2886
+ if (child.exitCode === null) child.kill("SIGKILL");
2887
+ });
2888
+ return;
2889
+ }
2890
+
2724
2891
  // A channel is one publisher and everybody listening to them. Two or three
2725
2892
  // devices can publish at once, each to their own channel, and a listener
2726
2893
  // picks which to hear.
@@ -4163,6 +4330,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
4163
4330
  status: () => ({
4164
4331
  live: publisher !== null,
4165
4332
  code: listing?.code ?? "",
4333
+ channelCodes: listing?.channelCodes ?? {},
4166
4334
  name: listing?.name ?? (options.name || hostname()),
4167
4335
  url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey, false) : ""),
4168
4336
  // Whether going live is even possible here. A laptop behind a router
@@ -4210,6 +4378,11 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
4210
4378
  paywall,
4211
4379
  ffmpeg: tools.ffmpeg,
4212
4380
  ffprobe: tools.ffprobe,
4381
+ // For a pasted link: where its media is, and the whole of it to keep.
4382
+ // A cookie jar beside the state, when the operator has put one there,
4383
+ // for the sites that will not talk to a datacenter without one.
4384
+ ytdlp: tools.ytdlp ?? null,
4385
+ cookies: cookiesFile(),
4213
4386
  ...(tls ? { tls } : {}),
4214
4387
  // Untagged, so a directory of five thousand files answers at once; the
4215
4388
  // tags follow through `tag` below.
package/src/share.ts CHANGED
@@ -354,8 +354,10 @@ export function allowedForListening(path: string): boolean {
354
354
  if (path === "/api/source" || path.startsWith("/api/source/")) return false;
355
355
  // Listing this machine in a public directory is not listening to it. The
356
356
  // listen address itself, /api/live, stays open: that is the thing a listen
357
- // key is for.
358
- if (path === "/api/live/state" || path === "/api/live/start" || path === "/api/live/stop") return false;
357
+ // key is for -- and so is knowing the phone code, which /api/live/state
358
+ // answers: a joiner is told the number to call and the code to key, and
359
+ // the code is in the public directory anyway.
360
+ if (path === "/api/live/start" || path === "/api/live/stop") return false;
359
361
  return true;
360
362
  }
361
363
 
@@ -1 +1 @@
1
- import{t as e}from"./index-BeB8GvFX.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-Bqujphpd.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-5T3M0hCY.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-DUwEL0l2.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 ne=2048;function re(e,t){return e||t===`hls`||t===`mpegts`}var ie=class{elements;handlers;attached=null;source=``;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(ae(t))});let e=e=>()=>{t===this.active&&this.handlers.onBusy?.(e)};for(let n of[`loadstart`,`waiting`,`stalled`,`seeking`])t.addEventListener(n,e(!0));for(let n of[`playing`,`canplay`,`pause`,`ended`,`error`,`emptied`,`seeked`,`abort`])t.addEventListener(n,e(!1))}}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=ne,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){this.source=e.objectUrl?``:e.url;let n=e.objectUrl?e.video?`mp4`:`audio`:d({src:e.url}),r=re(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.source=``,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 ae(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 oe(){return{revision:0,tracks:[],trackCount:0,index:0,playing:!1,position:0,bars:[],levels:[0,0],silent:!0,note:``,root:``}}function se(e,t){return{...t,tracks:t.tracks??e.tracks}}function ce(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 x(e,t,n=``){let r=`${e===``?``:ce(e)}${t.startsWith(`/`)?t:`/${t}`}`;return n?`${r}${r.includes(`?`)?`&`:`?`}k=${encodeURIComponent(n)}`:r}function le(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:ce(`${n.origin}${n.pathname}`),key:decodeURIComponent(i)}}function S(e,t,n=0,r=``){return x(e,n>0?`/api/media/${t}?kbps=${Math.round(n)}`:`/api/media/${t}`,r)}function C(e){if(typeof e!=`object`||!e)return null;let t=e,n=oe(),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 ue=class{handlers;source=null;base=``;key=``;shape=`/admin/`;lastRevision=-1;constructor(e){this.handlers=e}get address(){return this.base}url(e){return x(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}=le(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(x(t,`/api/events`,n));this.source=r,r.onopen=()=>this.handlers.onStatus(`live`),r.onmessage=e=>{let t=C(de(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(x(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=C(await t.json());n&&this.handlers.onSnapshot(n)}media(e,t=0){return S(this.base,e,t,this.key)}close(){this.source?.close(),this.source=null}};function de(e){try{return JSON.parse(e)}catch{return null}}async function fe(e,t,n=``){try{let r=await fetch(x(e,`/api/state`,n),{signal:t});return r.ok?C(await r.json()):null}catch{return null}}function pe(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 me(e,t=``,n){let r;try{r=await fetch(x(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 he(e,t,n=``){try{let r=await fetch(x(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 ge(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 w=.14,T=.02;function _e(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 ve(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 ye(e,t,n=w){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function be(e,t,n=T){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function xe(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 Se=`nixamp.remote`,Ce=`nixamp.volume`,we=`nixamp.listenHere`,Te=!1;function E(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function Ee(){let n={status:E(`status`),source:E(`source`),install:E(`install`),video:E(`video`),audio:E(`audio`),title:E(`title-line`),album:E(`album-line`),meta:E(`meta-line`),liveLine:E(`live-line`),downloadNow:E(`download-now`),linkForm:E(`link-form`),linkUrl:E(`link-url`),goLiveNow:E(`go-live-now`),elapsed:E(`elapsed`),total:E(`total`),seek:E(`seek`),fullscreen:E(`fullscreen`),copyNow:E(`copy-now`),canvas:E(`spectrum`),glyphs:E(`glyphs`),levels:E(`levels`),playlist:E(`playlist`),crumbs:E(`crumbs`),filter:E(`filter`),playlistTitle:E(`playlist-panel`),note:E(`note`),files:E(`files`),folder:E(`folder`),remoteUrl:E(`remote-url`),remoteForm:E(`remote-form`),remoteState:E(`remote-state`),disconnect:E(`disconnect`),browse:E(`browse`),accountForm:E(`account-form`),accountEmail:E(`account-email`),accountPassword:E(`account-password`),accountSubmit:E(`account-submit`),accountToggle:E(`account-toggle`),accountProviders:E(`account-providers`),accountPanel:E(`account-panel`),accountElsewhere:E(`account-elsewhere`),accountSignOut:E(`account-signout`),accountNote:E(`account-note`),adminPanel:E(`admin-panel`),adminNote:E(`admin-note`),adminSaid:E(`admin-said`),adminConnections:E(`admin-connections`),publishPanel:E(`publish-panel`),publishNote:E(`publish-note`),publishList:E(`publish-list`),adminRestream:E(`admin-restream`),adminReplace:E(`admin-replace`),adminSource:E(`admin-source`),adminName:E(`admin-name`),adminAdd:E(`admin-add`),homeNote:E(`home-note`),loadHome:E(`load-home`),directory:E(`directory`),recentNote:E(`recent-note`),recentList:E(`recent-list`),followingNote:E(`following-note`),followingList:E(`following-list`),serversPanel:E(`servers-panel`),serversNote:E(`servers-note`),serversList:E(`servers-list`),favoritesPanel:E(`favorites-panel`),favoritesNote:E(`favorites-note`),favoritesList:E(`favorites-list`),favHere:E(`fav-here`),catalogsPanel:E(`catalogs-panel`),catalogsNote:E(`catalogs-note`),catalogsForm:E(`catalogs-form`),catalogSource:E(`catalog-source`),catalogName:E(`catalog-name`),catalogsList:E(`catalogs-list`),catalogsCrumbs:E(`catalogs-crumbs`),catalogsFilter:E(`catalogs-filter`),catalogsEntries:E(`catalogs-entries`),notifyPanel:E(`notify-panel`),notifyNote:E(`notify-note`),notifyWeb:E(`notify-web`),notifyEmail:E(`notify-email`),notifySms:E(`notify-sms`),notifyPhone:E(`notify-phone`),notifyPhoneForm:E(`notify-phone-form`),notifyPhoneNote:E(`notify-phone-note`),directoryNote:E(`directory-note`),directoryList:E(`directory-list`),onairPanel:E(`onair-panel`),onairNote:E(`onair-note`),onairList:E(`onair-list`),sharePanel:E(`share-panel`),shareNote:E(`share-note`),shareLink:E(`share-link`),shareCopy:E(`share-copy`),sharePhone:E(`share-phone`),shareSend:E(`share-send`),liveControls:E(`live-controls`),goLive:E(`go-live`),stopLive:E(`stop-live`),shareTo:E(`share-to`),listenOnly:E(`listen-only`),listenHere:E(`listen-here`),volume:E(`volume`),prev:E(`prev`),playPause:E(`play-pause`),stop:E(`stop`),next:E(`next`)},r={live:`<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="2.5"/><path d="M8.5 15.5a5 5 0 0 1 0-7"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M5.6 18.4a9 9 0 0 1 0-12.8"/><path d="M18.4 5.6a9 9 0 0 1 0 12.8"/></svg>`,link:`<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg>`,copy:`<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>`,restart:`<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>`,remove:`<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`,check:`<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5L20 7"/></svg>`},i=(e,t)=>{e.innerHTML=r[t]},a=document.title||`nixamp`,o=`local`,s=``,c=!1,l=``,u=0,d=``,f=[],p=0,m=oe(),h=`idle`,g=``,_=`Pick files, or connect to a nixamp running somewhere else.`,v=!1,y=-1,b=null,ne=0,re=0,ae=!1,ce=()=>re>0||ae;async function x(e){re+=1,I();try{return await e()}finally{--re,I()}}let S=null,C=null,de=!1,w=``,T=``,Ee=null,De=``,D=Array(24).fill(0),O=Array(24).fill(0),Oe=[],k=()=>o===`remote`&&!n.listenHere.checked,ke=()=>o===`remote`&&!n.adminPanel.hidden;function Ae(){if(o!==`remote`)return null;if(S?.catalog&&S.entry)return{kind:`entry`,catalog:S.catalog,entry:S.entry};if(b)return{kind:`channel`,id:b.id,name:b.name};let e=m.tracks[N()];return e&&(A.source!==``||k())?{kind:`track`,index:N(),name:t(e)}:null}async function je(e,t){t.disabled=!0;let n=e.kind===`entry`?e.entry.title:e.name;_=`Putting ${n} on the air…`,I();try{let r=``;if(e.kind===`track`)await j.send({type:`play`,index:e.index}),r=`live`;else{let t=e.kind===`entry`?`/api/catalogs/${encodeURIComponent(e.catalog.id)}/entries/${encodeURIComponent(e.entry.id)}/live`:`/api/channels/${encodeURIComponent(e.id)}/keep`,i=await fetch(j.url(t),{method:`POST`}),a=await i.json().catch(()=>({}));if(!i.ok){_=a.error??`${n} would not go on the air.`,I();return}r=`channel:${e.kind===`entry`?a.channel??``:e.id}`}de?await fetch(j.url(`/api/live/start`),{method:`POST`}).catch(()=>void 0):await dn(!0),await X(),Z();let i=an(r),a=w?` Call ${T||`the line`} and key ${w} to talk about it.`:``;i===``?_=`${n} is on the air.${a}`:(await $(i,t,`✓`),_=`${n} is on the air. Link copied.${a}`),I()}catch{_=`could not reach the server`,I()}finally{t.disabled=!1}}function Me(e,t){let n=document.createElement(`button`);return n.type=`button`,n.className=`row-copy row-live`,i(n,`live`),n.title=`Go live with ${t}: on the air for everyone, listed, link copied`,n.setAttribute(`aria-label`,`Go live with ${t}`),n.addEventListener(`click`,t=>{t.stopPropagation(),je(e(),n)}),n}let A=new ie({audio:n.audio,video:n.video},{onTime:(e,t)=>{let n=f[p];o===`local`&&n&&t>0&&n.duration!==t&&(n.duration=t),I()},onEnded:()=>{tn()||F(1)},onState:()=>I(),onBusy:e=>{ae!==e&&(ae=e,I())},onError:e=>{tn()||(_=e,I(),Zt())}}),j=new ue({onSnapshot:e=>{if(m=se(m,e),l.startsWith(`track:`)&&m.tracks.length>0){let e=Number(l.slice(6));if(l=``,Number.isInteger(e)&&e>=0&&e<m.tracks.length){let t=u;ze(e).then(()=>{t>0&&(A.seek(t),setTimeout(()=>A.seek(t),600))})}}k()&&(D=e.bars.length>0?e.bars:D,O=be(O,D)),I()},onStatus:(e,t)=>{h=e,g=t??``,I()}}),M=()=>o===`remote`?m.tracks.length:f.length,N=()=>o===`remote`?k()||y<0?m.index:Math.min(y,Math.max(0,m.tracks.length-1)):p,Ne=()=>{if(b)return b.name;let e=o===`remote`?m.tracks[N()]:f[N()];return e?t(e):`Nothing loaded.`},Pe=()=>b?`live on this server`:S?.kind===`live`&&o===`remote`?`live on ${s||`this server`}`:(o===`remote`?m.tracks[N()]:f[N()])?.album||`—`;function Fe(){if(o!==`remote`||b===null&&S?.kind!==`live`){n.liveLine.hidden=!0,n.liveLine.replaceChildren();return}let e=s||`this server`,t=b?b.name:C?.server.nowPlaying||Ne(),r=[b?`Live on ${e}: `:`Live from ${e}, now playing: `,fn(t)],i=b?C?.channels.find(e=>e.id===b?.id)?.code??``:de?w:``;i&&r.push(`. To talk about it, call `,fn(T||`the line`),` and key `,fn(i),`.`);let a=r.map(e=>typeof e==`string`?e:e.textContent).join(``);n.liveLine.dataset.drawn!==a&&(n.liveLine.dataset.drawn=a,n.liveLine.hidden=!1,n.liveLine.replaceChildren(...r.map(e=>typeof e==`string`?document.createTextNode(e):e)))}let Ie=()=>k()?m.tracks[N()]?.duration??0:A.duration,Le=()=>k()?m.position:A.position,Re=()=>k()?m.playing:A.playing;async function P(e){if(o===`remote`){if(k()){await j.send({type:`play`,index:e});return}await ze(e);return}let t=f[e];t&&(p=e,b=null,S={kind:`file`},await x(()=>A.load(t,!0)),R(t.video),et(),I())}async function ze(e){let t=m.tracks[e];t&&(y=e,b=null,S={kind:`file`},await x(()=>A.load({title:t.title,artist:t.artist,album:t.album,duration:t.duration,url:j.media(e,0),video:t.video===!0,objectUrl:!1},!0)),R(t.video===!0),et())}async function Be(){if(k()){await j.send({type:`toggle`});return}M()!==0&&(A.playing?A.pause():A.position>0?await A.play():await P(N()),I())}async function F(e){let t=M();if(t!==0){if(k()){await j.send({type:e>0?`next`:`prev`});return}await P((N()+e+t)%t)}}async function Ve(){if(k()){await j.send({type:`stop`});return}b=null,S=null,A.stop(),D=Array(24).fill(0),O=[...D],I()}let He=(e,t)=>`L${`▮`.repeat(Math.round(e*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(t*6)).padEnd(6,`·`)}`,Ue=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))],We=``;function Ge(){let t=[],r=``,i=b?C?.channels.find(e=>e.id===b?.id):void 0;if(A.source!==``||b||k()&&m.tracks[N()]){if(b?t.push(S?.entry?.live===!1?`ON DEMAND · LIVE CHANNEL`:`LIVE`):S?.kind===`vod`?t.push(`ON DEMAND`):S?.kind===`live`?t.push(`LIVE`):o===`remote`&&k()?t.push(`ON THE SERVER`):t.push(`FILE`),!n.video.hidden&&n.video.videoWidth>0?t.push(`${n.video.videoWidth}×${n.video.videoHeight}`):n.video.hidden?t.push(`audio`):t.push(`video`),i?(t.push(i.via===`pull`?`${i.listeners} watching`:`${i.listeners} listening · over ${i.via}`),i.startedAt>0&&t.push(`on air ${e(Math.max(0,(Date.now()-i.startedAt)/1e3))}`),i.redials&&t.push(`redialled ${i.redials}×`),ke()&&i.error&&t.push(i.error)):o===`remote`&&!b?(m.tracks[N()]&&M()>0&&t.push(`track ${N()+1} of ${M()}`),k()&&C&&t.push(`${C.server.playing?`playing`:`stopped`} on ${s||`the server`}`)):o===`local`&&M()>0&&t.push(`track ${N()+1} of ${M()}`),S?.catalog!==void 0&&(S.kind===`channel`?b!==null:S.kind===`vod`&&A.source!==``)&&S?.catalog&&(t.push(S.entry?.group?`${S.catalog.name} › ${S.entry.group}`:S.catalog.name),r=S.entry?.logo??``),b&&S?.link){let e=``;try{e=new URL(S.link.url).hostname.replace(/^www\./,``)}catch{}t.push(S.link.extractor&&S.link.extractor!==`direct`&&S.link.extractor!==`generic`?`${S.link.extractor} · ${e}`:e||`link`)}de&&w&&k()&&!b&&S?.kind!==`live`&&t.push(T?`☎ ${T} · key ${w}`:`☎ code ${w}`)}let a=`${r}|${t.join(`|`)}`;if(a===We)return;We=a,n.meta.hidden=t.length===0;let c=[];if(r!==``&&/^https?:\/\//.test(r)){let e=document.createElement(`img`);e.className=`meta-logo`,e.alt=``,e.src=r,e.addEventListener(`error`,()=>{e.hidden=!0}),c.push(e)}for(let e of t){let t=document.createElement(`span`);t.className=`meta-chip`,t.textContent=e,c.push(t)}n.meta.replaceChildren(...c)}function I(){let t=M(),r=Re(),i=ce();n.status.textContent=i?`LOADING`:r?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=i?`loading`:String(r),n.title.textContent=Ne(),Fe(),Ge(),n.goLiveNow.hidden=!ke()||Ae()===null;let c=r?`${Ne()} · ${a}`:a;document.title!==c&&(document.title=c),n.copyNow.hidden=A.source===``,n.downloadNow.hidden=!(b&&S?.link?.download),n.album.textContent=Pe();let l=Le(),u=Ie();n.elapsed.textContent=e(l),n.total.textContent=u>0?e(u):`--:--`,v||(n.seek.value=String(u>0?Math.round(l/u*1e3):0),n.seek.disabled=u<=0||k()),n.playPause.textContent=r?`❚❚`:`▶`,n.playPause.setAttribute(`aria-label`,r?`Pause`:`Play`),n.playlistTitle.dataset.title=o===`remote`?`Files on ${s||`this server`} (${t.toLocaleString()})`:`Playlist (${t})`,n.source.textContent=o===`remote`?`connected · ${s||j.address.replace(/^https?:\/\//,``)||`—`}`:f.length>0?`local · ${f.length} files`:`no source`,n.remoteState.textContent=o===`remote`?`${h}${g?` — ${g}`:``}`:`not connected`,n.remoteState.dataset.status=o===`remote`?h:`idle`,n.disconnect.hidden=o!==`remote`;let d=o===`remote`&&m.note!==``?m.note:_;n.note.textContent=d,n.note.hidden=d===``,Xe(),n.glyphs.textContent=D.map(Ue).join(``);let[p,ee]=k()?m.levels:A.levels();n.levels.textContent=He(p,ee)}let Ke=``,qe=-1,L=``;function Je(e){if(n.crumbs.hidden=!e,!e)return;let t=L===``?[]:L.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`,()=>{L=t,Ke=``,Xe()}),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 Ye(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`,()=>{L=L===``?e:`${L}/${e}`,Ke=``,Xe()}),n}function Xe(){let r=o===`remote`?m.tracks.map(e=>({name:t(e),seconds:e.duration,group:e.group??``,folder:e.folder??``,remote:e.remote===!0})):f.map(e=>({name:t(e),seconds:e.duration,group:``,folder:``,remote:!1})),a=n.filter.value.trim().toLowerCase(),s=r.map((e,t)=>({...e,index:t})).filter(e=>!e.remote).filter(e=>a===``||`${e.folder}/${e.name}`.toLowerCase().includes(a)),c=e=>a!==``||L===``||e===L||e.startsWith(`${L}/`),l=e=>a!==``||e===L,u=e=>{let t=L===``?e:e.slice(L.length+1),n=t.indexOf(`/`);return n===-1?t:t.slice(0,n)},d=new Map;for(let e of s){if(!c(e.folder)||l(e.folder))continue;let t=u(e.folder);t!==``&&d.set(t,(d.get(t)??0)+1)}let p=s.filter(e=>l(e.folder)&&c(e.folder)),h=`${o}:${L}:${a}:${[...d].join(`,`)}:${p.map(e=>`${e.index}@${e.name}@${e.seconds}@${e.group}`).join(`|`)}`;if(h!==Ke){Ke=h,Je(a===``&&([...d.keys()].length>0||L!==``));let t=[];for(let[e,n]of[...d].sort((e,t)=>e[0].localeCompare(t[0],void 0,{numeric:!0})))t.push(Ye(e,n));let r=``,s=p.some(e=>e.group!==``);for(let n of p){n.group!==r&&(s||n.group!==``)&&(r=n.group,t.push(Ze(n.group)));let a=document.createElement(`li`);a.className=`row`,a.dataset.index=String(n.index);let c=document.createElement(`span`);c.className=`n`,c.textContent=String(n.index+1).padStart(2,` `);let l=document.createElement(`span`);l.className=`name`,l.textContent=n.name;let u=document.createElement(`span`);if(u.className=`time`,u.textContent=n.seconds>0?e(n.seconds):`--:--`,a.append(c,l,u),o===`remote`){let e=document.createElement(`button`);e.type=`button`,e.className=`row-copy`,i(e,`copy`),e.title=`Copy a link that plays this here, from where it is`,e.setAttribute(`aria-label`,`Copy a link that plays ${n.name}`),e.addEventListener(`click`,t=>{t.stopPropagation(),$(an(`track:${n.index}`,y===n.index?A.position:0),e,`✓`)}),a.append(e),ke()&&a.append(Me(()=>({kind:`track`,index:n.index,name:n.name}),n.name))}t.push(a)}n.playlist.replaceChildren(...t)}let g=N(),_=Re(),v;for(let e of Array.from(n.playlist.children)){let t=e,n=Number(t.dataset.index),r=Number.isInteger(n)&&n===g;t.classList.toggle(`selected`,r),t.classList.toggle(`playing`,r&&_),r&&(v=t)}g!==qe&&(qe=g,v?.scrollIntoView({block:`nearest`}))}function Ze(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(),Qe(e)}),t.append(n)}return t}async function Qe(e){try{let t=await fetch(j.url(`/api/source/remove`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({group:e})}),n=await t.json();B(t.ok?`Removed ${n.removed??0} tracks from ${e}.`:n.error??`that did not work`)}catch{B(`could not reach the server`)}}function $e(){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 o=t.getContext(`2d`);if(k())O=be(O,D);else{let e=A.read();e.length>0&&(Oe.length!==25&&(Oe=_e(24,e.length)),D=ye(D,ve(e,Oe)),O=be(O,D))}if(o){let e=getComputedStyle(document.documentElement);xe(o,{width:t.width,height:t.height},D,O,{bar:e.getPropertyValue(`--green`).trim()||`#4af689`,peak:e.getPropertyValue(`--green-dim`).trim()||`#227a4a`,background:`transparent`})}if(Re()){n.glyphs.textContent=D.map(Ue).join(``);let[t,r]=k()?m.levels:A.levels();n.levels.textContent=He(t,r),n.elapsed.textContent=e(Le());let i=Ie();!v&&i>0&&(n.seek.value=String(Math.round(Le()/i*1e3)))}requestAnimationFrame($e)}function R(e){n.video.hidden=!e,n.fullscreen.hidden=!e}function et(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:Ne(),album:Pe(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void Be()),navigator.mediaSession.setActionHandler(`pause`,()=>void Be()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void F(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void F(-1)))}n.filter.addEventListener(`input`,()=>{Ke=``,Xe()}),n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&P(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 F(-1)),n.next.addEventListener(`click`,()=>void F(1)),n.stop.addEventListener(`click`,()=>void Ve()),n.playPause.addEventListener(`click`,()=>void Be());async function tt(e){if(o!==`remote`){_=`Connect to a server first: it fetches the link and plays it to you.`,I();return}_=`Reading ${e}…`,await x(async()=>{let t,n={};try{t=await fetch(j.url(`/api/links/play`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({url:e})}),n=await t.json().catch(()=>({}))}catch{_=`could not reach the server`;return}if(!t.ok||!n.channel){_=n.error??`that link would not play.`;return}await en({id:n.channel,name:n.name||e,video:n.video!==!1},!0,{kind:`channel`,link:{url:e,extractor:n.extractor??``,download:n.download===!0,live:n.live===!0,video:n.video!==!1}})}),I()}n.linkForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.linkUrl.value.trim();t!==``&&tt(t)}),n.downloadNow.addEventListener(`click`,()=>{let e=S?.link;if(!e||o!==`remote`)return;let t=e.video?``:`&audio=1`;globalThis.open(j.url(`/api/links/download?url=${encodeURIComponent(e.url)}${t}`),`_blank`),_=`Fetching it through the server; your browser will save it when it arrives.`,I()}),n.goLiveNow.addEventListener(`click`,()=>{let e=Ae();e&&je(e,n.goLiveNow)}),n.seek.addEventListener(`input`,()=>{v=!0}),n.seek.addEventListener(`change`,()=>{let e=Ie();e>0&&A.seek(Number(n.seek.value)/1e3*e),v=!1}),n.volume.addEventListener(`input`,()=>{let e=Number(n.volume.value)/100;A.volume=e;try{localStorage.setItem(Ce,String(e))}catch{}});let nt=e=>{e.addEventListener(`change`,()=>{let t=ee(Array.from(e.files??[]));if(t.length===0){_=`Nothing playable in that selection.`,I();return}te(f),f=t,p=0,o=`local`,j.close(),_=``,P(0)})};nt(n.files),nt(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.remoteUrl.value,{base:r,key:i}=le(t);if(r===``){_=`That is not an address.`,I();return}(async()=>{h=`connecting`,I();let e=ge(r);if(e){h=`error`,g=e,_=e,o=`local`,I();return}if(await he(r,void 0,i)===null){h=`error`;let e=pe(r);g=e?`needs the server's name`:`not answering`,_=e||`Nothing answered at ${r}. If that is your machine, it is off or nixamp is not running on it; otherwise check the address.`,o=`local`,I();return}let n=await me(r,i);if(n){h=`error`,g=n,_=n,o=`local`,I();return}o=`remote`,_=``;try{localStorage.setItem(Se,t.trim())}catch{}j.connect(t),Z(),wt(),Xt(!0),dt(),X(),I()})()});let rt=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??[],mt(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} ${e.length===1?`server is`:`servers are`} on. Connect to one to browse its files and watch what is live on it. No account needed.`;for(let t of e){let e=document.createElement(`li`),r=document.createElement(`span`);r.className=`server-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=t.name;let a=document.createElement(`span`);a.className=`detail`;let o=[`${t.tracks.toLocaleString()} files to browse`];t.playing!==!1&&t.nowPlaying?o.push(`playing ${t.nowPlaying}`):o.push(`player idle`),t.code&&o.push(t.callers?`☎ ${t.code} · ${t.callers} on the phone`:`☎ ${t.code}`),a.textContent=o.join(` · `),r.append(i,a);let s=!!t.admin,u=(e,r=``)=>{c=e,l=r,n.remoteUrl.value=e?t.url:t.admin??t.url,n.directory.hidden=!0,n.remoteForm.requestSubmit()},d=document.createElement(`ul`);d.className=`server-lives`;for(let e of t.channels??[]){let n=document.createElement(`li`),r=document.createElement(`span`);r.className=`detail live`;let i=t.channelCodes?.[e]??``,a=t.channelCallers?.[e]??0;r.textContent=`● ${e}`+(i?` · ☎ ${i}${a?` · ${a} on the phone`:``}`:``);let o=document.createElement(`button`);o.type=`button`,o.className=`button`,o.textContent=`Play`,o.title=`Watch ${e}, live on ${t.name}`,o.addEventListener(`click`,()=>u(!0,`channel:${e}`)),n.append(r,o),d.append(n)}let f=document.createElement(`button`);f.type=`button`,f.className=`button`,f.textContent=`Viewer`,f.title=`Browse and watch. Changes nothing on the server.`,f.addEventListener(`click`,()=>u(!0));let p=document.createElement(`button`);if(p.type=`button`,p.className=`button`,p.textContent=`Admin`,p.disabled=!s,p.title=s?`Drive this server: what plays, what is live, what is on it.`:Y?`You do not administer this server.`:`Sign in as this server's owner to administer it.`,p.addEventListener(`click`,()=>u(!1)),e.append(r,f,p),Y&&e.append(_t(t.url,t.name)),t.ownerId&&Y&&t.ownerId!==Y&&e.append(Lt(t.ownerId,t.name)),d.childElementCount>0&&e.append(d),t.ownerId&&Y&&t.ownerId===Y){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 rt()}})()}),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),rt()}let z=null,it=e=>e===`cgnat`?`mobile or tailscale`:e===`private`?`your network`:e===`local`?`this machine`:e===`public`?`the internet`:e,at=e=>e===`events`?`watching the panel`:e===`page`?`opened the page`:e===`media`?`playing a track`:e===`stream`?`listening live`:e,ot=``,st=``,ct=e=>{let t=e.map(e=>`${e.address}|${e.kind}|${e.track}|${Math.round(e.bytes/4096)}|${e.endedAt}`).join(`~`);if(t===ot)return;ot=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,``],[it(t.network),`network-${t.network}`],[at(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 B(e){n.adminSaid.textContent=e,n.adminSaid.hidden=e===``}let lt=async()=>{try{let e=await fetch(j.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.`,ct(t.connections??[]),ut(t.publish??[],(t.channels??[]).map(e=>e.id)),un(t.home??``,t.root??``),Z()}catch{n.adminNote.textContent=`lost touch with the server`}};function ut(e,t){n.publishPanel.hidden=e.length===0;let r=`${e.map(e=>`${e.id}=${e.url}`).join(`~`)}::${t.join(`,`)}`;if(r===st)return;if(st=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 dt=async()=>{if(o!==`remote`){n.adminPanel.hidden=!0,n.publishPanel.hidden=!0,z&&clearInterval(z),z=null;return}let e=!1,t=null,r=!1;try{let n=await fetch(j.url(`/api/admin`));if(n.ok){let i=await n.json();e=i.allowed===!0,t=i.as??null,r=i.claimed===!0}}catch{e=!1}if(c&&(e=!1),n.adminPanel.hidden=!e,z&&clearInterval(z),z=null,Z(),wt(),X(),n.listenOnly.hidden=e,!e){n.listenOnly.textContent=r?`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.`,lt(),z=setInterval(()=>void lt(),2e3)};function ft(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)}`;B(`Starting ${t||e}…`),(async()=>{try{let a=await fetch(j.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){B(o.error??`that did not work`);return}B(`${o.channel?.name||t||e} is on the air.`),n.adminSource.value=``,n.adminName.value=``,X(),Z()}catch{B(`could not reach the server`)}})()}n.adminRestream.addEventListener(`submit`,e=>{e.preventDefault();let t=n.adminSource.value.trim();t&&ft(t,n.adminName.value.trim())}),n.adminAdd.addEventListener(`click`,()=>{let e=n.adminSource.value.trim();if(!e)return;B(`Reading ${e}…`);let t=n.adminReplace.checked,r=n.adminName.value.trim();(async()=>{try{let i=await fetch(j.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();B(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=``,X(),Z())}catch{B(`could not reach the server`)}})()});let pt=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`},mt=e=>{n.recentList.replaceChildren();let t=Y?e.filter(e=>e.ownerId&&e.ownerId!==Y):[];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 ${pt(e.endedAt)}`:`ended ${pt(e.endedAt)}`,r.append(i,a),t.append(r,Lt(e.ownerId,e.name)),n.recentList.append(t)}},V=new Set,H=e=>{try{return new URL(e).origin}catch{return e}},U=e=>[...V].some(t=>H(t)===H(e));async function ht(){if(!Y){V=new Set,n.favoritesPanel.hidden=!0,vt();return}try{let e=await fetch(`/api/v1/favorites`);if(!e.ok){n.favoritesPanel.hidden=!0;return}let t=(await e.json()).favorites??[];V=new Set(t.map(e=>e.url)),n.favoritesPanel.hidden=t.length===0,n.favoritesNote.textContent=`Servers you hearted. Connect to one, or let it go.`,n.favoritesList.replaceChildren(...t.map(e=>{let t=document.createElement(`li`),r=document.createElement(`span`);r.className=`server-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name||e.url.replace(/^https?:\/\//,``);let a=document.createElement(`span`);a.className=e.live?`detail live`:`detail`,a.textContent=e.live?[`● on now`,e.nowPlaying?`playing ${e.nowPlaying}`:``,e.channels.length>0?`live: ${e.channels.join(`, `)}`:``].filter(Boolean).join(` · `):`not on right now`,r.append(i,a);let o=document.createElement(`button`);return o.type=`button`,o.className=`button`,o.textContent=`Connect`,o.addEventListener(`click`,()=>{n.remoteUrl.value=e.url,n.remoteForm.requestSubmit()}),t.append(r,o,_t(e.url,e.name)),t}))}catch{n.favoritesPanel.hidden=!0}vt()}async function gt(e,t,n){try{if(!(n?await fetch(`/api/v1/favorites`,{method:`PUT`,headers:{"content-type":`application/json`},body:JSON.stringify({url:e,name:t})}):await fetch(`/api/v1/favorites?url=${encodeURIComponent(e)}`,{method:`DELETE`})).ok){_=n?`Could not save that favourite.`:`Could not remove that favourite.`,I();return}}catch{_=`could not reach nixamp.com`,I();return}if(n)V.add(e);else for(let t of[...V])H(t)===H(e)&&V.delete(t);await ht()}function _t(e,t){let n=document.createElement(`button`);n.type=`button`,n.className=`heart`;let r=()=>{let t=U(e);n.textContent=t?`♥`:`♡`,n.dataset.on=t?`yes`:`no`,n.title=t?`Remove from favourites`:`Add to favourites`,n.setAttribute(`aria-label`,n.title)};return r(),n.addEventListener(`click`,n=>{n.stopPropagation();let i=[...V].find(t=>H(t)===H(e))??e;gt(U(e)?i:e,t,!U(e)).then(r)}),n}function vt(){let e=o===`remote`?j.shareLink:``;if(n.favHere.hidden=!(Y&&e),n.favHere.hidden)return;let t=U(e);n.favHere.textContent=t?`♥`:`♡`,n.favHere.dataset.on=t?`yes`:`no`,n.favHere.title=t?`Remove this server from your favourites`:`Add this server to your favourites`,n.favHere.setAttribute(`aria-label`,n.favHere.title)}i(n.copyNow,`copy`),n.copyNow.addEventListener(`click`,()=>{$(A.source,n.copyNow,`✓`)}),n.favHere.addEventListener(`click`,()=>{let e=on()||j.shareLink;if(!e)return;let t=[...V].find(t=>H(t)===H(e))??e;gt(U(e)?t:e,s||j.address,!U(e)).then(vt)});let W=[],G=null,K=null,yt=``,q=[],bt=0,xt=null,St=0;function Ct(e){if(!e)return`never`;let t=Math.max(0,Math.round((Date.now()-e)/1e3));if(t<90)return`just now`;let n=Math.round(t/60);if(n<90)return`${n} min ago`;let r=Math.round(n/60);return r<36?`${r} h ago`:`${Math.round(r/24)} d ago`}async function wt(){if(o!==`remote`){n.catalogsPanel.hidden=!0;return}let e;try{e=await fetch(j.url(`/api/catalogs`))}catch{n.catalogsPanel.hidden=!0;return}if(!e.ok){n.catalogsPanel.hidden=!0;return}W=(await e.json().catch(()=>({}))).catalogs??[],G&&=W.find(e=>e.id===G?.id)??null,G||(K=null),n.catalogsPanel.hidden=!1,Tt()}function Tt(){let e=!n.adminPanel.hidden;n.catalogsForm.hidden=!e;let t=W.reduce((e,t)=>e+t.live,0),r=W.reduce((e,t)=>e+t.vod,0);n.catalogsNote.textContent=W.length===0?e?`No catalogs yet. Add an m3u list of channels or films.`:`No catalogs yet.`:`${W.length} ${W.length===1?`catalog`:`catalogs`} · ${t} live ${t===1?`channel`:`channels`} · ${r} on demand`,Et();let i=G!==null&&K!==null;if(n.catalogsList.hidden=i,n.catalogsFilter.hidden=!i,n.catalogsEntries.hidden=!i,i){jt();return}if(G){Ot(G);return}n.catalogsList.replaceChildren(...W.map(t=>Dt(t,e)))}function Et(){let e=G!==null;if(n.catalogsCrumbs.hidden=!e,!e)return;let t=(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`,t),r},r=()=>{let e=document.createElement(`span`);return e.textContent=`/`,e},i=[t(`All catalogs`,()=>{G=null,K=null,Tt()},!1),r(),t(G?.name??``,()=>{K=null,Tt()},K===null)];K!==null&&i.push(r(),t(K===``?`All groups`:K,()=>void 0,!0)),n.catalogsCrumbs.replaceChildren(...i)}function Dt(e,t){let n=document.createElement(`li`),r=document.createElement(`span`);r.className=`server-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name;let a=document.createElement(`span`);if(a.className=`detail`,a.textContent=[`${e.entries.toLocaleString()} ${e.entries===1?`entry`:`entries`}`,`${e.live.toLocaleString()} live`,`${e.vod.toLocaleString()} on demand`,`refreshed ${Ct(e.refreshedAt)}`].join(` · `),r.append(i,a),t&&e.error){let t=document.createElement(`span`);t.className=`detail`,t.textContent=e.error,r.append(t)}let o=document.createElement(`button`);if(o.type=`button`,o.className=`button`,o.textContent=`Browse`,o.addEventListener(`click`,()=>{G=e,K=null,Tt()}),n.append(r,o),t){let t=document.createElement(`button`);t.type=`button`,t.className=`ghost`,t.textContent=`Refresh`,t.title=`Read the list again`,t.addEventListener(`click`,()=>{Nt(e)});let r=document.createElement(`button`);r.type=`button`,r.className=`ghost`,r.textContent=`Remove`,r.title=`Take this catalog off the server`,r.addEventListener(`click`,()=>{confirm(`Remove ${e.name} from this server?`)&&Pt(e)}),n.append(t,r)}return n}async function Ot(e){n.catalogsList.replaceChildren();let t=[];try{let n=await x(()=>fetch(j.url(`/api/catalogs/${encodeURIComponent(e.id)}/groups`)));if(!n.ok)throw Error(String(n.status));t=(await n.json()).groups??[]}catch{_=`Could not read the groups in ${e.name}.`,I();return}if(G?.id!==e.id||K!==null)return;let r=[kt(`All groups`,``,e.entries,e.live,e.vod),...t.map(e=>kt(e.name||`(no group)`,e.name,e.count,e.live,e.vod))];n.catalogsList.replaceChildren(...r)}function kt(e,t,r,i,a){let o=document.createElement(`li`),s=document.createElement(`span`);s.className=`server-label`;let c=document.createElement(`span`);c.className=`name`,c.textContent=e;let l=document.createElement(`span`);l.className=`detail`,l.textContent=`${r.toLocaleString()} · ${i.toLocaleString()} live · ${a.toLocaleString()} on demand`,s.append(c,l);let u=document.createElement(`button`);return u.type=`button`,u.className=`button`,u.textContent=`Open`,u.addEventListener(`click`,()=>{K=t,yt=``,n.catalogsFilter.value=``,q=[],bt=0,Tt(),At(0)}),o.append(s,u),o}async function At(e){let t=G,n=K;if(!t||n===null)return;let r=++St,i=new URLSearchParams({group:n,q:yt,offset:String(e),limit:`200`}),a;try{let e=await x(()=>fetch(j.url(`/api/catalogs/${encodeURIComponent(t.id)}/entries?${i}`)));if(!e.ok)throw Error(String(e.status));a=await e.json()}catch{_=`Could not read ${t.name}.`,I();return}r===St&&(bt=a.total??0,q=e===0?a.entries??[]:[...q,...a.entries??[]],jt())}function jt(){let t=G;if(!t)return;let r=q.map(n=>{let r=document.createElement(`li`);r.className=`row`;let a=document.createElement(`span`);a.className=`name`,a.textContent=n.title;let o=document.createElement(`span`);if(o.className=n.live?`catalog-tag catalog-live`:`catalog-tag`,o.textContent=n.live?`LIVE`:n.duration>0?e(n.duration):`VOD`,r.append(a,o),!n.live){let e=document.createElement(`button`);e.type=`button`,e.className=`row-copy`,i(e,`copy`),e.title=`Copy this entry's URL`,e.setAttribute(`aria-label`,`Copy the URL of ${n.title}`),e.addEventListener(`click`,r=>{r.stopPropagation();let i=`/api/catalogs/${encodeURIComponent(t.id)}/entries/${encodeURIComponent(n.id)}/stream`;$(j.url(i),e,`✓`)}),r.append(e)}return ke()&&r.append(Me(()=>({kind:`entry`,catalog:{id:t.id,name:t.name},entry:n}),n.title)),r.addEventListener(`click`,()=>{Mt(t,n,r)}),r});if(q.length===0){let e=document.createElement(`li`);e.className=`group`;let t=document.createElement(`span`);t.className=`group-name`,t.textContent=yt?`Nothing called "${yt}" here.`:`Nothing in this group.`,e.append(t),r.push(e)}else if(q.length<bt){let e=document.createElement(`li`);e.className=`group`;let t=document.createElement(`button`);t.type=`button`,t.className=`ghost`,t.textContent=`Show more (${q.length.toLocaleString()} of ${bt.toLocaleString()})`,t.addEventListener(`click`,e=>{e.stopPropagation(),At(q.length)}),e.append(t),r.push(e)}n.catalogsEntries.replaceChildren(...r)}async function Mt(e,t,n){n?.classList.add(`loading`),_=`Starting ${t.title}…`;let r={catalog:{id:e.id,name:e.name},entry:t};try{await x(async()=>{let n,i={};try{n=await fetch(j.url(`/api/catalogs/${encodeURIComponent(e.id)}/entries/${encodeURIComponent(t.id)}/play`),{method:`POST`}),i=await n.json().catch(()=>({}))}catch{_=`could not reach the server`;return}if(!n.ok){_=i.error??`${t.title} would not play.`;return}let a=i.name||t.title;if(i.kind===`live`&&i.channel){await en({id:i.channel,name:a,video:!0},!0,{kind:`channel`,...r});return}if(i.kind===`vod`&&i.url){b=null,y=-1,S={kind:`vod`,...r},await A.load({title:a,artist:``,album:``,duration:0,url:j.url(i.url),video:!0,objectUrl:!1},!0),R(!0),_=`Playing ${a}.`;return}_=`${t.title} would not play.`})}finally{n?.classList.remove(`loading`),I()}}async function Nt(e){B(`Reading ${e.name} again…`);try{let t=await fetch(j.url(`/api/catalogs/${encodeURIComponent(e.id)}/refresh`),{method:`POST`}),n=await t.json().catch(()=>({}));B(t.ok?`${n.catalog?.name??e.name}: ${(n.catalog?.entries??0).toLocaleString()} entries.`:n.error??`that did not work`)}catch{B(`could not reach the server`)}wt()}async function Pt(e){try{B((await fetch(j.url(`/api/catalogs/${encodeURIComponent(e.id)}`),{method:`DELETE`})).ok?`${e.name} is off the server.`:`that did not work`)}catch{B(`could not reach the server`)}G?.id===e.id&&(G=null,K=null),wt()}n.catalogsForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.catalogSource.value.trim(),r=n.catalogName.value.trim();t&&(async()=>{B(`Reading ${r||t}…`);try{let e=await fetch(j.url(`/api/catalogs`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({source:t,name:r})}),i=await e.json().catch(()=>({}));if(!e.ok){B(i.error??`that did not work`);return}B(`${i.catalog?.name??r??t}: ${(i.catalog?.entries??0).toLocaleString()} entries.`),n.catalogSource.value=``,n.catalogName.value=``}catch{B(`could not reach the server`)}wt()})()}),n.catalogsFilter.addEventListener(`input`,()=>{xt&&clearTimeout(xt),xt=setTimeout(()=>{xt=null,yt=n.catalogsFilter.value.trim(),At(0)},250)});let Ft=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()}),he(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 Ft()}catch{s.disabled=!1}})()}),t.append(r,o,s),n.serversList.append(t)}}catch{n.serversPanel.hidden=!0}},It=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}},Lt=(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),It())}catch{}finally{n.disabled=!1}})()}),n},Rt=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},zt=()=>`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window,Bt=async()=>{if(!zt())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:Rt(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}},Vt=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{}},Ht=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`}},Ut=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=zt()&&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 Bt();n.notifyWeb.checked=e,await Ht({wantsWeb:e});return}await Vt(),await Ht({wantsWeb:!1}),n.notifyNote.textContent=`Turn on “On this device” to be told here.`})()}),n.notifyEmail.addEventListener(`change`,()=>{Ht({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 Ht({wantsSms:n.notifySms.checked})})()}),n.notifyPhoneForm.addEventListener(`submit`,e=>{e.preventDefault(),Ht({phone:n.notifyPhone.value.trim()})});let J=!1,Y=``,Wt=e=>{let t=e!==null;n.notifyPanel.hidden=!t,t?(Ut(),It(),Ft()):(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}.`:J?`Create an account on nixamp.com.`:`Listening needs no account. Sign in to keep favourites, follow people, and publish.`,n.accountSubmit.textContent=J?`Create account`:`Sign in`,n.accountToggle.textContent=J?`I have one`:`Create one`,n.accountPassword.autocomplete=J?`new-password`:`current-password`},Gt=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)}},Kt=async()=>{try{let e=await fetch(`/api/v1/auth/me`),t=await e.json();Y=e.ok?t.account?.id??``:``,Wt(e.ok?t.account?.email??`you`:null)}catch{Y=``,Wt(null)}ht(),qt()};function qt(){if(De===``)return;let e=De;De=``,n.remoteUrl.value=e,n.remoteForm.requestSubmit()}n.accountToggle.addEventListener(`click`,()=>{J=!J,Wt(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/${J?`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}Y=i.account?.id??``,n.accountPassword.value=``,Wt(i.account?.email??t),dt(),qt()}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{}Y=``,Wt(null),j.close(),y=-1,o=`local`,h=`idle`,g=``,n.remoteUrl.value=``,n.sharePanel.hidden=!0,n.publishPanel.hidden=!0,n.adminPanel.hidden=!0,n.onairPanel.hidden=!0,n.catalogsPanel.hidden=!0,n.listenOnly.hidden=!0,Xt(!1);try{localStorage.removeItem(Se)}catch{}_=`Signed out, and disconnected from the server.`,dt(),I()})()});try{let e=new URL(globalThis.location.href).searchParams,t=e.get(`url`)??``;t!==``&&(De=t,l=e.get(`play`)??``,u=Math.max(0,Number(e.get(`t`)??`0`)||0),n.remoteUrl.value=t,_=`Opening the stream you were sent…`,globalThis.history?.replaceState(null,``,globalThis.location.pathname))}catch{}Gt(),Kt(),dt(),n.browse.addEventListener(`click`,()=>{if(!n.directory.hidden){n.directory.hidden=!0;return}rt(),n.directory.scrollIntoView({behavior:`smooth`,block:`nearest`})}),n.disconnect.addEventListener(`click`,()=>{j.close(),n.listenOnly.hidden=!0,y=-1,n.sharePanel.hidden=!0,n.publishPanel.hidden=!0,n.adminPanel.hidden=!0,n.onairPanel.hidden=!0,n.onairPanel.dataset.title=`Live on this server`,n.catalogsPanel.hidden=!0,n.catalogsPanel.dataset.title=`Catalogs on this server`,s=``,d=``,vt(),Xt(!1),o=`local`,h=`idle`,g=``,I()});async function X(){if(o!==`remote`||j.shareLink===``){n.sharePanel.hidden=!0;return}n.sharePanel.hidden=!1;let e=on(),t=globalThis.location.origin;n.shareLink.value=e===``?``: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 r=``;try{let e=await fetch(`/api/directory`);e.ok&&(r=(await e.json()).callIn??``)}catch{}let i=null;try{let r=await fetch(j.url(`/api/live/state`));r.ok&&(i=await r.json()),i?.url&&(e=i.url,d=i.url,n.shareLink.value=e.startsWith(`https://`)?`${t}/?url=${encodeURIComponent(e)}`:e)}catch{}if(i){if(de=i.live,w=i.live?i.code:``,T=r,n.liveControls.hidden=n.adminPanel.hidden||!i.possible,n.goLive.hidden=i.live,n.stopLive.hidden=!i.live,n.sharePhone.hidden=!1,!i.live){n.sharePhone.textContent=i.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(!r){n.sharePhone.textContent=`Listed. The code for the phone line is ${i.code}.`,n.shareSend.hidden=!1;return}n.sharePhone.replaceChildren(document.createTextNode(`To talk about it, call `),fn(r),document.createTextNode(` and key `),fn(i.code),document.createTextNode(`. That is a room with everyone else watching — not the stream itself.`)),n.shareSend.hidden=!1}}let Jt=``,Yt=null,Xt=e=>{Yt&&clearInterval(Yt),Yt=null,e&&(Yt=setInterval(()=>void Z(),6e3))};async function Z(){if(o!==`remote`){n.onairPanel.hidden=!0;return}let e;try{let t=await fetch(j.url(`/api/streams`));if(!t.ok){n.onairPanel.hidden=!0;return}e=await t.json(),e.server.name&&e.server.name!==s&&(s=e.server.name,n.onairPanel.dataset.title=`Live on ${s}`,n.catalogsPanel.dataset.title=`Catalogs on ${s}`,vt(),I())}catch{n.onairPanel.hidden=!0;return}n.onairPanel.hidden=!1,C=e,sn(e);let t=`${n.adminPanel.hidden?`view`:`drive`}:${JSON.stringify(e)}`;if(t===Jt)return;Jt=t;let r=e.restreams??[],i=e.channels.length+r.length;n.onairNote.textContent=i===0?`One stream, from this server's own files.`:`${i+1} streams: this server's own files, and ${i} more on it.`;let a=[],c=e.server.playing,l=!n.adminPanel.hidden;a.push(cn({title:e.server.name,detail:[c?`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:c?`Join live`:l?`Start the stream`:`Nothing playing`,onPlay:()=>{if(c){$t(e.server.nowPlaying);return}l&&Qt()},link:e.server.live?e.server.url:``,...c?{page:an(`live`)}:{},direct:c?j.url(`/api/live`):``}));for(let e of r)a.push(cn({title:e.name,detail:e.tracks===1?`re-streamed from the web`:`re-streamed from the web · ${e.tracks} tracks`,onPlay:()=>{P(e.at)},link:``,direct:j.media(e.at)}));for(let t of e.channels){let e=t.kind!==`audio`,n=j.url(`/api/channels/${encodeURIComponent(t.id)}`),r=[t.via===`pull`?`on the air · ${t.listeners} watching`:`live over ${t.via} · ${t.listeners} listening`];t.code&&r.push(`☎ ${t.code}`),t.redials&&r.push(`redialled ${t.redials}×`),l&&t.error&&r.push(t.error),a.push(cn({title:t.name,detail:r.join(` · `),onPlay:()=>{en({id:t.id,name:t.name,video:e})},link:n,page:an(`channel:${t.id}`),direct:n,onRestart:l&&t.via===`pull`?()=>{nn(t.id,t.name)}:void 0,onStop:l?()=>{rn(t.id,t.name)}:void 0}))}n.onairList.replaceChildren(...a)}async function Zt(){if(o===`remote`)try{if((await fetch(j.media(N(),0),{method:`GET`,headers:{range:`bytes=0-1`}})).status!==402)return;_=Y===``?`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.`,Y===``&&n.accountPanel.scrollIntoView({behavior:`smooth`,block:`center`}),I()}catch{}}async function Qt(){B(`Starting the stream on the server…`);try{await j.send({type:`play`,index:Math.max(0,N())})}catch{B(`could not reach the server`);return}await $t(m.tracks[N()]?.title??``),B(`Playing to the room. Anybody with the view link sees this.`),await Z()}async function $t(e){y=-1,b=null,S={kind:`live`},await x(()=>A.load({title:e||`Live`,artist:``,album:``,duration:0,url:j.url(`/api/live`),video:!0,objectUrl:!1},!0)),R(!0),_=`Watching what this server is playing. Everyone here sees the same thing.`,I()}async function en(e,t=!0,n){y=-1,b=e,t&&(ne=0),t&&(S=n??{kind:`channel`}),await x(()=>A.load({title:e.name,artist:``,album:``,duration:0,url:j.url(`/api/channels/${encodeURIComponent(e.id)}`),video:e.video,objectUrl:!1},!0)),R(e.video),b===e&&(_=`Watching ${e.name}, live on this server.`),I()}function tn(){let e=b;return e?Ee?!0:ne>=5?(_=`${e.name} stopped, and did not come back.`,b=null,S=null,I(),!0):(ne+=1,_=`${e.name} started over; rejoining…`,I(),Ee=setTimeout(()=>{Ee=null,b===e&&en(e,!1)},2e3),!0):!1}function Q(e){n.onairNote.textContent=e,B(e)}async function nn(e,t){Q(`Restarting ${t}…`);try{let n=await fetch(j.url(`/api/channels/${encodeURIComponent(e)}/restart`),{method:`POST`}),r=await n.json().catch(()=>({}));Q(n.ok?`${t} is dialling its source again.`:r.error??`that did not work`)}catch{Q(`could not reach the server`)}Jt=``,Z()}async function rn(e,t){Q(`Taking ${t} off the air…`);try{let n=await fetch(j.url(`/api/channels/${encodeURIComponent(e)}`),{method:`DELETE`}),r=await n.json().catch(()=>({}));Q(n.ok?`${t} is off the air.`:r.error??`that did not work`)}catch{Q(`could not reach the server`)}b?.id===e&&(b=null,A.stop()),Jt=``,Z()}async function $(e,t,n=`Copied`){if(!e)return;let r=t.innerHTML;try{await navigator.clipboard.writeText(e)}catch{_=e,I();return}n===`✓`||n===`✓`?i(t,`check`):t.textContent=n,setTimeout(()=>{t.innerHTML=r},1200)}function an(e,t=0){let n=on();if(n===``)return``;let r=globalThis.location.origin,i=t>1?`&t=${Math.floor(t)}`:``;return`${r}/?url=${encodeURIComponent(n)}&play=${encodeURIComponent(e)}${i}`}function on(){if(d!==``)return d;let e=o===`remote`?j.shareLink:``;return/\/admin\//.test(e)?``:e}function sn(e){if(l===``)return;let t=l;if(t===`live`){l=``,e.server.playing?$t(e.server.nowPlaying):(_=`Nothing is playing on this server right now.`,I());return}if(t.startsWith(`link:`)){l=``,tt(t.slice(5));return}let n=t.startsWith(`channel:`)?t.slice(8):``,r=e.channels.find(e=>e.id===n)??e.channels.find(e=>e.name===n);r&&(l=``,en({id:r.id,name:r.name,video:r.kind!==`audio`}))}function cn(e){let t=document.createElement(`li`);t.className=`onair`;let n=document.createElement(`span`);n.className=`recent-label`;let r=document.createElement(`span`);r.className=`name`,r.textContent=e.title;let a=document.createElement(`span`);a.className=`detail`,a.textContent=e.detail,n.append(r,a);let o=document.createElement(`span`);o.className=`onair-actions`;let s=document.createElement(`button`);s.type=`button`,s.className=`button`,s.textContent=e.playLabel??`Play`,s.addEventListener(`click`,e.onPlay),o.append(s);let c=(e,t,n)=>{let r=document.createElement(`button`);return r.type=`button`,r.className=`icon`,i(r,e),r.title=t,r.setAttribute(`aria-label`,t),r.addEventListener(`click`,()=>n(r)),r};return(e.link||e.page)&&o.append(c(`link`,`Copy a link that opens this in the player`,t=>{let n=globalThis.location.origin;$(e.page??(e.link.startsWith(`https://`)?`${n}/?url=${encodeURIComponent(e.link)}`:e.link),t,`✓`)})),e.direct&&o.append(c(`copy`,`Copy the stream's own URL, for VLC or mpv`,t=>{$(e.direct??``,t,`✓`)})),e.onRestart&&o.append(c(`restart`,`Restart: dial the source again`,()=>e.onRestart?.())),e.onStop&&o.append(c(`remove`,`Remove: take it off the air`,()=>e.onStop?.())),t.append(n,o),t}let ln=``;function un(e,t){ln=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`,()=>{ln!==``&&(n.loadHome.disabled=!0,B(`Reading this server's files…`),(async()=>{try{let e=await fetch(j.url(`/api/source`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({source:ln})}),t=await e.json();B(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{B(`could not reach the server`)}finally{n.loadHome.disabled=!1}})())});let dn=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(j.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 X()}};n.goLive.addEventListener(`click`,()=>void dn(!0)),n.stopLive.addEventListener(`click`,()=>void dn(!1));function fn(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:on()})}),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(we,n.listenHere.checked?`1`:`0`)}catch{}o===`remote`&&(async()=>{n.listenHere.checked?(await j.send({type:`stop`}),await ze(m.index)):(A.stop(),y=-1),I()})()}),document.addEventListener(`keydown`,e=>{let t=e.target;if(!(t&&/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))switch(e.key){case` `:e.preventDefault(),Be();return;case`s`:Ve();return;case`n`:case`ArrowRight`:F(1);return;case`p`:case`ArrowLeft`:F(-1);return;case`ArrowDown`:e.preventDefault(),P(Math.min(M()-1,N()+1));return;case`ArrowUp`:e.preventDefault(),P(Math.max(0,N()-1));return}});let pn=null;globalThis.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),pn=e,n.install.hidden=!1}),n.install.addEventListener(`click`,()=>{pn?.prompt(),pn=null,n.install.hidden=!0});try{let e=localStorage.getItem(Ce);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),A.volume=Number(e));let t=localStorage.getItem(Se);t&&(n.remoteUrl.value=t),localStorage.getItem(we)===`0`&&(n.listenHere.checked=!1)}catch{}(async()=>{if(n.remoteUrl.value!==``)return;let e=globalThis.location.origin;if(await he(e)===null)return;let t=await fe(e);t&&t.trackCount!==0&&(n.remoteUrl.value=e,o=`remote`,_=``,j.connect(e),I())})(),(()=>{if(Te||De!==``)return;let e=()=>A.source!==``||A.playing||ce()||b!==null,t=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``},n=new Audio;n.volume=.7;let r=()=>{Te=!0},i=()=>{document.removeEventListener(`pointerdown`,i),document.removeEventListener(`keydown`,i),r(),setTimeout(()=>{e()||n.play().catch(()=>{})},150)};t().then(t=>{if(!(t===``||e()))return n.src=t,n.play().then(r,()=>{document.addEventListener(`pointerdown`,i,{once:!0}),document.addEventListener(`keydown`,i,{once:!0})})})})(),I(),requestAnimationFrame($e)}Ee(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});export{c as t};