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.
- package/dist/guard.d.ts +69 -0
- package/dist/guard.js +103 -0
- package/dist/main.js +2 -2
- package/dist/playlist.d.ts +15 -0
- package/dist/playlist.js +26 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +44 -2
- package/package.json +1 -1
- package/src/guard.ts +132 -0
- package/src/main.ts +2 -2
- package/src/playlist.ts +27 -0
- package/src/server.ts +52 -2
- package/web/dist/assets/index-BD37tdcP.js +1 -0
- package/web/dist/index.html +8 -3
- package/web/dist/sw.js +2 -2
- package/web/dist/assets/index-pztl5rKf.js +0 -1
package/dist/guard.d.ts
ADDED
|
@@ -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
|
|
320
|
+
void loadTagged(tools, target)
|
|
321
321
|
.then((tagged) => {
|
|
322
322
|
if (tagged.length !== state.tracks.length)
|
|
323
323
|
return;
|
package/dist/playlist.d.ts
CHANGED
|
@@ -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
|
|
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
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
|
|
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
|
|
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(()=>{})});
|
package/web/dist/index.html
CHANGED
|
@@ -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-
|
|
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 — 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
|
-
|
|
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-
|
|
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(()=>{})});
|