nixamp 0.7.13 → 0.7.15
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/audio.d.ts +2 -1
- package/dist/audio.js +44 -1
- package/dist/server.js +35 -7
- package/dist/share.d.ts +15 -2
- package/dist/share.js +31 -3
- package/package.json +1 -1
- package/src/audio.ts +43 -1
- package/src/server.ts +35 -6
- package/src/share.ts +30 -3
- package/web/dist/assets/{hls-3VKVEQE3-iVZjnz1t.js → hls-3VKVEQE3-CWp-NFwF.js} +1 -1
- package/web/dist/assets/index-CdFGc48E.js +1 -0
- package/web/dist/assets/{mpegts-C-iz_LAz.js → mpegts-CzR0uBNp.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-DZsfN4L7.js → mpegts-LO6RVLD6-BHffnxfi.js} +1 -1
- package/web/dist/index.html +2 -1
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-DLPoZuPj.js +0 -1
package/dist/audio.d.ts
CHANGED
|
@@ -17,7 +17,8 @@ export interface Tools {
|
|
|
17
17
|
/**
|
|
18
18
|
* Find the tools. A bare `ffmpeg` on PATH is tried first; mise shims are common
|
|
19
19
|
* on developer machines and need `mise exec` because the shim itself fails when
|
|
20
|
-
* no version is pinned.
|
|
20
|
+
* no version is pinned. Failing both, the places these are actually installed
|
|
21
|
+
* are looked in directly, because a detached daemon's PATH is not the operator's.
|
|
21
22
|
*/
|
|
22
23
|
export declare function detectTools(): Tools;
|
|
23
24
|
export declare function probe(tools: Tools, path: string): Track;
|
package/dist/audio.js
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* apart within seconds and the bars would stop matching what you hear.
|
|
8
8
|
*/
|
|
9
9
|
import { spawn, spawnSync } from "node:child_process";
|
|
10
|
+
import { readdirSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
10
13
|
export const RATE = 44100;
|
|
11
14
|
export const CHANNELS = 2;
|
|
12
15
|
function works(argv) {
|
|
@@ -16,15 +19,55 @@ function works(argv) {
|
|
|
16
19
|
const r = spawnSync(cmd, [...rest, "-version"], { encoding: "utf8", timeout: 10_000 });
|
|
17
20
|
return !r.error && r.status === 0;
|
|
18
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Everywhere a version manager or a package manager tends to leave ffmpeg.
|
|
24
|
+
*
|
|
25
|
+
* A daemon is started detached and inherits whatever environment happened to
|
|
26
|
+
* be around, which on a machine that installs ffmpeg through mise is a PATH
|
|
27
|
+
* with neither `ffmpeg` nor `mise` on it. Every probe then fails, and a
|
|
28
|
+
* failed probe means "no video stream" -- so a television channel arrived as
|
|
29
|
+
* its own soundtrack and nothing anywhere said why. Looking is cheaper than
|
|
30
|
+
* asking somebody to fix their PATH for a process they did not start.
|
|
31
|
+
*
|
|
32
|
+
* Newest first, so a machine with several installed uses the one it would
|
|
33
|
+
* have used anyway.
|
|
34
|
+
*/
|
|
35
|
+
function installedElsewhere(name) {
|
|
36
|
+
const home = homedir();
|
|
37
|
+
const roots = [
|
|
38
|
+
join(home, ".local", "share", "mise", "installs", "ffmpeg"),
|
|
39
|
+
join(home, ".asdf", "installs", "ffmpeg"),
|
|
40
|
+
];
|
|
41
|
+
const found = [];
|
|
42
|
+
for (const root of roots) {
|
|
43
|
+
let versions;
|
|
44
|
+
try {
|
|
45
|
+
versions = readdirSync(root).sort().reverse();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
for (const version of versions)
|
|
51
|
+
found.push([join(root, version, "bin", name)]);
|
|
52
|
+
}
|
|
53
|
+
// The ordinary absolute places, for a PATH that has been emptied rather than
|
|
54
|
+
// merely shortened.
|
|
55
|
+
for (const dir of ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", "/snap/bin"]) {
|
|
56
|
+
found.push([join(dir, name)]);
|
|
57
|
+
}
|
|
58
|
+
return found;
|
|
59
|
+
}
|
|
19
60
|
/**
|
|
20
61
|
* Find the tools. A bare `ffmpeg` on PATH is tried first; mise shims are common
|
|
21
62
|
* on developer machines and need `mise exec` because the shim itself fails when
|
|
22
|
-
* no version is pinned.
|
|
63
|
+
* no version is pinned. Failing both, the places these are actually installed
|
|
64
|
+
* are looked in directly, because a detached daemon's PATH is not the operator's.
|
|
23
65
|
*/
|
|
24
66
|
export function detectTools() {
|
|
25
67
|
const candidates = (name) => [
|
|
26
68
|
[name],
|
|
27
69
|
["mise", "exec", `ffmpeg@latest`, "--", name],
|
|
70
|
+
...installedElsewhere(name),
|
|
28
71
|
];
|
|
29
72
|
const pick = (name) => candidates(name).find((argv) => works(argv)) ?? null;
|
|
30
73
|
const ffmpeg = pick("ffmpeg");
|
package/dist/server.js
CHANGED
|
@@ -42,7 +42,7 @@ import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
|
42
42
|
import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
|
|
43
43
|
import { isRemote, playsInBrowser, sourceLabel } from "./sources.js";
|
|
44
44
|
import { codecsOf, probeAsync, videoArgs } from "./audio.js";
|
|
45
|
-
import { allowedForListening, elevate, firewallInUse, certifiable, keyCookie, rememberedKeys, keyFrom, lookupPublicIp, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
|
|
45
|
+
import { allowedForListening, elevate, firewallInUse, certifiable, keyCookie, keyInPath, rememberedKeys, keyFrom, lookupPublicIp, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
|
|
46
46
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
47
47
|
import { fileURLToPath } from "node:url";
|
|
48
48
|
import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
|
|
@@ -775,8 +775,9 @@ export function createHandler(engine, options) {
|
|
|
775
775
|
// cookie, so every later fetch, EventSource and <audio src> carries it
|
|
776
776
|
// without the page knowing anything about keys. Either key works here, and
|
|
777
777
|
// which one was used decides what the browser can then do.
|
|
778
|
-
|
|
779
|
-
|
|
778
|
+
const offeredInPath = key !== null ? keyInPath(path) : null;
|
|
779
|
+
if (offeredInPath !== null) {
|
|
780
|
+
const offered = offeredInPath;
|
|
780
781
|
if (scopeOf(offered, key, listenKey) === null) {
|
|
781
782
|
json(response, 404, { error: "not found" });
|
|
782
783
|
return;
|
|
@@ -2644,7 +2645,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2644
2645
|
live: publisher !== null,
|
|
2645
2646
|
code: listing?.code ?? "",
|
|
2646
2647
|
name: listing?.name ?? (options.name || hostname()),
|
|
2647
|
-
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey) : ""),
|
|
2648
|
+
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey, false) : ""),
|
|
2648
2649
|
// Whether going live is even possible here. A laptop behind a router
|
|
2649
2650
|
// with no address the world can reach cannot be listed, and a button
|
|
2650
2651
|
// that could only fail is worse than one that is not offered.
|
|
@@ -2829,7 +2830,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2829
2830
|
for (const { label, url } of addresses) {
|
|
2830
2831
|
if (label === "here")
|
|
2831
2832
|
continue;
|
|
2832
|
-
console.log(` ${shareLink(url, listenKey)}`);
|
|
2833
|
+
console.log(` ${shareLink(url, listenKey, false)}`);
|
|
2833
2834
|
}
|
|
2834
2835
|
}
|
|
2835
2836
|
}
|
|
@@ -2926,7 +2927,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2926
2927
|
function makePublisher() {
|
|
2927
2928
|
if (!publishable_)
|
|
2928
2929
|
return null;
|
|
2929
|
-
const listen = shareLink(publishable_.url, listenKey);
|
|
2930
|
+
const listen = shareLink(publishable_.url, listenKey, false);
|
|
2930
2931
|
// Announced next to the listen link, not instead of it: one is for a person
|
|
2931
2932
|
// with a browser, the other for the phone line and anything else that is
|
|
2932
2933
|
// handed one address and expected to play it.
|
|
@@ -2964,7 +2965,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2964
2965
|
});
|
|
2965
2966
|
}
|
|
2966
2967
|
if (options.publish !== "no" && publishable_) {
|
|
2967
|
-
const listen = shareLink(publishable_.url, listenKey);
|
|
2968
|
+
const listen = shareLink(publishable_.url, listenKey, false);
|
|
2968
2969
|
const wanted = options.publish === "yes"
|
|
2969
2970
|
? true
|
|
2970
2971
|
: await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
|
|
@@ -2981,6 +2982,33 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2981
2982
|
console.log("");
|
|
2982
2983
|
console.log(" --publish needs an address the world can reach. This machine has none.");
|
|
2983
2984
|
}
|
|
2985
|
+
// Remember this machine on the account it belongs to, without being asked.
|
|
2986
|
+
//
|
|
2987
|
+
// `nixamp server add --here` existed and did exactly this, which is a manual
|
|
2988
|
+
// step for something the daemon already knows: it has just worked out its
|
|
2989
|
+
// own address and minted its own key, and the session file says whose it is.
|
|
2990
|
+
// Somebody who has signed in on this machine has said which account it is;
|
|
2991
|
+
// being on their list is what they meant by that.
|
|
2992
|
+
//
|
|
2993
|
+
// Idempotent: adding the same URL again updates the row rather than making
|
|
2994
|
+
// a second one, so this is safe on every start. Silent about failure, since
|
|
2995
|
+
// nothing here is worth stopping a player for.
|
|
2996
|
+
if (session?.token && publishable_) {
|
|
2997
|
+
const remembered = shareLink(publishable_.url, key);
|
|
2998
|
+
void fetch(`${DEFAULT_DIRECTORY}/api/v1/servers`, {
|
|
2999
|
+
method: "POST",
|
|
3000
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${session.token}` },
|
|
3001
|
+
body: JSON.stringify({ url: publishable_.url, name: options.name || hostname(), key: key ?? "" }),
|
|
3002
|
+
})
|
|
3003
|
+
.then((answer) => {
|
|
3004
|
+
if (answer.ok)
|
|
3005
|
+
console.log(` Remembered on your account at ${DEFAULT_DIRECTORY}.`);
|
|
3006
|
+
})
|
|
3007
|
+
.catch(() => {
|
|
3008
|
+
// The directory being unreachable is not a reason to stop serving.
|
|
3009
|
+
});
|
|
3010
|
+
void remembered;
|
|
3011
|
+
}
|
|
2984
3012
|
// A last resort, not a licence.
|
|
2985
3013
|
//
|
|
2986
3014
|
// Every throw reachable from a request should be caught where it happens,
|
package/dist/share.d.ts
CHANGED
|
@@ -84,8 +84,21 @@ export declare function reachableAddresses(host: string, port: number, publicUrl
|
|
|
84
84
|
label: string;
|
|
85
85
|
url: string;
|
|
86
86
|
}[];
|
|
87
|
-
/**
|
|
88
|
-
|
|
87
|
+
/**
|
|
88
|
+
* The three paths a key can arrive on, and what each one says about itself.
|
|
89
|
+
*
|
|
90
|
+
* `/s/` came first and means only "here is a key" -- which is why a person
|
|
91
|
+
* handed one of two links had no way to tell which they had, and reported the
|
|
92
|
+
* controls as missing when they were holding the listening one. `/a/` is the
|
|
93
|
+
* link that administers and `/v/` is the one that only views, so the link says
|
|
94
|
+
* what it is before anybody clicks it. All three still work: links already
|
|
95
|
+
* given out do not stop working because the naming improved.
|
|
96
|
+
*/
|
|
97
|
+
export declare const KEY_PATHS: readonly ["/a/", "/v/", "/s/"];
|
|
98
|
+
/** The key in a share link, whichever of the three shapes it came in. */
|
|
99
|
+
export declare function keyInPath(path: string): string | null;
|
|
100
|
+
/** The full link, key and all. `admin` picks the shape that says which it is. */
|
|
101
|
+
export declare function shareLink(base: string, key: string | null, admin?: boolean): string;
|
|
89
102
|
/**
|
|
90
103
|
* The same stream, as bytes rather than as a page.
|
|
91
104
|
*
|
package/dist/share.js
CHANGED
|
@@ -241,9 +241,37 @@ export function reachableAddresses(host, port, publicUrl = "", scheme = "http")
|
|
|
241
241
|
.map((entry) => ({ label: `${entry.label} (no certificate for an IP)`, url: entry.url })),
|
|
242
242
|
];
|
|
243
243
|
}
|
|
244
|
-
/**
|
|
245
|
-
|
|
246
|
-
|
|
244
|
+
/**
|
|
245
|
+
* The three paths a key can arrive on, and what each one says about itself.
|
|
246
|
+
*
|
|
247
|
+
* `/s/` came first and means only "here is a key" -- which is why a person
|
|
248
|
+
* handed one of two links had no way to tell which they had, and reported the
|
|
249
|
+
* controls as missing when they were holding the listening one. `/a/` is the
|
|
250
|
+
* link that administers and `/v/` is the one that only views, so the link says
|
|
251
|
+
* what it is before anybody clicks it. All three still work: links already
|
|
252
|
+
* given out do not stop working because the naming improved.
|
|
253
|
+
*/
|
|
254
|
+
export const KEY_PATHS = ["/a/", "/v/", "/s/"];
|
|
255
|
+
/** The key in a share link, whichever of the three shapes it came in. */
|
|
256
|
+
export function keyInPath(path) {
|
|
257
|
+
for (const prefix of KEY_PATHS) {
|
|
258
|
+
if (!path.startsWith(prefix))
|
|
259
|
+
continue;
|
|
260
|
+
const rest = path.slice(prefix.length).replace(/\/+$/, "");
|
|
261
|
+
if (rest === "" || rest.includes("/"))
|
|
262
|
+
return null;
|
|
263
|
+
try {
|
|
264
|
+
return decodeURIComponent(rest);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return rest;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
/** The full link, key and all. `admin` picks the shape that says which it is. */
|
|
273
|
+
export function shareLink(base, key, admin = true) {
|
|
274
|
+
return key === null ? base : `${base}${admin ? "/a/" : "/v/"}${key}`;
|
|
247
275
|
}
|
|
248
276
|
/**
|
|
249
277
|
* The same stream, as bytes rather than as a page.
|
package/package.json
CHANGED
package/src/audio.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
* apart within seconds and the bars would stop matching what you hear.
|
|
8
8
|
*/
|
|
9
9
|
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
|
10
|
+
import { readdirSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
10
13
|
|
|
11
14
|
export const RATE = 44100;
|
|
12
15
|
export const CHANNELS = 2;
|
|
@@ -34,15 +37,54 @@ function works(argv: string[]): boolean {
|
|
|
34
37
|
return !r.error && r.status === 0;
|
|
35
38
|
}
|
|
36
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Everywhere a version manager or a package manager tends to leave ffmpeg.
|
|
42
|
+
*
|
|
43
|
+
* A daemon is started detached and inherits whatever environment happened to
|
|
44
|
+
* be around, which on a machine that installs ffmpeg through mise is a PATH
|
|
45
|
+
* with neither `ffmpeg` nor `mise` on it. Every probe then fails, and a
|
|
46
|
+
* failed probe means "no video stream" -- so a television channel arrived as
|
|
47
|
+
* its own soundtrack and nothing anywhere said why. Looking is cheaper than
|
|
48
|
+
* asking somebody to fix their PATH for a process they did not start.
|
|
49
|
+
*
|
|
50
|
+
* Newest first, so a machine with several installed uses the one it would
|
|
51
|
+
* have used anyway.
|
|
52
|
+
*/
|
|
53
|
+
function installedElsewhere(name: string): string[][] {
|
|
54
|
+
const home = homedir();
|
|
55
|
+
const roots = [
|
|
56
|
+
join(home, ".local", "share", "mise", "installs", "ffmpeg"),
|
|
57
|
+
join(home, ".asdf", "installs", "ffmpeg"),
|
|
58
|
+
];
|
|
59
|
+
const found: string[][] = [];
|
|
60
|
+
for (const root of roots) {
|
|
61
|
+
let versions: string[];
|
|
62
|
+
try {
|
|
63
|
+
versions = readdirSync(root).sort().reverse();
|
|
64
|
+
} catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
for (const version of versions) found.push([join(root, version, "bin", name)]);
|
|
68
|
+
}
|
|
69
|
+
// The ordinary absolute places, for a PATH that has been emptied rather than
|
|
70
|
+
// merely shortened.
|
|
71
|
+
for (const dir of ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", "/snap/bin"]) {
|
|
72
|
+
found.push([join(dir, name)]);
|
|
73
|
+
}
|
|
74
|
+
return found;
|
|
75
|
+
}
|
|
76
|
+
|
|
37
77
|
/**
|
|
38
78
|
* Find the tools. A bare `ffmpeg` on PATH is tried first; mise shims are common
|
|
39
79
|
* on developer machines and need `mise exec` because the shim itself fails when
|
|
40
|
-
* no version is pinned.
|
|
80
|
+
* no version is pinned. Failing both, the places these are actually installed
|
|
81
|
+
* are looked in directly, because a detached daemon's PATH is not the operator's.
|
|
41
82
|
*/
|
|
42
83
|
export function detectTools(): Tools {
|
|
43
84
|
const candidates = (name: string): string[][] => [
|
|
44
85
|
[name],
|
|
45
86
|
["mise", "exec", `ffmpeg@latest`, "--", name],
|
|
87
|
+
...installedElsewhere(name),
|
|
46
88
|
];
|
|
47
89
|
const pick = (name: string): string[] | null =>
|
|
48
90
|
candidates(name).find((argv) => works(argv)) ?? null;
|
package/src/server.ts
CHANGED
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
firewallInUse,
|
|
69
69
|
certifiable,
|
|
70
70
|
keyCookie,
|
|
71
|
+
keyInPath,
|
|
71
72
|
rememberedKeys,
|
|
72
73
|
keyFrom,
|
|
73
74
|
keysMatch,
|
|
@@ -1073,8 +1074,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1073
1074
|
// cookie, so every later fetch, EventSource and <audio src> carries it
|
|
1074
1075
|
// without the page knowing anything about keys. Either key works here, and
|
|
1075
1076
|
// which one was used decides what the browser can then do.
|
|
1076
|
-
|
|
1077
|
-
|
|
1077
|
+
const offeredInPath = key !== null ? keyInPath(path) : null;
|
|
1078
|
+
if (offeredInPath !== null) {
|
|
1079
|
+
const offered = offeredInPath;
|
|
1078
1080
|
if (scopeOf(offered, key, listenKey) === null) {
|
|
1079
1081
|
json(response, 404, { error: "not found" });
|
|
1080
1082
|
return;
|
|
@@ -3078,7 +3080,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3078
3080
|
live: publisher !== null,
|
|
3079
3081
|
code: listing?.code ?? "",
|
|
3080
3082
|
name: listing?.name ?? (options.name || hostname()),
|
|
3081
|
-
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey) : ""),
|
|
3083
|
+
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey, false) : ""),
|
|
3082
3084
|
// Whether going live is even possible here. A laptop behind a router
|
|
3083
3085
|
// with no address the world can reach cannot be listed, and a button
|
|
3084
3086
|
// that could only fail is worse than one that is not offered.
|
|
@@ -3285,7 +3287,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3285
3287
|
console.log(" A listen-only link, for someone you want to hear it but not drive it:");
|
|
3286
3288
|
for (const { label, url } of addresses) {
|
|
3287
3289
|
if (label === "here") continue;
|
|
3288
|
-
console.log(` ${shareLink(url, listenKey)}`);
|
|
3290
|
+
console.log(` ${shareLink(url, listenKey, false)}`);
|
|
3289
3291
|
}
|
|
3290
3292
|
}
|
|
3291
3293
|
}
|
|
@@ -3382,7 +3384,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3382
3384
|
*/
|
|
3383
3385
|
function makePublisher(): Publisher | null {
|
|
3384
3386
|
if (!publishable_) return null;
|
|
3385
|
-
const listen = shareLink(publishable_.url, listenKey);
|
|
3387
|
+
const listen = shareLink(publishable_.url, listenKey, false);
|
|
3386
3388
|
// Announced next to the listen link, not instead of it: one is for a person
|
|
3387
3389
|
// with a browser, the other for the phone line and anything else that is
|
|
3388
3390
|
// handed one address and expected to play it.
|
|
@@ -3420,7 +3422,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3420
3422
|
}
|
|
3421
3423
|
|
|
3422
3424
|
if (options.publish !== "no" && publishable_) {
|
|
3423
|
-
const listen = shareLink(publishable_.url, listenKey);
|
|
3425
|
+
const listen = shareLink(publishable_.url, listenKey, false);
|
|
3424
3426
|
const wanted = options.publish === "yes"
|
|
3425
3427
|
? true
|
|
3426
3428
|
: await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
|
|
@@ -3438,6 +3440,33 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3438
3440
|
console.log(" --publish needs an address the world can reach. This machine has none.");
|
|
3439
3441
|
}
|
|
3440
3442
|
|
|
3443
|
+
// Remember this machine on the account it belongs to, without being asked.
|
|
3444
|
+
//
|
|
3445
|
+
// `nixamp server add --here` existed and did exactly this, which is a manual
|
|
3446
|
+
// step for something the daemon already knows: it has just worked out its
|
|
3447
|
+
// own address and minted its own key, and the session file says whose it is.
|
|
3448
|
+
// Somebody who has signed in on this machine has said which account it is;
|
|
3449
|
+
// being on their list is what they meant by that.
|
|
3450
|
+
//
|
|
3451
|
+
// Idempotent: adding the same URL again updates the row rather than making
|
|
3452
|
+
// a second one, so this is safe on every start. Silent about failure, since
|
|
3453
|
+
// nothing here is worth stopping a player for.
|
|
3454
|
+
if (session?.token && publishable_) {
|
|
3455
|
+
const remembered = shareLink(publishable_.url, key);
|
|
3456
|
+
void fetch(`${DEFAULT_DIRECTORY}/api/v1/servers`, {
|
|
3457
|
+
method: "POST",
|
|
3458
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${session.token}` },
|
|
3459
|
+
body: JSON.stringify({ url: publishable_.url, name: options.name || hostname(), key: key ?? "" }),
|
|
3460
|
+
})
|
|
3461
|
+
.then((answer) => {
|
|
3462
|
+
if (answer.ok) console.log(` Remembered on your account at ${DEFAULT_DIRECTORY}.`);
|
|
3463
|
+
})
|
|
3464
|
+
.catch(() => {
|
|
3465
|
+
// The directory being unreachable is not a reason to stop serving.
|
|
3466
|
+
});
|
|
3467
|
+
void remembered;
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3441
3470
|
// A last resort, not a licence.
|
|
3442
3471
|
//
|
|
3443
3472
|
// Every throw reachable from a request should be caught where it happens,
|
package/src/share.ts
CHANGED
|
@@ -271,9 +271,36 @@ export function reachableAddresses(
|
|
|
271
271
|
];
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
-
/**
|
|
275
|
-
|
|
276
|
-
|
|
274
|
+
/**
|
|
275
|
+
* The three paths a key can arrive on, and what each one says about itself.
|
|
276
|
+
*
|
|
277
|
+
* `/s/` came first and means only "here is a key" -- which is why a person
|
|
278
|
+
* handed one of two links had no way to tell which they had, and reported the
|
|
279
|
+
* controls as missing when they were holding the listening one. `/a/` is the
|
|
280
|
+
* link that administers and `/v/` is the one that only views, so the link says
|
|
281
|
+
* what it is before anybody clicks it. All three still work: links already
|
|
282
|
+
* given out do not stop working because the naming improved.
|
|
283
|
+
*/
|
|
284
|
+
export const KEY_PATHS = ["/a/", "/v/", "/s/"] as const;
|
|
285
|
+
|
|
286
|
+
/** The key in a share link, whichever of the three shapes it came in. */
|
|
287
|
+
export function keyInPath(path: string): string | null {
|
|
288
|
+
for (const prefix of KEY_PATHS) {
|
|
289
|
+
if (!path.startsWith(prefix)) continue;
|
|
290
|
+
const rest = path.slice(prefix.length).replace(/\/+$/, "");
|
|
291
|
+
if (rest === "" || rest.includes("/")) return null;
|
|
292
|
+
try {
|
|
293
|
+
return decodeURIComponent(rest);
|
|
294
|
+
} catch {
|
|
295
|
+
return rest;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** The full link, key and all. `admin` picks the shape that says which it is. */
|
|
302
|
+
export function shareLink(base: string, key: string | null, admin = true): string {
|
|
303
|
+
return key === null ? base : `${base}${admin ? "/a/" : "/v/"}${key}`;
|
|
277
304
|
}
|
|
278
305
|
|
|
279
306
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-CdFGc48E.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-CWp-NFwF.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-BHffnxfi.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 ee(e){return e.filter(e=>_(e.name,e.type)).sort((e,t)=>v(y(e),y(t))).map(e=>({title:n(e.name),artist:``,album:b(y(e)),duration:0,url:URL.createObjectURL(e),video:i(e.name,e.type),objectUrl:!0}))}function y(e){return e.webkitRelativePath||e.name}function b(e){let t=e.split(`/`);return t.length>1?t[t.length-2]:``}function te(e){for(let t of e)t.objectUrl&&URL.revokeObjectURL(t.url)}var x=2048;function S(e,t){return e||t===`hls`||t===`mpegts`}var ne=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(C(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=x,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=S(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 C(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 w(){return{revision:0,tracks:[],trackCount:0,index:0,playing:!1,position:0,bars:[],levels:[0,0],silent:!0,note:``,root:``}}function re(e,t){return{...t,tracks:t.tracks??e.tracks}}function T(e){let t=e.trim();if(t===``)return``;/^https?:\/\//i.test(t)||(t=`http://${t}`);let n;try{n=new URL(t)}catch{return``}let r=n.pathname.replace(/\/+$/,``);return r=r.replace(/\/api(\/.*)?$/,``),`${n.origin}${r}`}function E(e,t,n=``){let r=`${e===``?``:T(e)}${t.startsWith(`/`)?t:`/${t}`}`;return n?`${r}${r.includes(`?`)?`&`:`?`}k=${encodeURIComponent(n)}`:r}function ie(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=/^\/[avs]\/([^/]+)\/?$/.exec(n.pathname),i=r?.[1]??n.searchParams.get(`k`)??``;return r&&(n.pathname=`/`),n.searchParams.delete(`k`),{base:T(`${n.origin}${n.pathname}`),key:decodeURIComponent(i)}}function D(e,t,n=0,r=``){return E(e,n>0?`/api/media/${t}?kbps=${Math.round(n)}`:`/api/media/${t}`,r)}function O(e){if(typeof e!=`object`||!e)return null;let t=e,n=w(),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 ae=class{handlers;source=null;base=``;key=``;shape=`/a/`;lastRevision=-1;constructor(e){this.handlers=e}get address(){return this.base}url(e){return E(this.base,e,this.key)}get shareLink(){return this.base===``?``:this.key===``?this.base:`${this.base}${this.shape}${this.key}`}get connected(){return this.source!==null}connect(e){let{base:t,key:n}=ie(e);this.close(),this.base=t,this.key=n,this.shape=/\/v\/[^/]+\/?$/.test(e.trim())?`/v/`:`/a/`,this.lastRevision=-1,this.handlers.onStatus(`connecting`);let r=new EventSource(E(t,`/api/events`,n));this.source=r,r.onopen=()=>this.handlers.onStatus(`live`),r.onmessage=e=>{let t=O(k(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(E(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=O(await t.json());n&&this.handlers.onSnapshot(n)}media(e,t=0){return D(this.base,e,t,this.key)}close(){this.source?.close(),this.source=null}};function k(e){try{return JSON.parse(e)}catch{return null}}async function oe(e,t,n=``){try{let r=await fetch(E(e,`/api/state`,n),{signal:t});return r.ok?O(await r.json()):null}catch{return null}}function se(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 ce(e,t=``,n){let r;try{r=await fetch(E(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 A(e,t,n=``){try{let r=await fetch(E(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 le(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 j=.14,M=.02;function ue(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 de(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 fe(e,t,n=j){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function N(e,t,n=M){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function pe(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 P=`nixamp.remote`,F=`nixamp.volume`,I=`nixamp.listenHere`;function L(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function R(){let n={status:L(`status`),source:L(`source`),install:L(`install`),video:L(`video`),audio:L(`audio`),title:L(`title-line`),album:L(`album-line`),elapsed:L(`elapsed`),total:L(`total`),seek:L(`seek`),fullscreen:L(`fullscreen`),canvas:L(`spectrum`),glyphs:L(`glyphs`),levels:L(`levels`),playlist:L(`playlist`),playlistTitle:L(`playlist-panel`),note:L(`note`),files:L(`files`),folder:L(`folder`),remoteUrl:L(`remote-url`),remoteForm:L(`remote-form`),remoteState:L(`remote-state`),disconnect:L(`disconnect`),browse:L(`browse`),accountForm:L(`account-form`),accountEmail:L(`account-email`),accountPassword:L(`account-password`),accountSubmit:L(`account-submit`),accountToggle:L(`account-toggle`),accountProviders:L(`account-providers`),accountPanel:L(`account-panel`),accountElsewhere:L(`account-elsewhere`),accountSignOut:L(`account-signout`),accountNote:L(`account-note`),adminPanel:L(`admin-panel`),adminNote:L(`admin-note`),adminSaid:L(`admin-said`),adminConnections:L(`admin-connections`),publishPanel:L(`publish-panel`),publishNote:L(`publish-note`),publishList:L(`publish-list`),adminRestream:L(`admin-restream`),adminReplace:L(`admin-replace`),adminSource:L(`admin-source`),directory:L(`directory`),recentNote:L(`recent-note`),recentList:L(`recent-list`),followingNote:L(`following-note`),followingList:L(`following-list`),serversPanel:L(`servers-panel`),serversNote:L(`servers-note`),serversList:L(`servers-list`),notifyPanel:L(`notify-panel`),notifyNote:L(`notify-note`),notifyWeb:L(`notify-web`),notifyEmail:L(`notify-email`),notifySms:L(`notify-sms`),notifyPhone:L(`notify-phone`),notifyPhoneForm:L(`notify-phone-form`),notifyPhoneNote:L(`notify-phone-note`),directoryNote:L(`directory-note`),directoryList:L(`directory-list`),sharePanel:L(`share-panel`),shareNote:L(`share-note`),shareLink:L(`share-link`),shareCopy:L(`share-copy`),sharePhone:L(`share-phone`),shareSend:L(`share-send`),liveControls:L(`live-controls`),goLive:L(`go-live`),stopLive:L(`stop-live`),shareTo:L(`share-to`),listenOnly:L(`listen-only`),listenHere:L(`listen-here`),volume:L(`volume`),prev:L(`prev`),playPause:L(`play-pause`),stop:L(`stop`),next:L(`next`)},r=`local`,i=[],a=0,o=w(),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 ne({audio:n.audio,video:n.video},{onTime:(e,t)=>{let n=i[a];r===`local`&&n&&t>0&&n.duration!==t&&(n.duration=t),z()},onEnded:()=>j(1),onState:()=>z(),onError:e=>{l=e,z()}}),v=new ae({onSnapshot:e=>{o=re(o,e),g()&&(p=e.bars.length>0?e.bars:p,m=N(m,p)),z()},onStatus:(e,t)=>{s=e,c=t??``,z()}}),y=()=>r===`remote`?o.tracks.length:i.length,b=()=>r===`remote`?g()||d<0?o.index:Math.min(d,Math.max(0,o.tracks.length-1)):a,x=()=>{let e=r===`remote`?o.tracks[b()]:i[b()];return e?t(e):`Nothing loaded.`},S=()=>(r===`remote`?o.tracks[b()]:i[b()])?.album||`—`,C=()=>g()?o.tracks[b()]?.duration??0:_.duration,T=()=>g()?o.position:_.position,E=()=>g()?o.playing:_.playing;async function D(e){if(r===`remote`){if(g()){await v.send({type:`play`,index:e});return}await O(e);return}let t=i[e];t&&(a=e,await _.load(t,!0),U(t.video),_e(),z())}async function O(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),U(t.video===!0),_e())}async function k(){if(g()){await v.send({type:`toggle`});return}y()!==0&&(_.playing?_.pause():_.position>0?await _.play():await D(b()),z())}async function j(e){let t=y();if(t!==0){if(g()){await v.send({type:e>0?`next`:`prev`});return}await D((b()+e+t)%t)}}async function M(){if(g()){await v.send({type:`stop`});return}_.stop(),p=Array(24).fill(0),m=[...p],z()}let R=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))];function z(){let t=y(),a=E();n.status.textContent=a?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=String(a),n.title.textContent=x(),n.album.textContent=S();let d=T(),f=C();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===``,me(),n.glyphs.textContent=p.map(R).join(``);let[h,ee]=g()?o.levels:_.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(h*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(ee*6)).padEnd(6,`·`)}`}let B=``,V=-1;function me(){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!==B){B=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(he(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=b(),l=E(),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!==V&&(V=c,u?.scrollIntoView({block:`nearest`}))}function he(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(),ge(e)}),t.append(n)}return t}async function ge(e){try{let t=await fetch(v.url(`/api/source/remove`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({group:e})}),n=await t.json();G(t.ok?`Removed ${n.removed??0} tracks from ${e}.`:n.error??`that did not work`)}catch{G(`could not reach the server`)}}function H(){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=N(m,p);else{let e=_.read();e.length>0&&(h.length!==25&&(h=ue(24,e.length)),p=fe(p,de(e,h)),m=N(m,p))}if(s){let e=getComputedStyle(document.documentElement);pe(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(E()){n.glyphs.textContent=p.map(R).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(T());let i=C();!u&&i>0&&(n.seek.value=String(Math.round(T()/i*1e3)))}requestAnimationFrame(H)}function U(e){n.video.hidden=!e,n.fullscreen.hidden=!e}function _e(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:x(),album:S(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void k()),navigator.mediaSession.setActionHandler(`pause`,()=>void k()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void j(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void j(-1)))}n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&D(n)}),n.fullscreen.addEventListener(`click`,()=>{let e=n.video;if(document.fullscreenElement){document.exitFullscreen().catch(()=>{});return}if(typeof e.requestFullscreen==`function`){e.requestFullscreen().catch(()=>{e.webkitEnterFullscreen?.()});return}e.webkitEnterFullscreen?.()}),n.prev.addEventListener(`click`,()=>void j(-1)),n.next.addEventListener(`click`,()=>void j(1)),n.stop.addEventListener(`click`,()=>void M()),n.playPause.addEventListener(`click`,()=>void k()),n.seek.addEventListener(`input`,()=>{u=!0}),n.seek.addEventListener(`change`,()=>{let e=C();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(F,String(e))}catch{}});let ve=e=>{e.addEventListener(`change`,()=>{let t=ee(Array.from(e.files??[]));if(t.length===0){l=`Nothing playable in that selection.`,z();return}te(i),i=t,a=0,r=`local`,v.close(),l=``,D(0)})};ve(n.files),ve(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.remoteUrl.value,{base:i,key:a}=ie(t);if(i===``){l=`That is not an address.`,z();return}(async()=>{s=`connecting`,z();let e=le(i);if(e){s=`error`,c=e,l=e,r=`local`,z();return}if(await A(i,void 0,a)===null){s=`error`;let e=se(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`,z();return}let n=await ce(i,a);if(n){s=`error`,c=n,l=n,r=`local`,z();return}r=`remote`,l=``;try{localStorage.setItem(P,t.trim())}catch{}v.connect(t),K(),Z(),z()})()});let ye=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??[],Oe(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&&Y&&t.ownerId!==Y&&e.append(je(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),ye()}let W=null,be=e=>e===`cgnat`?`mobile or tailscale`:e===`private`?`your network`:e===`local`?`this machine`:e===`public`?`the internet`:e,xe=e=>e===`events`?`watching the panel`:e===`page`?`opened the page`:e===`media`?`playing a track`:e===`stream`?`listening live`:e,Se=``,Ce=``,we=e=>{let t=e.map(e=>`${e.address}|${e.kind}|${e.track}|${Math.round(e.bytes/4096)}|${e.endedAt}`).join(`~`);if(t===Se)return;Se=t,n.adminConnections.replaceChildren();let r=document.createElement(`tr`);for(let e of[`Where`,`Network`,`Kind`,`Client`,`Track`,`Sent`]){let t=document.createElement(`th`);t.textContent=e,r.append(t)}n.adminConnections.append(r);for(let t of e.slice(0,40)){let e=document.createElement(`tr`);t.endedAt!==null&&(e.className=`ended`);let r=[[t.address,``],[be(t.network),`network-${t.network}`],[xe(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)}};function G(e){n.adminSaid.textContent=e,n.adminSaid.hidden=e===``}let Te=async()=>{try{let e=await fetch(v.url(`/api/connections`));if(!e.ok)return;let t=await e.json(),r=(t.connections??[]).filter(e=>e.endedAt===null&&e.kind!==`media`&&e.kind!==`stream`).length,i=t.active??0;n.adminNote.textContent=r===0?`${i} listening now.`:`${i} listening now, and ${r} with the page open.`,we(t.connections??[]),Ee(t.publish??[],(t.channels??[]).map(e=>e.id))}catch{n.adminNote.textContent=`lost touch with the server`}};function Ee(e,t){n.publishPanel.hidden=e.length===0;let r=`${e.map(e=>`${e.id}=${e.url}`).join(`~`)}::${t.join(`,`)}`;if(r===Ce)return;if(Ce=r,e.length===0){n.publishList.replaceChildren();return}let i=e.length-t.length;n.publishNote.textContent=`Point OBS, Larix or ffmpeg at one of these. One publisher per URL — ${e.length} at once, ${i} free right now.`,n.publishList.replaceChildren(...e.map(e=>{let n=t.includes(e.id),r=document.createElement(`li`);n&&(r.className=`in-use`);let i=document.createElement(`span`);i.className=`slot`,i.textContent=n?`${e.id} · live`:e.id;let a=document.createElement(`input`);a.type=`text`,a.readOnly=!0,a.value=e.url,a.setAttribute(`aria-label`,`RTMP URL for ${e.id}`);let o=document.createElement(`button`);return o.type=`button`,o.className=`ghost`,o.textContent=`Copy`,o.addEventListener(`click`,()=>{a.select(),navigator.clipboard?.writeText(e.url).catch(()=>{})}),r.append(i,a,o),r}))}let K=async()=>{if(r!==`remote`){n.adminPanel.hidden=!0,n.publishPanel.hidden=!0,W&&clearInterval(W),W=null;return}let e=!1,t=null,i=!1;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,i=r.claimed===!0}}catch{e=!1}if(n.adminPanel.hidden=!e,W&&clearInterval(W),W=null,Z(),n.listenOnly.hidden=e,!e){n.listenOnly.textContent=i?`This is a listen-only link: you can hear this server but not change what it plays. Use its control link — the first one it printed — or sign in as its owner.`:`This is a listen-only link: you can hear this server but not change what it plays. Use its control link, the first one it printed.`;return}n.adminNote.textContent=t===`owner`?`You own this server.`:`You hold this server's control link.`,Te(),W=setInterval(()=>void Te(),2e3)};n.adminRestream.addEventListener(`submit`,e=>{e.preventDefault();let t=n.adminSource.value.trim();if(!t)return;G(`Reading ${t}…`);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();G(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=``,Z())}catch{G(`could not reach the server`)}})()});let De=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`},Oe=e=>{n.recentList.replaceChildren();let t=Y?e.filter(e=>e.ownerId&&e.ownerId!==Y):[];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 ${De(e.endedAt)}`:`ended ${De(e.endedAt)}`,r.append(i,a),t.append(r,je(e.ownerId,e.name)),n.recentList.append(t)}},ke=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()}),A(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 ke()}catch{s.disabled=!1}})()}),t.append(r,o,s),n.serversList.append(t)}}catch{n.serversPanel.hidden=!0}},Ae=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}},je=(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),Ae())}catch{}finally{n.disabled=!1}})()}),n},Me=e=>{let t=(e+`=`.repeat((4-e.length%4)%4)).replace(/-/g,`+`).replace(/_/g,`/`),n=atob(t),r=new Uint8Array(new ArrayBuffer(n.length));for(let e=0;e<n.length;e+=1)r[e]=n.charCodeAt(e);return r},Ne=()=>`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window,Pe=async()=>{if(!Ne())return n.notifyNote.textContent=`This browser cannot show notifications.`,!1;if(Notification.permission===`denied`)return n.notifyNote.textContent=`This browser is blocking notifications. Allow them in site settings first.`,!1;if(await Notification.requestPermission()!==`granted`)return n.notifyNote.textContent=`Not allowed, so nothing will be sent here.`,!1;try{let e=await navigator.serviceWorker.ready,{publicKey:t}=await(await fetch(`/api/v1/notify/key`)).json();if(!t)return n.notifyNote.textContent=`This server is not set up to send notifications.`,!1;let r=await e.pushManager.getSubscription()??await e.pushManager.subscribe({userVisibleOnly:!0,applicationServerKey:Me(t)}),i=await fetch(`/api/v1/notify/subscribe`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify(r.toJSON())});if(!i.ok)throw Error(String(i.status));return n.notifyNote.textContent=`This device will be told.`,!0}catch{return n.notifyNote.textContent=`Could not set this device up.`,!1}},Fe=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{}},q=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`}},Ie=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=Ne()&&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 Pe();n.notifyWeb.checked=e,await q({wantsWeb:e});return}await Fe(),await q({wantsWeb:!1}),n.notifyNote.textContent=`Turn on “On this device” to be told here.`})()}),n.notifyEmail.addEventListener(`change`,()=>{q({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 q({wantsSms:n.notifySms.checked})})()}),n.notifyPhoneForm.addEventListener(`submit`,e=>{e.preventDefault(),q({phone:n.notifyPhone.value.trim()})});let J=!1,Y=``,X=e=>{let t=e!==null;n.notifyPanel.hidden=!t,t?(Ie(),Ae(),ke()):(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}.`:J?`Create an account on nixamp.com.`:`Sign in to nixamp.com to publish and get paid.`,n.accountSubmit.textContent=J?`Create account`:`Sign in`,n.accountToggle.textContent=J?`I have one`:`Create one`,n.accountPassword.autocomplete=J?`new-password`:`current-password`},Le=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)}},Re=async()=>{try{let e=await fetch(`/api/v1/auth/me`),t=await e.json();Y=e.ok?t.account?.id??``:``,X(e.ok?t.account?.email??`you`:null)}catch{Y=``,X(null)}ze()};function ze(){if(f===``)return;if(Y===``){n.accountNote.textContent=`Sign in to watch the stream you were sent.`,n.accountPanel.scrollIntoView({behavior:`smooth`,block:`center`});return}let e=f;f=``,n.remoteUrl.value=e,n.remoteForm.requestSubmit()}n.accountToggle.addEventListener(`click`,()=>{J=!J,X(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/${J?`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}Y=i.account?.id??``,n.accountPassword.value=``,X(i.account?.email??t),K(),ze()}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{}Y=``,X(null),K()})()});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{}Le(),Re(),K(),n.browse.addEventListener(`click`,()=>{if(!n.directory.hidden){n.directory.hidden=!0;return}ye(),n.directory.scrollIntoView({behavior:`smooth`,block:`nearest`})}),n.disconnect.addEventListener(`click`,()=>{v.close(),n.listenOnly.hidden=!0,d=-1,n.sharePanel.hidden=!0,n.publishPanel.hidden=!0,n.adminPanel.hidden=!0,r=`local`,s=`idle`,c=``,z()});async function Z(){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 r=await fetch(v.url(`/api/live/state`));r.ok&&(a=await r.json()),a?.url&&(e=a.url,n.shareLink.value=e.startsWith(`https://`)?`${t}/?url=${encodeURIComponent(e)}`:e)}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, so nobody can find this in the directory. Go live to list it, with 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}}let Be=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 Z()}};n.goLive.addEventListener(`click`,()=>void Be(!0)),n.stopLive.addEventListener(`click`,()=>void Be(!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(I,n.listenHere.checked?`1`:`0`)}catch{}r===`remote`&&(async()=>{n.listenHere.checked?(await v.send({type:`stop`}),await O(o.index)):(_.stop(),d=-1),z()})()}),document.addEventListener(`keydown`,e=>{let t=e.target;if(!(t&&/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))switch(e.key){case` `:e.preventDefault(),k();return;case`s`:M();return;case`n`:case`ArrowRight`:j(1);return;case`p`:case`ArrowLeft`:j(-1);return;case`ArrowDown`:e.preventDefault(),D(Math.min(y()-1,b()+1));return;case`ArrowUp`:e.preventDefault(),D(Math.max(0,b()-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(F);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),_.volume=Number(e));let t=localStorage.getItem(P);t&&(n.remoteUrl.value=t),localStorage.getItem(I)===`0`&&(n.listenHere.checked=!1)}catch{}(async()=>{if(n.remoteUrl.value!==``)return;let e=globalThis.location.origin;if(await A(e)===null)return;let t=await oe(e);t&&t.trackCount!==0&&(n.remoteUrl.value=e,r=`remote`,l=``,v.connect(e),z())})(),z(),requestAnimationFrame(H)}R(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});export{c as t};
|