nixamp 0.5.5 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/admin.d.ts CHANGED
@@ -28,6 +28,19 @@ interface Snapshot {
28
28
  export interface AdminOptions {
29
29
  url: string;
30
30
  key: string | null;
31
+ /**
32
+ * Every address the server found, labelled, share key not yet applied.
33
+ *
34
+ * `url` is the one this process talks to, which for a local daemon is
35
+ * loopback -- correct for asking it questions and useless for handing to
36
+ * anybody. These are the ones worth reading off the screen.
37
+ */
38
+ links: {
39
+ label: string;
40
+ url: string;
41
+ }[];
42
+ /** What it is serving, so the admin view says so without being asked. */
43
+ source: string;
31
44
  }
32
45
  /** Where to point, from the flags or from the daemon that is running. */
33
46
  export declare function resolveTarget(argv: string[]): AdminOptions;
@@ -42,6 +55,13 @@ export interface View {
42
55
  error: string;
43
56
  typing: boolean;
44
57
  restreaming: string;
58
+ /** Labelled addresses, and the key that makes them work. */
59
+ links: {
60
+ label: string;
61
+ url: string;
62
+ }[];
63
+ key: string | null;
64
+ source: string;
45
65
  }
46
66
  export declare function draw(ui: Container, theme: Theme, view: View): void;
47
67
  export {};
package/dist/admin.js CHANGED
@@ -14,13 +14,22 @@ export function resolveTarget(argv) {
14
14
  const keyAt = argv.findIndex((a) => a === "--key");
15
15
  const url = at === -1 ? null : argv[at + 1];
16
16
  const key = keyAt === -1 ? null : (argv[keyAt + 1] ?? null);
17
- if (url)
18
- return { url: url.replace(/\/+$/, ""), key };
17
+ // Pointed somewhere by hand, that address is the only one known.
18
+ if (url) {
19
+ const bare = url.replace(/\/+$/, "");
20
+ return { url: bare, key, links: [{ label: "there", url: bare }], source: "" };
21
+ }
19
22
  const state = readState();
20
23
  if (state === null) {
21
24
  throw new Error("nixamp: no daemon is running. Start one with `nixamp daemon start`, or pass --url.");
22
25
  }
23
- return { url: daemonUrl(state), key: key ?? state.key };
26
+ return {
27
+ url: daemonUrl(state),
28
+ key: key ?? state.key,
29
+ // A state file written before 0.5.3 has no list; loopback stands in.
30
+ links: state.urls ?? [{ label: "here", url: daemonUrl(state) }],
31
+ source: state.source,
32
+ };
24
33
  }
25
34
  /** Seconds as something a person reads at a glance. */
26
35
  export function since(ms) {
@@ -118,6 +127,7 @@ export async function admin(argv) {
118
127
  app.on("exit", () => clearInterval(timer));
119
128
  app.render(({ ui, theme }) => draw(ui, theme, {
120
129
  url: target.url, report, snapshot, error, typing, restreaming,
130
+ links: target.links, key: target.key, source: target.source,
121
131
  }));
122
132
  await app.start();
123
133
  clearInterval(timer);
@@ -159,6 +169,22 @@ export function draw(ui, theme, view) {
159
169
  ]);
160
170
  });
161
171
  });
