nixamp 0.7.6 → 0.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin.js +9 -1
- package/dist/main.js +5 -1
- package/dist/owner.js +10 -0
- package/dist/server.d.ts +55 -0
- package/dist/server.js +187 -50
- package/dist/session.js +3 -1
- package/dist/share.d.ts +10 -0
- package/dist/share.js +36 -1
- package/package.json +1 -1
- package/src/admin.ts +11 -1
- package/src/main.ts +5 -1
- package/src/owner.ts +10 -0
- package/src/server.ts +196 -31
- package/src/session.ts +5 -1
- package/src/share.ts +33 -1
- package/web/dist/assets/{hls-3VKVEQE3-CKpUWwhK.js → hls-3VKVEQE3-CtqtOX7p.js} +1 -1
- package/web/dist/assets/index-B5ijv1SY.css +1 -0
- package/web/dist/assets/index-BL-Q9Q2e.js +1 -0
- package/web/dist/assets/{mpegts-6hEDu2OP.js → mpegts-DtUWYYid.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-BAhx7mvs.js → mpegts-LO6RVLD6-DIRN6lqd.js} +1 -1
- package/web/dist/index.html +17 -11
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-Bs7oVbTk.js +0 -1
- package/web/dist/assets/index-CTgfCs0m.css +0 -1
package/src/server.ts
CHANGED
|
@@ -65,6 +65,7 @@ import {
|
|
|
65
65
|
allowedForListening,
|
|
66
66
|
elevate,
|
|
67
67
|
firewallInUse,
|
|
68
|
+
certifiable,
|
|
68
69
|
keyCookie,
|
|
69
70
|
keyFrom,
|
|
70
71
|
keysMatch,
|
|
@@ -426,6 +427,8 @@ export interface Engine {
|
|
|
426
427
|
* this server somewhere else", which throws the library away on purpose.
|
|
427
428
|
*/
|
|
428
429
|
replace(tracks: Track[], root: string): void;
|
|
430
|
+
/** The library, arriving after the server was already listening. */
|
|
431
|
+
fill(tracks: Track[], root: string): void;
|
|
429
432
|
/**
|
|
430
433
|
* Play something as well as everything here.
|
|
431
434
|
*
|
|
@@ -757,6 +760,30 @@ export class PlayerEngine implements Engine {
|
|
|
757
760
|
return seen;
|
|
758
761
|
}
|
|
759
762
|
|
|
763
|
+
/**
|
|
764
|
+
* The library, arriving after the server was already listening.
|
|
765
|
+
*
|
|
766
|
+
* Walking a directory is the slow part of starting -- eighty thousand files
|
|
767
|
+
* under a home directory takes far longer than the fifteen seconds
|
|
768
|
+
* `nixamp daemon start` waits for the server to say it is up, so starting a
|
|
769
|
+
* daemon with no source given looked exactly like hanging and then failed
|
|
770
|
+
* about a server that was working. The port opens first now and this puts
|
|
771
|
+
* the library in behind it.
|
|
772
|
+
*
|
|
773
|
+
* Unlike `replace` it stops nothing and clears no listeners: whoever
|
|
774
|
+
* connected in the first second is still connected, and simply sees the
|
|
775
|
+
* playlist appear.
|
|
776
|
+
*/
|
|
777
|
+
fill(tracks: Loaded[], root: string): void {
|
|
778
|
+
// Something is already loaded, so this is a scan that finished after
|
|
779
|
+
// somebody pointed the server elsewhere. Theirs wins.
|
|
780
|
+
if (this.tracks.length > 0) return;
|
|
781
|
+
this.tracks = tracks;
|
|
782
|
+
this.root = root;
|
|
783
|
+
this.state.note = tracks.length === 0 ? `No audio files under ${root}.` : "";
|
|
784
|
+
this.push(true);
|
|
785
|
+
}
|
|
786
|
+
|
|
760
787
|
retag(tracks: Track[], root: string): void {
|
|
761
788
|
// Matched by path rather than by position, because the list is no longer
|
|
762
789
|
// required to be the one that was sent for tagging: somebody can add an
|
|
@@ -800,6 +827,7 @@ export class EmptyEngine implements Engine {
|
|
|
800
827
|
return undefined;
|
|
801
828
|
}
|
|
802
829
|
replace(): void {}
|
|
830
|
+
fill(): void {}
|
|
803
831
|
add(): number {
|
|
804
832
|
return 0;
|
|
805
833
|
}
|
|
@@ -930,6 +958,28 @@ export interface HandlerOptions {
|
|
|
930
958
|
broadcaster?: Broadcaster;
|
|
931
959
|
/** Where a broadcast should send, and what it should look like. */
|
|
932
960
|
broadcast?: () => { destinations: Destination[]; settings: EncoderSettings };
|
|
961
|
+
/**
|
|
962
|
+
* Going live: whether this server is listed, and how to change that.
|
|
963
|
+
*
|
|
964
|
+
* Listing used to be a question asked once at startup and never again, so a
|
|
965
|
+
* server started with --no-publish had no listing, no phone code, and no
|
|
966
|
+
* link to hand anybody -- and no way to change its mind short of stopping
|
|
967
|
+
* and starting it. It is an action now, because that is what it is.
|
|
968
|
+
*/
|
|
969
|
+
live?: {
|
|
970
|
+
status: () => { live: boolean; code: string; name: string; url: string; possible: boolean };
|
|
971
|
+
start: () => Promise<{ live: boolean; code: string; name: string; url: string; error?: string }>;
|
|
972
|
+
stop: () => Promise<void>;
|
|
973
|
+
};
|
|
974
|
+
/**
|
|
975
|
+
* Where OBS should point, one entry per stream this server will accept.
|
|
976
|
+
*
|
|
977
|
+
* There is deliberately no single link. ffmpeg's RTMP listener serves one
|
|
978
|
+
* connection per process, so three people going live at once is three ports
|
|
979
|
+
* and three URLs -- and a panel offering one address for all of them would
|
|
980
|
+
* be offering an address that works exactly once.
|
|
981
|
+
*/
|
|
982
|
+
publishUrls?: () => { id: string; url: string }[];
|
|
933
983
|
/** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
|
|
934
984
|
accounts?: Accounts;
|
|
935
985
|
/** Providers to sign in with, and the terminals waiting to be connected. */
|
|
@@ -2135,12 +2185,46 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2135
2185
|
|
|
2136
2186
|
// Everything the admin view draws, in one request: who is connected, and
|
|
2137
2187
|
// what this server is.
|
|
2188
|
+
// Going live, and coming back off. Admin-gated by ADMIN_PATHS, because
|
|
2189
|
+
// listing somebody's machine in a public directory is not a thing a
|
|
2190
|
+
// listener gets to do.
|
|
2191
|
+
if (path === "/api/live/state") {
|
|
2192
|
+
if (!options.live) {
|
|
2193
|
+
json(response, 200, { live: false, possible: false, code: "", name: "", url: "" });
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
json(response, 200, options.live.status());
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
|
|
2200
|
+
if (path === "/api/live/start" || path === "/api/live/stop") {
|
|
2201
|
+
if (request.method !== "POST") {
|
|
2202
|
+
json(response, 405, { error: "POST only" });
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
if (!options.live) {
|
|
2206
|
+
json(response, 409, { error: "this nixamp cannot be listed; it has no address the world can reach" });
|
|
2207
|
+
return;
|
|
2208
|
+
}
|
|
2209
|
+
if (path === "/api/live/stop") {
|
|
2210
|
+
await options.live.stop();
|
|
2211
|
+
json(response, 200, options.live.status());
|
|
2212
|
+
return;
|
|
2213
|
+
}
|
|
2214
|
+
const started = await options.live.start();
|
|
2215
|
+
json(response, started.error ? 502 : 200, started);
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2138
2219
|
if (path === "/api/connections") {
|
|
2139
2220
|
json(response, 200, {
|
|
2140
2221
|
connections: tracker.list(),
|
|
2141
2222
|
active: tracker.active,
|
|
2142
2223
|
startedAt: started,
|
|
2143
2224
|
now: Date.now(),
|
|
2225
|
+
// Where to point OBS. Printed at startup since RTMP was added, which
|
|
2226
|
+
// is no use at all to somebody looking at the admin panel a day later.
|
|
2227
|
+
publish: options.publishUrls?.() ?? [],
|
|
2144
2228
|
});
|
|
2145
2229
|
return;
|
|
2146
2230
|
}
|
|
@@ -2726,10 +2810,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
2726
2810
|
//
|
|
2727
2811
|
// So the filenames are enough to start: the server is up and answering in the
|
|
2728
2812
|
// time it takes to walk the directory, and the titles fill in behind it.
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
: new EmptyEngine(`No audio files under ${root}.`);
|
|
2813
|
+
// Empty on purpose. The walk happens below, once the port is open: it is
|
|
2814
|
+
// the slowest part of starting and nothing about it needs to happen first.
|
|
2815
|
+
const engine = new PlayerEngine([], root, tools);
|
|
2733
2816
|
|
|
2734
2817
|
const web = options.web !== null ? resolve(options.web) : defaultWebDir();
|
|
2735
2818
|
const key = options.key ? newKey() : null;
|
|
@@ -2947,11 +3030,60 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
2947
3030
|
})()
|
|
2948
3031
|
: undefined;
|
|
2949
3032
|
|
|
3033
|
+
// Filled in below, when the RTMP listeners are opened. Read through a
|
|
3034
|
+
// function so the handler sees the list rather than the empty array it was
|
|
3035
|
+
// built with.
|
|
3036
|
+
let publishUrls: { id: string; url: string }[] = [];
|
|
3037
|
+
|
|
3038
|
+
// Declared up here, not beside the publishing below: the port opens before
|
|
3039
|
+
// that code runs, so an admin asking to go live in the first moments would
|
|
3040
|
+
// otherwise reach a binding that has not been initialised.
|
|
3041
|
+
let publisher: Publisher | null = null;
|
|
3042
|
+
let listing: Listing | null = null;
|
|
3043
|
+
let publishable_: { label: string; url: string } | undefined;
|
|
3044
|
+
|
|
2950
3045
|
const server = createServer(engine, {
|
|
2951
3046
|
web,
|
|
2952
3047
|
media: options.media,
|
|
2953
3048
|
owner,
|
|
2954
3049
|
channels,
|
|
3050
|
+
publishUrls: () => publishUrls,
|
|
3051
|
+
live: {
|
|
3052
|
+
status: () => ({
|
|
3053
|
+
live: publisher !== null,
|
|
3054
|
+
code: listing?.code ?? "",
|
|
3055
|
+
name: listing?.name ?? (options.name || hostname()),
|
|
3056
|
+
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey) : ""),
|
|
3057
|
+
// Whether going live is even possible here. A laptop behind a router
|
|
3058
|
+
// with no address the world can reach cannot be listed, and a button
|
|
3059
|
+
// that could only fail is worse than one that is not offered.
|
|
3060
|
+
possible: publishable_ !== undefined,
|
|
3061
|
+
}),
|
|
3062
|
+
start: async () => {
|
|
3063
|
+
if (publisher === null) publisher = makePublisher();
|
|
3064
|
+
if (publisher === null) {
|
|
3065
|
+
return { live: false, code: "", name: "", url: "", error: "this machine has no address the world can reach" };
|
|
3066
|
+
}
|
|
3067
|
+
const first = await publisher.start();
|
|
3068
|
+
if (first === null) {
|
|
3069
|
+
// Nothing was listed, so nothing should claim to be: a publisher
|
|
3070
|
+
// left running here would heartbeat at a directory that refused it.
|
|
3071
|
+
await publisher.stop();
|
|
3072
|
+
publisher = null;
|
|
3073
|
+
return {
|
|
3074
|
+
live: false, code: "", name: "", url: "",
|
|
3075
|
+
error: `${DEFAULT_DIRECTORY} would not list this stream. Run \`nixamp login\` on that machine.`,
|
|
3076
|
+
};
|
|
3077
|
+
}
|
|
3078
|
+
listing = first;
|
|
3079
|
+
return { live: true, code: first.code, name: first.name, url: first.url };
|
|
3080
|
+
},
|
|
3081
|
+
stop: async () => {
|
|
3082
|
+
await publisher?.stop();
|
|
3083
|
+
publisher = null;
|
|
3084
|
+
listing = null;
|
|
3085
|
+
},
|
|
3086
|
+
},
|
|
2955
3087
|
...(ingest ? { ingest } : {}),
|
|
2956
3088
|
broadcaster,
|
|
2957
3089
|
broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
|
|
@@ -3077,20 +3209,30 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3077
3209
|
);
|
|
3078
3210
|
}
|
|
3079
3211
|
|
|
3080
|
-
//
|
|
3081
|
-
//
|
|
3082
|
-
//
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
.
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
});
|
|
3091
|
-
|
|
3212
|
+
// Names now, tags later, and both after the door is open. Not awaited:
|
|
3213
|
+
// nothing below needs the library, and a directory that takes a minute to
|
|
3214
|
+
// walk should cost nobody a minute of not being able to connect.
|
|
3215
|
+
void loadSource(tools, root, false)
|
|
3216
|
+
.then((found) => {
|
|
3217
|
+
engine.fill(found, root);
|
|
3218
|
+
if (found.length === 0) {
|
|
3219
|
+
console.log(`nixamp serve — no audio files under ${root}`);
|
|
3220
|
+
return;
|
|
3221
|
+
}
|
|
3222
|
+
console.log(`nixamp serve — ${found.length} tracks under ${root}`);
|
|
3223
|
+
if (isRemote(root)) return;
|
|
3224
|
+
return loadTagged(tools, root)
|
|
3225
|
+
.then((tagged) => engine.retag(tagged, root))
|
|
3226
|
+
.catch(() => {
|
|
3227
|
+
// Filenames are a working player. A failure here is worth nothing
|
|
3228
|
+
// but titles that stay as they are.
|
|
3229
|
+
});
|
|
3230
|
+
})
|
|
3231
|
+
.catch((error: unknown) => {
|
|
3232
|
+
console.log(`nixamp: could not read ${root}: ${(error as Error).message}`);
|
|
3233
|
+
});
|
|
3092
3234
|
|
|
3093
|
-
console.log(`nixamp serve —
|
|
3235
|
+
console.log(`nixamp serve — reading ${root}`);
|
|
3094
3236
|
console.log("");
|
|
3095
3237
|
|
|
3096
3238
|
const width = Math.max(...addresses.map((a) => a.label.length));
|
|
@@ -3145,9 +3287,14 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3145
3287
|
rtmp = new RtmpListeners(channels, tools.ffmpeg, listenKey ?? "live");
|
|
3146
3288
|
rtmp.listen(slots);
|
|
3147
3289
|
|
|
3290
|
+
publishUrls = slots.map((slot) => ({
|
|
3291
|
+
id: slot.id,
|
|
3292
|
+
url: `rtmp://${host}:${slot.port}/live/${listenKey ?? "live"}`,
|
|
3293
|
+
}));
|
|
3294
|
+
|
|
3148
3295
|
console.log(" Or publish from OBS, Larix or ffmpeg, one per URL:");
|
|
3149
|
-
for (const
|
|
3150
|
-
console.log(`
|
|
3296
|
+
for (const entry of publishUrls) {
|
|
3297
|
+
console.log(` ${entry.url} -> "${entry.id}"`);
|
|
3151
3298
|
}
|
|
3152
3299
|
}
|
|
3153
3300
|
if (destinations.length > 0) {
|
|
@@ -3191,27 +3338,35 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3191
3338
|
|
|
3192
3339
|
// The listing carries the listen link, and only ever a public address: an
|
|
3193
3340
|
// entry pointing at 192.168.1.5 is one nobody outside that house can open.
|
|
3194
|
-
|
|
3341
|
+
// Never a bare IP over https: a certificate is issued for a name, so a
|
|
3342
|
+
// listing pointing at one is a listing nobody can open.
|
|
3343
|
+
publishable_ = addresses.find((a) => a.label === "on the internet" && certifiable(a.url))
|
|
3344
|
+
?? addresses.find((a) => a.label === "on tailscale" && certifiable(a.url))
|
|
3345
|
+
?? addresses.find((a) => a.label === "on the internet")
|
|
3195
3346
|
?? addresses.find((a) => a.label === "on tailscale");
|
|
3196
|
-
let publisher: Publisher | null = null;
|
|
3197
3347
|
|
|
3198
|
-
|
|
3348
|
+
/**
|
|
3349
|
+
* Make a publisher for this server. Called at startup when the operator says
|
|
3350
|
+
* yes, and again whenever somebody goes live from the admin panel.
|
|
3351
|
+
*
|
|
3352
|
+
* A declaration rather than an assignment, so it exists from the moment the
|
|
3353
|
+
* function is entered -- the port is open well before this line is reached.
|
|
3354
|
+
*/
|
|
3355
|
+
function makePublisher(): Publisher | null {
|
|
3356
|
+
if (!publishable_) return null;
|
|
3199
3357
|
const listen = shareLink(publishable_.url, listenKey);
|
|
3200
3358
|
// Announced next to the listen link, not instead of it: one is for a person
|
|
3201
3359
|
// with a browser, the other for the phone line and anything else that is
|
|
3202
3360
|
// handed one address and expected to play it.
|
|
3203
3361
|
const audio = audioLink(publishable_.url, listenKey);
|
|
3204
|
-
|
|
3205
|
-
? true
|
|
3206
|
-
: await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
|
|
3207
|
-
|
|
3208
|
-
if (wanted) {
|
|
3209
|
-
publisher = new Publisher({
|
|
3362
|
+
return new Publisher({
|
|
3210
3363
|
directory: DEFAULT_DIRECTORY,
|
|
3211
3364
|
name: options.name || hostname(),
|
|
3212
3365
|
url: listen,
|
|
3213
3366
|
audio,
|
|
3214
|
-
|
|
3367
|
+
// Asked of the engine rather than a variable, because the library is
|
|
3368
|
+
// now read after the port opens and may still be arriving.
|
|
3369
|
+
tracks: engine.snapshot(false).trackCount,
|
|
3215
3370
|
// From `nixamp login`. The directory will not list a stream it cannot
|
|
3216
3371
|
// attribute to somebody, because a listing is now a phone code that
|
|
3217
3372
|
// costs money to answer.
|
|
@@ -3233,8 +3388,18 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3233
3388
|
? ` nixamp.com turned paid listening on: $${(next.priceCents / 100).toFixed(2)} for ${next.passMinutes} minutes, over ${FREE_LISTENERS} listeners.`
|
|
3234
3389
|
: " nixamp.com turned paid listening off.");
|
|
3235
3390
|
},
|
|
3236
|
-
|
|
3237
|
-
|
|
3391
|
+
});
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
if (options.publish !== "no" && publishable_) {
|
|
3395
|
+
const listen = shareLink(publishable_.url, listenKey);
|
|
3396
|
+
const wanted = options.publish === "yes"
|
|
3397
|
+
? true
|
|
3398
|
+
: await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
|
|
3399
|
+
|
|
3400
|
+
if (wanted) {
|
|
3401
|
+
publisher = makePublisher();
|
|
3402
|
+
listing = (await publisher?.start()) ?? null;
|
|
3238
3403
|
console.log("");
|
|
3239
3404
|
console.log(listing
|
|
3240
3405
|
? ` Listed at ${DEFAULT_DIRECTORY}/directory as "${listing.name}". It leaves the list when this stops.`
|
package/src/session.ts
CHANGED
|
@@ -582,7 +582,11 @@ export async function servers(argv: string[], fetcher: typeof fetch = fetch): Pr
|
|
|
582
582
|
const daemon = await import("./daemon.ts");
|
|
583
583
|
const state = daemon.readState();
|
|
584
584
|
if (state === null) {
|
|
585
|
-
console.error(
|
|
585
|
+
console.error(
|
|
586
|
+
"nixamp: no daemon is running here.\n" +
|
|
587
|
+
" Start one: nixamp daemon start ~/Music\n" +
|
|
588
|
+
" Or give the share link of the one you mean: https://host:4321/s/KEY",
|
|
589
|
+
);
|
|
586
590
|
return 1;
|
|
587
591
|
}
|
|
588
592
|
// The address worth remembering is the one somebody else can open.
|
package/src/share.ts
CHANGED
|
@@ -128,6 +128,24 @@ export async function lookupPublicIp(send: typeof fetch = fetch, timeoutMs = 250
|
|
|
128
128
|
* somewhere else can open. It is labelled for what it is, because the key in
|
|
129
129
|
* the link is then the only thing between a stranger and the library.
|
|
130
130
|
*/
|
|
131
|
+
/**
|
|
132
|
+
* Whether a browser could ever verify a certificate for this address.
|
|
133
|
+
*
|
|
134
|
+
* A certificate is issued for a name. Handed an https link to a bare IP, a
|
|
135
|
+
* browser has nothing to match it against and refuses the connection before it
|
|
136
|
+
* asks anything -- and from the page it looks identical to a machine that is
|
|
137
|
+
* switched off. Offering such a link is offering one that cannot work, so the
|
|
138
|
+
* links are labelled and the ones that can work go first.
|
|
139
|
+
*/
|
|
140
|
+
export function certifiable(url: string): boolean {
|
|
141
|
+
if (!url.startsWith("https://")) return true;
|
|
142
|
+
try {
|
|
143
|
+
return !isIpAddress(new URL(url).hostname.replace(/^\[|\]$/g, ""));
|
|
144
|
+
} catch {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
131
149
|
export function reachableAddresses(
|
|
132
150
|
host: string,
|
|
133
151
|
port: number,
|
|
@@ -161,11 +179,21 @@ export function reachableAddresses(
|
|
|
161
179
|
}
|
|
162
180
|
const order = { private: 0, cgnat: 1, public: 2 } as const;
|
|
163
181
|
found.sort((x, y) => order[x.kind] - order[y.kind]);
|
|
164
|
-
|
|
182
|
+
const all = [
|
|
165
183
|
...told,
|
|
166
184
|
{ label: "here", url: `${scheme}://localhost:${port}` },
|
|
167
185
|
...found.map(({ label, url }) => ({ label, url })),
|
|
168
186
|
];
|
|
187
|
+
// Serving https, an address that is a bare IP cannot be verified by anyone,
|
|
188
|
+
// so it is said last and said differently rather than handed out as though
|
|
189
|
+
// it were a link somebody could use.
|
|
190
|
+
if (scheme !== "https") return all;
|
|
191
|
+
return [
|
|
192
|
+
...all.filter((entry) => certifiable(entry.url)),
|
|
193
|
+
...all
|
|
194
|
+
.filter((entry) => !certifiable(entry.url))
|
|
195
|
+
.map((entry) => ({ label: `${entry.label} (no certificate for an IP)`, url: entry.url })),
|
|
196
|
+
];
|
|
169
197
|
}
|
|
170
198
|
|
|
171
199
|
/** The full link, key and all. */
|
|
@@ -204,6 +232,10 @@ export function allowedForListening(path: string): boolean {
|
|
|
204
232
|
// and an exact check let a listen key reach /api/source/remove and delete an
|
|
205
233
|
// album out of somebody else's playlist.
|
|
206
234
|
if (path === "/api/source" || path.startsWith("/api/source/")) return false;
|
|
235
|
+
// Listing this machine in a public directory is not listening to it. The
|
|
236
|
+
// listen address itself, /api/live, stays open: that is the thing a listen
|
|
237
|
+
// key is for.
|
|
238
|
+
if (path === "/api/live/state" || path === "/api/live/start" || path === "/api/live/stop") return false;
|
|
207
239
|
return true;
|
|
208
240
|
}
|
|
209
241
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-BL-Q9Q2e.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg:#080c09;--panel:#0c120e;--edge:#1d2c22;--green:#4af689;--green-dim:#227a4a;--fg:#cfe8d8;--muted:#6d8a79;--warn:#e8c35a;--accent:#7ef0c4;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}[hidden]{display:none!important}html,body{background:var(--bg);min-height:100%;color:var(--fg);margin:0;font:14px/1.45 ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace}body{background-image:repeating-linear-gradient(#4af68906 0 1px,#0000 1px 3px)}#app{max-width:1100px;padding:calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left));flex-direction:column;gap:10px;margin:0 auto;display:flex}.bar{flex-wrap:wrap;align-items:center;gap:10px;display:flex}.brand{color:var(--green);letter-spacing:.14em;font-weight:700}.status{color:var(--muted)}.status[data-playing=true]{color:var(--green)}.chip{color:var(--muted);border:1px solid var(--edge);text-overflow:ellipsis;white-space:nowrap;border-radius:999px;max-width:46vw;margin-left:auto;padding:1px 10px;font-size:12px;overflow:hidden}.panel{border:1px solid var(--edge);background:var(--panel);border-radius:6px;min-width:0;padding:14px 12px 12px;position:relative}.panel:before{content:attr(data-title);background:var(--panel);color:var(--green-dim);letter-spacing:.06em;padding:0 6px;font-size:12px;position:absolute;top:-.72em;left:10px}.split{grid-template-columns:1.3fr 1fr;gap:10px;display:grid}.split>.col-a{grid-column:1}.split>.col-b{grid-column:2}@media (max-width:720px){.split{grid-template-columns:1fr}.split>.col-a,.split>.col-b{grid-column:auto}}.track-title{color:var(--accent);text-overflow:ellipsis;white-space:nowrap;font-weight:700;overflow:hidden}.track-sub{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}#video{border:1px solid var(--edge);background:#000;border-radius:4px;width:100%;max-height:46vh;margin-bottom:8px}.scrub{align-items:center;gap:10px;margin-top:6px;display:flex}.time{color:var(--fg);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}input[type=range]{appearance:none;cursor:pointer;background:0 0;flex:1;min-width:0;height:14px}input[type=range]::-webkit-slider-runnable-track{background:linear-gradient(var(--edge), var(--edge));border:1px solid var(--edge);border-radius:3px;height:6px}input[type=range]::-moz-range-track{background:var(--edge);border-radius:3px;height:6px}input[type=range]::-webkit-slider-thumb{appearance:none;background:var(--green);border-radius:2px;width:10px;height:16px;margin-top:-6px}input[type=range]::-moz-range-thumb{background:var(--green);border:0;border-radius:2px;width:10px;height:16px}input[type=range]:disabled{opacity:.45;cursor:default}#spectrum{border:1px solid var(--edge);background:#060a07;border-radius:4px;width:100%;height:190px;display:block}.meters{align-items:baseline;gap:10px;margin-top:6px;display:flex;overflow:hidden}.glyphs{color:var(--green);letter-spacing:1px;white-space:nowrap;min-height:1.4em;overflow:hidden}.levelmeter{color:var(--accent);white-space:nowrap;margin-left:auto}.playlist{scrollbar-color:var(--green-dim) transparent;max-height:214px;margin:0;padding:0;list-style:none;overflow-y:auto}.row{cursor:pointer;white-space:nowrap;border-radius:3px;gap:8px;padding:2px 6px;display:flex}.row:hover{background:#142019}.row.selected{background:#16241c}.row.selected .name{color:var(--accent)}.row.playing .name{color:var(--green)}.group{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;border-top:1px solid var(--edge);align-items:center;gap:8px;margin-top:4px;padding:6px 6px 2px;font-size:11px;display:flex}.group:first-child{border-top:0;margin-top:0}.group-name{text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}.group-remove{border:1px solid var(--edge);color:var(--muted);cursor:pointer;background:0 0;border-radius:3px;flex:none;padding:0 6px;line-height:1.4}.group-remove:hover{color:var(--accent);border-color:var(--accent)}.row .n{color:var(--muted);text-align:right;flex:none;width:2.4em}.row .name{text-overflow:ellipsis;flex:1;overflow:hidden}.row .time{color:var(--muted);flex:none}.transport{border:1px solid var(--edge);background:var(--panel);border-radius:6px;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 10px;display:flex}button{font:inherit;color:var(--fg);border:1px solid var(--edge);cursor:pointer;background:#121a15;border-radius:4px;padding:6px 12px}button:hover{border-color:var(--green-dim);color:var(--green)}button:active{transform:translateY(1px)}button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid var(--green);outline-offset:2px}button.primary{color:var(--green);border-color:var(--green-dim);min-width:64px}button.ghost{color:var(--muted);background:0 0}.volume{flex:1;align-items:center;gap:8px;min-width:120px;max-width:220px;margin-left:auto;display:flex}.vol{color:var(--muted);letter-spacing:.08em;font-size:12px}.picker{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.button{border:1px solid var(--edge);cursor:pointer;color:var(--fg);background:#121a15;border-radius:4px;padding:6px 12px;display:inline-block}.button:hover{border-color:var(--green-dim);color:var(--green)}.button input[type=file]{display:none}#remote-url{font:inherit;min-width:12ch;color:var(--fg);border:1px solid var(--edge);background:#060a07;border-radius:4px;flex:1;padding:6px 10px}.check{color:var(--muted);align-items:center;gap:8px;margin-top:10px;display:flex}.check input{accent-color:var(--green)}.hint{color:var(--muted);margin:0 0 8px;font-size:13px}.hint code{color:var(--accent)}#remote-state[data-status=live]{color:var(--green)}#remote-state[data-status=connecting],#remote-state[data-status=error]{color:var(--warn)}.note{color:var(--warn);border-left:2px solid var(--warn);margin:0;padding-left:8px}.statusbar{color:var(--muted);border-top:1px solid var(--edge);flex-wrap:wrap;gap:14px;padding-top:8px;font-size:12px;display:flex}.statusbar b{color:var(--green);font-weight:700}.statusbar .spacer{flex:1}.statusbar a{color:var(--muted)}.directory{border-top:1px solid var(--line);margin-top:.6rem;padding-top:.6rem}.directory-list{max-height:12rem;margin:0;padding:0;list-style:none;overflow-y:auto}.directory-list li+li{margin-top:.3rem}.directory-list button{border:1px solid var(--line);color:inherit;font:inherit;cursor:pointer;background:0 0;border-radius:4px;padding:.4rem .5rem}.directory-list button:hover,.directory-list button:focus-visible{border-color:var(--accent);background:#ffffff0a}.directory-list .name{color:var(--accent);display:block}.directory-list .detail{opacity:.7;text-overflow:ellipsis;white-space:nowrap;font-size:.85em;display:block;overflow:hidden}.publish-list{margin:.3rem 0 0;padding:0;list-style:none}.publish-list li{align-items:center;gap:8px;margin-top:.3rem;display:flex}.publish-list .slot{color:var(--muted);flex:none;min-width:4.5em;font-size:12px}.publish-list input{flex:auto;min-width:0}.directory-list li.offline .recent-label{opacity:.55}.directory-list li.offline .detail{color:var(--warn)}.share-line{align-items:center;gap:8px;margin:.4rem 0;display:flex}.share-what{color:var(--muted);letter-spacing:.06em;text-transform:uppercase;flex:none;font-size:12px}.share-line input{flex:auto;min-width:0}#share-phone{color:var(--fg);font-size:1.05em}#share-phone b{color:var(--accent)}.admin-table{border-collapse:collapse;width:100%;max-height:14rem;margin:.4rem 0;font-size:.85em;display:block;overflow-y:auto}.admin-table th{text-align:left;opacity:.6;padding:.2rem .4rem .2rem 0;font-weight:400}.admin-table td{white-space:nowrap;text-overflow:ellipsis;max-width:12rem;padding:.2rem .4rem .2rem 0;overflow:hidden}.admin-table td.network-public{color:var(--warning,#e0b341)}.admin-table td.network-private{color:var(--success,#7fd18b)}.admin-table tr.ended{opacity:.45}body.route-directory .player-only{display:none}.directory-list li{align-items:stretch;gap:.4rem;display:flex}.directory-list li>:first-child{flex:auto;min-width:0}.directory-list li>button:first-child{text-align:left;width:100%}.directory-list li>.button,.directory-list li>.ghost{white-space:nowrap;flex:none;align-items:center;display:flex}.directory-list .follow{white-space:nowrap;width:auto;color:var(--muted);flex:none;padding-inline:.6rem}.directory-list .follow[data-following=yes]{border-color:var(--accent);color:var(--accent)}.toggle{color:var(--muted);cursor:pointer;align-items:center;gap:.35rem;display:inline-flex}.toggle input{accent-color:var(--accent)}.recent-label{border:1px solid var(--line);border-radius:4px;flex:auto;min-width:0;padding:.4rem .5rem}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();function e(e){if(!Number.isFinite(e)||e<0)return`--:--`;let t=Math.floor(e),n=Math.floor(t/60),r=t%60;return`${String(n).padStart(2,`0`)}:${String(r).padStart(2,`0`)}`}function t(e){return e.artist?`${e.artist} — ${e.title}`:e.title}function n(e){let t=e.split(`/`).pop()??e,n=t.lastIndexOf(`.`);return n>0?t.slice(0,n):t}var r=new Set([`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`,`avi`]);function i(e,t=``){if(t.startsWith(`video/`))return!0;if(t.startsWith(`audio/`))return!1;let n=e.lastIndexOf(`.`);return n>0&&r.has(e.slice(n+1).toLowerCase())}var a=`modulepreload`,o=function(e){return`/`+e},s={},c=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),c=i?.nonce||i?.getAttribute(`nonce`);function l(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function u(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=l(t.map(t=>{if(t=o(t,n),t=u(t),t in s)return;s[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:a,r||(i.as=`script`),i.crossOrigin=``,i.href=t,c&&i.setAttribute(`nonce`,c),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},l=[[/\.m3u8$/i,`hls`],[/\.(ts|mts|m2ts|mpegts)$/i,`mpegts`],[/\.(mp3|m4a|aac|oga|ogg|opus|wav|flac)$/i,`audio`],[/\.(mp4|m4v|webm|mov|ogv)$/i,`mp4`]],u=[[/mpegurl/i,`hls`],[/mp2t|mpeg-?ts/i,`mpegts`],[/^audio\//i,`audio`],[/^video\//i,`mp4`]];function d(e){if(e.kind)return e.kind;if(e.mimeType){for(let[t,n]of u)if(t.test(e.mimeType))return n}let t=f(e.src);for(let[e,n]of l)if(e.test(t))return n;return`unknown`}function f(e){try{return new URL(e,`https://placeholder.invalid`).pathname}catch{return e.split(/[?#]/)[0]??e}}function p(e=globalThis,t=null){let n=e.MediaSource!==void 0,r=!1;try{let e=t??(typeof document>`u`?null:document.createElement(`video`));r=e?e.canPlayType(`application/vnd.apple.mpegurl`)!==``||e.canPlayType(`application/x-mpegURL`)!==``:!1}catch{r=!1}return{mediaSource:n,nativeHls:r}}function m(e,t){let n=d(e);switch(n){case`hls`:return t.mediaSource?{engine:`hls`,kind:n}:t.nativeHls?{engine:`native`,kind:n}:{engine:`native`,kind:n,unplayable:`This browser cannot play HLS streams.`};case`mpegts`:return t.mediaSource?{engine:`mpegts`,kind:n}:{engine:`mpegts`,kind:n,unplayable:`This browser cannot play transport streams.`};default:return{engine:`native`,kind:n}}}async function h(e,t){let n=t.capabilities??p(),r=m({src:t.src,...t.kind?{kind:t.kind}:{},...t.mimeType?{mimeType:t.mimeType}:{}},n),i=()=>void 0,a={media:e,src:t.src,isTv:t.isTv??!1,live:t.live??r.kind===`mpegts`,onError:t.onError??i,onNotice:t.onNotice??i,...t.onReady?{onReady:t.onReady}:{}};if(r.unplayable)return t.onError?.(r.unplayable),{destroy:i,engine:r.engine,kind:r.kind,levels:()=>[],unplayable:r.unplayable};let o,s=t.engines?.[r.engine];if(s)o=await s(a);else if(r.engine===`hls`){let{createHlsEngine:e}=await c(async()=>{let{createHlsEngine:e}=await import(`./hls-3VKVEQE3-CtqtOX7p.js`);return{createHlsEngine:e}},[]);o=await e(a)}else if(r.engine===`mpegts`){let{createMpegtsEngine:e}=await c(async()=>{let{createMpegtsEngine:e}=await import(`./mpegts-LO6RVLD6-DIRN6lqd.js`);return{createMpegtsEngine:e}},[]);o=await e(a,{withCredentials:t.withCredentials??!1,unplayableAdvice:t.unplayableAdvice??``})}else{let{createNativeEngine:e}=await c(async()=>{let{createNativeEngine:e}=await import(`./native-C7JTKWJH-BUyIoj0P.js`);return{createNativeEngine:e}},[]);o=await e(a)}return{destroy:()=>{o.destroy()},engine:r.engine,kind:r.kind,levels:o.levels,...o.setLevel?{setLevel:o.setLevel}:{},...o.currentLevel?{currentLevel:o.currentLevel}:{}}}var g=new Set([`mp3`,`flac`,`ogg`,`oga`,`opus`,`m4a`,`aac`,`wav`,`wma`,`aiff`,`aif`,`alac`,`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`]);function _(e,t=``){if(t.startsWith(`audio/`)||t.startsWith(`video/`))return!0;let n=e.lastIndexOf(`.`);return n>0&&g.has(e.slice(n+1).toLowerCase())}function v(e,t){return e.localeCompare(t,void 0,{numeric:!0,sensitivity:`base`})}function y(e){return e.filter(e=>_(e.name,e.type)).sort((e,t)=>v(b(e),b(t))).map(e=>({title:n(e.name),artist:``,album:x(b(e)),duration:0,url:URL.createObjectURL(e),video:i(e.name,e.type),objectUrl:!0}))}function b(e){return e.webkitRelativePath||e.name}function x(e){let t=e.split(`/`);return t.length>1?t[t.length-2]:``}function ee(e){for(let t of e)t.objectUrl&&URL.revokeObjectURL(t.url)}var S=2048;function C(e,t){return e||t===`hls`||t===`mpegts`}var te=class{elements;handlers;attached=null;context=null;analyser=null;wired=new WeakSet;active;frequencies=new Uint8Array;constructor(e,t){this.elements=e,this.handlers=t,this.active=e.audio;for(let t of[e.audio,e.video])t.crossOrigin=`anonymous`,t.addEventListener(`timeupdate`,()=>{t===this.active&&this.handlers.onTime(t.currentTime,Number.isFinite(t.duration)?t.duration:0)}),t.addEventListener(`loadedmetadata`,()=>{t===this.active&&this.handlers.onTime(t.currentTime,Number.isFinite(t.duration)?t.duration:0)}),t.addEventListener(`ended`,()=>{t===this.active&&this.handlers.onEnded()}),t.addEventListener(`play`,()=>{t===this.active&&this.handlers.onState(!0)}),t.addEventListener(`pause`,()=>{t===this.active&&this.handlers.onState(!1)}),t.addEventListener(`error`,()=>{t===this.active&&this.handlers.onError(w(t))})}get playing(){return!this.active.paused&&!this.active.ended}get position(){return this.active.currentTime}get duration(){return Number.isFinite(this.active.duration)?this.active.duration:0}get showingVideo(){return this.active===this.elements.video}ensureGraph(e){let t=globalThis.AudioContext??globalThis.webkitAudioContext;if(t){if(this.context??=new t,this.analyser||(this.analyser=this.context.createAnalyser(),this.analyser.fftSize=S,this.analyser.smoothingTimeConstant=.6,this.analyser.connect(this.context.destination),this.frequencies=new Uint8Array(this.analyser.frequencyBinCount)),!this.wired.has(e))try{this.context.createMediaElementSource(e).connect(this.analyser),this.wired.add(e)}catch{this.wired.add(e)}this.context.resume()}}read(){return this.analyser&&this.analyser.getByteFrequencyData(this.frequencies),this.frequencies}levels(){if(!this.analyser)return[0,0];let e=this.read(),t=0;for(let n of e)t+=n;let n=e.length===0?0:t/e.length/255;return[Math.min(1,n*2.2),Math.min(1,n*2.2)]}async load(e,t){let n=e.objectUrl?e.video?`mp4`:`audio`:d({src:e.url}),r=C(e.video,n)?this.elements.video:this.elements.audio;r!==this.active&&(this.active.pause(),this.active.removeAttribute(`src`),this.active.load(),this.active=r),this.attached?.destroy(),this.attached=null;try{this.attached=await h(this.active,{src:e.url,kind:n,unplayableAdvice:`VLC or mpv will play it; nixamp can only hand it to your browser.`,onError:e=>this.handlers.onError(e),onNotice:e=>{e&&this.handlers.onError(e)}})}catch(e){this.handlers.onError(e instanceof Error?e.message:`that would not play`);return}t&&await this.play()}async play(){this.ensureGraph(this.active);try{await this.active.play()}catch(e){this.handlers.onError(e instanceof Error?e.message:`playback was refused`)}}pause(){this.active.pause()}stop(){this.active.pause(),this.active.currentTime=0,this.attached?.destroy(),this.attached=null}seek(e){Number.isFinite(e)&&(this.active.currentTime=Math.max(0,e))}set volume(e){this.elements.audio.volume=e,this.elements.video.volume=e}get volume(){return this.active.volume}};function w(e){switch(e.error?.code){case MediaError.MEDIA_ERR_ABORTED:return`playback was aborted`;case MediaError.MEDIA_ERR_NETWORK:return`the network dropped mid-track`;case MediaError.MEDIA_ERR_DECODE:return`this browser could not decode that`;case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:return`this browser cannot play that format`;default:return`playback failed`}}function T(){return{revision:0,tracks:[],trackCount:0,index:0,playing:!1,position:0,bars:[],levels:[0,0],silent:!0,note:``,root:``}}function ne(e,t){return{...t,tracks:t.tracks??e.tracks}}function E(e){let t=e.trim();if(t===``)return``;/^https?:\/\//i.test(t)||(t=`http://${t}`);let n;try{n=new URL(t)}catch{return``}let r=n.pathname.replace(/\/+$/,``);return r=r.replace(/\/api(\/.*)?$/,``),`${n.origin}${r}`}function D(e,t,n=``){let r=`${e===``?``:E(e)}${t.startsWith(`/`)?t:`/${t}`}`;return n?`${r}${r.includes(`?`)?`&`:`?`}k=${encodeURIComponent(n)}`:r}function re(e){let t=e.trim();if(t===``)return{base:``,key:``};let n;try{n=new URL(/^https?:\/\//i.test(t)?t:`http://${t}`)}catch{return{base:``,key:``}}let r=/^\/s\/([^/]+)\/?$/.exec(n.pathname),i=r?.[1]??n.searchParams.get(`k`)??``;return r&&(n.pathname=`/`),n.searchParams.delete(`k`),{base:E(`${n.origin}${n.pathname}`),key:decodeURIComponent(i)}}function O(e,t,n=0,r=``){return D(e,n>0?`/api/media/${t}?kbps=${Math.round(n)}`:`/api/media/${t}`,r)}function k(e){if(typeof e!=`object`||!e)return null;let t=e,n=T(),r=(e,t)=>typeof e==`number`&&Number.isFinite(e)?e:t,i=Array.isArray(t.levels)?t.levels:[],a=Array.isArray(t.tracks)?t.tracks.map(e=>{let t=typeof e==`object`&&e?e:{};return{title:typeof t.title==`string`?t.title:`Untitled`,artist:typeof t.artist==`string`?t.artist:``,album:typeof t.album==`string`?t.album:``,duration:r(t.duration,0),...t.video===!0?{video:!0}:{},...typeof t.group==`string`&&t.group!==``?{group:t.group}:{}}}):void 0;return{revision:r(t.revision,0),...a?{tracks:a}:{},trackCount:r(t.trackCount,a?.length??0),index:r(t.index,0),playing:t.playing===!0,position:r(t.position,0),bars:Array.isArray(t.bars)?t.bars.map(e=>r(e,0)):[],levels:[r(i[0],0),r(i[1],0)],silent:t.silent===!0,note:typeof t.note==`string`?t.note:``,root:typeof t.root==`string`?t.root:n.root}}var ie=class{handlers;source=null;base=``;key=``;lastRevision=-1;constructor(e){this.handlers=e}get address(){return this.base}url(e){return D(this.base,e,this.key)}get shareLink(){return this.base===``?``:this.key===``?this.base:`${this.base}/s/${this.key}`}get connected(){return this.source!==null}connect(e){let{base:t,key:n}=re(e);this.close(),this.base=t,this.key=n,this.lastRevision=-1,this.handlers.onStatus(`connecting`);let r=new EventSource(D(t,`/api/events`,n));this.source=r,r.onopen=()=>this.handlers.onStatus(`live`),r.onmessage=e=>{let t=k(A(e.data));t&&(t.revision<this.lastRevision||(this.lastRevision=t.revision,this.handlers.onStatus(`live`),this.handlers.onSnapshot(t)))},r.onerror=()=>{this.handlers.onStatus(`error`,`reconnecting…`)}}async send(e){if(this.base===``&&!this.connected)return;let t=await fetch(D(this.base,`/api/command`,this.key),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify(e)});if(!t.ok){this.handlers.onStatus(`error`,`command refused (${t.status})`);return}let n=k(await t.json());n&&this.handlers.onSnapshot(n)}media(e,t=0){return O(this.base,e,t,this.key)}close(){this.source?.close(),this.source=null}};function A(e){try{return JSON.parse(e)}catch{return null}}async function ae(e,t,n=``){try{let r=await fetch(D(e,`/api/state`,n),{signal:t});return r.ok?k(await r.json()):null}catch{return null}}function oe(e){if(!/^https:\/\//i.test(e))return``;let t;try{t=new URL(e).hostname.replace(/^\[|\]$/g,``)}catch{return``}return/^\d{1,3}(\.\d{1,3}){3}$/.test(t)||t.includes(`:`)?`That is an https address for a bare IP, and a certificate is issued for a name — a browser refuses it before it asks anything. Use the server's name instead (the address it printed first), or connect over http.`:``}async function se(e,t=``,n){let r;try{r=await fetch(D(e,`/api/state`,t),{signal:n})}catch{return``}return r.ok?``:r.status===401?t===``?`That server needs its share link. Paste the whole link — the one ending in /s/… — or sign in as its owner.`:`That share link is not accepted by that server. It may have been restarted, which gives it a new one.`:r.status===403?`That link can listen but not drive this server.`:r.status===429?`That server is asking us to slow down. Try again in a moment.`:``}async function j(e,t,n=``){try{let r=await fetch(D(e,`/api/health`,n),{signal:t});if(!r.ok)return null;let i=await r.json();return i.name===`nixamp`?i.version??`unknown`:null}catch{return null}}function ce(e,t=globalThis.location?.protocol){return t!==`https:`||!/^http:\/\//i.test(e.trim())?``:`This page is https, and a browser refuses every request from an https page to an http one. Open that address directly, or give the server a certificate: nixamp serve --tls-cert cert.pem --tls-key key.pem.`}var M=.14,N=.02;function le(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 ue(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 de(e,t,n=M){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function P(e,t,n=N){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function fe(e,t,n,r,i){let{width:a,height:o}=t;if(a<=0||o<=0||n.length===0)return;e.clearRect(0,0,a,o);let s=a/n.length,c=Math.max(1,s*.72),l=Math.max(2,o*.012);e.fillStyle=i.bar,n.forEach((t,n)=>{let r=Math.max(1,t*(o-l*2));e.fillRect(n*s+(s-c)/2,o-r,c,r)}),e.fillStyle=i.peak,r.forEach((t,n)=>{let r=o-Math.max(1,t*(o-l*2))-l*2;e.fillRect(n*s+(s-c)/2,Math.max(0,r),c,l)})}var F=`nixamp.remote`,I=`nixamp.volume`,L=`nixamp.listenHere`;function R(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function z(){let n={status:R(`status`),source:R(`source`),install:R(`install`),video:R(`video`),audio:R(`audio`),title:R(`title-line`),album:R(`album-line`),elapsed:R(`elapsed`),total:R(`total`),seek:R(`seek`),canvas:R(`spectrum`),glyphs:R(`glyphs`),levels:R(`levels`),playlist:R(`playlist`),playlistTitle:R(`playlist-panel`),note:R(`note`),files:R(`files`),folder:R(`folder`),remoteUrl:R(`remote-url`),remoteForm:R(`remote-form`),remoteState:R(`remote-state`),disconnect:R(`disconnect`),browse:R(`browse`),accountForm:R(`account-form`),accountEmail:R(`account-email`),accountPassword:R(`account-password`),accountSubmit:R(`account-submit`),accountToggle:R(`account-toggle`),accountProviders:R(`account-providers`),accountPanel:R(`account-panel`),accountElsewhere:R(`account-elsewhere`),accountSignOut:R(`account-signout`),accountNote:R(`account-note`),adminPanel:R(`admin-panel`),adminNote:R(`admin-note`),adminConnections:R(`admin-connections`),publishNote:R(`publish-note`),publishList:R(`publish-list`),adminRestream:R(`admin-restream`),adminReplace:R(`admin-replace`),adminSource:R(`admin-source`),directory:R(`directory`),recentNote:R(`recent-note`),recentList:R(`recent-list`),followingNote:R(`following-note`),followingList:R(`following-list`),serversPanel:R(`servers-panel`),serversNote:R(`servers-note`),serversList:R(`servers-list`),notifyPanel:R(`notify-panel`),notifyNote:R(`notify-note`),notifyWeb:R(`notify-web`),notifyEmail:R(`notify-email`),notifySms:R(`notify-sms`),notifyPhone:R(`notify-phone`),notifyPhoneForm:R(`notify-phone-form`),notifyPhoneNote:R(`notify-phone-note`),directoryNote:R(`directory-note`),directoryList:R(`directory-list`),sharePanel:R(`share-panel`),shareNote:R(`share-note`),shareLink:R(`share-link`),shareCopy:R(`share-copy`),sharePhone:R(`share-phone`),shareSend:R(`share-send`),liveControls:R(`live-controls`),goLive:R(`go-live`),stopLive:R(`stop-live`),shareTo:R(`share-to`),listenHere:R(`listen-here`),volume:R(`volume`),prev:R(`prev`),playPause:R(`play-pause`),stop:R(`stop`),next:R(`next`)},r=`local`,i=[],a=0,o=T(),s=`idle`,c=``,l=`Pick files, or connect to a nixamp running somewhere else.`,u=!1,d=-1,f=``,p=Array(24).fill(0),m=Array(24).fill(0),h=[],g=()=>r===`remote`&&!n.listenHere.checked,_=new te({audio:n.audio,video:n.video},{onTime:(e,t)=>{let n=i[a];r===`local`&&n&&t>0&&n.duration!==t&&(n.duration=t),B()},onEnded:()=>M(1),onState:()=>B(),onError:e=>{l=e,B()}}),v=new ie({onSnapshot:e=>{o=ne(o,e),g()&&(p=e.bars.length>0?e.bars:p,m=P(m,p)),B()},onStatus:(e,t)=>{s=e,c=t??``,B()}}),b=()=>r===`remote`?o.tracks.length:i.length,x=()=>r===`remote`?g()||d<0?o.index:Math.min(d,Math.max(0,o.tracks.length-1)):a,S=()=>{let e=r===`remote`?o.tracks[x()]:i[x()];return e?t(e):`Nothing loaded.`},C=()=>(r===`remote`?o.tracks[x()]:i[x()])?.album||`—`,w=()=>g()?o.tracks[x()]?.duration??0:_.duration,E=()=>g()?o.position:_.position,D=()=>g()?o.playing:_.playing;async function O(e){if(r===`remote`){if(g()){await v.send({type:`play`,index:e});return}await k(e);return}let t=i[e];t&&(a=e,await _.load(t,!0),W(t.video),G(),B())}async function k(e){let t=o.tracks[e];t&&(d=e,await _.load({title:t.title,artist:t.artist,album:t.album,duration:t.duration,url:v.media(e,0),video:t.video===!0,objectUrl:!1},!0),W(t.video===!0),G())}async function A(){if(g()){await v.send({type:`toggle`});return}b()!==0&&(_.playing?_.pause():_.position>0?await _.play():await O(x()),B())}async function M(e){let t=b();if(t!==0){if(g()){await v.send({type:e>0?`next`:`prev`});return}await O((x()+e+t)%t)}}async function N(){if(g()){await v.send({type:`stop`});return}_.stop(),p=Array(24).fill(0),m=[...p],B()}let z=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))];function B(){let t=b(),a=D();n.status.textContent=a?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=String(a),n.title.textContent=S(),n.album.textContent=C();let d=E(),f=w();n.elapsed.textContent=e(d),n.total.textContent=f>0?e(f):`--:--`,u||(n.seek.value=String(f>0?Math.round(d/f*1e3):0),n.seek.disabled=f<=0||g()),n.playPause.textContent=a?`❚❚`:`▶`,n.playPause.setAttribute(`aria-label`,a?`Pause`:`Play`),n.playlistTitle.dataset.title=`Playlist (${t})`,n.source.textContent=r===`remote`?`remote · ${v.address.replace(/^https?:\/\//,``)||`—`}`:i.length>0?`local · ${i.length} files`:`no source`,n.remoteState.textContent=r===`remote`?`${s}${c?` — ${c}`:``}`:`not connected`,n.remoteState.dataset.status=r===`remote`?s:`idle`,n.disconnect.hidden=r!==`remote`;let m=r===`remote`&&o.note!==``?o.note:l;n.note.textContent=m,n.note.hidden=m===``,pe(),n.glyphs.textContent=p.map(z).join(``);let[h,y]=g()?o.levels:_.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(h*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(y*6)).padEnd(6,`·`)}`}let V=``,H=-1;function pe(){let a=r===`remote`?o.tracks.map(e=>({name:t(e),seconds:e.duration,group:e.group??``})):i.map(e=>({name:t(e),seconds:e.duration,group:``})),s=`${r}:${a.map(e=>`${e.name}@${e.seconds}@${e.group}`).join(`|`)}`;if(s!==V){V=s;let t=[],r=``,i=a.some(e=>e.group!==``);a.forEach((n,a)=>{n.group!==r&&(i||n.group!==``)&&(r=n.group,t.push(me(n.group)));let o=document.createElement(`li`);o.className=`row`,o.dataset.index=String(a);let s=document.createElement(`span`);s.className=`n`,s.textContent=String(a+1).padStart(2,` `);let c=document.createElement(`span`);c.className=`name`,c.textContent=n.name;let l=document.createElement(`span`);l.className=`time`,l.textContent=n.seconds>0?e(n.seconds):`--:--`,o.append(s,c,l),t.push(o)}),n.playlist.replaceChildren(...t)}let c=x(),l=D(),u;for(let e of Array.from(n.playlist.children)){let t=e,n=Number(t.dataset.index),r=Number.isInteger(n)&&n===c;t.classList.toggle(`selected`,r),t.classList.toggle(`playing`,r&&l),r&&(u=t)}c!==H&&(H=c,u?.scrollIntoView({block:`nearest`}))}function me(e){let t=document.createElement(`li`);t.className=`group`;let r=document.createElement(`span`);if(r.className=`group-name`,r.textContent=e===``?`This server's library`:e,t.append(r),e!==``&&!n.adminPanel.hidden){let n=document.createElement(`button`);n.type=`button`,n.className=`group-remove`,n.textContent=`×`,n.title=`Remove ${e} from the playlist`,n.setAttribute(`aria-label`,`Remove ${e} from the playlist`),n.addEventListener(`click`,t=>{t.stopPropagation(),he(e)}),t.append(n)}return t}async function he(e){try{let t=await fetch(v.url(`/api/source/remove`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({group:e})}),r=await t.json();n.adminNote.textContent=t.ok?`Removed ${r.removed??0} tracks from ${e}.`:r.error??`that did not work`}catch{n.adminNote.textContent=`could not reach the server`}}function U(){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(g())m=P(m,p);else{let e=_.read();e.length>0&&(h.length!==25&&(h=le(24,e.length)),p=de(p,ue(e,h)),m=P(m,p))}if(s){let e=getComputedStyle(document.documentElement);fe(s,{width:t.width,height:t.height},p,m,{bar:e.getPropertyValue(`--green`).trim()||`#4af689`,peak:e.getPropertyValue(`--green-dim`).trim()||`#227a4a`,background:`transparent`})}if(D()){n.glyphs.textContent=p.map(z).join(``);let[t,r]=g()?o.levels:_.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(t*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(r*6)).padEnd(6,`·`)}`,n.elapsed.textContent=e(E());let i=w();!u&&i>0&&(n.seek.value=String(Math.round(E()/i*1e3)))}requestAnimationFrame(U)}function W(e){n.video.hidden=!e}function G(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:S(),album:C(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void A()),navigator.mediaSession.setActionHandler(`pause`,()=>void A()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void M(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void M(-1)))}n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&O(n)}),n.prev.addEventListener(`click`,()=>void M(-1)),n.next.addEventListener(`click`,()=>void M(1)),n.stop.addEventListener(`click`,()=>void N()),n.playPause.addEventListener(`click`,()=>void A()),n.seek.addEventListener(`input`,()=>{u=!0}),n.seek.addEventListener(`change`,()=>{let e=w();e>0&&_.seek(Number(n.seek.value)/1e3*e),u=!1}),n.volume.addEventListener(`input`,()=>{let e=Number(n.volume.value)/100;_.volume=e;try{localStorage.setItem(I,String(e))}catch{}});let ge=e=>{e.addEventListener(`change`,()=>{let t=y(Array.from(e.files??[]));if(t.length===0){l=`Nothing playable in that selection.`,B();return}ee(i),i=t,a=0,r=`local`,v.close(),l=``,O(0)})};ge(n.files),ge(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.remoteUrl.value,{base:i,key:a}=re(t);if(i===``){l=`That is not an address.`,B();return}(async()=>{s=`connecting`,B();let e=ce(i);if(e){s=`error`,c=e,l=e,r=`local`,B();return}if(await j(i,void 0,a)===null){s=`error`;let e=oe(i);c=e?`needs the server's name`:`not answering`,l=e||`Nothing answered at ${i}. If that is your machine, it is off or nixamp is not running on it; otherwise check the address.`,r=`local`,B();return}let n=await se(i,a);if(n){s=`error`,c=n,l=n,r=`local`,B();return}r=`remote`,l=``;try{localStorage.setItem(F,t.trim())}catch{}v.connect(t),Pe(),B()})()});let _e=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??[],Se(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&&X&&t.ownerId!==X&&e.append(Te(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),_e()}let K=null,ve=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)}},ye=async()=>{try{let e=await fetch(v.url(`/api/connections`));if(!e.ok)return;let t=await e.json();n.adminNote.textContent=`${t.active??0} listening now.`,ve(t.connections??[]),be(t.publish??[])}catch{n.adminNote.textContent=`lost touch with the server`}};function be(e){if(n.publishNote.hidden=e.length===0,e.length===0){n.publishList.replaceChildren(),n.publishNote.hidden=!1,n.publishNote.textContent=`This server takes no RTMP. Start it with --rtmp-in 1935 to publish into it from OBS.`;return}n.publishNote.textContent=e.length===1?`Publish into this server from OBS, Larix or ffmpeg:`:`Publish into this server from OBS, Larix or ffmpeg. One URL per stream — ${e.length} at once:`,n.publishList.replaceChildren(...e.map(e=>{let t=document.createElement(`li`),n=document.createElement(`span`);n.className=`slot`,n.textContent=e.id;let r=document.createElement(`input`);r.type=`text`,r.readOnly=!0,r.value=e.url,r.setAttribute(`aria-label`,`RTMP URL for ${e.id}`);let i=document.createElement(`button`);return i.type=`button`,i.className=`ghost`,i.textContent=`Copy`,i.addEventListener(`click`,()=>{r.select(),navigator.clipboard?.writeText(e.url).catch(()=>{})}),t.append(n,r,i),t}))}let q=async()=>{let e=!1,t=null;try{let n=await fetch(v.url(`/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,K&&clearInterval(K),K=null,e&&(n.adminNote.textContent=t===`owner`?`You own this server.`:`You hold this server's control link.`,ye(),K=setInterval(()=>void ye(),2e3))};n.adminRestream.addEventListener(`submit`,e=>{e.preventDefault();let t=n.adminSource.value.trim();if(!t)return;let r=n.adminReplace.checked;(async()=>{try{let e=await fetch(v.url(`/api/source`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({source:t,...r?{replace:!0}:{}})}),i=await e.json();n.adminNote.textContent=e.ok?r?`Now serving ${t}.`:i.added===0?`Everything there was already in the playlist.`:`Added ${i.added??0} tracks from ${t}.`:i.error??`that did not work`,e.ok&&(n.adminSource.value=``)}catch{n.adminNote.textContent=`could not reach the server`}})()});let xe=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`},Se=e=>{n.recentList.replaceChildren();let t=X?e.filter(e=>e.ownerId&&e.ownerId!==X):[];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 ${xe(e.endedAt)}`:`ended ${xe(e.endedAt)}`,r.append(i,a),t.append(r,Te(e.ownerId,e.name)),n.recentList.append(t)}},Ce=async()=>{n.serversList.replaceChildren();try{let e=await fetch(`/api/v1/servers`);if(!e.ok){n.serversPanel.hidden=!0;return}let t=(await e.json()).servers??[];n.serversPanel.hidden=!1,n.serversNote.textContent=t.length===0?"No servers yet. `nixamp server add --here` remembers the one you are running.":`The machines on your account. Open one, or forget it.`;for(let e of t){let t=document.createElement(`li`),r=document.createElement(`span`);r.className=`recent-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name;let a=document.createElement(`span`);a.className=`detail`,a.textContent=e.url,r.append(i,a);let o=document.createElement(`button`);o.type=`button`,o.className=`button`,o.textContent=`Open`,o.addEventListener(`click`,()=>{n.remoteUrl.value=e.key?`${e.url}/s/${e.key}`:e.url,n.remoteForm.requestSubmit()}),j(e.url).then(n=>{if(n!==null){a.textContent=`${e.url} · ${n}`;return}a.textContent=`${e.url} · not answering`,t.classList.add(`offline`),o.disabled=!0,o.title=`That machine is not answering. Start nixamp on it.`});let s=document.createElement(`button`);s.type=`button`,s.className=`ghost`,s.textContent=`Forget`,s.addEventListener(`click`,()=>{(async()=>{s.disabled=!0;try{await fetch(`/api/v1/servers/${encodeURIComponent(e.id)}`,{method:`DELETE`}),await Ce()}catch{s.disabled=!1}})()}),t.append(r,o,s),n.serversList.append(t)}}catch{n.serversPanel.hidden=!0}},we=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}},Te=(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),we())}catch{}finally{n.disabled=!1}})()}),n},Ee=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},De=()=>`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window,Oe=async()=>{if(!De())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:Ee(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}},ke=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{}},J=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`}},Ae=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=De()&&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 Oe();n.notifyWeb.checked=e,await J({wantsWeb:e});return}await ke(),await J({wantsWeb:!1}),n.notifyNote.textContent=`Turn on “On this device” to be told here.`})()}),n.notifyEmail.addEventListener(`change`,()=>{J({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 J({wantsSms:n.notifySms.checked})})()}),n.notifyPhoneForm.addEventListener(`submit`,e=>{e.preventDefault(),J({phone:n.notifyPhone.value.trim()})});let Y=!1,X=``,Z=e=>{let t=e!==null;n.notifyPanel.hidden=!t,t?(Ae(),we(),Ce()):(n.serversPanel.hidden=!0,n.followingNote.hidden=!0,n.followingList.replaceChildren(),n.recentNote.hidden=!0,n.recentList.replaceChildren()),n.accountForm.hidden=t,n.accountProviders.hidden=t||n.accountProviders.childElementCount===0,n.accountSignOut.hidden=!t,n.accountNote.textContent=t?`Signed in as ${e}.`:Y?`Create an account on nixamp.com.`:`Sign in to nixamp.com to publish and get paid.`,n.accountSubmit.textContent=Y?`Create account`:`Sign in`,n.accountToggle.textContent=Y?`I have one`:`Create one`,n.accountPassword.autocomplete=Y?`new-password`:`current-password`},je=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)}},Me=async()=>{try{let e=await fetch(`/api/v1/auth/me`),t=await e.json();X=e.ok?t.account?.id??``:``,Z(e.ok?t.account?.email??`you`:null)}catch{X=``,Z(null)}Ne()};function Ne(){if(f===``||X===``)return;let e=f;f=``,n.remoteUrl.value=e,n.remoteForm.requestSubmit()}n.accountToggle.addEventListener(`click`,()=>{Y=!Y,Z(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/${Y?`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}X=i.account?.id??``,n.accountPassword.value=``,Z(i.account?.email??t),q()}catch{n.accountNote.textContent=`could not reach nixamp.com`}finally{n.accountSubmit.disabled=!1}})()}),n.accountSignOut.addEventListener(`click`,()=>{(async()=>{try{await fetch(`/api/v1/auth/logout`,{method:`POST`})}catch{}X=``,Z(null),q()})()});try{let e=new URL(globalThis.location.href).searchParams.get(`url`)??``;e!==``&&(f=e,n.remoteUrl.value=e,l=`Sign in to watch this stream.`,globalThis.history?.replaceState(null,``,globalThis.location.pathname))}catch{}je(),Me(),q(),n.browse.addEventListener(`click`,()=>{if(!n.directory.hidden){n.directory.hidden=!0;return}_e(),n.directory.scrollIntoView({behavior:`smooth`,block:`nearest`})}),n.disconnect.addEventListener(`click`,()=>{v.close(),d=-1,n.sharePanel.hidden=!0,r=`local`,s=`idle`,c=``,B()});let Pe=async()=>{if(r!==`remote`||v.shareLink===``){n.sharePanel.hidden=!0;return}n.sharePanel.hidden=!1;let e=v.shareLink,t=globalThis.location.origin;n.shareLink.value=e.startsWith(`https://`)?`${t}/?url=${encodeURIComponent(e)}`:e,n.shareNote.textContent=`Anyone with this link can watch. They sign in once, then it opens.`,n.sharePhone.hidden=!0,n.shareSend.hidden=!0,n.liveControls.hidden=!0;let i=``;try{let e=await fetch(`/api/directory`);e.ok&&(i=(await e.json()).callIn??``)}catch{}let a=null;try{let e=await fetch(v.url(`/api/live/state`));e.ok&&(a=await e.json())}catch{}if(a){if(n.liveControls.hidden=n.adminPanel.hidden||!a.possible,n.goLive.hidden=a.live,n.stopLive.hidden=!a.live,n.sharePhone.hidden=!1,!a.live){n.sharePhone.textContent=a.possible?`Not listed yet. Go live to get a phone number and a code anyone can call.`:`This machine has no address the world can reach, so it cannot be listed.`;return}if(!i){n.sharePhone.textContent=`Listed. The code for the phone line is ${a.code}.`,n.shareSend.hidden=!1;return}n.sharePhone.replaceChildren(document.createTextNode(`To talk about it, call `),Q(i),document.createTextNode(` and key `),Q(a.code),document.createTextNode(`. That is a room with everyone else watching — not the stream itself.`)),n.shareSend.hidden=!1}},Fe=async e=>{n.goLive.disabled=!0,n.stopLive.disabled=!0,n.shareNote.textContent=e?`Going live…`:`Taking it off the list…`;try{let t=await fetch(v.url(e?`/api/live/start`:`/api/live/stop`),{method:`POST`}),r=await t.json();n.shareNote.textContent=t.ok?e?`Live. Anyone can call and key ${r.code??``} to talk about it.`:`Taken off the list. The link still works for anybody who has it.`:r.error??`that did not work`}catch{n.shareNote.textContent=`could not reach the server`}finally{n.goLive.disabled=!1,n.stopLive.disabled=!1,await Pe()}};n.goLive.addEventListener(`click`,()=>void Fe(!0)),n.stopLive.addEventListener(`click`,()=>void Fe(!1));function Q(e){let t=document.createElement(`b`);return t.textContent=e,t}n.shareCopy.addEventListener(`click`,()=>{n.shareLink.select(),navigator.clipboard?.writeText(n.shareLink.value).then(()=>{n.shareNote.textContent=`Copied. Send it to anybody.`},()=>{n.shareNote.textContent=`Copy it from the box above.`})}),n.shareSend.addEventListener(`submit`,e=>{e.preventDefault();let t=n.shareTo.value.trim();t!==``&&(async()=>{n.shareNote.textContent=`Sending…`;try{let e=await fetch(`/api/v1/invite`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({to:t,stream:v.shareLink})}),r=await e.json();n.shareNote.textContent=e.ok?`Sent to ${r.sent??t}.`:r.error??`that did not send`,e.ok&&(n.shareTo.value=``)}catch{n.shareNote.textContent=`could not send that`}})()}),n.listenHere.addEventListener(`change`,()=>{try{localStorage.setItem(L,n.listenHere.checked?`1`:`0`)}catch{}r===`remote`&&(async()=>{n.listenHere.checked?(await v.send({type:`stop`}),await k(o.index)):(_.stop(),d=-1),B()})()}),document.addEventListener(`keydown`,e=>{let t=e.target;if(!(t&&/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))switch(e.key){case` `:e.preventDefault(),A();return;case`s`:N();return;case`n`:case`ArrowRight`:M(1);return;case`p`:case`ArrowLeft`:M(-1);return;case`ArrowDown`:e.preventDefault(),O(Math.min(b()-1,x()+1));return;case`ArrowUp`:e.preventDefault(),O(Math.max(0,x()-1));return}});let $=null;globalThis.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),$=e,n.install.hidden=!1}),n.install.addEventListener(`click`,()=>{$?.prompt(),$=null,n.install.hidden=!0});try{let e=localStorage.getItem(I);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),_.volume=Number(e));let t=localStorage.getItem(F);t&&(n.remoteUrl.value=t),localStorage.getItem(L)===`0`&&(n.listenHere.checked=!1)}catch{}(async()=>{if(n.remoteUrl.value!==``)return;let e=globalThis.location.origin;if(await j(e)===null)return;let t=await ae(e);t&&t.trackCount!==0&&(n.remoteUrl.value=e,r=`remote`,l=``,v.connect(e),B())})(),B(),requestAnimationFrame(U)}z(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});export{c as t};
|