nixamp 0.7.7 → 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/owner.js +10 -0
- package/dist/server.d.ts +25 -0
- package/dist/server.js +124 -36
- package/dist/share.js +5 -0
- package/package.json +1 -1
- package/src/owner.ts +10 -0
- package/src/server.ts +115 -11
- package/src/share.ts +4 -0
- package/web/dist/assets/{hls-3VKVEQE3-BM7Le4NN.js → hls-3VKVEQE3-CtqtOX7p.js} +1 -1
- package/web/dist/assets/index-BL-Q9Q2e.js +1 -0
- package/web/dist/assets/{mpegts-C6TLllAG.js → mpegts-DtUWYYid.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-B_K_M921.js → mpegts-LO6RVLD6-DIRN6lqd.js} +1 -1
- package/web/dist/index.html +5 -1
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-CGXkk7_z.js +0 -1
package/dist/owner.js
CHANGED
|
@@ -85,7 +85,17 @@ export const ADMIN_PATHS = [
|
|
|
85
85
|
"/api/ingest",
|
|
86
86
|
"/api/admin",
|
|
87
87
|
];
|
|
88
|
+
/**
|
|
89
|
+
* Going live and coming back off, which is administering a server.
|
|
90
|
+
*
|
|
91
|
+
* Listed separately from ADMIN_PATHS on purpose: those match by prefix, and
|
|
92
|
+
* `/api/live` itself is the public listen address -- the one the phone line is
|
|
93
|
+
* handed. Gating it by prefix would shut the front door to lock the office.
|
|
94
|
+
*/
|
|
95
|
+
const LIVE_CONTROL = ["/api/live/state", "/api/live/start", "/api/live/stop"];
|
|
88
96
|
export function needsAdmin(path, method = "GET") {
|
|
97
|
+
if (LIVE_CONTROL.includes(path))
|
|
98
|
+
return true;
|
|
89
99
|
if (ADMIN_PATHS.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)))
|
|
90
100
|
return true;
|
|
91
101
|
// Publishing to a channel, or ending one, is administering the server.
|
package/dist/server.d.ts
CHANGED
|
@@ -357,6 +357,31 @@ export interface HandlerOptions {
|
|
|
357
357
|
destinations: Destination[];
|
|
358
358
|
settings: EncoderSettings;
|
|
359
359
|
};
|
|
360
|
+
/**
|
|
361
|
+
* Going live: whether this server is listed, and how to change that.
|
|
362
|
+
*
|
|
363
|
+
* Listing used to be a question asked once at startup and never again, so a
|
|
364
|
+
* server started with --no-publish had no listing, no phone code, and no
|
|
365
|
+
* link to hand anybody -- and no way to change its mind short of stopping
|
|
366
|
+
* and starting it. It is an action now, because that is what it is.
|
|
367
|
+
*/
|
|
368
|
+
live?: {
|
|
369
|
+
status: () => {
|
|
370
|
+
live: boolean;
|
|
371
|
+
code: string;
|
|
372
|
+
name: string;
|
|
373
|
+
url: string;
|
|
374
|
+
possible: boolean;
|
|
375
|
+
};
|
|
376
|
+
start: () => Promise<{
|
|
377
|
+
live: boolean;
|
|
378
|
+
code: string;
|
|
379
|
+
name: string;
|
|
380
|
+
url: string;
|
|
381
|
+
error?: string;
|
|
382
|
+
}>;
|
|
383
|
+
stop: () => Promise<void>;
|
|
384
|
+
};
|
|
360
385
|
/**
|
|
361
386
|
* Where OBS should point, one entry per stream this server will accept.
|
|
362
387
|
*
|
package/dist/server.js
CHANGED
|
@@ -41,7 +41,7 @@ import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
|
41
41
|
import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
|
|
42
42
|
import { isRemote, playsInBrowser, sourceLabel } from "./sources.js";
|
|
43
43
|
import { codecsOf, videoArgs } from "./audio.js";
|
|
44
|
-
import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, lookupPublicIp, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
|
|
44
|
+
import { allowedForListening, elevate, firewallInUse, certifiable, keyCookie, keyFrom, lookupPublicIp, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
|
|
45
45
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
46
46
|
import { fileURLToPath } from "node:url";
|
|
47
47
|
import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
|
|
@@ -1814,6 +1814,35 @@ export function createHandler(engine, options) {
|
|
|
1814
1814
|
}
|
|
1815
1815
|
// Everything the admin view draws, in one request: who is connected, and
|
|
1816
1816
|
// what this server is.
|
|
1817
|
+
// Going live, and coming back off. Admin-gated by ADMIN_PATHS, because
|
|
1818
|
+
// listing somebody's machine in a public directory is not a thing a
|
|
1819
|
+
// listener gets to do.
|
|
1820
|
+
if (path === "/api/live/state") {
|
|
1821
|
+
if (!options.live) {
|
|
1822
|
+
json(response, 200, { live: false, possible: false, code: "", name: "", url: "" });
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
json(response, 200, options.live.status());
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
if (path === "/api/live/start" || path === "/api/live/stop") {
|
|
1829
|
+
if (request.method !== "POST") {
|
|
1830
|
+
json(response, 405, { error: "POST only" });
|
|
1831
|
+
return;
|
|
1832
|
+
}
|
|
1833
|
+
if (!options.live) {
|
|
1834
|
+
json(response, 409, { error: "this nixamp cannot be listed; it has no address the world can reach" });
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
if (path === "/api/live/stop") {
|
|
1838
|
+
await options.live.stop();
|
|
1839
|
+
json(response, 200, options.live.status());
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
const started = await options.live.start();
|
|
1843
|
+
json(response, started.error ? 502 : 200, started);
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1817
1846
|
if (path === "/api/connections") {
|
|
1818
1847
|
json(response, 200, {
|
|
1819
1848
|
connections: tracker.list(),
|
|
@@ -2574,12 +2603,55 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2574
2603
|
// function so the handler sees the list rather than the empty array it was
|
|
2575
2604
|
// built with.
|
|
2576
2605
|
let publishUrls = [];
|
|
2606
|
+
// Declared up here, not beside the publishing below: the port opens before
|
|
2607
|
+
// that code runs, so an admin asking to go live in the first moments would
|
|
2608
|
+
// otherwise reach a binding that has not been initialised.
|
|
2609
|
+
let publisher = null;
|
|
2610
|
+
let listing = null;
|
|
2611
|
+
let publishable_;
|
|
2577
2612
|
const server = createServer(engine, {
|
|
2578
2613
|
web,
|
|
2579
2614
|
media: options.media,
|
|
2580
2615
|
owner,
|
|
2581
2616
|
channels,
|
|
2582
2617
|
publishUrls: () => publishUrls,
|
|
2618
|
+
live: {
|
|
2619
|
+
status: () => ({
|
|
2620
|
+
live: publisher !== null,
|
|
2621
|
+
code: listing?.code ?? "",
|
|
2622
|
+
name: listing?.name ?? (options.name || hostname()),
|
|
2623
|
+
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey) : ""),
|
|
2624
|
+
// Whether going live is even possible here. A laptop behind a router
|
|
2625
|
+
// with no address the world can reach cannot be listed, and a button
|
|
2626
|
+
// that could only fail is worse than one that is not offered.
|
|
2627
|
+
possible: publishable_ !== undefined,
|
|
2628
|
+
}),
|
|
2629
|
+
start: async () => {
|
|
2630
|
+
if (publisher === null)
|
|
2631
|
+
publisher = makePublisher();
|
|
2632
|
+
if (publisher === null) {
|
|
2633
|
+
return { live: false, code: "", name: "", url: "", error: "this machine has no address the world can reach" };
|
|
2634
|
+
}
|
|
2635
|
+
const first = await publisher.start();
|
|
2636
|
+
if (first === null) {
|
|
2637
|
+
// Nothing was listed, so nothing should claim to be: a publisher
|
|
2638
|
+
// left running here would heartbeat at a directory that refused it.
|
|
2639
|
+
await publisher.stop();
|
|
2640
|
+
publisher = null;
|
|
2641
|
+
return {
|
|
2642
|
+
live: false, code: "", name: "", url: "",
|
|
2643
|
+
error: `${DEFAULT_DIRECTORY} would not list this stream. Run \`nixamp login\` on that machine.`,
|
|
2644
|
+
};
|
|
2645
|
+
}
|
|
2646
|
+
listing = first;
|
|
2647
|
+
return { live: true, code: first.code, name: first.name, url: first.url };
|
|
2648
|
+
},
|
|
2649
|
+
stop: async () => {
|
|
2650
|
+
await publisher?.stop();
|
|
2651
|
+
publisher = null;
|
|
2652
|
+
listing = null;
|
|
2653
|
+
},
|
|
2654
|
+
},
|
|
2583
2655
|
...(ingest ? { ingest } : {}),
|
|
2584
2656
|
broadcaster,
|
|
2585
2657
|
broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
|
|
@@ -2811,51 +2883,67 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2811
2883
|
}
|
|
2812
2884
|
// The listing carries the listen link, and only ever a public address: an
|
|
2813
2885
|
// entry pointing at 192.168.1.5 is one nobody outside that house can open.
|
|
2814
|
-
|
|
2886
|
+
// Never a bare IP over https: a certificate is issued for a name, so a
|
|
2887
|
+
// listing pointing at one is a listing nobody can open.
|
|
2888
|
+
publishable_ = addresses.find((a) => a.label === "on the internet" && certifiable(a.url))
|
|
2889
|
+
?? addresses.find((a) => a.label === "on tailscale" && certifiable(a.url))
|
|
2890
|
+
?? addresses.find((a) => a.label === "on the internet")
|
|
2815
2891
|
?? addresses.find((a) => a.label === "on tailscale");
|
|
2816
|
-
|
|
2817
|
-
|
|
2892
|
+
/**
|
|
2893
|
+
* Make a publisher for this server. Called at startup when the operator says
|
|
2894
|
+
* yes, and again whenever somebody goes live from the admin panel.
|
|
2895
|
+
*
|
|
2896
|
+
* A declaration rather than an assignment, so it exists from the moment the
|
|
2897
|
+
* function is entered -- the port is open well before this line is reached.
|
|
2898
|
+
*/
|
|
2899
|
+
function makePublisher() {
|
|
2900
|
+
if (!publishable_)
|
|
2901
|
+
return null;
|
|
2818
2902
|
const listen = shareLink(publishable_.url, listenKey);
|
|
2819
2903
|
// Announced next to the listen link, not instead of it: one is for a person
|
|
2820
2904
|
// with a browser, the other for the phone line and anything else that is
|
|
2821
2905
|
// handed one address and expected to play it.
|
|
2822
2906
|
const audio = audioLink(publishable_.url, listenKey);
|
|
2907
|
+
return new Publisher({
|
|
2908
|
+
directory: DEFAULT_DIRECTORY,
|
|
2909
|
+
name: options.name || hostname(),
|
|
2910
|
+
url: listen,
|
|
2911
|
+
audio,
|
|
2912
|
+
// Asked of the engine rather than a variable, because the library is
|
|
2913
|
+
// now read after the port opens and may still be arriving.
|
|
2914
|
+
tracks: engine.snapshot(false).trackCount,
|
|
2915
|
+
// From `nixamp login`. The directory will not list a stream it cannot
|
|
2916
|
+
// attribute to somebody, because a listing is now a phone code that
|
|
2917
|
+
// costs money to answer.
|
|
2918
|
+
...(session?.token ? { token: session.token } : {}),
|
|
2919
|
+
onRefused: () => {
|
|
2920
|
+
console.log("");
|
|
2921
|
+
console.log(" nixamp.com would not list this stream: it needs an account.");
|
|
2922
|
+
console.log(" Run `nixamp login` (or `nixamp signup`) and start again.");
|
|
2923
|
+
},
|
|
2924
|
+
nowPlaying: () => {
|
|
2925
|
+
const snapshot = engine.snapshot();
|
|
2926
|
+
return snapshot.tracks?.[snapshot.index]?.title ?? "";
|
|
2927
|
+
},
|
|
2928
|
+
onConfig: (remote) => {
|
|
2929
|
+
const next = applyRemoteConfig(paywallConfig, remote?.x402);
|
|
2930
|
+
if (JSON.stringify(next) === JSON.stringify(paywallConfig))
|
|
2931
|
+
return;
|
|
2932
|
+
paywallConfig = next;
|
|
2933
|
+
console.log(next.enabled
|
|
2934
|
+
? ` nixamp.com turned paid listening on: $${(next.priceCents / 100).toFixed(2)} for ${next.passMinutes} minutes, over ${FREE_LISTENERS} listeners.`
|
|
2935
|
+
: " nixamp.com turned paid listening off.");
|
|
2936
|
+
},
|
|
2937
|
+
});
|
|
2938
|
+
}
|
|
2939
|
+
if (options.publish !== "no" && publishable_) {
|
|
2940
|
+
const listen = shareLink(publishable_.url, listenKey);
|
|
2823
2941
|
const wanted = options.publish === "yes"
|
|
2824
2942
|
? true
|
|
2825
2943
|
: await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
|
|
2826
2944
|
if (wanted) {
|
|
2827
|
-
publisher =
|
|
2828
|
-
|
|
2829
|
-
name: options.name || hostname(),
|
|
2830
|
-
url: listen,
|
|
2831
|
-
audio,
|
|
2832
|
-
// Asked of the engine rather than a variable, because the library is
|
|
2833
|
-
// now read after the port opens and may still be arriving.
|
|
2834
|
-
tracks: engine.snapshot(false).trackCount,
|
|
2835
|
-
// From `nixamp login`. The directory will not list a stream it cannot
|
|
2836
|
-
// attribute to somebody, because a listing is now a phone code that
|
|
2837
|
-
// costs money to answer.
|
|
2838
|
-
...(session?.token ? { token: session.token } : {}),
|
|
2839
|
-
onRefused: () => {
|
|
2840
|
-
console.log("");
|
|
2841
|
-
console.log(" nixamp.com would not list this stream: it needs an account.");
|
|
2842
|
-
console.log(" Run `nixamp login` (or `nixamp signup`) and start again.");
|
|
2843
|
-
},
|
|
2844
|
-
nowPlaying: () => {
|
|
2845
|
-
const snapshot = engine.snapshot();
|
|
2846
|
-
return snapshot.tracks?.[snapshot.index]?.title ?? "";
|
|
2847
|
-
},
|
|
2848
|
-
onConfig: (remote) => {
|
|
2849
|
-
const next = applyRemoteConfig(paywallConfig, remote?.x402);
|
|
2850
|
-
if (JSON.stringify(next) === JSON.stringify(paywallConfig))
|
|
2851
|
-
return;
|
|
2852
|
-
paywallConfig = next;
|
|
2853
|
-
console.log(next.enabled
|
|
2854
|
-
? ` nixamp.com turned paid listening on: $${(next.priceCents / 100).toFixed(2)} for ${next.passMinutes} minutes, over ${FREE_LISTENERS} listeners.`
|
|
2855
|
-
: " nixamp.com turned paid listening off.");
|
|
2856
|
-
},
|
|
2857
|
-
});
|
|
2858
|
-
const listing = await publisher.start();
|
|
2945
|
+
publisher = makePublisher();
|
|
2946
|
+
listing = (await publisher?.start()) ?? null;
|
|
2859
2947
|
console.log("");
|
|
2860
2948
|
console.log(listing
|
|
2861
2949
|
? ` Listed at ${DEFAULT_DIRECTORY}/directory as "${listing.name}". It leaves the list when this stops.`
|
package/dist/share.js
CHANGED
|
@@ -217,6 +217,11 @@ export function allowedForListening(path) {
|
|
|
217
217
|
// album out of somebody else's playlist.
|
|
218
218
|
if (path === "/api/source" || path.startsWith("/api/source/"))
|
|
219
219
|
return false;
|
|
220
|
+
// Listing this machine in a public directory is not listening to it. The
|
|
221
|
+
// listen address itself, /api/live, stays open: that is the thing a listen
|
|
222
|
+
// key is for.
|
|
223
|
+
if (path === "/api/live/state" || path === "/api/live/start" || path === "/api/live/stop")
|
|
224
|
+
return false;
|
|
220
225
|
return true;
|
|
221
226
|
}
|
|
222
227
|
/**
|
package/package.json
CHANGED
package/src/owner.ts
CHANGED
|
@@ -104,7 +104,17 @@ export const ADMIN_PATHS = [
|
|
|
104
104
|
"/api/admin",
|
|
105
105
|
];
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Going live and coming back off, which is administering a server.
|
|
109
|
+
*
|
|
110
|
+
* Listed separately from ADMIN_PATHS on purpose: those match by prefix, and
|
|
111
|
+
* `/api/live` itself is the public listen address -- the one the phone line is
|
|
112
|
+
* handed. Gating it by prefix would shut the front door to lock the office.
|
|
113
|
+
*/
|
|
114
|
+
const LIVE_CONTROL = ["/api/live/state", "/api/live/start", "/api/live/stop"];
|
|
115
|
+
|
|
107
116
|
export function needsAdmin(path: string, method = "GET"): boolean {
|
|
117
|
+
if (LIVE_CONTROL.includes(path)) return true;
|
|
108
118
|
if (ADMIN_PATHS.some((prefix) => path === prefix || path.startsWith(`${prefix}/`))) return true;
|
|
109
119
|
// Publishing to a channel, or ending one, is administering the server.
|
|
110
120
|
// Listening to a channel is not: that is what the share link is for.
|
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,
|
|
@@ -957,6 +958,19 @@ export interface HandlerOptions {
|
|
|
957
958
|
broadcaster?: Broadcaster;
|
|
958
959
|
/** Where a broadcast should send, and what it should look like. */
|
|
959
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
|
+
};
|
|
960
974
|
/**
|
|
961
975
|
* Where OBS should point, one entry per stream this server will accept.
|
|
962
976
|
*
|
|
@@ -2171,6 +2185,37 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2171
2185
|
|
|
2172
2186
|
// Everything the admin view draws, in one request: who is connected, and
|
|
2173
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
|
+
|
|
2174
2219
|
if (path === "/api/connections") {
|
|
2175
2220
|
json(response, 200, {
|
|
2176
2221
|
connections: tracker.list(),
|
|
@@ -2990,12 +3035,55 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
2990
3035
|
// built with.
|
|
2991
3036
|
let publishUrls: { id: string; url: string }[] = [];
|
|
2992
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
|
+
|
|
2993
3045
|
const server = createServer(engine, {
|
|
2994
3046
|
web,
|
|
2995
3047
|
media: options.media,
|
|
2996
3048
|
owner,
|
|
2997
3049
|
channels,
|
|
2998
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
|
+
},
|
|
2999
3087
|
...(ingest ? { ingest } : {}),
|
|
3000
3088
|
broadcaster,
|
|
3001
3089
|
broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
|
|
@@ -3250,22 +3338,28 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3250
3338
|
|
|
3251
3339
|
// The listing carries the listen link, and only ever a public address: an
|
|
3252
3340
|
// entry pointing at 192.168.1.5 is one nobody outside that house can open.
|
|
3253
|
-
|
|
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")
|
|
3254
3346
|
?? addresses.find((a) => a.label === "on tailscale");
|
|
3255
|
-
let publisher: Publisher | null = null;
|
|
3256
3347
|
|
|
3257
|
-
|
|
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;
|
|
3258
3357
|
const listen = shareLink(publishable_.url, listenKey);
|
|
3259
3358
|
// Announced next to the listen link, not instead of it: one is for a person
|
|
3260
3359
|
// with a browser, the other for the phone line and anything else that is
|
|
3261
3360
|
// handed one address and expected to play it.
|
|
3262
3361
|
const audio = audioLink(publishable_.url, listenKey);
|
|
3263
|
-
|
|
3264
|
-
? true
|
|
3265
|
-
: await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
|
|
3266
|
-
|
|
3267
|
-
if (wanted) {
|
|
3268
|
-
publisher = new Publisher({
|
|
3362
|
+
return new Publisher({
|
|
3269
3363
|
directory: DEFAULT_DIRECTORY,
|
|
3270
3364
|
name: options.name || hostname(),
|
|
3271
3365
|
url: listen,
|
|
@@ -3294,8 +3388,18 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3294
3388
|
? ` nixamp.com turned paid listening on: $${(next.priceCents / 100).toFixed(2)} for ${next.passMinutes} minutes, over ${FREE_LISTENERS} listeners.`
|
|
3295
3389
|
: " nixamp.com turned paid listening off.");
|
|
3296
3390
|
},
|
|
3297
|
-
|
|
3298
|
-
|
|
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;
|
|
3299
3403
|
console.log("");
|
|
3300
3404
|
console.log(listing
|
|
3301
3405
|
? ` Listed at ${DEFAULT_DIRECTORY}/directory as "${listing.name}". It leaves the list when this stops.`
|
package/src/share.ts
CHANGED
|
@@ -232,6 +232,10 @@ export function allowedForListening(path: string): boolean {
|
|
|
232
232
|
// and an exact check let a listen key reach /api/source/remove and delete an
|
|
233
233
|
// album out of somebody else's playlist.
|
|
234
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;
|
|
235
239
|
return true;
|
|
236
240
|
}
|
|
237
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
|
+
(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};
|