nixamp 0.5.12 → 0.6.1

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/server.ts CHANGED
@@ -28,6 +28,7 @@ import { Ingest, normaliseFormat } from "./ingest.ts";
28
28
  import { Channels, cleanId } from "./channels.ts";
29
29
  import { RtmpListeners } from "./rtmp-in.ts";
30
30
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
31
+ import { Servers } from "./servers.ts";
31
32
  import { DeviceGrants } from "./device.ts";
32
33
  import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.ts";
33
34
  import {
@@ -685,7 +686,14 @@ const OAUTH_ROUTE = /^\/api\/v1\/([a-z0-9-]+)\/oauth\/(start|callback)$/;
685
686
  * the share-key check rather than behind it.
686
687
  */
687
688
  export function isSignInPath(path: string): boolean {
688
- return path.startsWith("/api/v1/auth/") || OAUTH_ROUTE.test(path);
689
+ return (
690
+ path.startsWith("/api/v1/auth/") ||
691
+ // The account's own server list: nixamp.com's API, gated by the session
692
+ // rather than by a share key it has nothing to do with.
693
+ path === "/api/v1/servers" ||
694
+ path.startsWith("/api/v1/servers/") ||
695
+ OAUTH_ROUTE.test(path)
696
+ );
689
697
  }
690
698
 
691
699
  function html(response: ServerResponse, code: number, body: string): void {
@@ -743,6 +751,12 @@ export interface HandlerOptions {
743
751
  * imported so the handler stays a plain function of a request.
744
752
  */
745
753
  load: (source: string) => Promise<Track[]>;
754
+ /**
755
+ * The same source, with its tags, read without holding the event loop. Called
756
+ * after `load` and never awaited: the titles arrive into a player that is
757
+ * already playing.
758
+ */
759
+ tag?: (source: string) => Promise<Track[]>;
746
760
  /**
747
761
  * The public directory, on the instance that hosts one. Only nixamp.com
748
762
  * passes this; a nixamp on your laptop is a publisher, not a registry.
@@ -762,6 +776,8 @@ export interface HandlerOptions {
762
776
  accounts?: Accounts;
763
777
  /** Providers to sign in with, and the terminals waiting to be connected. */
764
778
  signIn?: SignIn;
779
+ /** The servers each account runs, on the instance that keeps accounts. */
780
+ servers?: Servers;
765
781
  /** True when this instance is reached over https, for the cookie's Secure. */
766
782
  secureCookies?: boolean;
767
783
  /** A certificate and key in PEM, when this server is to speak https itself. */
@@ -1240,6 +1256,77 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1240
1256
  return;
1241
1257
  }
1242
1258
 
1259
+ // --- the servers this account runs ----------------------------------
1260
+ //
1261
+ // Kept against the account rather than the machine, so the list reads the
1262
+ // same from the CLI, the PWA and the desktop app -- which is the whole
1263
+ // point: a share link in a terminal you closed is a server you have lost.
1264
+ if ((path === "/api/v1/servers" || path.startsWith("/api/v1/servers/")) && options.servers) {
1265
+ const servers = options.servers;
1266
+ const who = await accounts.whoIs(tokenFrom(request.headers));
1267
+ if (who === null) {
1268
+ json(response, 401, { error: "not signed in" });
1269
+ return;
1270
+ }
1271
+
1272
+ if (path === "/api/v1/servers" && request.method === "GET") {
1273
+ json(response, 200, { servers: await servers.list(who.id) });
1274
+ return;
1275
+ }
1276
+
1277
+ if (path === "/api/v1/servers" && request.method === "POST") {
1278
+ let body: { name?: unknown; url?: unknown; key?: unknown };
1279
+ try {
1280
+ body = JSON.parse(await readBody(request)) as typeof body;
1281
+ } catch {
1282
+ json(response, 400, { error: "bad JSON" });
1283
+ return;
1284
+ }
1285
+ const made = await servers.add(who, {
1286
+ ...(typeof body.name === "string" ? { name: body.name } : {}),
1287
+ ...(typeof body.url === "string" ? { url: body.url } : {}),
1288
+ ...(typeof body.key === "string" ? { key: body.key } : {}),
1289
+ });
1290
+ if (made === null) {
1291
+ json(response, 422, { error: "that needs an http or https address" });
1292
+ return;
1293
+ }
1294
+ json(response, 201, { server: made });
1295
+ return;
1296
+ }
1297
+
1298
+ const id = path.slice("/api/v1/servers/".length);
1299
+ if (id && request.method === "DELETE") {
1300
+ const gone = await servers.remove(who.id, id);
1301
+ json(response, gone ? 200 : 404, gone ? { ok: true } : { error: "no such server" });
1302
+ return;
1303
+ }
1304
+
1305
+ if (id && (request.method === "PATCH" || request.method === "PUT")) {
1306
+ let body: { name?: unknown; url?: unknown; key?: unknown };
1307
+ try {
1308
+ body = JSON.parse(await readBody(request)) as typeof body;
1309
+ } catch {
1310
+ json(response, 400, { error: "bad JSON" });
1311
+ return;
1312
+ }
1313
+ const changed = await servers.update(who.id, id, {
1314
+ ...(typeof body.name === "string" ? { name: body.name } : {}),
1315
+ ...(typeof body.url === "string" ? { url: body.url } : {}),
1316
+ ...(typeof body.key === "string" ? { key: body.key } : {}),
1317
+ });
1318
+ if (changed === null) {
1319
+ json(response, 404, { error: "no such server, or a bad address" });
1320
+ return;
1321
+ }
1322
+ json(response, 200, { server: changed });
1323
+ return;
1324
+ }
1325
+
1326
+ json(response, 405, { error: "GET, POST, PATCH or DELETE" });
1327
+ return;
1328
+ }
1329
+
1243
1330
  // --- tokens a person made on purpose --------------------------------
1244
1331
  if (path === "/api/v1/auth/tokens" || path.startsWith("/api/v1/auth/tokens/")) {
1245
1332
  const who = await accounts.whoIs(tokenFrom(request.headers));
@@ -1835,6 +1922,15 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1835
1922
  return;
1836
1923
  }
1837
1924
  engine.replace(tracks, source);
1925
+ // Names now, tags later, here as much as at startup: re-streaming a
1926
+ // directory of five thousand files used to read every tag before it
1927
+ // answered, with the event loop held the whole time.
1928
+ if (options.tag) {
1929
+ void options
1930
+ .tag(source)
1931
+ .then((tagged) => engine.retag(tagged, source))
1932
+ .catch(() => {});
1933
+ }
1838
1934
  json(response, 200, engine.snapshot());
1839
1935
  } catch (error) {
1840
1936
  json(response, 422, { error: (error as Error).message.replace(/^nixamp: /, "") });
@@ -2496,7 +2592,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2496
2592
  ffmpeg: tools.ffmpeg,
2497
2593
  ffprobe: tools.ffprobe,
2498
2594
  ...(tls ? { tls } : {}),
2499
- load: (next) => loadSource(tools, next),
2595
+ // Untagged, so a directory of five thousand files answers at once; the
2596
+ // tags follow through `tag` below.
2597
+ load: (next) => loadSource(tools, next, false),
2598
+ tag: (next) => loadTagged(tools, next),
2500
2599
  ...(directory ? { directory } : {}),
2501
2600
  ...(follows ? { follows, vapidPublicKey } : {}),
2502
2601
  ...(partyLine ? { partyLine } : {}),
@@ -2517,6 +2616,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2517
2616
  // Whichever providers this deployment was given both halves of, plus
2518
2617
  // the device grant, which is worth having even with no provider at
2519
2618
  // all: a browser already signed in can approve a terminal.
2619
+ // The same pool the follows and reminders use: three small tables in
2620
+ // one database do not want three sets of connections.
2621
+ ...(pool ? { servers: new Servers(pool) } : {}),
2520
2622
  signIn: new SignIn(
2521
2623
  providersFrom(process.env),
2522
2624
  new DeviceGrants(),
package/src/servers.ts ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * The servers a person runs.
3
+ *
4
+ * A nixamp is a machine somewhere with an address and a key, and until now the
5
+ * only record of one was the share link in whatever terminal printed it. Lose
6
+ * the terminal and you have lost the server: the daemon is still playing, and
7
+ * nothing anywhere can tell you where. This is the list, kept against the
8
+ * account rather than the machine, so it reads the same from the CLI, the PWA
9
+ * and the desktop app.
10
+ *
11
+ * The share key is optional and, when given, is the listen-or-control secret
12
+ * for somebody else's machine sitting in our database. It is stored because a
13
+ * list you cannot click is half a feature, and it is optional because that is
14
+ * a choice which belongs to the person whose server it is.
15
+ */
16
+ import { randomBytes } from "node:crypto";
17
+ import type { Account } from "./accounts.ts";
18
+ import type { Queryable } from "./follows.ts";
19
+
20
+ const TABLE = "nixamp_servers";
21
+
22
+ const SCHEMA = `
23
+ CREATE TABLE IF NOT EXISTS ${TABLE} (
24
+ id TEXT PRIMARY KEY,
25
+ user_id TEXT NOT NULL,
26
+ name TEXT NOT NULL DEFAULT '',
27
+ url TEXT NOT NULL,
28
+ share_key TEXT NOT NULL DEFAULT '',
29
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
30
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
31
+ last_seen_at TIMESTAMPTZ
32
+ );
33
+ CREATE INDEX IF NOT EXISTS ${TABLE}_user ON ${TABLE} (user_id);
34
+ CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_user_url ON ${TABLE} (user_id, url);
35
+ `;
36
+
37
+ export interface ServerEntry {
38
+ id: string;
39
+ name: string;
40
+ url: string;
41
+ /** Empty when the owner chose not to keep it here. */
42
+ key: string;
43
+ createdAt: number;
44
+ updatedAt: number;
45
+ lastSeenAt: number | null;
46
+ }
47
+
48
+ /** What a caller may set. Anything else is the server's business. */
49
+ export interface ServerInput {
50
+ name?: string;
51
+ url?: string;
52
+ key?: string;
53
+ }
54
+
55
+ function asTime(value: unknown): number | null {
56
+ if (value instanceof Date) return value.getTime();
57
+ if (typeof value === "string") {
58
+ const at = Date.parse(value);
59
+ return Number.isNaN(at) ? null : at;
60
+ }
61
+ return typeof value === "number" ? value : null;
62
+ }
63
+
64
+ function toEntry(row: Record<string, unknown>): ServerEntry {
65
+ return {
66
+ id: String(row["id"] ?? ""),
67
+ name: String(row["name"] ?? ""),
68
+ url: String(row["url"] ?? ""),
69
+ key: String(row["share_key"] ?? ""),
70
+ createdAt: asTime(row["created_at"]) ?? 0,
71
+ updatedAt: asTime(row["updated_at"]) ?? 0,
72
+ lastSeenAt: asTime(row["last_seen_at"]),
73
+ };
74
+ }
75
+
76
+ /**
77
+ * An address a browser could open, with nothing else smuggled in.
78
+ *
79
+ * A stored URL is handed back to other clients and put in an href, so a
80
+ * javascript: or data: entry here is a link somebody else clicks. Only http
81
+ * and https, and nothing after the origin: a nixamp is a host and a port.
82
+ */
83
+ export function cleanUrl(value: unknown): string {
84
+ if (typeof value !== "string" || value.trim() === "") return "";
85
+ let parsed: URL;
86
+ try {
87
+ parsed = new URL(value.trim());
88
+ } catch {
89
+ return "";
90
+ }
91
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
92
+ return parsed.origin;
93
+ }
94
+
95
+ /** A name is a label, not an essay, and never empty on screen. */
96
+ export function cleanName(value: unknown, url: string): string {
97
+ const given = typeof value === "string" ? value.trim().slice(0, 60) : "";
98
+ if (given) return given;
99
+ // The host is a better default than "untitled", and it is what somebody
100
+ // would have typed anyway.
101
+ try {
102
+ return new URL(url).host;
103
+ } catch {
104
+ return "a nixamp";
105
+ }
106
+ }
107
+
108
+ export class Servers {
109
+ private ready: Promise<void> | null = null;
110
+
111
+ constructor(private readonly db: Queryable) {}
112
+
113
+ private async ensure(): Promise<void> {
114
+ this.ready ??= this.db.query(SCHEMA).then(() => undefined);
115
+ await this.ready;
116
+ }
117
+
118
+ async list(userId: string): Promise<ServerEntry[]> {
119
+ await this.ensure();
120
+ const { rows } = await this.db.query(
121
+ `SELECT id, name, url, share_key, created_at, updated_at, last_seen_at FROM ${TABLE}
122
+ WHERE user_id = $1 ORDER BY created_at`,
123
+ [userId],
124
+ );
125
+ return rows.map(toEntry);
126
+ }
127
+
128
+ /**
129
+ * Remember a server, or update the one already at that address.
130
+ *
131
+ * Upserted on the address rather than refused, because the common way to
132
+ * call this twice is a daemon announcing itself after a restart, and that
133
+ * should move the entry forward rather than fail.
134
+ */
135
+ async add(account: Account, input: ServerInput): Promise<ServerEntry | null> {
136
+ const url = cleanUrl(input.url);
137
+ if (!url) return null;
138
+ await this.ensure();
139
+ const { rows } = await this.db.query(
140
+ `INSERT INTO ${TABLE} (id, user_id, name, url, share_key, last_seen_at)
141
+ VALUES ($1, $2, $3, $4, $5, NOW())
142
+ ON CONFLICT (user_id, url) DO UPDATE
143
+ SET name = EXCLUDED.name, share_key = EXCLUDED.share_key,
144
+ updated_at = NOW(), last_seen_at = NOW()
145
+ RETURNING id, name, url, share_key, created_at, updated_at, last_seen_at`,
146
+ [
147
+ randomBytes(8).toString("hex"),
148
+ account.id,
149
+ cleanName(input.name, url),
150
+ url,
151
+ typeof input.key === "string" ? input.key.slice(0, 200) : "",
152
+ ],
153
+ );
154
+ return rows[0] ? toEntry(rows[0]) : null;
155
+ }
156
+
157
+ /** Scoped to the owner, so somebody else's id changes nothing. */
158
+ async update(userId: string, id: string, input: ServerInput): Promise<ServerEntry | null> {
159
+ await this.ensure();
160
+ const url = input.url === undefined ? undefined : cleanUrl(input.url);
161
+ if (input.url !== undefined && !url) return null;
162
+ const { rows } = await this.db.query(
163
+ `UPDATE ${TABLE}
164
+ SET name = COALESCE($3, name),
165
+ url = COALESCE($4, url),
166
+ share_key = COALESCE($5, share_key),
167
+ updated_at = NOW()
168
+ WHERE user_id = $1 AND id = $2
169
+ RETURNING id, name, url, share_key, created_at, updated_at, last_seen_at`,
170
+ [
171
+ userId,
172
+ id,
173
+ input.name === undefined ? null : cleanName(input.name, url ?? ""),
174
+ url ?? null,
175
+ input.key === undefined ? null : input.key.slice(0, 200),
176
+ ],
177
+ );
178
+ return rows[0] ? toEntry(rows[0]) : null;
179
+ }
180
+
181
+ async remove(userId: string, id: string): Promise<boolean> {
182
+ await this.ensure();
183
+ const { rows } = await this.db.query(
184
+ `DELETE FROM ${TABLE} WHERE user_id = $1 AND id = $2 RETURNING id`,
185
+ [userId, id],
186
+ );
187
+ return rows.length > 0;
188
+ }
189
+ }
package/src/session.ts CHANGED
@@ -549,3 +549,113 @@ export async function whoami(fetcher: typeof fetch = fetch): Promise<number> {
549
549
  return 0;
550
550
  }
551
551
  }
552
+
553
+ /**
554
+ * `nixamp server list|add|remove`, the account's own list of machines.
555
+ *
556
+ * A share link printed in a terminal you have since closed is a server you
557
+ * have lost: the daemon is still playing and nothing can tell you where. This
558
+ * keeps the address against the account, so the answer is the same here, in
559
+ * the browser and in the desktop app.
560
+ */
561
+ export async function servers(argv: string[], fetcher: typeof fetch = fetch): Promise<number> {
562
+ const session = readSession();
563
+ if (session === null) {
564
+ console.error("nixamp: not signed in. Try `nixamp login`.");
565
+ return 1;
566
+ }
567
+ const [command = "list", ...rest] = argv;
568
+ const where = `${session.site}/api/v1/servers`;
569
+ const headers = { authorization: `Bearer ${session.token}`, "content-type": "application/json" };
570
+ const at = (flag: string): string | undefined => {
571
+ const index = rest.indexOf(flag);
572
+ return index === -1 ? undefined : rest[index + 1];
573
+ };
574
+
575
+ try {
576
+ if (command === "add" || command === "register") {
577
+ // `--here` is the common case: the daemon on this machine, with the
578
+ // address and key it already printed, rather than retyped by hand.
579
+ let url = rest.find((a) => /^https?:\/\//.test(a)) ?? at("--url") ?? "";
580
+ let key = at("--key") ?? "";
581
+ if (rest.includes("--here")) {
582
+ const daemon = await import("./daemon.ts");
583
+ const state = daemon.readState();
584
+ if (state === null) {
585
+ console.error("nixamp: no daemon is running here. Start one, or pass a URL.");
586
+ return 1;
587
+ }
588
+ // The address worth remembering is the one somebody else can open.
589
+ const reachable = state.urls?.find((u) => u.label === "on the internet") ?? state.urls?.[0];
590
+ url = reachable?.url ?? "";
591
+ key = key || (state.key ?? "");
592
+ }
593
+ if (!url) {
594
+ console.error("nixamp: which server? Give a URL, or --here for the daemon on this machine.");
595
+ return 64;
596
+ }
597
+
598
+ const answer = await fetcher(where, {
599
+ method: "POST",
600
+ headers,
601
+ body: JSON.stringify({ url, name: at("--name") ?? "", ...(key ? { key } : {}) }),
602
+ });
603
+ const body = (await answer.json().catch(() => ({}))) as { server?: ServerRow; error?: string };
604
+ if (!answer.ok || !body.server) {
605
+ console.error(`nixamp: ${body.error ?? `could not add it (${answer.status})`}`);
606
+ return 1;
607
+ }
608
+ console.log(`${body.server.id} ${body.server.name} ${body.server.url}`);
609
+ return 0;
610
+ }
611
+
612
+ if (command === "remove" || command === "rm" || command === "forget") {
613
+ const id = rest.find((a) => !a.startsWith("-")) ?? "";
614
+ if (!id) {
615
+ console.error("nixamp: which one? `nixamp server list` shows their ids.");
616
+ return 64;
617
+ }
618
+ const answer = await fetcher(`${where}/${encodeURIComponent(id)}`, { method: "DELETE", headers });
619
+ if (!answer.ok) {
620
+ console.error(`nixamp: ${answer.status === 404 ? "no server with that id" : "could not remove it"}`);
621
+ return 1;
622
+ }
623
+ console.log(`Forgot ${id}.`);
624
+ return 0;
625
+ }
626
+
627
+ if (command === "list" || command === "ls") {
628
+ const answer = await fetcher(where, { headers });
629
+ const body = (await answer.json().catch(() => ({}))) as { servers?: ServerRow[]; error?: string };
630
+ if (!answer.ok) {
631
+ console.error(`nixamp: ${body.error ?? `could not list them (${answer.status})`}`);
632
+ return 1;
633
+ }
634
+ const rows = body.servers ?? [];
635
+ if (rows.length === 0) {
636
+ console.log("No servers yet. `nixamp server add --here` remembers the one on this machine.");
637
+ return 0;
638
+ }
639
+ const width = Math.max(...rows.map((row) => row.name.length));
640
+ for (const row of rows) {
641
+ const link = row.key ? `${row.url}/s/${row.key}` : row.url;
642
+ console.log(`${row.id} ${row.name.padEnd(width)} ${link}`);
643
+ }
644
+ return 0;
645
+ }
646
+
647
+ console.error(`nixamp: no such server command: ${command}`);
648
+ return 64;
649
+ } catch (error) {
650
+ console.error(`nixamp: could not reach ${session.site}: ${(error as Error).message}`);
651
+ return 69;
652
+ }
653
+ }
654
+
655
+ /** One row of the account's server list, as the API sends it. */
656
+ interface ServerRow {
657
+ id: string;
658
+ name: string;
659
+ url: string;
660
+ key: string;
661
+ }
@@ -1 +1 @@
1
- import{t as e}from"./index-B816i-Dk.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-PgqcCBW4.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-CSh9YGnu.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-BybaRBHq.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 x(e){for(let t of e)t.objectUrl&&URL.revokeObjectURL(t.url)}var S=2048;function C(e){return e===`audio`}var te=class{elements;handlers;attached=null;context=null;analyser=null;wired=new WeakSet;active;frequencies=new Uint8Array;constructor(e,t){this.elements=e,this.handlers=t,this.active=e.audio;for(let t of[e.audio,e.video])t.crossOrigin=`anonymous`,t.addEventListener(`timeupdate`,()=>{t===this.active&&this.handlers.onTime(t.currentTime,Number.isFinite(t.duration)?t.duration:0)}),t.addEventListener(`loadedmetadata`,()=>{t===this.active&&this.handlers.onTime(t.currentTime,Number.isFinite(t.duration)?t.duration:0)}),t.addEventListener(`ended`,()=>{t===this.active&&this.handlers.onEnded()}),t.addEventListener(`play`,()=>{t===this.active&&this.handlers.onState(!0)}),t.addEventListener(`pause`,()=>{t===this.active&&this.handlers.onState(!1)}),t.addEventListener(`error`,()=>{t===this.active&&this.handlers.onError(w(t))})}get playing(){return!this.active.paused&&!this.active.ended}get position(){return this.active.currentTime}get duration(){return Number.isFinite(this.active.duration)?this.active.duration:0}get showingVideo(){return this.active===this.elements.video}ensureGraph(e){let t=globalThis.AudioContext??globalThis.webkitAudioContext;if(t){if(this.context??=new t,this.analyser||(this.analyser=this.context.createAnalyser(),this.analyser.fftSize=S,this.analyser.smoothingTimeConstant=.6,this.analyser.connect(this.context.destination),this.frequencies=new Uint8Array(this.analyser.frequencyBinCount)),!this.wired.has(e))try{this.context.createMediaElementSource(e).connect(this.analyser),this.wired.add(e)}catch{this.wired.add(e)}this.context.resume()}}read(){return this.analyser&&this.analyser.getByteFrequencyData(this.frequencies),this.frequencies}levels(){if(!this.analyser)return[0,0];let e=this.read(),t=0;for(let n of e)t+=n;let n=e.length===0?0:t/e.length/255;return[Math.min(1,n*2.2),Math.min(1,n*2.2)]}async load(e,t){let n=e.objectUrl?e.video?`mp4`:`audio`:d({src:e.url}),r=e.video||!C(n)?this.elements.video:this.elements.audio;r!==this.active&&(this.active.pause(),this.active.removeAttribute(`src`),this.active.load(),this.active=r),this.attached?.destroy(),this.attached=null;try{this.attached=await h(this.active,{src:e.url,kind:n,unplayableAdvice:`VLC or mpv will play it; nixamp can only hand it to your browser.`,onError:e=>this.handlers.onError(e),onNotice:e=>{e&&this.handlers.onError(e)}})}catch(e){this.handlers.onError(e instanceof Error?e.message:`that would not play`);return}t&&await this.play()}async play(){this.ensureGraph(this.active);try{await this.active.play()}catch(e){this.handlers.onError(e instanceof Error?e.message:`playback was refused`)}}pause(){this.active.pause()}stop(){this.active.pause(),this.active.currentTime=0,this.attached?.destroy(),this.attached=null}seek(e){Number.isFinite(e)&&(this.active.currentTime=Math.max(0,e))}set volume(e){this.elements.audio.volume=e,this.elements.video.volume=e}get volume(){return this.active.volume}};function w(e){switch(e.error?.code){case MediaError.MEDIA_ERR_ABORTED:return`playback was aborted`;case MediaError.MEDIA_ERR_NETWORK:return`the network dropped mid-track`;case MediaError.MEDIA_ERR_DECODE:return`this browser could not decode that`;case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:return`this browser cannot play that format`;default:return`playback failed`}}function T(){return{revision:0,tracks:[],trackCount:0,index:0,playing:!1,position:0,bars:[],levels:[0,0],silent:!0,note:``,root:``}}function ne(e,t){return{...t,tracks:t.tracks??e.tracks}}function E(e){let t=e.trim();if(t===``)return``;/^https?:\/\//i.test(t)||(t=`http://${t}`);let n;try{n=new URL(t)}catch{return``}let r=n.pathname.replace(/\/+$/,``);return r=r.replace(/\/api(\/.*)?$/,``),`${n.origin}${r}`}function D(e,t){return`${e===``?``:E(e)}${t.startsWith(`/`)?t:`/${t}`}`}function O(e,t){return D(e,`/api/media/${t}`)}function k(e){if(typeof e!=`object`||!e)return null;let t=e,n=T(),r=(e,t)=>typeof e==`number`&&Number.isFinite(e)?e:t,i=Array.isArray(t.levels)?t.levels:[],a=Array.isArray(t.tracks)?t.tracks.map(e=>{let t=typeof e==`object`&&e?e:{};return{title:typeof t.title==`string`?t.title:`Untitled`,artist:typeof t.artist==`string`?t.artist:``,album:typeof t.album==`string`?t.album:``,duration:r(t.duration,0),...t.video===!0?{video:!0}:{}}}):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 re=class{handlers;source=null;base=``;lastRevision=-1;constructor(e){this.handlers=e}get address(){return this.base}get connected(){return this.source!==null}connect(e){let t=E(e);this.close(),this.base=t,this.lastRevision=-1,this.handlers.onStatus(`connecting`);let n=new EventSource(D(t,`/api/events`));this.source=n,n.onopen=()=>this.handlers.onStatus(`live`),n.onmessage=e=>{let t=k(A(e.data));t&&(t.revision<this.lastRevision||(this.lastRevision=t.revision,this.handlers.onStatus(`live`),this.handlers.onSnapshot(t)))},n.onerror=()=>{this.handlers.onStatus(`error`,`reconnecting…`)}}async send(e){if(this.base===``&&!this.connected)return;let t=await fetch(D(this.base,`/api/command`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify(e)});if(!t.ok){this.handlers.onStatus(`error`,`command refused (${t.status})`);return}let n=k(await t.json());n&&this.handlers.onSnapshot(n)}media(e){return O(this.base,e)}close(){this.source?.close(),this.source=null}};function A(e){try{return JSON.parse(e)}catch{return null}}async function ie(e,t){try{let n=await fetch(D(e,`/api/state`),{signal:t});return n.ok?k(await n.json()):null}catch{return null}}async function j(e,t){try{let n=await fetch(D(e,`/api/health`),{signal:t});if(!n.ok)return null;let r=await n.json();return r.name===`nixamp`?r.version??`unknown`:null}catch{return null}}function ae(e,t=globalThis.location?.protocol){return t!==`https:`||!/^http:\/\//i.test(e.trim())?``:`This page is https, and a browser refuses every request from an https page to an http one. Open that address directly, or give the server a certificate: nixamp serve --tls-cert cert.pem --tls-key key.pem.`}var M=.14,N=.02;function oe(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 se(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 ce(e,t,n=M){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function P(e,t,n=N){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function le(e,t,n,r,i){let{width:a,height:o}=t;if(a<=0||o<=0||n.length===0)return;e.clearRect(0,0,a,o);let s=a/n.length,c=Math.max(1,s*.72),l=Math.max(2,o*.012);e.fillStyle=i.bar,n.forEach((t,n)=>{let r=Math.max(1,t*(o-l*2));e.fillRect(n*s+(s-c)/2,o-r,c,r)}),e.fillStyle=i.peak,r.forEach((t,n)=>{let r=o-Math.max(1,t*(o-l*2))-l*2;e.fillRect(n*s+(s-c)/2,Math.max(0,r),c,l)})}var F=`nixamp.remote`,I=`nixamp.volume`;function L(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function R(){let n={status:L(`status`),source:L(`source`),install:L(`install`),video:L(`video`),audio:L(`audio`),title:L(`title-line`),album:L(`album-line`),elapsed:L(`elapsed`),total:L(`total`),seek:L(`seek`),canvas:L(`spectrum`),glyphs:L(`glyphs`),levels:L(`levels`),playlist:L(`playlist`),playlistTitle:L(`playlist-panel`),note:L(`note`),files:L(`files`),folder:L(`folder`),remoteUrl:L(`remote-url`),remoteForm:L(`remote-form`),remoteState:L(`remote-state`),disconnect:L(`disconnect`),browse:L(`browse`),accountForm:L(`account-form`),accountEmail:L(`account-email`),accountPassword:L(`account-password`),accountSubmit:L(`account-submit`),accountToggle:L(`account-toggle`),accountProviders:L(`account-providers`),accountPanel:L(`account-panel`),accountElsewhere:L(`account-elsewhere`),accountSignOut:L(`account-signout`),accountNote:L(`account-note`),adminPanel:L(`admin-panel`),adminNote:L(`admin-note`),adminConnections:L(`admin-connections`),adminRestream:L(`admin-restream`),adminSource:L(`admin-source`),directory:L(`directory`),recentNote:L(`recent-note`),recentList:L(`recent-list`),followingNote:L(`following-note`),followingList:L(`following-list`),serversPanel:L(`servers-panel`),serversNote:L(`servers-note`),serversList:L(`servers-list`),notifyPanel:L(`notify-panel`),notifyNote:L(`notify-note`),notifyWeb:L(`notify-web`),notifyEmail:L(`notify-email`),notifySms:L(`notify-sms`),notifyPhone:L(`notify-phone`),notifyPhoneForm:L(`notify-phone-form`),notifyPhoneNote:L(`notify-phone-note`),directoryNote:L(`directory-note`),directoryList:L(`directory-list`),listenHere:L(`listen-here`),volume:L(`volume`),prev:L(`prev`),playPause:L(`play-pause`),stop:L(`stop`),next:L(`next`)},r=`local`,i=[],a=0,o=T(),s=`idle`,c=``,l=`Pick files, or connect to a nixamp running somewhere else.`,u=!1,d=Array(24).fill(0),f=Array(24).fill(0),p=[],m=()=>r===`remote`&&!n.listenHere.checked,h=new te({audio:n.audio,video:n.video},{onTime:(e,t)=>{let n=i[a];r===`local`&&n&&t>0&&n.duration!==t&&(n.duration=t),R()},onEnded:()=>A(1),onState:()=>R(),onError:e=>{l=e,R()}}),g=new re({onSnapshot:e=>{o=ne(o,e),m()&&(d=e.bars.length>0?e.bars:d,f=P(f,d)),R()},onStatus:(e,t)=>{s=e,c=t??``,R()}}),_=()=>r===`remote`?o.tracks.length:i.length,v=()=>r===`remote`?o.index:a,y=()=>{if(r===`remote`){let e=o.tracks[o.index];return e?t(e):`Nothing loaded.`}let e=i[a];return e?t(e):`Nothing loaded.`},b=()=>(r===`remote`?o.tracks[o.index]:i[a])?.album||`—`,S=()=>m()?o.tracks[o.index]?.duration??0:h.duration,C=()=>m()?o.position:h.position,w=()=>m()?o.playing:h.playing;async function D(e){if(r===`remote`){if(m()){await g.send({type:`play`,index:e});return}await g.send({type:`select`,index:e}),await O(e);return}let t=i[e];t&&(a=e,await h.load(t,!0),V(t.video),H(),R())}async function O(e){let t=o.tracks[e];t&&(await h.load({title:t.title,artist:t.artist,album:t.album,duration:t.duration,url:g.media(e),video:t.video===!0,objectUrl:!1},!0),V(t.video===!0),H())}async function k(){if(m()){await g.send({type:`toggle`});return}_()!==0&&(h.playing?h.pause():h.position>0?await h.play():await D(v()),R())}async function A(e){let t=_();if(t!==0){if(m()){await g.send({type:e>0?`next`:`prev`});return}await D((v()+e+t)%t)}}async function M(){if(m()){await g.send({type:`stop`});return}h.stop(),d=Array(24).fill(0),f=[...d],R()}let N=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))];function R(){let t=_(),a=w();n.status.textContent=a?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=String(a),n.title.textContent=y(),n.album.textContent=b();let f=C(),p=S();n.elapsed.textContent=e(f),n.total.textContent=p>0?e(p):`--:--`,u||(n.seek.value=String(p>0?Math.round(f/p*1e3):0),n.seek.disabled=p<=0||m()),n.playPause.textContent=a?`❚❚`:`▶`,n.playPause.setAttribute(`aria-label`,a?`Pause`:`Play`),n.playlistTitle.dataset.title=`Playlist (${t})`,n.source.textContent=r===`remote`?`remote · ${g.address.replace(/^https?:\/\//,``)||`—`}`:i.length>0?`local · ${i.length} files`:`no source`,n.remoteState.textContent=r===`remote`?`${s}${c?` — ${c}`:``}`:`not connected`,n.remoteState.dataset.status=r===`remote`?s:`idle`,n.disconnect.hidden=r!==`remote`;let v=r===`remote`&&o.note!==``?o.note:l;n.note.textContent=v,n.note.hidden=v===``,ue(),n.glyphs.textContent=d.map(N).join(``);let[ee,x]=m()?o.levels:h.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(ee*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(x*6)).padEnd(6,`·`)}`}let z=``;function ue(){let a=r===`remote`?o.tracks.map(e=>[t(e),e.duration]):i.map(e=>[t(e),e.duration]),s=`${r}:${a.map(([e,t])=>`${e}@${t}`).join(`|`)}`;s!==z&&(z=s,n.playlist.replaceChildren(...a.map(([t,n],r)=>{let i=document.createElement(`li`);i.className=`row`,i.dataset.index=String(r);let a=document.createElement(`span`);a.className=`n`,a.textContent=String(r+1).padStart(2,` `);let o=document.createElement(`span`);o.className=`name`,o.textContent=t;let s=document.createElement(`span`);return s.className=`time`,s.textContent=n>0?e(n):`--:--`,i.append(a,o,s),i})));let c=v(),l=w();Array.from(n.playlist.children).forEach((e,t)=>{let n=e;n.classList.toggle(`selected`,t===c),n.classList.toggle(`playing`,t===c&&l)}),n.playlist.children[c]?.scrollIntoView({block:`nearest`})}function B(){let t=n.canvas,r=Math.min(2,globalThis.devicePixelRatio||1),i=Math.round(t.clientWidth*r),a=Math.round(t.clientHeight*r);i>0&&a>0&&(t.width!==i||t.height!==a)&&(t.width=i,t.height=a);let s=t.getContext(`2d`);if(m())f=P(f,d);else{let e=h.read();e.length>0&&(p.length!==25&&(p=oe(24,e.length)),d=ce(d,se(e,p)),f=P(f,d))}if(s){let e=getComputedStyle(document.documentElement);le(s,{width:t.width,height:t.height},d,f,{bar:e.getPropertyValue(`--green`).trim()||`#4af689`,peak:e.getPropertyValue(`--green-dim`).trim()||`#227a4a`,background:`transparent`})}if(w()){n.glyphs.textContent=d.map(N).join(``);let[t,r]=m()?o.levels:h.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(t*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(r*6)).padEnd(6,`·`)}`,n.elapsed.textContent=e(C());let i=S();!u&&i>0&&(n.seek.value=String(Math.round(C()/i*1e3)))}requestAnimationFrame(B)}function V(e){n.video.hidden=!e}function H(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:y(),album:b(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void k()),navigator.mediaSession.setActionHandler(`pause`,()=>void k()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void A(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void A(-1)))}n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&D(n)}),n.prev.addEventListener(`click`,()=>void A(-1)),n.next.addEventListener(`click`,()=>void A(1)),n.stop.addEventListener(`click`,()=>void M()),n.playPause.addEventListener(`click`,()=>void k()),n.seek.addEventListener(`input`,()=>{u=!0}),n.seek.addEventListener(`change`,()=>{let e=S();e>0&&h.seek(Number(n.seek.value)/1e3*e),u=!1}),n.volume.addEventListener(`input`,()=>{let e=Number(n.volume.value)/100;h.volume=e;try{localStorage.setItem(I,String(e))}catch{}});let U=e=>{e.addEventListener(`change`,()=>{let t=ee(Array.from(e.files??[]));if(t.length===0){l=`Nothing playable in that selection.`,R();return}x(i),i=t,a=0,r=`local`,g.close(),l=``,D(0)})};U(n.files),U(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=E(n.remoteUrl.value);if(t===``){l=`That is not an address.`,R();return}(async()=>{s=`connecting`,R();let e=ae(t);if(e){s=`error`,c=e,l=e,r=`local`,R();return}if(await j(t)===null){s=`error`,c=`no nixamp answered there`,r=`local`,R();return}r=`remote`,l=``;try{localStorage.setItem(F,t)}catch{}g.connect(t),R()})()});let W=async()=>{n.directory.hidden=!1,n.directoryNote.textContent=`Looking for live streams…`,n.directoryList.replaceChildren();let e;try{let t=await fetch(`/api/directory`);if(!t.ok)throw Error(String(t.status));let n=await t.json();e=n.streams??[],fe(n.recent??[])}catch{n.directoryNote.textContent=`The directory is not answering. Type an address instead.`;return}if(e.length===0){n.directoryNote.textContent=`Nobody is streaming right now.`;return}n.directoryNote.textContent=`${e.length} live ${e.length===1?`stream`:`streams`}:`;for(let t of e){let e=document.createElement(`li`),r=document.createElement(`button`);r.type=`button`;let i=document.createElement(`span`);i.className=`name`,i.textContent=t.name;let a=document.createElement(`span`);a.className=`detail`;let o=[t.nowPlaying,`${t.tracks} tracks`].filter(Boolean);t.code&&o.push(t.callers?`☎ ${t.code} · ${t.callers} on the phone`:`☎ ${t.code}`),a.textContent=o.join(` · `),r.append(i,a),r.addEventListener(`click`,()=>{n.remoteUrl.value=t.url,n.directory.hidden=!0,n.remoteForm.requestSubmit()}),e.append(r),t.ownerId&&Z&&t.ownerId!==Z&&e.append(he(t.ownerId,t.name)),n.directoryList.append(e)}};if(location.pathname.replace(/\/+$/,``)===`/directory`){document.body.classList.add(`route-directory`);let e=document.getElementById(`directory-back`);e&&(e.hidden=!1),W()}let G=null,de=e=>{n.adminConnections.replaceChildren();let t=document.createElement(`tr`);for(let e of[`Where`,`Network`,`Kind`,`Client`,`Track`,`Sent`]){let n=document.createElement(`th`);n.textContent=e,t.append(n)}n.adminConnections.append(t);for(let t of e.slice(0,40)){let e=document.createElement(`tr`);t.endedAt!==null&&(e.className=`ended`);let r=[[t.address,``],[t.network,`network-${t.network}`],[t.kind,``],[t.agent,``],[t.track||`—`,``],[`${Math.round(t.bytes/1024)} KiB`,``]];for(let[t,n]of r){let r=document.createElement(`td`);r.textContent=t,n&&(r.className=n),e.append(r)}n.adminConnections.append(e)}},K=async()=>{try{let e=await fetch(`/api/connections`);if(!e.ok)return;let t=await e.json();n.adminNote.textContent=`${t.active??0} listening now.`,de(t.connections??[])}catch{n.adminNote.textContent=`lost touch with the server`}},q=async()=>{let e=!1,t=null;try{let n=await fetch(`/api/admin`);if(n.ok){let r=await n.json();e=r.allowed===!0,t=r.as??null}}catch{e=!1}n.adminPanel.hidden=!e,G&&clearInterval(G),G=null,e&&(n.adminNote.textContent=t===`owner`?`You own this server.`:`You hold this server's control link.`,K(),G=setInterval(()=>void K(),2e3))};n.adminRestream.addEventListener(`submit`,e=>{e.preventDefault();let t=n.adminSource.value.trim();t&&(async()=>{try{let e=await fetch(`/api/source`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({source:t})}),r=await e.json();n.adminNote.textContent=e.ok?`Now serving ${t}.`:r.error??`that did not work`,e.ok&&(n.adminSource.value=``)}catch{n.adminNote.textContent=`could not reach the server`}})()});let J=e=>{let t=Math.max(1,Math.round((Date.now()-e)/6e4));if(t<60)return`${t} minute${t===1?``:`s`} ago`;let n=Math.round(t/60);return`${n} hour${n===1?``:`s`} ago`},fe=e=>{n.recentList.replaceChildren();let t=Z?e.filter(e=>e.ownerId&&e.ownerId!==Z):[];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 ${J(e.endedAt)}`:`ended ${J(e.endedAt)}`,r.append(i,a),t.append(r,he(e.ownerId,e.name)),n.recentList.append(t)}},pe=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(`a`);o.className=`button`,o.textContent=`Open`,o.href=e.key?`${e.url}/s/${e.key}`:e.url,o.rel=`noreferrer`;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 pe()}catch{s.disabled=!1}})()}),t.append(r,o,s),n.serversList.append(t)}}catch{n.serversPanel.hidden=!0}},me=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}},he=(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),me())}catch{}finally{n.disabled=!1}})()}),n},ge=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},_e=()=>`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window,ve=async()=>{if(!_e())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:ge(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}},ye=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{}},Y=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`}},be=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=_e()&&Notification.permission===`granted`?await(await navigator.serviceWorker.ready).pushManager.getSubscription()!==null:!1;n.notifyWeb.checked=t.wantsWeb!==!1&&r,n.notifyNote.textContent=r?`Get told when someone you follow goes live.`:`Turn on “On this device” to be told here.`}catch{}};n.notifyWeb.addEventListener(`change`,()=>{(async()=>{if(n.notifyWeb.checked){let e=await ve();n.notifyWeb.checked=e,await Y({wantsWeb:e});return}await ye(),await Y({wantsWeb:!1}),n.notifyNote.textContent=`Turn on “On this device” to be told here.`})()}),n.notifyEmail.addEventListener(`change`,()=>{Y({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 Y({wantsSms:n.notifySms.checked})})()}),n.notifyPhoneForm.addEventListener(`submit`,e=>{e.preventDefault(),Y({phone:n.notifyPhone.value.trim()})});let X=!1,Z=``,Q=e=>{let t=e!==null;n.notifyPanel.hidden=!t,t?(be(),me(),pe()):(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}.`:X?`Create an account on nixamp.com.`:`Sign in to nixamp.com to publish and get paid.`,n.accountSubmit.textContent=X?`Create account`:`Sign in`,n.accountToggle.textContent=X?`I have one`:`Create one`,n.accountPassword.autocomplete=X?`new-password`:`current-password`};n.accountToggle.addEventListener(`click`,()=>{X=!X,Q(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/${X?`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}Z=i.account?.id??``,n.accountPassword.value=``,Q(i.account?.email??t),q()}catch{n.accountNote.textContent=`could not reach nixamp.com`}finally{n.accountSubmit.disabled=!1}})()}),n.accountSignOut.addEventListener(`click`,()=>{(async()=>{try{await fetch(`/api/v1/auth/logout`,{method:`POST`})}catch{}Z=``,Q(null),q()})()}),(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)}})(),(async()=>{try{let e=await fetch(`/api/v1/auth/me`),t=await e.json();Z=e.ok?t.account?.id??``:``,Q(e.ok?t.account?.email??`you`:null)}catch{Z=``,Q(null)}})(),q(),n.browse.addEventListener(`click`,()=>{if(!n.directory.hidden){n.directory.hidden=!0;return}W(),n.directory.scrollIntoView({behavior:`smooth`,block:`nearest`})}),n.disconnect.addEventListener(`click`,()=>{g.close(),r=`local`,s=`idle`,c=``,R()}),n.listenHere.addEventListener(`change`,()=>{r===`remote`&&(async()=>{n.listenHere.checked?(await g.send({type:`stop`}),await O(o.index)):h.stop(),R()})()}),document.addEventListener(`keydown`,e=>{let t=e.target;if(!(t&&/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))switch(e.key){case` `:e.preventDefault(),k();return;case`s`:M();return;case`n`:case`ArrowRight`:A(1);return;case`p`:case`ArrowLeft`:A(-1);return;case`ArrowDown`:e.preventDefault(),D(Math.min(_()-1,v()+1));return;case`ArrowUp`:e.preventDefault(),D(Math.max(0,v()-1));return}});let $=null;globalThis.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),$=e,n.install.hidden=!1}),n.install.addEventListener(`click`,()=>{$?.prompt(),$=null,n.install.hidden=!0});try{let e=localStorage.getItem(I);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),h.volume=Number(e));let t=localStorage.getItem(F);t&&(n.remoteUrl.value=t)}catch{}(async()=>{if(n.remoteUrl.value!==``)return;let e=globalThis.location.origin;if(await j(e)===null)return;let t=await ie(e);t&&t.trackCount!==0&&(n.remoteUrl.value=e,r=`remote`,l=``,g.connect(e),R())})(),R(),requestAnimationFrame(B)}R(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});export{c as t};