nixamp 0.5.4 → 0.5.6

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.
@@ -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/main.js CHANGED
@@ -12,7 +12,7 @@ import { resolve } from "node:path";
12
12
  import { detectTools, formatTime, peaks, RATE, Stream, toMono, } from "./audio.js";
13
13
  import { Analyser, bandEdges, bands, decay } from "./fft.js";
14
14
  import { version } from "./meta.js";
15
- import { displayName, loadSource } from "./playlist.js";
15
+ import { displayName, loadSource, loadTagged } from "./playlist.js";
16
16
  import { isRemote } from "./sources.js";
17
17
  import { DEFAULT_PORT } from "./server.js";
18
18
  const FFT_SIZE = 2048;
@@ -317,7 +317,7 @@ export async function main() {
317
317
  // The titles, arriving into a player that is already up. Not awaited, and
318
318
  // applied only if the list is still the one it describes.
319
319
  if (!isRemote(target)) {
320
- void loadSource(tools, target, true)
320
+ void loadTagged(tools, target)
321
321
  .then((tagged) => {
322
322
  if (tagged.length !== state.tracks.length)
323
323
  return;
@@ -24,4 +24,19 @@ export declare function loadSource(tools: Tools, source: string, probeTags?: boo
24
24
  * the caller decides when to pay for it. Untagged entries still play.
25
25
  */
26
26
  export declare function loadPlaylist(tools: Tools, root: string, probeTags?: boolean): Track[];
27
+ /**
28
+ * The same tags, read without holding the process still.
29
+ *
30
+ * `probe` is a spawnSync per file, so reading a library inside one async
31
+ * function never yields: the listening socket keeps accepting connections,
32
+ * the kernel completes their handshakes, and the process answers none of them
33
+ * until the last file is done. From outside that is indistinguishable from a
34
+ * firewall -- a connection that opens and then says nothing -- and on a library
35
+ * of any size it lasts minutes.
36
+ *
37
+ * One turn of the event loop per file fixes it. A request then waits for one
38
+ * ffprobe rather than for the whole library, and the tagging still finishes in
39
+ * about the time it did.
40
+ */
41
+ export declare function loadTagged(tools: Tools, source: string): Promise<Track[]>;
27
42
  export declare function displayName(track: Track): string;
package/dist/playlist.js CHANGED
@@ -121,6 +121,32 @@ export function loadPlaylist(tools, root, probeTags = true) {
121
121
  artist: "", album: "", duration: 0,
122
122
  });
123
123
  }
124
+ /**
125
+ * The same tags, read without holding the process still.
126
+ *
127
+ * `probe` is a spawnSync per file, so reading a library inside one async
128
+ * function never yields: the listening socket keeps accepting connections,
129
+ * the kernel completes their handshakes, and the process answers none of them
130
+ * until the last file is done. From outside that is indistinguishable from a
131
+ * firewall -- a connection that opens and then says nothing -- and on a library
132
+ * of any size it lasts minutes.
133
+ *
134
+ * One turn of the event loop per file fixes it. A request then waits for one
135
+ * ffprobe rather than for the whole library, and the tagging still finishes in
136
+ * about the time it did.
137
+ */
138
+ export async function loadTagged(tools, source) {
139
+ // A URL is one thing and is never probed; a playlist carries its own titles.
140
+ if (isRemote(source) || isPlaylistFile(source))
141
+ return loadSource(tools, source, true);
142
+ const paths = findAudio(source);
143
+ const tracks = [];
144
+ for (const path of paths) {
145
+ tracks.push(probe(tools, path));
146
+ await new Promise((done) => setImmediate(done));
147
+ }
148
+ return tracks;
149
+ }
124
150
  export function displayName(track) {
125
151
  return track.artist ? `${track.artist} — ${track.title}` : track.title;
126
152
  }
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";
@@ -39,7 +40,7 @@ import { extname, join, normalize, resolve, sep } from "node:path";
39
40
  import { fileURLToPath } from "node:url";
40
41
  import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
41
42
  import { Analyser, bandEdges, bands, decay } from "./fft.js";
42
- import { loadSource } from "./playlist.js";
43
+ import { loadSource, loadTagged } from "./playlist.js";
43
44
  import { emptySnapshot, parseCommand, } from "./protocol.js";
44
45
  const FFT_SIZE = 2048;
45
46
  export const SERVE_BAND_COUNT = 24;
@@ -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.
@@ -2131,7 +2173,7 @@ export async function serve(argv, version = "0.1.0") {
2131
2173
  // awaited: nothing downstream needs it, and a library that takes a minute to
2132
2174
  // read should cost nobody a minute of silence.
2133
2175
  if (tracks.length > 0 && !isRemote(root)) {
2134
- void loadSource(tools, root, true)
2176
+ void loadTagged(tools, root)
2135
2177
  .then((tagged) => engine.retag(tagged, root))
2136
2178
  .catch(() => {
2137
2179
  // Filenames are a working player. A failure here is worth nothing but
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
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/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/main.ts CHANGED
@@ -15,7 +15,7 @@ import {
15
15
  } from "./audio.ts";
16
16
  import { Analyser, bandEdges, bands, decay } from "./fft.ts";
17
17
  import { version } from "./meta.ts";
18
- import { displayName, loadSource } from "./playlist.ts";
18
+ import { displayName, loadSource, loadTagged } from "./playlist.ts";
19
19
  import { isRemote } from "./sources.ts";
20
20
  import { DEFAULT_PORT } from "./server.ts";
21
21
  import type { DaemonState } from "./daemon.ts";
@@ -352,7 +352,7 @@ export async function main(): Promise<void> {
352
352
  // The titles, arriving into a player that is already up. Not awaited, and
353
353
  // applied only if the list is still the one it describes.
354
354
  if (!isRemote(target)) {
355
- void loadSource(tools, target, true)
355
+ void loadTagged(tools, target)
356
356
  .then((tagged) => {
357
357
  if (tagged.length !== state.tracks.length) return;
358
358
  if (tagged.some((track, at) => track.path !== state.tracks[at]?.path)) return;
package/src/playlist.ts CHANGED
@@ -132,6 +132,33 @@ export function loadPlaylist(tools: Tools, root: string, probeTags = true): Trac
132
132
  });
133
133
  }
134
134
 
135
+ /**
136
+ * The same tags, read without holding the process still.
137
+ *
138
+ * `probe` is a spawnSync per file, so reading a library inside one async
139
+ * function never yields: the listening socket keeps accepting connections,
140
+ * the kernel completes their handshakes, and the process answers none of them
141
+ * until the last file is done. From outside that is indistinguishable from a
142
+ * firewall -- a connection that opens and then says nothing -- and on a library
143
+ * of any size it lasts minutes.
144
+ *
145
+ * One turn of the event loop per file fixes it. A request then waits for one
146
+ * ffprobe rather than for the whole library, and the tagging still finishes in
147
+ * about the time it did.
148
+ */
149
+ export async function loadTagged(tools: Tools, source: string): Promise<Track[]> {
150
+ // A URL is one thing and is never probed; a playlist carries its own titles.
151
+ if (isRemote(source) || isPlaylistFile(source)) return loadSource(tools, source, true);
152
+
153
+ const paths = findAudio(source);
154
+ const tracks: Track[] = [];
155
+ for (const path of paths) {
156
+ tracks.push(probe(tools, path));
157
+ await new Promise<void>((done) => setImmediate(done));
158
+ }
159
+ return tracks;
160
+ }
161
+
135
162
  export function displayName(track: Track): string {
136
163
  return track.artist ? `${track.artist} — ${track.title}` : track.title;
137
164
  }
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,
@@ -76,7 +77,7 @@ import {
76
77
  type Tools, type Track,
77
78
  } from "./audio.ts";
78
79
  import { Analyser, bandEdges, bands, decay } from "./fft.ts";
79
- import { loadSource } from "./playlist.ts";
80
+ import { loadSource, loadTagged } from "./playlist.ts";
80
81
  import {
81
82
  emptySnapshot, parseCommand,
82
83
  type Command, type RemoteTrack, type Snapshot,
@@ -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.
@@ -2448,7 +2498,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2448
2498
  // awaited: nothing downstream needs it, and a library that takes a minute to
2449
2499
  // read should cost nobody a minute of silence.
2450
2500
  if (tracks.length > 0 && !isRemote(root)) {
2451
- void loadSource(tools, root, true)
2501
+ void loadTagged(tools, root)
2452
2502
  .then((tagged) => engine.retag(tagged, root))
2453
2503
  .catch(() => {
2454
2504
  // Filenames are a working player. A failure here is worth nothing but
@@ -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-1788937565929";
2
+ const CACHE = "nixamp-1788939876425";
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(()=>{})});