172
+ // The links, because the point of a daemon is the phone in the other room
173
+ // and the address this process happens to talk to is the one that will not
174
+ // reach it. The key is on them: without it every address is a 401.
175
+ if (view.links.length > 0) {
176
+ const width = Math.max(...view.links.map((link) => link.label.length));
177
+ ui.panel({ title: "Share links", size: view.links.length + (view.source ? 3 : 2) }, (p) => {
178
+ for (const link of view.links) {
179
+ const full = view.key === null ? link.url : `${link.url}/s/${view.key}`;
180
+ p.text(`${link.label.padEnd(width)} ${full}`, {
181
+ fg: link.label === "on the internet" ? theme.accent : theme.foreground,
182
+ });
183
+ }
184
+ if (view.source)
185
+ p.label(view.source);
186
+ });
187
+ }
162
188
  ui.panel({ title: `Connections (${report?.active ?? 0} live)` }, (p) => {
163
189
  if (view.error) {
164
190
  p.text(view.error, { fg: theme.danger });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Saying no to somebody who is asking too often.
3
+ *
4
+ * There was nothing here at all: no throttle, no lockout, no 429. Against the
5
+ * share key that is survivable -- it is 128 random bits and nobody guesses one
6
+ * -- but the sign-in endpoints take a password, and a password with an eight
7
+ * character floor and no composition rules is guessable at a few hundred
8
+ * attempts a second by anyone with a word list.
9
+ *
10
+ * A fixed window rather than a token bucket, because the thing being counted
11
+ * is failed attempts by one caller and the useful question is "how many in the
12
+ * last few minutes", which a window answers exactly and a bucket only
13
+ * approximates. Windows are kept in memory: a limiter that outlives a restart
14
+ * would want a table, and a restart is not how somebody gets past this.
15
+ *
16
+ * Deliberately free of anything nixamp: it takes a name and an address and
17
+ * answers yes or no, so it can be lifted into a package unchanged.
18
+ */
19
+ export interface Limit {
20
+ /** How many are allowed in one window. */
21
+ allowed: number;
22
+ /** How long the window is, in milliseconds. */
23
+ windowMs: number;
24
+ }
25
+ export interface Verdict {
26
+ /** Whether to go ahead. */
27
+ ok: boolean;
28
+ /** What is left in this window, after this call. */
29
+ left: number;
30
+ /** Seconds until the window rolls, for Retry-After. */
31
+ retryAfter: number;
32
+ }
33
+ export declare class Guard {
34
+ private readonly now;
35
+ private readonly windows;
36
+ constructor(now?: () => number);
37
+ /**
38
+ * Count one attempt against `key`, and say whether it may proceed.
39
+ *
40
+ * The count rises whether or not the attempt succeeds; callers who only want
41
+ * to punish failures should call `forget` on success, which is what makes a
42
+ * correct password cost nothing.
43
+ */
44
+ check(key: string, limit: Limit): Verdict;
45
+ /** A success wipes the slate, so ordinary use never approaches a limit. */
46
+ forget(key: string): void;
47
+ private sweep;
48
+ get size(): number;
49
+ }
50
+ /**
51
+ * Who is asking, for counting purposes.
52
+ *
53
+ * Behind a proxy the socket address is the proxy, so the forwarded header is
54
+ * used where one is trusted. It is only trusted when told to be: anyone can
55
+ * send `x-forwarded-for`, and believing it from a direct caller would let them
56
+ * pick a fresh identity per request and never hit a limit at all.
57
+ */
58
+ export declare function callerOf(headers: Record<string, string | string[] | undefined>, socketAddress: string | undefined, behindProxy: boolean): string;
59
+ /**
60
+ * Signing in. Ten wrong answers in fifteen minutes is far more than a person
61
+ * mistypes and far less than a word list needs.
62
+ */
63
+ export declare const SIGN_IN_LIMIT: Limit;
64
+ /**
65
+ * A wrong share key. Higher, because a browser with a stale link retries by
66
+ * itself, and the key is not guessable anyway -- this is about noise and logs
67
+ * rather than about the key falling.
68
+ */
69
+ export declare const BAD_KEY_LIMIT: Limit;
package/dist/guard.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Saying no to somebody who is asking too often.
3
+ *
4
+ * There was nothing here at all: no throttle, no lockout, no 429. Against the
5
+ * share key that is survivable -- it is 128 random bits and nobody guesses one
6
+ * -- but the sign-in endpoints take a password, and a password with an eight
7
+ * character floor and no composition rules is guessable at a few hundred
8
+ * attempts a second by anyone with a word list.
9
+ *
10
+ * A fixed window rather than a token bucket, because the thing being counted
11
+ * is failed attempts by one caller and the useful question is "how many in the
12
+ * last few minutes", which a window answers exactly and a bucket only
13
+ * approximates. Windows are kept in memory: a limiter that outlives a restart
14
+ * would want a table, and a restart is not how somebody gets past this.
15
+ *
16
+ * Deliberately free of anything nixamp: it takes a name and an address and
17
+ * answers yes or no, so it can be lifted into a package unchanged.
18
+ */
19
+ /** Nothing here is worth more memory than this; the oldest windows go first. */
20
+ const MAX_TRACKED = 10_000;
21
+ export class Guard {
22
+ now;
23
+ windows = new Map();
24
+ constructor(now = Date.now) {
25
+ this.now = now;
26
+ }
27
+ /**
28
+ * Count one attempt against `key`, and say whether it may proceed.
29
+ *
30
+ * The count rises whether or not the attempt succeeds; callers who only want
31
+ * to punish failures should call `forget` on success, which is what makes a
32
+ * correct password cost nothing.
33
+ */
34
+ check(key, limit) {
35
+ const at = this.now();
36
+ this.sweep(at);
37
+ const found = this.windows.get(key);
38
+ const window = found && found.until > at ? found : { count: 0, until: at + limit.windowMs };
39
+ window.count += 1;
40
+ this.windows.set(key, window);
41
+ const retryAfter = Math.max(1, Math.ceil((window.until - at) / 1000));
42
+ if (window.count > limit.allowed)
43
+ return { ok: false, left: 0, retryAfter };
44
+ return { ok: true, left: limit.allowed - window.count, retryAfter };
45
+ }
46
+ /** A success wipes the slate, so ordinary use never approaches a limit. */
47
+ forget(key) {
48
+ this.windows.delete(key);
49
+ }
50
+ sweep(at) {
51
+ if (this.windows.size < MAX_TRACKED)
52
+ return;
53
+ for (const [key, window] of this.windows) {
54
+ if (window.until <= at)
55
+ this.windows.delete(key);
56
+ }
57
+ // Still full of live windows: somebody is spreading attempts across many
58
+ // keys. Drop the oldest half rather than grow without limit -- forgetting
59
+ // is the safe direction, since the alternative is running out of memory.
60
+ if (this.windows.size >= MAX_TRACKED) {
61
+ const half = Math.floor(this.windows.size / 2);
62
+ let dropped = 0;
63
+ for (const key of this.windows.keys()) {
64
+ this.windows.delete(key);
65
+ if (++dropped >= half)
66
+ break;
67
+ }
68
+ }
69
+ }
70
+ get size() {
71
+ return this.windows.size;
72
+ }
73
+ }
74
+ /**
75
+ * Who is asking, for counting purposes.
76
+ *
77
+ * Behind a proxy the socket address is the proxy, so the forwarded header is
78
+ * used where one is trusted. It is only trusted when told to be: anyone can
79
+ * send `x-forwarded-for`, and believing it from a direct caller would let them
80
+ * pick a fresh identity per request and never hit a limit at all.
81
+ */
82
+ export function callerOf(headers, socketAddress, behindProxy) {
83
+ if (behindProxy) {
84
+ const raw = headers["x-forwarded-for"];
85
+ const header = Array.isArray(raw) ? raw[0] : raw;
86
+ // The leftmost entry is the original client; the rest are proxies.
87
+ const first = header?.split(",")[0]?.trim();
88
+ if (first)
89
+ return first;
90
+ }
91
+ return socketAddress ?? "unknown";
92
+ }
93
+ /**
94
+ * Signing in. Ten wrong answers in fifteen minutes is far more than a person
95
+ * mistypes and far less than a word list needs.
96
+ */
97
+ export const SIGN_IN_LIMIT = { allowed: 10, windowMs: 15 * 60_000 };
98
+ /**
99
+ * A wrong share key. Higher, because a browser with a stale link retries by
100
+ * itself, and the key is not guessable anyway -- this is about noise and logs
101
+ * rather than about the key falling.
102
+ */
103
+ export const BAD_KEY_LIMIT = { allowed: 60, windowMs: 60_000 };
package/dist/server.d.ts CHANGED
@@ -235,6 +235,13 @@ export interface HandlerOptions {
235
235
  signIn?: SignIn;
236
236
  /** True when this instance is reached over https, for the cookie's Secure. */
237
237
  secureCookies?: boolean;
238
+ /**
239
+ * True when a proxy sits in front, so `x-forwarded-for` names the caller.
240
+ * False everywhere else on purpose: the header is trivially forged, and
241
+ * believing it from a direct caller hands them a fresh identity per request
242
+ * and with it an unlimited number of password attempts.
243
+ */
244
+ behindProxy?: boolean;
238
245
  /** Who may administer this server. */
239
246
  owner?: Owner;
240
247
  /**
package/dist/server.js CHANGED
@@ -21,6 +21,7 @@ import { Channels, cleanId } from "./channels.js";
21
21
  import { RtmpListeners } from "./rtmp-in.js";
22
22
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.js";
23
23
  import { DeviceGrants } from "./device.js";
24
+ import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.js";
24
25
  import { deviceDonePage, devicePage, exchangeCode, providersFrom, signInFailedPage, SignIn, } from "./oauth.js";
25
26
  import { needsAdmin, Owner } from "./owner.js";
26
27
  import { readSession } from "./session.js";
@@ -546,6 +547,8 @@ async function readBody(request, limit = 64 * 1024) {
546
547
  * drive it with a real socket and no ffmpeg in sight.
547
548
  */
548
549
  export function createHandler(engine, options) {
550
+ // One per server, so the counters survive between requests and die with it.
551
+ const guard = new Guard();
549
552
  const tracker = options.connections ?? new Connections();
550
553
  const started = Date.now();
551
554
  /** Count a request in, count its bytes, and close it out exactly once. */
@@ -566,6 +569,7 @@ export function createHandler(engine, options) {
566
569
  const url = new URL(request.url ?? "/", "http://localhost");
567
570
  const path = url.pathname;
568
571
  const key = options.key ?? null;
572
+ const behindProxy = options.behindProxy ?? false;
569
573
  const listenKey = options.listenKey ?? null;
570
574
  if (request.method === "OPTIONS") {
571
575
  response.writeHead(204, CORS);
@@ -791,6 +795,15 @@ export function createHandler(engine, options) {
791
795
  !isSignInPath(path)) {
792
796
  const scope = scopeOf(keyFrom(request, url), key, listenKey);
793
797
  if (scope === null) {
798
+ // Counted, not because a 128-bit key falls to guessing, but because
799
+ // somebody hammering one should stop costing this server anything.
800
+ const who = callerOf(request.headers, request.socket.remoteAddress, behindProxy);
801
+ const verdict = guard.check(`key:${who}`, BAD_KEY_LIMIT);
802
+ if (!verdict.ok) {
803
+ response.writeHead(429, { ...CORS, "content-type": "application/json; charset=utf-8", "retry-after": String(verdict.retryAfter) });
804
+ response.end(JSON.stringify({ error: "too many attempts; wait a moment" }));
805
+ return;
806
+ }
794
807
  json(response, 401, { error: "this nixamp needs the key from its share link" });
795
808
  return;
796
809
  }
@@ -993,9 +1006,33 @@ export function createHandler(engine, options) {
993
1006
  json(response, 400, { error: "bad JSON" });
994
1007
  return;
995
1008
  }
1009
+ // A password is the one secret here small enough to guess, so the
1010
+ // attempts are counted per caller and per address being tried: one
1011
+ // machine working through a word list and a thousand machines trying one
1012
+ // address are the same attack, and each is stopped by its own counter.
1013
+ const who = callerOf(request.headers, request.socket.remoteAddress, behindProxy);
1014
+ const target = typeof body.email === "string" ? body.email.toLowerCase().slice(0, 200) : "";
1015
+ const buckets = [`signin:${who}`, `signin:${target}`];
1016
+ for (const bucket of buckets) {
1017
+ const verdict = guard.check(bucket, SIGN_IN_LIMIT);
1018
+ if (!verdict.ok) {
1019
+ response.writeHead(429, {
1020
+ ...CORS,
1021
+ "content-type": "application/json; charset=utf-8",
1022
+ "retry-after": String(verdict.retryAfter),
1023
+ });
1024
+ response.end(JSON.stringify({ error: "too many attempts; try again later" }));
1025
+ return;
1026
+ }
1027
+ }
996
1028
  const result = signingUp
997
1029
  ? await accounts.signUp(body.email, body.password)
998
1030
  : await accounts.signIn(body.email, body.password);
1031
+ // Getting it right costs nothing: the counters only exist to stop people
1032
+ // who keep getting it wrong.
1033
+ if (result.ok)
1034
+ for (const bucket of buckets)
1035
+ guard.forget(bucket);
999
1036
  if (!result.ok) {
1000
1037
  // 409 for an address that is taken, 401 for credentials that are not.
1001
1038
  json(response, signingUp ? 409 : 401, { error: result.error });
@@ -2060,6 +2097,11 @@ export async function serve(argv, version = "0.1.0") {
2060
2097
  secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
2061
2098
  }),
2062
2099
  secureCookies: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"),
2100
+ // A deployment reached over https is one behind somebody's proxy, so
2101
+ // the socket address is that proxy and the forwarded header is the
2102
+ // caller. A nixamp on a laptop is reached directly and must not
2103
+ // believe a header anybody can send.
2104
+ behindProxy: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"),
2063
2105
  // Whichever providers this deployment was given both halves of, plus
2064
2106
  // the device grant, which is worth having even with no provider at
2065
2107
  // all: a browser already signed in can approve a terminal.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/admin.ts CHANGED
@@ -30,6 +30,16 @@ interface Snapshot {
30
30
  export interface AdminOptions {
31
31
  url: string;
32
32
  key: string | null;
33
+ /**
34
+ * Every address the server found, labelled, share key not yet applied.
35
+ *
36
+ * `url` is the one this process talks to, which for a local daemon is
37
+ * loopback -- correct for asking it questions and useless for handing to
38
+ * anybody. These are the ones worth reading off the screen.
39
+ */
40
+ links: { label: string; url: string }[];
41
+ /** What it is serving, so the admin view says so without being asked. */
42
+ source: string;
33
43
  }
34
44
 
35
45
  /** Where to point, from the flags or from the daemon that is running. */
@@ -39,13 +49,23 @@ export function resolveTarget(argv: string[]): AdminOptions {
39
49
  const url = at === -1 ? null : argv[at + 1];
40
50
  const key = keyAt === -1 ? null : (argv[keyAt + 1] ?? null);
41
51
 
42
- if (url) return { url: url.replace(/\/+$/, ""), key };
52
+ // Pointed somewhere by hand, that address is the only one known.
53
+ if (url) {
54
+ const bare = url.replace(/\/+$/, "");
55
+ return { url: bare, key, links: [{ label: "there", url: bare }], source: "" };
56
+ }
43
57
 
44
58
  const state = readState();
45
59
  if (state === null) {
46
60
  throw new Error("nixamp: no daemon is running. Start one with `nixamp daemon start`, or pass --url.");
47
61
  }
48
- return { url: daemonUrl(state), key: key ?? state.key };
62
+ return {
63
+ url: daemonUrl(state),
64
+ key: key ?? state.key,
65
+ // A state file written before 0.5.3 has no list; loopback stands in.
66
+ links: state.urls ?? [{ label: "here", url: daemonUrl(state) }],
67
+ source: state.source,
68
+ };
49
69
  }
50
70
 
51
71
  /** Seconds as something a person reads at a glance. */
@@ -134,6 +154,7 @@ export async function admin(argv: string[]): Promise<void> {
134
154
  app.on("exit", () => clearInterval(timer));
135
155
  app.render(({ ui, theme }) => draw(ui, theme, {
136
156
  url: target.url, report, snapshot, error, typing, restreaming,
157
+ links: target.links, key: target.key, source: target.source,
137
158
  }));
138
159
 
139
160
  await app.start();
@@ -161,6 +182,10 @@ export interface View {
161
182
  error: string;
162
183
  typing: boolean;
163
184
  restreaming: string;
185
+ /** Labelled addresses, and the key that makes them work. */
186
+ links: { label: string; url: string }[];
187
+ key: string | null;
188
+ source: string;
164
189
  }
165
190
 
166
191
  export function draw(ui: Container, theme: Theme, view: View): void {
@@ -189,6 +214,22 @@ export function draw(ui: Container, theme: Theme, view: View): void {
189
214
  });
190
215
  });
191
216
 
217
+ // The links, because the point of a daemon is the phone in the other room
218
+ // and the address this process happens to talk to is the one that will not
219
+ // reach it. The key is on them: without it every address is a 401.
220
+ if (view.links.length > 0) {
221
+ const width = Math.max(...view.links.map((link) => link.label.length));
222
+ ui.panel({ title: "Share links", size: view.links.length + (view.source ? 3 : 2) }, (p) => {
223
+ for (const link of view.links) {
224
+ const full = view.key === null ? link.url : `${link.url}/s/${view.key}`;
225
+ p.text(`${link.label.padEnd(width)} ${full}`, {
226
+ fg: link.label === "on the internet" ? theme.accent : theme.foreground,
227
+ });
228
+ }
229
+ if (view.source) p.label(view.source);
230
+ });
231
+ }
232
+
192
233
  ui.panel({ title: `Connections (${report?.active ?? 0} live)` }, (p) => {
193
234
  if (view.error) {
194
235
  p.text(view.error, { fg: theme.danger });
package/src/guard.ts ADDED
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Saying no to somebody who is asking too often.
3
+ *
4
+ * There was nothing here at all: no throttle, no lockout, no 429. Against the
5
+ * share key that is survivable -- it is 128 random bits and nobody guesses one
6
+ * -- but the sign-in endpoints take a password, and a password with an eight
7
+ * character floor and no composition rules is guessable at a few hundred
8
+ * attempts a second by anyone with a word list.
9
+ *
10
+ * A fixed window rather than a token bucket, because the thing being counted
11
+ * is failed attempts by one caller and the useful question is "how many in the
12
+ * last few minutes", which a window answers exactly and a bucket only
13
+ * approximates. Windows are kept in memory: a limiter that outlives a restart
14
+ * would want a table, and a restart is not how somebody gets past this.
15
+ *
16
+ * Deliberately free of anything nixamp: it takes a name and an address and
17
+ * answers yes or no, so it can be lifted into a package unchanged.
18
+ */
19
+
20
+ export interface Limit {
21
+ /** How many are allowed in one window. */
22
+ allowed: number;
23
+ /** How long the window is, in milliseconds. */
24
+ windowMs: number;
25
+ }
26
+
27
+ export interface Verdict {
28
+ /** Whether to go ahead. */
29
+ ok: boolean;
30
+ /** What is left in this window, after this call. */
31
+ left: number;
32
+ /** Seconds until the window rolls, for Retry-After. */
33
+ retryAfter: number;
34
+ }
35
+
36
+ interface Window {
37
+ count: number;
38
+ until: number;
39
+ }
40
+
41
+ /** Nothing here is worth more memory than this; the oldest windows go first. */
42
+ const MAX_TRACKED = 10_000;
43
+
44
+ export class Guard {
45
+ private readonly windows = new Map<string, Window>();
46
+
47
+ constructor(private readonly now: () => number = Date.now) {}
48
+
49
+ /**
50
+ * Count one attempt against `key`, and say whether it may proceed.
51
+ *
52
+ * The count rises whether or not the attempt succeeds; callers who only want
53
+ * to punish failures should call `forget` on success, which is what makes a
54
+ * correct password cost nothing.
55
+ */
56
+ check(key: string, limit: Limit): Verdict {
57
+ const at = this.now();
58
+ this.sweep(at);
59
+
60
+ const found = this.windows.get(key);
61
+ const window = found && found.until > at ? found : { count: 0, until: at + limit.windowMs };
62
+ window.count += 1;
63
+ this.windows.set(key, window);
64
+
65
+ const retryAfter = Math.max(1, Math.ceil((window.until - at) / 1000));
66
+ if (window.count > limit.allowed) return { ok: false, left: 0, retryAfter };
67
+ return { ok: true, left: limit.allowed - window.count, retryAfter };
68
+ }
69
+
70
+ /** A success wipes the slate, so ordinary use never approaches a limit. */
71
+ forget(key: string): void {
72
+ this.windows.delete(key);
73
+ }
74
+
75
+ private sweep(at: number): void {
76
+ if (this.windows.size < MAX_TRACKED) return;
77
+ for (const [key, window] of this.windows) {
78
+ if (window.until <= at) this.windows.delete(key);
79
+ }
80
+ // Still full of live windows: somebody is spreading attempts across many
81
+ // keys. Drop the oldest half rather than grow without limit -- forgetting
82
+ // is the safe direction, since the alternative is running out of memory.
83
+ if (this.windows.size >= MAX_TRACKED) {
84
+ const half = Math.floor(this.windows.size / 2);
85
+ let dropped = 0;
86
+ for (const key of this.windows.keys()) {
87
+ this.windows.delete(key);
88
+ if (++dropped >= half) break;
89
+ }
90
+ }
91
+ }
92
+
93
+ get size(): number {
94
+ return this.windows.size;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Who is asking, for counting purposes.
100
+ *
101
+ * Behind a proxy the socket address is the proxy, so the forwarded header is
102
+ * used where one is trusted. It is only trusted when told to be: anyone can
103
+ * send `x-forwarded-for`, and believing it from a direct caller would let them
104
+ * pick a fresh identity per request and never hit a limit at all.
105
+ */
106
+ export function callerOf(
107
+ headers: Record<string, string | string[] | undefined>,
108
+ socketAddress: string | undefined,
109
+ behindProxy: boolean,
110
+ ): string {
111
+ if (behindProxy) {
112
+ const raw = headers["x-forwarded-for"];
113
+ const header = Array.isArray(raw) ? raw[0] : raw;
114
+ // The leftmost entry is the original client; the rest are proxies.
115
+ const first = header?.split(",")[0]?.trim();
116
+ if (first) return first;
117
+ }
118
+ return socketAddress ?? "unknown";
119
+ }
120
+
121
+ /**
122
+ * Signing in. Ten wrong answers in fifteen minutes is far more than a person
123
+ * mistypes and far less than a word list needs.
124
+ */
125
+ export const SIGN_IN_LIMIT: Limit = { allowed: 10, windowMs: 15 * 60_000 };
126
+
127
+ /**
128
+ * A wrong share key. Higher, because a browser with a stale link retries by
129
+ * itself, and the key is not guessable anyway -- this is about noise and logs
130
+ * rather than about the key falling.
131
+ */
132
+ export const BAD_KEY_LIMIT: Limit = { allowed: 60, windowMs: 60_000 };
package/src/server.ts CHANGED
@@ -28,6 +28,7 @@ import { Channels, cleanId } from "./channels.ts";
28
28
  import { RtmpListeners } from "./rtmp-in.ts";
29
29
  import { Accounts, clearedCookie, sessionCookie, tokenFrom } from "./accounts.ts";
30
30
  import { DeviceGrants } from "./device.ts";
31
+ import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.ts";
31
32
  import {
32
33
  deviceDonePage,
33
34
  devicePage,
@@ -708,6 +709,13 @@ export interface HandlerOptions {
708
709
  signIn?: SignIn;
709
710
  /** True when this instance is reached over https, for the cookie's Secure. */
710
711
  secureCookies?: boolean;
712
+ /**
713
+ * True when a proxy sits in front, so `x-forwarded-for` names the caller.
714
+ * False everywhere else on purpose: the header is trivially forged, and
715
+ * believing it from a direct caller hands them a fresh identity per request
716
+ * and with it an unlimited number of password attempts.
717
+ */
718
+ behindProxy?: boolean;
711
719
  /** Who may administer this server. */
712
720
  owner?: Owner;
713
721
  /**
@@ -730,6 +738,9 @@ export interface HandlerOptions {
730
738
  * drive it with a real socket and no ffmpeg in sight.
731
739
  */
732
740
  export function createHandler(engine: Engine, options: HandlerOptions) {
741
+ // One per server, so the counters survive between requests and die with it.
742
+ const guard = new Guard();
743
+
733
744
  const tracker = options.connections ?? new Connections();
734
745
  const started = Date.now();
735
746
 
@@ -757,6 +768,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
757
768
  const url = new URL(request.url ?? "/", "http://localhost");
758
769
  const path = url.pathname;
759
770
  const key = options.key ?? null;
771
+ const behindProxy = options.behindProxy ?? false;
760
772
  const listenKey = options.listenKey ?? null;
761
773
 
762
774
  if (request.method === "OPTIONS") {
@@ -1004,6 +1016,15 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1004
1016
  ) {
1005
1017
  const scope = scopeOf(keyFrom(request, url), key, listenKey);
1006
1018
  if (scope === null) {
1019
+ // Counted, not because a 128-bit key falls to guessing, but because
1020
+ // somebody hammering one should stop costing this server anything.
1021
+ const who = callerOf(request.headers, request.socket.remoteAddress, behindProxy);
1022
+ const verdict = guard.check(`key:${who}`, BAD_KEY_LIMIT);
1023
+ if (!verdict.ok) {
1024
+ response.writeHead(429, { ...CORS, "content-type": "application/json; charset=utf-8", "retry-after": String(verdict.retryAfter) });
1025
+ response.end(JSON.stringify({ error: "too many attempts; wait a moment" }));
1026
+ return;
1027
+ }
1007
1028
  json(response, 401, { error: "this nixamp needs the key from its share link" });
1008
1029
  return;
1009
1030
  }
@@ -1224,10 +1245,34 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1224
1245
  return;
1225
1246
  }
1226
1247
 
1248
+ // A password is the one secret here small enough to guess, so the
1249
+ // attempts are counted per caller and per address being tried: one
1250
+ // machine working through a word list and a thousand machines trying one
1251
+ // address are the same attack, and each is stopped by its own counter.
1252
+ const who = callerOf(request.headers, request.socket.remoteAddress, behindProxy);
1253
+ const target = typeof body.email === "string" ? body.email.toLowerCase().slice(0, 200) : "";
1254
+ const buckets = [`signin:${who}`, `signin:${target}`];
1255
+ for (const bucket of buckets) {
1256
+ const verdict = guard.check(bucket, SIGN_IN_LIMIT);
1257
+ if (!verdict.ok) {
1258
+ response.writeHead(429, {
1259
+ ...CORS,
1260
+ "content-type": "application/json; charset=utf-8",
1261
+ "retry-after": String(verdict.retryAfter),
1262
+ });
1263
+ response.end(JSON.stringify({ error: "too many attempts; try again later" }));
1264
+ return;
1265
+ }
1266
+ }
1267
+
1227
1268
  const result = signingUp
1228
1269
  ? await accounts.signUp(body.email, body.password)
1229
1270
  : await accounts.signIn(body.email, body.password);
1230
1271
 
1272
+ // Getting it right costs nothing: the counters only exist to stop people
1273
+ // who keep getting it wrong.
1274
+ if (result.ok) for (const bucket of buckets) guard.forget(bucket);
1275
+
1231
1276
  if (!result.ok) {
1232
1277
  // 409 for an address that is taken, 401 for credentials that are not.
1233
1278
  json(response, signingUp ? 409 : 401, { error: result.error });
@@ -2360,6 +2405,11 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2360
2405
  secret: process.env["NIXAMP_JWT_SECRET"] ?? "",
2361
2406
  }),
2362
2407
  secureCookies: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"),
2408
+ // A deployment reached over https is one behind somebody's proxy, so
2409
+ // the socket address is that proxy and the forwarded header is the
2410
+ // caller. A nixamp on a laptop is reached directly and must not
2411
+ // believe a header anybody can send.
2412
+ behindProxy: (process.env["NIXAMP_SITE"] ?? "").startsWith("https://"),
2363
2413
  // Whichever providers this deployment was given both halves of, plus
2364
2414
  // the device grant, which is worth having even with no provider at
2365
2415
  // all: a browser already signed in can approve a terminal.
@@ -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=new Set([`mp3`,`flac`,`ogg`,`oga`,`opus`,`m4a`,`aac`,`wav`,`wma`,`aiff`,`aif`,`alac`,`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`]);function o(e,t=``){if(t.startsWith(`audio/`)||t.startsWith(`video/`))return!0;let n=e.lastIndexOf(`.`);return n>0&&a.has(e.slice(n+1).toLowerCase())}function s(e,t){return e.localeCompare(t,void 0,{numeric:!0,sensitivity:`base`})}function c(e){return e.filter(e=>o(e.name,e.type)).sort((e,t)=>s(l(e),l(t))).map(e=>({title:n(e.name),artist:``,album:u(l(e)),duration:0,url:URL.createObjectURL(e),video:i(e.name,e.type),objectUrl:!0}))}function l(e){return e.webkitRelativePath||e.name}function u(e){let t=e.split(`/`);return t.length>1?t[t.length-2]:``}function d(e){for(let t of e)t.objectUrl&&URL.revokeObjectURL(t.url)}var f=2048,p=class{elements;handlers;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(m(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=f,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.video?this.elements.video:this.elements.audio;n!==this.active&&(this.active.pause(),this.active.removeAttribute(`src`),this.active.load(),this.active=n),this.active.src=e.url,this.active.load(),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}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 m(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 h(){return{revision:0,tracks:[],index:0,playing:!1,position:0,bars:[],levels:[0,0],silent:!0,note:``,root:``}}function g(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 _(e,t){return`${e===``?``:g(e)}${t.startsWith(`/`)?t:`/${t}`}`}function v(e,t){return _(e,`/api/media/${t}`)}function y(e){if(typeof e!=`object`||!e)return null;let t=e;if(!Array.isArray(t.tracks))return null;let n=h(),r=(e,t)=>typeof e==`number`&&Number.isFinite(e)?e:t,i=Array.isArray(t.levels)?t.levels:[];return{revision:r(t.revision,0),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)}}),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 ee=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=g(e);this.close(),this.base=t,this.lastRevision=-1,this.handlers.onStatus(`connecting`);let n=new EventSource(_(t,`/api/events`));this.source=n,n.onopen=()=>this.handlers.onStatus(`live`),n.onmessage=e=>{let t=y(b(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(_(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=y(await t.json());n&&this.handlers.onSnapshot(n)}media(e){return v(this.base,e)}close(){this.source?.close(),this.source=null}};function b(e){try{return JSON.parse(e)}catch{return null}}async function te(e,t){try{let n=await fetch(_(e,`/api/state`),{signal:t});return n.ok?y(await n.json()):null}catch{return null}}async function x(e,t){try{let n=await fetch(_(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}}var S=.14,C=.02;function ne(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 re(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 ie(e,t,n=S){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function w(e,t,n=C){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function ae(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 oe=`nixamp.remote`,se=`nixamp.volume`;function T(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function E(){let n={status:T(`status`),source:T(`source`),install:T(`install`),video:T(`video`),audio:T(`audio`),title:T(`title-line`),album:T(`album-line`),elapsed:T(`elapsed`),total:T(`total`),seek:T(`seek`),canvas:T(`spectrum`),glyphs:T(`glyphs`),levels:T(`levels`),playlist:T(`playlist`),playlistTitle:T(`playlist-panel`),note:T(`note`),files:T(`files`),folder:T(`folder`),remoteUrl:T(`remote-url`),remoteForm:T(`remote-form`),remoteState:T(`remote-state`),disconnect:T(`disconnect`),browse:T(`browse`),accountForm:T(`account-form`),accountEmail:T(`account-email`),accountPassword:T(`account-password`),accountSubmit:T(`account-submit`),accountToggle:T(`account-toggle`),accountProviders:T(`account-providers`),accountPanel:T(`account-panel`),accountElsewhere:T(`account-elsewhere`),accountSignOut:T(`account-signout`),accountNote:T(`account-note`),adminPanel:T(`admin-panel`),adminNote:T(`admin-note`),adminConnections:T(`admin-connections`),adminRestream:T(`admin-restream`),adminSource:T(`admin-source`),directory:T(`directory`),recentNote:T(`recent-note`),recentList:T(`recent-list`),followingNote:T(`following-note`),followingList:T(`following-list`),notifyPanel:T(`notify-panel`),notifyNote:T(`notify-note`),notifyWeb:T(`notify-web`),notifyEmail:T(`notify-email`),notifySms:T(`notify-sms`),notifyPhone:T(`notify-phone`),notifyPhoneForm:T(`notify-phone-form`),notifyPhoneNote:T(`notify-phone-note`),directoryNote:T(`directory-note`),directoryList:T(`directory-list`),listenHere:T(`listen-here`),volume:T(`volume`),prev:T(`prev`),playPause:T(`play-pause`),stop:T(`stop`),next:T(`next`)},r=`local`,i=[],a=0,o=h(),s=`idle`,l=``,u=`Pick files, or connect to a nixamp running somewhere else.`,f=!1,m=Array(24).fill(0),_=Array(24).fill(0),v=[],y=()=>r===`remote`&&!n.listenHere.checked,b=new p({audio:n.audio,video:n.video},{onTime:(e,t)=>{let n=i[a];r===`local`&&n&&t>0&&n.duration!==t&&(n.duration=t),I()},onEnded:()=>F(1),onState:()=>I(),onError:e=>{u=e,I()}}),S=new ee({onSnapshot:e=>{o=e,y()&&(m=e.bars.length>0?e.bars:m,_=w(_,m)),I()},onStatus:(e,t)=>{s=e,l=t??``,I()}}),C=()=>r===`remote`?o.tracks.length:i.length,E=()=>r===`remote`?o.index:a,D=()=>{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.`},O=()=>(r===`remote`?o.tracks[o.index]:i[a])?.album||`—`,k=()=>y()?o.tracks[o.index]?.duration??0:b.duration,A=()=>y()?o.position:b.position,j=()=>y()?o.playing:b.playing;async function M(e){if(r===`remote`){if(y()){await S.send({type:`play`,index:e});return}await S.send({type:`select`,index:e}),await N(e);return}let t=i[e];t&&(a=e,await b.load(t,!0),de(t.video),z(),I())}async function N(e){let t=o.tracks[e];t&&(await b.load({title:t.title,artist:t.artist,album:t.album,duration:t.duration,url:S.media(e),video:!1,objectUrl:!1},!0),z())}async function P(){if(y()){await S.send({type:`toggle`});return}C()!==0&&(b.playing?b.pause():b.position>0?await b.play():await M(E()),I())}async function F(e){let t=C();if(t!==0){if(y()){await S.send({type:e>0?`next`:`prev`});return}await M((E()+e+t)%t)}}async function ce(){if(y()){await S.send({type:`stop`});return}b.stop(),m=Array(24).fill(0),_=[...m],I()}let le=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))];function I(){let t=C(),a=j();n.status.textContent=a?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=String(a),n.title.textContent=D(),n.album.textContent=O();let c=A(),d=k();n.elapsed.textContent=e(c),n.total.textContent=d>0?e(d):`--:--`,f||(n.seek.value=String(d>0?Math.round(c/d*1e3):0),n.seek.disabled=d<=0||y()),n.playPause.textContent=a?`❚❚`:`▶`,n.playPause.setAttribute(`aria-label`,a?`Pause`:`Play`),n.playlistTitle.dataset.title=`Playlist (${t})`,n.source.textContent=r===`remote`?`remote · ${S.address.replace(/^https?:\/\//,``)||`—`}`:i.length>0?`local · ${i.length} files`:`no source`,n.remoteState.textContent=r===`remote`?`${s}${l?` — ${l}`:``}`:`not connected`,n.remoteState.dataset.status=r===`remote`?s:`idle`,n.disconnect.hidden=r!==`remote`;let p=r===`remote`&&o.note!==``?o.note:u;n.note.textContent=p,n.note.hidden=p===``,ue(),n.glyphs.textContent=m.map(le).join(``);let[h,g]=y()?o.levels:b.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(h*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(g*6)).padEnd(6,`·`)}`}let L=``;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!==L&&(L=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=E(),l=j();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 R(){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(y())_=w(_,m);else{let e=b.read();e.length>0&&(v.length!==25&&(v=ne(24,e.length)),m=ie(m,re(e,v)),_=w(_,m))}if(s){let e=getComputedStyle(document.documentElement);ae(s,{width:t.width,height:t.height},m,_,{bar:e.getPropertyValue(`--green`).trim()||`#4af689`,peak:e.getPropertyValue(`--green-dim`).trim()||`#227a4a`,background:`transparent`})}if(j()){n.glyphs.textContent=m.map(le).join(``);let[t,r]=y()?o.levels:b.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(t*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(r*6)).padEnd(6,`·`)}`,n.elapsed.textContent=e(A());let i=k();!f&&i>0&&(n.seek.value=String(Math.round(A()/i*1e3)))}requestAnimationFrame(R)}function de(e){n.video.hidden=!e}function z(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:D(),album:O(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void P()),navigator.mediaSession.setActionHandler(`pause`,()=>void P()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void F(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void F(-1)))}n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&M(n)}),n.prev.addEventListener(`click`,()=>void F(-1)),n.next.addEventListener(`click`,()=>void F(1)),n.stop.addEventListener(`click`,()=>void ce()),n.playPause.addEventListener(`click`,()=>void P()),n.seek.addEventListener(`input`,()=>{f=!0}),n.seek.addEventListener(`change`,()=>{let e=k();e>0&&b.seek(Number(n.seek.value)/1e3*e),f=!1}),n.volume.addEventListener(`input`,()=>{let e=Number(n.volume.value)/100;b.volume=e;try{localStorage.setItem(se,String(e))}catch{}});let B=e=>{e.addEventListener(`change`,()=>{let t=c(Array.from(e.files??[]));if(t.length===0){u=`Nothing playable in that selection.`,I();return}d(i),i=t,a=0,r=`local`,S.close(),u=``,M(0)})};B(n.files),B(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=g(n.remoteUrl.value);if(t===``){u=`That is not an address.`,I();return}(async()=>{if(s=`connecting`,I(),await x(t)===null){s=`error`,l=`no nixamp answered there`,r=`local`,I();return}r=`remote`,u=``;try{localStorage.setItem(oe,t)}catch{}S.connect(t),I()})()});let V=async()=>{n.directory.hidden=!1,n.directoryNote.textContent=`Looking for live streams…`,n.directoryList.replaceChildren();let e;try{let t=await fetch(`/api/directory`);if(!t.ok)throw Error(String(t.status));let n=await t.json();e=n.streams??[],pe(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(q(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),V()}let H=null,fe=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)}},U=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.`,fe(t.connections??[])}catch{n.adminNote.textContent=`lost touch with the server`}},W=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,H&&clearInterval(H),H=null,e&&(n.adminNote.textContent=t===`owner`?`You own this server.`:`You hold this server's control link.`,U(),H=setInterval(()=>void U(),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 G=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`},pe=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 ${G(e.endedAt)}`:`ended ${G(e.endedAt)}`,r.append(i,a),t.append(r,q(e.ownerId,e.name)),n.recentList.append(t)}},K=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}},q=(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),K())}catch{}finally{n.disabled=!1}})()}),n},me=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},J=()=>`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window,he=async()=>{if(!J())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:me(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}},ge=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`}},_e=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=J()&&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 he();n.notifyWeb.checked=e,await Y({wantsWeb:e});return}await ge(),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?(_e(),K()):(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),W()}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),W()})()}),(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)}})(),W(),n.browse.addEventListener(`click`,()=>{if(!n.directory.hidden){n.directory.hidden=!0;return}V(),n.directory.scrollIntoView({behavior:`smooth`,block:`nearest`})}),n.disconnect.addEventListener(`click`,()=>{S.close(),r=`local`,s=`idle`,l=``,I()}),n.listenHere.addEventListener(`change`,()=>{r===`remote`&&(async()=>{n.listenHere.checked?(await S.send({type:`stop`}),await N(o.index)):b.stop(),I()})()}),document.addEventListener(`keydown`,e=>{let t=e.target;if(!(t&&/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))switch(e.key){case` `:e.preventDefault(),P();return;case`s`:ce();return;case`n`:case`ArrowRight`:F(1);return;case`p`:case`ArrowLeft`:F(-1);return;case`ArrowDown`:e.preventDefault(),M(Math.min(C()-1,E()+1));return;case`ArrowUp`:e.preventDefault(),M(Math.max(0,E()-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(se);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),b.volume=Number(e));let t=localStorage.getItem(oe);t&&(n.remoteUrl.value=t)}catch{}(async()=>{if(n.remoteUrl.value!==``)return;let e=globalThis.location.origin;if(await x(e)===null)return;let t=await te(e);t&&t.tracks.length!==0&&(n.remoteUrl.value=e,r=`remote`,u=``,S.connect(e),I())})(),I(),requestAnimationFrame(R)}E(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});
@@ -16,7 +16,7 @@
16
16
  <meta property="og:title" content="nixamp" />
17
17
  <meta property="og:description" content="It really whips the terminal's ass." />
18
18
  <meta property="og:type" content="website" />
19
- <script type="module" crossorigin src="/assets/index-pztl5rKf.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-BD37tdcP.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-DSIDSSPF.css">
21
21
  </head>
22
22
  <body>
@@ -88,7 +88,7 @@
88
88
  <p class="hint"><a href="/" id="directory-back" hidden>Back to the player</a></p>
89
89
  </section>
90
90
 
91
- <section class="panel player-only" data-title="Account">
91
+ <section class="panel player-only" data-title="Account" id="account-panel">
92
92
  <p class="hint" id="account-note">Sign in to nixamp.com to publish and get paid.</p>
93
93
  <div id="account-providers" class="picker" hidden></div>
94
94
  <form id="account-form" class="picker">
@@ -102,6 +102,11 @@
102
102
  </form>
103
103
  </section>
104
104
 
105
+ <p class="hint player-only" id="account-elsewhere" hidden>
106
+ This nixamp keeps no accounts &mdash; it is somebody's own machine.
107
+ Accounts live at <a href="https://nixamp.com">nixamp.com</a>.
108
+ </p>
109
+
105
110
  <section class="panel player-only" data-title="Notifications" id="notify-panel" hidden>
106
111
  <p class="hint" id="notify-note">Get told when someone you follow goes live.</p>
107
112
  <div class="picker">
@@ -146,7 +151,7 @@
146
151
 
147
152
  <label class="check">
148
153
  <input id="listen-here" type="checkbox" />
149
- Listen on this device
154
+ Play on this device
150
155
  </label>
151
156
  <p class="hint">Status: <span id="remote-state" data-status="idle">not connected</span></p>
152
157
  </section>
package/web/dist/sw.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /* nixamp service worker — generated, do not edit */
2
- const CACHE = "nixamp-1788938369729";
2
+ const CACHE = "nixamp-1788940143847";
3
3
  const PRECACHE = [
4
4
  "/",
5
5
  "/apple-touch-icon.png",
6
+ "/assets/index-BD37tdcP.js",
6
7
  "/assets/index-DSIDSSPF.css",
7
- "/assets/index-pztl5rKf.js",
8
8
  "/icons/icon-192-maskable.png",
9
9
  "/icons/icon-192.png",
10
10
  "/icons/icon-512-maskable.png",
@@ -1 +0,0 @@
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=new Set([`mp3`,`flac`,`ogg`,`oga`,`opus`,`m4a`,`aac`,`wav`,`wma`,`aiff`,`aif`,`alac`,`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`]);function o(e,t=``){if(t.startsWith(`audio/`)||t.startsWith(`video/`))return!0;let n=e.lastIndexOf(`.`);return n>0&&a.has(e.slice(n+1).toLowerCase())}function s(e,t){return e.localeCompare(t,void 0,{numeric:!0,sensitivity:`base`})}function c(e){return e.filter(e=>o(e.name,e.type)).sort((e,t)=>s(l(e),l(t))).map(e=>({title:n(e.name),artist:``,album:u(l(e)),duration:0,url:URL.createObjectURL(e),video:i(e.name,e.type),objectUrl:!0}))}function l(e){return e.webkitRelativePath||e.name}function u(e){let t=e.split(`/`);return t.length>1?t[t.length-2]:``}function d(e){for(let t of e)t.objectUrl&&URL.revokeObjectURL(t.url)}var f=2048,p=class{elements;handlers;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(m(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=f,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.video?this.elements.video:this.elements.audio;n!==this.active&&(this.active.pause(),this.active.removeAttribute(`src`),this.active.load(),this.active=n),this.active.src=e.url,this.active.load(),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}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 m(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 h(){return{revision:0,tracks:[],index:0,playing:!1,position:0,bars:[],levels:[0,0],silent:!0,note:``,root:``}}function g(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 _(e,t){return`${e===``?``:g(e)}${t.startsWith(`/`)?t:`/${t}`}`}function v(e,t){return _(e,`/api/media/${t}`)}function y(e){if(typeof e!=`object`||!e)return null;let t=e;if(!Array.isArray(t.tracks))return null;let n=h(),r=(e,t)=>typeof e==`number`&&Number.isFinite(e)?e:t,i=Array.isArray(t.levels)?t.levels:[];return{revision:r(t.revision,0),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)}}),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 ee=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=g(e);this.close(),this.base=t,this.lastRevision=-1,this.handlers.onStatus(`connecting`);let n=new EventSource(_(t,`/api/events`));this.source=n,n.onopen=()=>this.handlers.onStatus(`live`),n.onmessage=e=>{let t=y(b(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(_(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=y(await t.json());n&&this.handlers.onSnapshot(n)}media(e){return v(this.base,e)}close(){this.source?.close(),this.source=null}};function b(e){try{return JSON.parse(e)}catch{return null}}async function te(e,t){try{let n=await fetch(_(e,`/api/state`),{signal:t});return n.ok?y(await n.json()):null}catch{return null}}async function x(e,t){try{let n=await fetch(_(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}}var S=.14,C=.02;function ne(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 re(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 ie(e,t,n=S){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function w(e,t,n=C){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function ae(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 oe=`nixamp.remote`,se=`nixamp.volume`;function T(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function E(){let n={status:T(`status`),source:T(`source`),install:T(`install`),video:T(`video`),audio:T(`audio`),title:T(`title-line`),album:T(`album-line`),elapsed:T(`elapsed`),total:T(`total`),seek:T(`seek`),canvas:T(`spectrum`),glyphs:T(`glyphs`),levels:T(`levels`),playlist:T(`playlist`),playlistTitle:T(`playlist-panel`),note:T(`note`),files:T(`files`),folder:T(`folder`),remoteUrl:T(`remote-url`),remoteForm:T(`remote-form`),remoteState:T(`remote-state`),disconnect:T(`disconnect`),browse:T(`browse`),accountForm:T(`account-form`),accountEmail:T(`account-email`),accountPassword:T(`account-password`),accountSubmit:T(`account-submit`),accountToggle:T(`account-toggle`),accountProviders:T(`account-providers`),accountSignOut:T(`account-signout`),accountNote:T(`account-note`),adminPanel:T(`admin-panel`),adminNote:T(`admin-note`),adminConnections:T(`admin-connections`),adminRestream:T(`admin-restream`),adminSource:T(`admin-source`),directory:T(`directory`),recentNote:T(`recent-note`),recentList:T(`recent-list`),followingNote:T(`following-note`),followingList:T(`following-list`),notifyPanel:T(`notify-panel`),notifyNote:T(`notify-note`),notifyWeb:T(`notify-web`),notifyEmail:T(`notify-email`),notifySms:T(`notify-sms`),notifyPhone:T(`notify-phone`),notifyPhoneForm:T(`notify-phone-form`),notifyPhoneNote:T(`notify-phone-note`),directoryNote:T(`directory-note`),directoryList:T(`directory-list`),listenHere:T(`listen-here`),volume:T(`volume`),prev:T(`prev`),playPause:T(`play-pause`),stop:T(`stop`),next:T(`next`)},r=`local`,i=[],a=0,o=h(),s=`idle`,l=``,u=`Pick files, or connect to a nixamp running somewhere else.`,f=!1,m=Array(24).fill(0),_=Array(24).fill(0),v=[],y=()=>r===`remote`&&!n.listenHere.checked,b=new p({audio:n.audio,video:n.video},{onTime:(e,t)=>{let n=i[a];r===`local`&&n&&t>0&&n.duration!==t&&(n.duration=t),I()},onEnded:()=>F(1),onState:()=>I(),onError:e=>{u=e,I()}}),S=new ee({onSnapshot:e=>{o=e,y()&&(m=e.bars.length>0?e.bars:m,_=w(_,m)),I()},onStatus:(e,t)=>{s=e,l=t??``,I()}}),C=()=>r===`remote`?o.tracks.length:i.length,E=()=>r===`remote`?o.index:a,D=()=>{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.`},O=()=>(r===`remote`?o.tracks[o.index]:i[a])?.album||`—`,k=()=>y()?o.tracks[o.index]?.duration??0:b.duration,A=()=>y()?o.position:b.position,j=()=>y()?o.playing:b.playing;async function M(e){if(r===`remote`){if(y()){await S.send({type:`play`,index:e});return}await S.send({type:`select`,index:e}),await N(e);return}let t=i[e];t&&(a=e,await b.load(t,!0),de(t.video),z(),I())}async function N(e){let t=o.tracks[e];t&&(await b.load({title:t.title,artist:t.artist,album:t.album,duration:t.duration,url:S.media(e),video:!1,objectUrl:!1},!0),z())}async function P(){if(y()){await S.send({type:`toggle`});return}C()!==0&&(b.playing?b.pause():b.position>0?await b.play():await M(E()),I())}async function F(e){let t=C();if(t!==0){if(y()){await S.send({type:e>0?`next`:`prev`});return}await M((E()+e+t)%t)}}async function ce(){if(y()){await S.send({type:`stop`});return}b.stop(),m=Array(24).fill(0),_=[...m],I()}let le=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))];function I(){let t=C(),a=j();n.status.textContent=a?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=String(a),n.title.textContent=D(),n.album.textContent=O();let c=A(),d=k();n.elapsed.textContent=e(c),n.total.textContent=d>0?e(d):`--:--`,f||(n.seek.value=String(d>0?Math.round(c/d*1e3):0),n.seek.disabled=d<=0||y()),n.playPause.textContent=a?`❚❚`:`▶`,n.playPause.setAttribute(`aria-label`,a?`Pause`:`Play`),n.playlistTitle.dataset.title=`Playlist (${t})`,n.source.textContent=r===`remote`?`remote · ${S.address.replace(/^https?:\/\//,``)||`—`}`:i.length>0?`local · ${i.length} files`:`no source`,n.remoteState.textContent=r===`remote`?`${s}${l?` — ${l}`:``}`:`not connected`,n.remoteState.dataset.status=r===`remote`?s:`idle`,n.disconnect.hidden=r!==`remote`;let p=r===`remote`&&o.note!==``?o.note:u;n.note.textContent=p,n.note.hidden=p===``,ue(),n.glyphs.textContent=m.map(le).join(``);let[h,g]=y()?o.levels:b.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(h*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(g*6)).padEnd(6,`·`)}`}let L=``;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!==L&&(L=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=E(),l=j();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 R(){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(y())_=w(_,m);else{let e=b.read();e.length>0&&(v.length!==25&&(v=ne(24,e.length)),m=ie(m,re(e,v)),_=w(_,m))}if(s){let e=getComputedStyle(document.documentElement);ae(s,{width:t.width,height:t.height},m,_,{bar:e.getPropertyValue(`--green`).trim()||`#4af689`,peak:e.getPropertyValue(`--green-dim`).trim()||`#227a4a`,background:`transparent`})}if(j()){n.glyphs.textContent=m.map(le).join(``);let[t,r]=y()?o.levels:b.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(t*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(r*6)).padEnd(6,`·`)}`,n.elapsed.textContent=e(A());let i=k();!f&&i>0&&(n.seek.value=String(Math.round(A()/i*1e3)))}requestAnimationFrame(R)}function de(e){n.video.hidden=!e}function z(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:D(),album:O(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void P()),navigator.mediaSession.setActionHandler(`pause`,()=>void P()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void F(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void F(-1)))}n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&M(n)}),n.prev.addEventListener(`click`,()=>void F(-1)),n.next.addEventListener(`click`,()=>void F(1)),n.stop.addEventListener(`click`,()=>void ce()),n.playPause.addEventListener(`click`,()=>void P()),n.seek.addEventListener(`input`,()=>{f=!0}),n.seek.addEventListener(`change`,()=>{let e=k();e>0&&b.seek(Number(n.seek.value)/1e3*e),f=!1}),n.volume.addEventListener(`input`,()=>{let e=Number(n.volume.value)/100;b.volume=e;try{localStorage.setItem(se,String(e))}catch{}});let B=e=>{e.addEventListener(`change`,()=>{let t=c(Array.from(e.files??[]));if(t.length===0){u=`Nothing playable in that selection.`,I();return}d(i),i=t,a=0,r=`local`,S.close(),u=``,M(0)})};B(n.files),B(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=g(n.remoteUrl.value);if(t===``){u=`That is not an address.`,I();return}(async()=>{if(s=`connecting`,I(),await x(t)===null){s=`error`,l=`no nixamp answered there`,r=`local`,I();return}r=`remote`,u=``;try{localStorage.setItem(oe,t)}catch{}S.connect(t),I()})()});let V=async()=>{n.directory.hidden=!1,n.directoryNote.textContent=`Looking for live streams…`,n.directoryList.replaceChildren();let e;try{let t=await fetch(`/api/directory`);if(!t.ok)throw Error(String(t.status));let n=await t.json();e=n.streams??[],pe(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(q(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),V()}let H=null,fe=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)}},U=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.`,fe(t.connections??[])}catch{n.adminNote.textContent=`lost touch with the server`}},W=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,H&&clearInterval(H),H=null,e&&(n.adminNote.textContent=t===`owner`?`You own this server.`:`You hold this server's control link.`,U(),H=setInterval(()=>void U(),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 G=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`},pe=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 ${G(e.endedAt)}`:`ended ${G(e.endedAt)}`,r.append(i,a),t.append(r,q(e.ownerId,e.name)),n.recentList.append(t)}},K=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}},q=(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),K())}catch{}finally{n.disabled=!1}})()}),n},me=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},J=()=>`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window,he=async()=>{if(!J())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:me(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}},ge=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`}},_e=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=J()&&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 he();n.notifyWeb.checked=e,await Y({wantsWeb:e});return}await ge(),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?(_e(),K()):(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),W()}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),W()})()}),(async()=>{let e=[];try{let t=await fetch(`/api/v1/auth/providers`);t.ok&&(e=(await t.json()).providers??[])}catch{}n.accountProviders.replaceChildren(),n.accountProviders.hidden=e.length===0;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)}})(),W(),n.browse.addEventListener(`click`,()=>{if(!n.directory.hidden){n.directory.hidden=!0;return}V(),n.directory.scrollIntoView({behavior:`smooth`,block:`nearest`})}),n.disconnect.addEventListener(`click`,()=>{S.close(),r=`local`,s=`idle`,l=``,I()}),n.listenHere.addEventListener(`change`,()=>{r===`remote`&&(async()=>{n.listenHere.checked?(await S.send({type:`stop`}),await N(o.index)):b.stop(),I()})()}),document.addEventListener(`keydown`,e=>{let t=e.target;if(!(t&&/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))switch(e.key){case` `:e.preventDefault(),P();return;case`s`:ce();return;case`n`:case`ArrowRight`:F(1);return;case`p`:case`ArrowLeft`:F(-1);return;case`ArrowDown`:e.preventDefault(),M(Math.min(C()-1,E()+1));return;case`ArrowUp`:e.preventDefault(),M(Math.max(0,E()-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(se);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),b.volume=Number(e));let t=localStorage.getItem(oe);t&&(n.remoteUrl.value=t)}catch{}(async()=>{if(n.remoteUrl.value!==``)return;let e=globalThis.location.origin;if(await x(e)===null)return;let t=await te(e);t&&t.tracks.length!==0&&(n.remoteUrl.value=e,r=`remote`,u=``,S.connect(e),I())})(),I(),requestAnimationFrame(R)}E(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});