nixamp 0.10.2 → 0.10.3
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.js +5 -1
- package/dist/hls.d.ts +55 -0
- package/dist/hls.js +254 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +55 -1
- package/package.json +1 -1
- package/src/audio.ts +5 -1
- package/src/hls.ts +265 -0
- package/src/server.ts +58 -1
- package/web/dist/assets/{hls-3VKVEQE3-5T3M0hCY.js → hls-3VKVEQE3-DR8q_kGX.js} +1 -1
- package/web/dist/assets/index-g9xRD0Ln.js +1 -0
- package/web/dist/assets/{mpegts-DeEbMNtT.js → mpegts-D-7xRRW6.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-DUwEL0l2.js → mpegts-LO6RVLD6-B0VjYPAC.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-Bqujphpd.js +0 -1
package/src/hls.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A channel as HLS, for the browsers that will not take it any other way.
|
|
3
|
+
*
|
|
4
|
+
* A channel is sent as one endless fragmented MP4 down a chunked response,
|
|
5
|
+
* which Chrome and Firefox play as they would a file. Safari on an iPhone
|
|
6
|
+
* will not: it asks for a byte range, gets a stream with no length, and gives
|
|
7
|
+
* up before the first picture -- a spinner that flashes and a player that
|
|
8
|
+
* never starts. What Safari plays live is HLS, a playlist of short files.
|
|
9
|
+
*
|
|
10
|
+
* So this packages the same bytes into HLS on demand: one ffmpeg per channel,
|
|
11
|
+
* copying (never re-encoding) the fragments it is fed into two-second
|
|
12
|
+
* MPEG-TS segments in a temporary directory, started when the first playlist
|
|
13
|
+
* is asked for and stopped a minute after the last. The channel itself is
|
|
14
|
+
* untouched; this is one more listener on it.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
17
|
+
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
|
|
21
|
+
/** Seconds of video per segment. Short, so joining is quick; long enough that a playlist is not churn. */
|
|
22
|
+
export const SEGMENT_SECONDS = 2;
|
|
23
|
+
/** How many segments the playlist offers. About twelve seconds of catch-up. */
|
|
24
|
+
export const PLAYLIST_SEGMENTS = 6;
|
|
25
|
+
/** How long a packager runs with nobody asking for its playlist. */
|
|
26
|
+
export const IDLE_MS = 60_000;
|
|
27
|
+
/** How long the first playlist may take to appear before it is a failure. */
|
|
28
|
+
export const FIRST_PLAYLIST_MS = 20_000;
|
|
29
|
+
|
|
30
|
+
const SEGMENT = /^seg\d{5}\.ts$/;
|
|
31
|
+
|
|
32
|
+
/** A segment file name, or "" for anything that is not one. Never a path. */
|
|
33
|
+
export function segmentName(requested: string): string {
|
|
34
|
+
return SEGMENT.test(requested) ? requested : "";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The playlist with the key on every segment.
|
|
39
|
+
*
|
|
40
|
+
* A browser resolves segment names against the playlist's URL and drops its
|
|
41
|
+
* query, so the key that opened the playlist never reaches the segments and
|
|
42
|
+
* each one answers 401. The key travels on every line instead.
|
|
43
|
+
*/
|
|
44
|
+
export function withKey(playlist: string, key: string): string {
|
|
45
|
+
if (key === "") return playlist;
|
|
46
|
+
return playlist
|
|
47
|
+
.split("\n")
|
|
48
|
+
.map((line) => (line !== "" && !line.startsWith("#") ? `${line}?k=${encodeURIComponent(key)}` : line))
|
|
49
|
+
.join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The ffmpeg arguments: copy what arrives on stdin into a rolling playlist. */
|
|
53
|
+
export function packagerArgs(dir: string): string[] {
|
|
54
|
+
return [
|
|
55
|
+
"-hide_banner",
|
|
56
|
+
"-loglevel", "error",
|
|
57
|
+
"-i", "pipe:0",
|
|
58
|
+
"-c", "copy",
|
|
59
|
+
"-f", "hls",
|
|
60
|
+
"-hls_time", String(SEGMENT_SECONDS),
|
|
61
|
+
"-hls_list_size", String(PLAYLIST_SEGMENTS),
|
|
62
|
+
// Old segments go; the playlist never ends; each segment starts on a
|
|
63
|
+
// keyframe so a joiner can begin anywhere; written whole then renamed so
|
|
64
|
+
// a request never reads half a file.
|
|
65
|
+
"-hls_flags", "delete_segments+omit_endlist+independent_segments+temp_file",
|
|
66
|
+
"-hls_segment_type", "mpegts",
|
|
67
|
+
"-hls_segment_filename", join(dir, "seg%05d.ts"),
|
|
68
|
+
join(dir, "index.m3u8"),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface Packaged {
|
|
73
|
+
/** Feed it the channel's bytes: the header first, then every fragment. */
|
|
74
|
+
write(chunk: Buffer): boolean;
|
|
75
|
+
/** The channel ended; finish and stop. */
|
|
76
|
+
end(): void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** One channel's packager. */
|
|
80
|
+
class Packager implements Packaged {
|
|
81
|
+
readonly dir: string;
|
|
82
|
+
private child: ChildProcess | null = null;
|
|
83
|
+
private idle: ReturnType<typeof setTimeout> | null = null;
|
|
84
|
+
private stopped = false;
|
|
85
|
+
private detach: (() => void) | null = null;
|
|
86
|
+
|
|
87
|
+
constructor(
|
|
88
|
+
private readonly id: string,
|
|
89
|
+
private readonly ffmpeg: string[],
|
|
90
|
+
private readonly onStop: (id: string) => void,
|
|
91
|
+
private readonly onEvent: (message: string) => void,
|
|
92
|
+
) {
|
|
93
|
+
this.dir = mkdtempSync(join(tmpdir(), `nixamp-hls-${id}-`));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
start(listen: (listener: Packaged) => (() => void) | null): boolean {
|
|
97
|
+
const [command, ...prefix] = this.ffmpeg as [string, ...string[]];
|
|
98
|
+
try {
|
|
99
|
+
this.child = spawn(command, [...prefix, ...packagerArgs(this.dir)], { stdio: ["pipe", "ignore", "pipe"] });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
this.onEvent(` HLS for "${this.id}" could not start: ${(error as Error).message}`);
|
|
102
|
+
this.stop();
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
let complaint = "";
|
|
106
|
+
this.child.stderr?.on("data", (chunk: Buffer) => {
|
|
107
|
+
if (complaint.length < 4000) complaint += chunk.toString("utf8");
|
|
108
|
+
});
|
|
109
|
+
this.child.stdin?.on("error", () => undefined);
|
|
110
|
+
this.child.on("error", (error) => {
|
|
111
|
+
this.onEvent(` HLS for "${this.id}" failed: ${error.message}`);
|
|
112
|
+
this.stop();
|
|
113
|
+
});
|
|
114
|
+
this.child.on("close", (code) => {
|
|
115
|
+
if (!this.stopped && code !== 0) {
|
|
116
|
+
this.onEvent(` HLS for "${this.id}" stopped: ${complaint.trim().split("\n").pop() ?? code}`);
|
|
117
|
+
}
|
|
118
|
+
this.stop();
|
|
119
|
+
});
|
|
120
|
+
this.detach = listen(this);
|
|
121
|
+
if (this.detach === null) {
|
|
122
|
+
this.stop();
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
this.touch();
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
write(chunk: Buffer): boolean {
|
|
130
|
+
const stdin = this.child?.stdin;
|
|
131
|
+
if (!stdin || stdin.destroyed) return false;
|
|
132
|
+
try {
|
|
133
|
+
stdin.write(chunk);
|
|
134
|
+
return true;
|
|
135
|
+
} catch {
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
end(): void {
|
|
141
|
+
try {
|
|
142
|
+
this.child?.stdin?.end();
|
|
143
|
+
} catch {
|
|
144
|
+
// Already gone.
|
|
145
|
+
}
|
|
146
|
+
this.stop();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Somebody asked for the playlist: it is wanted for another minute. */
|
|
150
|
+
touch(): void {
|
|
151
|
+
if (this.idle) clearTimeout(this.idle);
|
|
152
|
+
this.idle = setTimeout(() => this.stop(), IDLE_MS);
|
|
153
|
+
this.idle.unref?.();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The playlist as it stands, or "" before the first segment is written. */
|
|
157
|
+
playlist(): string {
|
|
158
|
+
try {
|
|
159
|
+
const text = readFileSync(join(this.dir, "index.m3u8"), "utf8");
|
|
160
|
+
return text.includes("#EXTINF") ? text : "";
|
|
161
|
+
} catch {
|
|
162
|
+
return "";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A segment's path, or "" when it is not there (any more). */
|
|
167
|
+
segment(name: string): string {
|
|
168
|
+
const safe = segmentName(name);
|
|
169
|
+
if (safe === "") return "";
|
|
170
|
+
const path = join(this.dir, safe);
|
|
171
|
+
try {
|
|
172
|
+
return statSync(path).isFile() ? path : "";
|
|
173
|
+
} catch {
|
|
174
|
+
return "";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
stop(): void {
|
|
179
|
+
if (this.stopped) return;
|
|
180
|
+
this.stopped = true;
|
|
181
|
+
if (this.idle) clearTimeout(this.idle);
|
|
182
|
+
this.idle = null;
|
|
183
|
+
this.detach?.();
|
|
184
|
+
this.detach = null;
|
|
185
|
+
const child = this.child;
|
|
186
|
+
this.child = null;
|
|
187
|
+
if (child && child.exitCode === null) {
|
|
188
|
+
try {
|
|
189
|
+
child.stdin?.end();
|
|
190
|
+
child.kill("SIGKILL");
|
|
191
|
+
} catch {
|
|
192
|
+
// Already gone.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
rmSync(this.dir, { recursive: true, force: true });
|
|
197
|
+
} catch {
|
|
198
|
+
// A directory that is already gone is fine.
|
|
199
|
+
}
|
|
200
|
+
this.onStop(this.id);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The packagers, one per channel that somebody is watching this way.
|
|
206
|
+
*
|
|
207
|
+
* `listen` is how a packager becomes a listener on its channel: it is handed
|
|
208
|
+
* the header and then every fragment, exactly as a browser would be.
|
|
209
|
+
*/
|
|
210
|
+
export class HlsPackagers {
|
|
211
|
+
private readonly running = new Map<string, Packager>();
|
|
212
|
+
|
|
213
|
+
constructor(
|
|
214
|
+
private readonly options: {
|
|
215
|
+
ffmpeg: string[];
|
|
216
|
+
listen: (id: string, listener: Packaged) => (() => void) | null;
|
|
217
|
+
onEvent?: (message: string) => void;
|
|
218
|
+
/** Injected for tests: how long to wait for the first playlist. */
|
|
219
|
+
firstPlaylistMs?: number;
|
|
220
|
+
},
|
|
221
|
+
) {}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The playlist for a channel, starting the packager if it is not running,
|
|
225
|
+
* and waiting for the first segments to exist. Null when the channel is not
|
|
226
|
+
* there or nothing could be packaged.
|
|
227
|
+
*/
|
|
228
|
+
async playlist(id: string): Promise<string | null> {
|
|
229
|
+
let packager = this.running.get(id);
|
|
230
|
+
if (!packager) {
|
|
231
|
+
packager = new Packager(id, this.options.ffmpeg, (gone) => this.running.delete(gone), this.options.onEvent ?? (() => undefined));
|
|
232
|
+
this.running.set(id, packager);
|
|
233
|
+
if (!packager.start((listener) => this.options.listen(id, listener))) return null;
|
|
234
|
+
}
|
|
235
|
+
packager.touch();
|
|
236
|
+
const deadline = Date.now() + (this.options.firstPlaylistMs ?? FIRST_PLAYLIST_MS);
|
|
237
|
+
for (;;) {
|
|
238
|
+
const text = packager.playlist();
|
|
239
|
+
if (text !== "") return text;
|
|
240
|
+
if (Date.now() > deadline || !this.running.has(id)) return null;
|
|
241
|
+
await new Promise((done) => setTimeout(done, 250));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A segment's path, or "" when there is no such segment. */
|
|
246
|
+
segment(id: string, name: string): string {
|
|
247
|
+
const packager = this.running.get(id);
|
|
248
|
+
if (!packager) return "";
|
|
249
|
+
packager.touch();
|
|
250
|
+
return packager.segment(name);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The channel went: stop packaging it. */
|
|
254
|
+
stop(id: string): void {
|
|
255
|
+
this.running.get(id)?.stop();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
stopAll(): void {
|
|
259
|
+
for (const packager of [...this.running.values()]) packager.stop();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
get count(): number {
|
|
263
|
+
return this.running.size;
|
|
264
|
+
}
|
|
265
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -51,6 +51,7 @@ import { stateDir } from "./daemon.ts";
|
|
|
51
51
|
import { readSession } from "./session.ts";
|
|
52
52
|
import { Directory, ENDED_TTL_MS, parseAnnouncement, type Listing } from "./directory.ts";
|
|
53
53
|
import { PartyLine, telnyxSms } from "./partyline.ts";
|
|
54
|
+
import { HlsPackagers, withKey } from "./hls.ts";
|
|
54
55
|
import {
|
|
55
56
|
contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, playableLink, resolveLink, saveFormat,
|
|
56
57
|
type ResolvedLink,
|
|
@@ -1163,6 +1164,8 @@ export interface HandlerOptions {
|
|
|
1163
1164
|
ffprobe?: string[];
|
|
1164
1165
|
/** yt-dlp, which turns a pasted page into a media address. Null when there is none. */
|
|
1165
1166
|
ytdlp?: string[] | null;
|
|
1167
|
+
/** Channels as HLS, for Safari on a phone, which plays a live stream no other way. */
|
|
1168
|
+
hls?: HlsPackagers;
|
|
1166
1169
|
/** A Netscape cookies file for sites that want a signed-in browser, when there is one. */
|
|
1167
1170
|
cookies?: string;
|
|
1168
1171
|
/** Who is listening, for the admin view. */
|
|
@@ -2913,9 +2916,54 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2913
2916
|
if (path.startsWith("/api/channels/") && options.channels) {
|
|
2914
2917
|
const channels = options.channels;
|
|
2915
2918
|
const rest = path.slice("/api/channels/".length);
|
|
2916
|
-
const [rawId, action] = rest.split("/");
|
|
2919
|
+
const [rawId, action, file] = rest.split("/");
|
|
2917
2920
|
const id = cleanId(rawId);
|
|
2918
2921
|
|
|
2922
|
+
// The same channel as HLS: a playlist of short files, which is what
|
|
2923
|
+
// Safari on an iPhone plays live -- it will not take the endless MP4
|
|
2924
|
+
// below, and spun on it a few times before giving up. Packaged on
|
|
2925
|
+
// demand, copying the fragments the browser would have got.
|
|
2926
|
+
if (action === "hls" && request.method === "GET") {
|
|
2927
|
+
if (!options.hls) {
|
|
2928
|
+
json(response, 503, { error: "this server cannot package HLS" });
|
|
2929
|
+
return;
|
|
2930
|
+
}
|
|
2931
|
+
if (!channels.has(id)) {
|
|
2932
|
+
json(response, 404, { error: "nothing is playing on that channel" });
|
|
2933
|
+
return;
|
|
2934
|
+
}
|
|
2935
|
+
if (file === "index.m3u8") {
|
|
2936
|
+
const playlist = await options.hls.playlist(id);
|
|
2937
|
+
if (playlist === null) {
|
|
2938
|
+
json(response, 503, { error: "that channel could not be packaged as HLS yet; try again in a moment" });
|
|
2939
|
+
return;
|
|
2940
|
+
}
|
|
2941
|
+
response.writeHead(200, {
|
|
2942
|
+
...CORS,
|
|
2943
|
+
"content-type": "application/vnd.apple.mpegurl",
|
|
2944
|
+
"cache-control": "no-store",
|
|
2945
|
+
});
|
|
2946
|
+
// The key rides on every segment line: a browser drops the query
|
|
2947
|
+
// when it resolves a segment against the playlist.
|
|
2948
|
+
response.end(withKey(playlist, url.searchParams.get("k") ?? ""));
|
|
2949
|
+
return;
|
|
2950
|
+
}
|
|
2951
|
+
const segment = options.hls.segment(id, file ?? "");
|
|
2952
|
+
if (segment === "") {
|
|
2953
|
+
json(response, 404, { error: "no such segment" });
|
|
2954
|
+
return;
|
|
2955
|
+
}
|
|
2956
|
+
watch(request, response, "stream", id);
|
|
2957
|
+
response.writeHead(200, {
|
|
2958
|
+
...CORS,
|
|
2959
|
+
"content-type": "video/mp2t",
|
|
2960
|
+
"cache-control": "no-store",
|
|
2961
|
+
"content-length": statSync(segment).size,
|
|
2962
|
+
});
|
|
2963
|
+
createReadStream(segment).pipe(response);
|
|
2964
|
+
return;
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2919
2967
|
if (action === undefined && request.method === "GET") {
|
|
2920
2968
|
// Listening. The response is the fan-out target: whatever ffmpeg
|
|
2921
2969
|
// produces for this channel is written to it until one end goes away.
|
|
@@ -4019,6 +4067,13 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
4019
4067
|
console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
|
|
4020
4068
|
onEnd: (info) => console.log(` "${info.id}" stopped.`),
|
|
4021
4069
|
});
|
|
4070
|
+
// Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
|
|
4071
|
+
// channel's fragments into short files while somebody is asking for them.
|
|
4072
|
+
const hls = new HlsPackagers({
|
|
4073
|
+
ffmpeg: tools.ffmpeg,
|
|
4074
|
+
listen: (id, listener) => channels.listen(id, listener),
|
|
4075
|
+
onEvent: (message) => console.log(message),
|
|
4076
|
+
});
|
|
4022
4077
|
|
|
4023
4078
|
// The channels this server was carrying when it was last stopped, put back
|
|
4024
4079
|
// on. A server is restarted to pick up a new version, which is often, and
|
|
@@ -4389,6 +4444,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
4389
4444
|
// for the sites that will not talk to a datacenter without one.
|
|
4390
4445
|
ytdlp: tools.ytdlp ?? null,
|
|
4391
4446
|
cookies: cookiesFile(),
|
|
4447
|
+
hls,
|
|
4392
4448
|
...(tls ? { tls } : {}),
|
|
4393
4449
|
// Untagged, so a directory of five thousand files answers at once; the
|
|
4394
4450
|
// tags follow through `tag` below.
|
|
@@ -4833,6 +4889,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
4833
4889
|
|
|
4834
4890
|
const shutdown = (): void => {
|
|
4835
4891
|
rtmp?.stop();
|
|
4892
|
+
hls.stopAll();
|
|
4836
4893
|
channels.stopAll();
|
|
4837
4894
|
ingest?.stopRtmp();
|
|
4838
4895
|
ingest?.close();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-g9xRD0Ln.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-DR8q_kGX.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-B0VjYPAC.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 ne=2048;function re(e,t){return e||t===`hls`||t===`mpegts`}var ie=class{elements;handlers;attached=null;source=``;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(ae(t))});let e=e=>()=>{t===this.active&&this.handlers.onBusy?.(e)};for(let n of[`loadstart`,`waiting`,`stalled`,`seeking`])t.addEventListener(n,e(!0));for(let n of[`playing`,`canplay`,`pause`,`ended`,`error`,`emptied`,`seeked`,`abort`])t.addEventListener(n,e(!1))}}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=ne,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){this.source=e.objectUrl?``:e.url;let n=e.objectUrl?e.video?`mp4`:`audio`:d({src:e.url}),r=re(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.source=``,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 ae(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 oe(){return{revision:0,tracks:[],trackCount:0,index:0,playing:!1,position:0,bars:[],levels:[0,0],silent:!0,note:``,root:``}}function se(e,t){return{...t,tracks:t.tracks??e.tracks}}function ce(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 x(e,t,n=``){let r=`${e===``?``:ce(e)}${t.startsWith(`/`)?t:`/${t}`}`;return n?`${r}${r.includes(`?`)?`&`:`?`}k=${encodeURIComponent(n)}`:r}function le(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=/^\/(?:admin|view|a|v)\/([^/]+)\/?$/.exec(n.pathname),i=r?.[1]??n.searchParams.get(`k`)??``;return r&&(n.pathname=`/`),n.searchParams.delete(`k`),{base:ce(`${n.origin}${n.pathname}`),key:decodeURIComponent(i)}}function S(e,t,n=0,r=``){return x(e,n>0?`/api/media/${t}?kbps=${Math.round(n)}`:`/api/media/${t}`,r)}function C(e){if(typeof e!=`object`||!e)return null;let t=e,n=oe(),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}:{},...typeof t.folder==`string`&&t.folder!==``?{folder:t.folder}:{},...t.remote===!0?{remote:!0}:{}}}):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 ue=class{handlers;source=null;base=``;key=``;shape=`/admin/`;lastRevision=-1;constructor(e){this.handlers=e}get address(){return this.base}url(e){return x(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}=le(e);this.close(),this.base=t,this.key=n,this.shape=/\/(?:view|v)\/[^/]+\/?$/.test(e.trim())?`/view/`:`/admin/`,this.lastRevision=-1,this.handlers.onStatus(`connecting`);let r=new EventSource(x(t,`/api/events`,n));this.source=r,r.onopen=()=>this.handlers.onStatus(`live`),r.onmessage=e=>{let t=C(de(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(x(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=C(await t.json());n&&this.handlers.onSnapshot(n)}media(e,t=0){return S(this.base,e,t,this.key)}close(){this.source?.close(),this.source=null}};function de(e){try{return JSON.parse(e)}catch{return null}}async function fe(e,t,n=``){try{let r=await fetch(x(e,`/api/state`,n),{signal:t});return r.ok?C(await r.json()):null}catch{return null}}function pe(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 me(e,t=``,n){let r;try{r=await fetch(x(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 with /admin/ or /view/ in it — 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 he(e,t,n=``){try{let r=await fetch(x(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 ge(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 w=.14,T=.02;function _e(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 ve(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 ye(e,t,n=w){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function be(e,t,n=T){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function xe(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 Se=`nixamp.remote`,Ce=`nixamp.volume`,we=`nixamp.listenHere`,Te=!1;function E(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function Ee(){let n={status:E(`status`),source:E(`source`),install:E(`install`),video:E(`video`),audio:E(`audio`),title:E(`title-line`),album:E(`album-line`),meta:E(`meta-line`),liveLine:E(`live-line`),downloadNow:E(`download-now`),linkForm:E(`link-form`),linkUrl:E(`link-url`),goLiveNow:E(`go-live-now`),elapsed:E(`elapsed`),total:E(`total`),seek:E(`seek`),fullscreen:E(`fullscreen`),copyNow:E(`copy-now`),canvas:E(`spectrum`),glyphs:E(`glyphs`),levels:E(`levels`),playlist:E(`playlist`),crumbs:E(`crumbs`),filter:E(`filter`),playlistTitle:E(`playlist-panel`),note:E(`note`),files:E(`files`),folder:E(`folder`),remoteUrl:E(`remote-url`),remoteForm:E(`remote-form`),remoteState:E(`remote-state`),disconnect:E(`disconnect`),browse:E(`browse`),accountForm:E(`account-form`),accountEmail:E(`account-email`),accountPassword:E(`account-password`),accountSubmit:E(`account-submit`),accountToggle:E(`account-toggle`),accountProviders:E(`account-providers`),accountPanel:E(`account-panel`),accountElsewhere:E(`account-elsewhere`),accountSignOut:E(`account-signout`),accountNote:E(`account-note`),adminPanel:E(`admin-panel`),adminNote:E(`admin-note`),adminSaid:E(`admin-said`),adminConnections:E(`admin-connections`),publishPanel:E(`publish-panel`),publishNote:E(`publish-note`),publishList:E(`publish-list`),adminRestream:E(`admin-restream`),adminReplace:E(`admin-replace`),adminSource:E(`admin-source`),adminName:E(`admin-name`),adminAdd:E(`admin-add`),homeNote:E(`home-note`),loadHome:E(`load-home`),directory:E(`directory`),recentNote:E(`recent-note`),recentList:E(`recent-list`),followingNote:E(`following-note`),followingList:E(`following-list`),serversPanel:E(`servers-panel`),serversNote:E(`servers-note`),serversList:E(`servers-list`),favoritesPanel:E(`favorites-panel`),favoritesNote:E(`favorites-note`),favoritesList:E(`favorites-list`),favHere:E(`fav-here`),catalogsPanel:E(`catalogs-panel`),catalogsNote:E(`catalogs-note`),catalogsForm:E(`catalogs-form`),catalogSource:E(`catalog-source`),catalogName:E(`catalog-name`),catalogsList:E(`catalogs-list`),catalogsCrumbs:E(`catalogs-crumbs`),catalogsFilter:E(`catalogs-filter`),catalogsEntries:E(`catalogs-entries`),notifyPanel:E(`notify-panel`),notifyNote:E(`notify-note`),notifyWeb:E(`notify-web`),notifyEmail:E(`notify-email`),notifySms:E(`notify-sms`),notifyPhone:E(`notify-phone`),notifyPhoneForm:E(`notify-phone-form`),notifyPhoneNote:E(`notify-phone-note`),directoryNote:E(`directory-note`),directoryList:E(`directory-list`),onairPanel:E(`onair-panel`),onairNote:E(`onair-note`),onairList:E(`onair-list`),sharePanel:E(`share-panel`),shareNote:E(`share-note`),shareLink:E(`share-link`),shareCopy:E(`share-copy`),sharePhone:E(`share-phone`),shareSend:E(`share-send`),liveControls:E(`live-controls`),goLive:E(`go-live`),stopLive:E(`stop-live`),shareTo:E(`share-to`),listenOnly:E(`listen-only`),listenHere:E(`listen-here`),volume:E(`volume`),prev:E(`prev`),playPause:E(`play-pause`),stop:E(`stop`),next:E(`next`)},r={live:`<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="2.5"/><path d="M8.5 15.5a5 5 0 0 1 0-7"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M5.6 18.4a9 9 0 0 1 0-12.8"/><path d="M18.4 5.6a9 9 0 0 1 0 12.8"/></svg>`,link:`<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/></svg>`,copy:`<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>`,restart:`<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 3v6h-6"/></svg>`,remove:`<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>`,check:`<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m5 12 5 5L20 7"/></svg>`},i=(e,t)=>{e.innerHTML=r[t]},a=document.title||`nixamp`,o=`local`,s=``,c=!1,l=``,u=0,d=``,f=[],p=0,m=oe(),h=`idle`,g=``,_=`Pick files, or connect to a nixamp running somewhere else.`,v=!1,y=-1,b=null,ne=0,re=0,ae=!1,ce=()=>re>0||ae;async function x(e){re+=1,I();try{return await e()}finally{--re,I()}}let S=null,C=null,de=!1,w=``,T=``,Ee=null,De=``,D=Array(24).fill(0),O=Array(24).fill(0),Oe=[],k=()=>o===`remote`&&!n.listenHere.checked,ke=()=>o===`remote`&&!n.adminPanel.hidden;function Ae(){try{if(localStorage.getItem(`nixamp.hls`)===`1`)return!0}catch{}return typeof MediaSource<`u`?!1:n.video.canPlayType(`application/vnd.apple.mpegurl`)!==``}function je(){if(o!==`remote`)return null;if(S?.catalog&&S.entry)return{kind:`entry`,catalog:S.catalog,entry:S.entry};if(b)return{kind:`channel`,id:b.id,name:b.name};let e=m.tracks[N()];return e&&(A.source!==``||k())?{kind:`track`,index:N(),name:t(e)}:null}async function Me(e,t){t.disabled=!0;let n=e.kind===`entry`?e.entry.title:e.name;_=`Putting ${n} on the air…`,I();try{let r=``;if(e.kind===`track`)await j.send({type:`play`,index:e.index}),r=`live`;else{let t=e.kind===`entry`?`/api/catalogs/${encodeURIComponent(e.catalog.id)}/entries/${encodeURIComponent(e.entry.id)}/live`:`/api/channels/${encodeURIComponent(e.id)}/keep`,i=await fetch(j.url(t),{method:`POST`}),a=await i.json().catch(()=>({}));if(!i.ok){_=a.error??`${n} would not go on the air.`,I();return}r=`channel:${e.kind===`entry`?a.channel??``:e.id}`}de?await fetch(j.url(`/api/live/start`),{method:`POST`}).catch(()=>void 0):await fn(!0),await X(),Z();let i=on(r),a=w?` Call ${T||`the line`} and key ${w} to talk about it.`:``;i===``?_=`${n} is on the air.${a}`:(await $(i,t,`✓`),_=`${n} is on the air. Link copied.${a}`),I()}catch{_=`could not reach the server`,I()}finally{t.disabled=!1}}function Ne(e,t){let n=document.createElement(`button`);return n.type=`button`,n.className=`row-copy row-live`,i(n,`live`),n.title=`Go live with ${t}: on the air for everyone, listed, link copied`,n.setAttribute(`aria-label`,`Go live with ${t}`),n.addEventListener(`click`,t=>{t.stopPropagation(),Me(e(),n)}),n}let A=new ie({audio:n.audio,video:n.video},{onTime:(e,t)=>{let n=f[p];o===`local`&&n&&t>0&&n.duration!==t&&(n.duration=t),I()},onEnded:()=>{nn()||o===`remote`&&y<0&&!k()||F(1)},onState:()=>I(),onBusy:e=>{ae!==e&&(ae=e,I())},onError:e=>{nn()||(_=e,I(),Qt())}}),j=new ue({onSnapshot:e=>{if(m=se(m,e),l.startsWith(`track:`)&&m.tracks.length>0){let e=Number(l.slice(6));if(l=``,Number.isInteger(e)&&e>=0&&e<m.tracks.length){let t=u;Be(e).then(()=>{t>0&&(A.seek(t),setTimeout(()=>A.seek(t),600))})}}k()&&(D=e.bars.length>0?e.bars:D,O=be(O,D)),I()},onStatus:(e,t)=>{h=e,g=t??``,I()}}),M=()=>o===`remote`?m.tracks.length:f.length,N=()=>o===`remote`?k()||y<0?m.index:Math.min(y,Math.max(0,m.tracks.length-1)):p,Pe=()=>{if(b)return b.name;let e=o===`remote`?m.tracks[N()]:f[N()];return e?t(e):`Nothing loaded.`},Fe=()=>b?`live on this server`:S?.kind===`live`&&o===`remote`?`live on ${s||`this server`}`:(o===`remote`?m.tracks[N()]:f[N()])?.album||`—`;function Ie(){if(o!==`remote`||b===null&&S?.kind!==`live`){n.liveLine.hidden=!0,n.liveLine.replaceChildren();return}let e=s||`this server`,t=b?b.name:C?.server.nowPlaying||Pe(),r=[b?`Live on ${e}: `:`Live from ${e}, now playing: `,pn(t)],i=b?C?.channels.find(e=>e.id===b?.id)?.code??``:de?w:``;i&&r.push(`. To talk about it, call `,pn(T||`the line`),` and key `,pn(i),`.`);let a=r.map(e=>typeof e==`string`?e:e.textContent).join(``);n.liveLine.dataset.drawn!==a&&(n.liveLine.dataset.drawn=a,n.liveLine.hidden=!1,n.liveLine.replaceChildren(...r.map(e=>typeof e==`string`?document.createTextNode(e):e)))}let Le=()=>k()?m.tracks[N()]?.duration??0:A.duration,Re=()=>k()?m.position:A.position,ze=()=>k()?m.playing:A.playing;async function P(e){if(o===`remote`){if(k()){await j.send({type:`play`,index:e});return}await Be(e);return}let t=f[e];t&&(p=e,b=null,S={kind:`file`},await x(()=>A.load(t,!0)),R(t.video),tt(),I())}async function Be(e){let t=m.tracks[e];t&&(y=e,b=null,S={kind:`file`},await x(()=>A.load({title:t.title,artist:t.artist,album:t.album,duration:t.duration,url:j.media(e,0),video:t.video===!0,objectUrl:!1},!0)),R(t.video===!0),tt())}async function Ve(){if(k()){await j.send({type:`toggle`});return}M()!==0&&(A.playing?A.pause():A.position>0?await A.play():await P(N()),I())}async function F(e){let t=M();if(t!==0){if(k()){await j.send({type:e>0?`next`:`prev`});return}await P((N()+e+t)%t)}}async function He(){if(k()){await j.send({type:`stop`});return}b=null,S=null,A.stop(),D=Array(24).fill(0),O=[...D],I()}let Ue=(e,t)=>`L${`▮`.repeat(Math.round(e*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(t*6)).padEnd(6,`·`)}`,We=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))],Ge=``;function Ke(){let t=[],r=``,i=b?C?.channels.find(e=>e.id===b?.id):void 0;if(A.source!==``||b||k()&&m.tracks[N()]){if(b?t.push(S?.entry?.live===!1?`ON DEMAND · LIVE CHANNEL`:`LIVE`):S?.kind===`vod`?t.push(`ON DEMAND`):S?.kind===`live`?t.push(`LIVE`):o===`remote`&&k()?t.push(`ON THE SERVER`):t.push(`FILE`),!n.video.hidden&&n.video.videoWidth>0?t.push(`${n.video.videoWidth}×${n.video.videoHeight}`):n.video.hidden?t.push(`audio`):t.push(`video`),i?(t.push(i.via===`pull`?`${i.listeners} watching`:`${i.listeners} listening · over ${i.via}`),i.startedAt>0&&t.push(`on air ${e(Math.max(0,(Date.now()-i.startedAt)/1e3))}`),i.redials&&t.push(`redialled ${i.redials}×`),ke()&&i.error&&t.push(i.error)):o===`remote`&&!b?(m.tracks[N()]&&M()>0&&t.push(`track ${N()+1} of ${M()}`),k()&&C&&t.push(`${C.server.playing?`playing`:`stopped`} on ${s||`the server`}`)):o===`local`&&M()>0&&t.push(`track ${N()+1} of ${M()}`),S?.catalog!==void 0&&(S.kind===`channel`?b!==null:S.kind===`vod`&&A.source!==``)&&S?.catalog&&(t.push(S.entry?.group?`${S.catalog.name} › ${S.entry.group}`:S.catalog.name),r=S.entry?.logo??``),b&&S?.link){let e=``;try{e=new URL(S.link.url).hostname.replace(/^www\./,``)}catch{}t.push(S.link.extractor&&S.link.extractor!==`direct`&&S.link.extractor!==`generic`?`${S.link.extractor} · ${e}`:e||`link`)}de&&w&&k()&&!b&&S?.kind!==`live`&&t.push(T?`☎ ${T} · key ${w}`:`☎ code ${w}`)}let a=`${r}|${t.join(`|`)}`;if(a===Ge)return;Ge=a,n.meta.hidden=t.length===0;let c=[];if(r!==``&&/^https?:\/\//.test(r)){let e=document.createElement(`img`);e.className=`meta-logo`,e.alt=``,e.src=r,e.addEventListener(`error`,()=>{e.hidden=!0}),c.push(e)}for(let e of t){let t=document.createElement(`span`);t.className=`meta-chip`,t.textContent=e,c.push(t)}n.meta.replaceChildren(...c)}function I(){let t=M(),r=ze(),i=ce();n.status.textContent=i?`LOADING`:r?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=i?`loading`:String(r),n.title.textContent=Pe(),Ie(),Ke(),n.goLiveNow.hidden=!ke()||je()===null;let c=r?`${Pe()} · ${a}`:a;document.title!==c&&(document.title=c),n.copyNow.hidden=A.source===``,n.downloadNow.hidden=!(b&&S?.link?.download),n.album.textContent=Fe();let l=Re(),u=Le();n.elapsed.textContent=e(l),n.total.textContent=u>0?e(u):`--:--`,v||(n.seek.value=String(u>0?Math.round(l/u*1e3):0),n.seek.disabled=u<=0||k()),n.playPause.textContent=r?`❚❚`:`▶`,n.playPause.setAttribute(`aria-label`,r?`Pause`:`Play`),n.playlistTitle.dataset.title=o===`remote`?`Files on ${s||`this server`} (${t.toLocaleString()})`:`Playlist (${t})`,n.source.textContent=o===`remote`?`connected · ${s||j.address.replace(/^https?:\/\//,``)||`—`}`:f.length>0?`local · ${f.length} files`:`no source`,n.remoteState.textContent=o===`remote`?`${h}${g?` — ${g}`:``}`:`not connected`,n.remoteState.dataset.status=o===`remote`?h:`idle`,n.disconnect.hidden=o!==`remote`;let d=o===`remote`&&m.note!==``?m.note:_;n.note.textContent=d,n.note.hidden=d===``,Ze(),n.glyphs.textContent=D.map(We).join(``);let[p,ee]=k()?m.levels:A.levels();n.levels.textContent=Ue(p,ee)}let qe=``,Je=-1,L=``;function Ye(e){if(n.crumbs.hidden=!e,!e)return;let t=L===``?[]:L.split(`/`),r=(e,t,n)=>{if(n){let t=document.createElement(`span`);return t.className=`here`,t.textContent=e,t}let r=document.createElement(`button`);return r.type=`button`,r.textContent=e,r.addEventListener(`click`,()=>{L=t,qe=``,Ze()}),r},i=[r(`All files`,``,t.length===0)],a=``;t.forEach((e,n)=>{a=a===``?e:`${a}/${e}`;let o=document.createElement(`span`);o.textContent=`/`,i.push(o,r(e,a,n===t.length-1))}),n.crumbs.replaceChildren(...i)}function Xe(e,t){let n=document.createElement(`li`);n.className=`folder`;let r=document.createElement(`span`);r.className=`name`,r.textContent=`${e}/`;let i=document.createElement(`span`);return i.className=`count`,i.textContent=`${t} file${t===1?``:`s`}`,n.append(r,i),n.addEventListener(`click`,()=>{L=L===``?e:`${L}/${e}`,qe=``,Ze()}),n}function Ze(){let r=o===`remote`?m.tracks.map(e=>({name:t(e),seconds:e.duration,group:e.group??``,folder:e.folder??``,remote:e.remote===!0})):f.map(e=>({name:t(e),seconds:e.duration,group:``,folder:``,remote:!1})),a=n.filter.value.trim().toLowerCase(),s=r.map((e,t)=>({...e,index:t})).filter(e=>!e.remote).filter(e=>a===``||`${e.folder}/${e.name}`.toLowerCase().includes(a)),c=e=>a!==``||L===``||e===L||e.startsWith(`${L}/`),l=e=>a!==``||e===L,u=e=>{let t=L===``?e:e.slice(L.length+1),n=t.indexOf(`/`);return n===-1?t:t.slice(0,n)},d=new Map;for(let e of s){if(!c(e.folder)||l(e.folder))continue;let t=u(e.folder);t!==``&&d.set(t,(d.get(t)??0)+1)}let p=s.filter(e=>l(e.folder)&&c(e.folder)),h=`${o}:${L}:${a}:${[...d].join(`,`)}:${p.map(e=>`${e.index}@${e.name}@${e.seconds}@${e.group}`).join(`|`)}`;if(h!==qe){qe=h,Ye(a===``&&([...d.keys()].length>0||L!==``));let t=[];for(let[e,n]of[...d].sort((e,t)=>e[0].localeCompare(t[0],void 0,{numeric:!0})))t.push(Xe(e,n));let r=``,s=p.some(e=>e.group!==``);for(let n of p){n.group!==r&&(s||n.group!==``)&&(r=n.group,t.push(Qe(n.group)));let a=document.createElement(`li`);a.className=`row`,a.dataset.index=String(n.index);let c=document.createElement(`span`);c.className=`n`,c.textContent=String(n.index+1).padStart(2,` `);let l=document.createElement(`span`);l.className=`name`,l.textContent=n.name;let u=document.createElement(`span`);if(u.className=`time`,u.textContent=n.seconds>0?e(n.seconds):`--:--`,a.append(c,l,u),o===`remote`){let e=document.createElement(`button`);e.type=`button`,e.className=`row-copy`,i(e,`copy`),e.title=`Copy a link that plays this here, from where it is`,e.setAttribute(`aria-label`,`Copy a link that plays ${n.name}`),e.addEventListener(`click`,t=>{t.stopPropagation(),$(on(`track:${n.index}`,y===n.index?A.position:0),e,`✓`)}),a.append(e),ke()&&a.append(Ne(()=>({kind:`track`,index:n.index,name:n.name}),n.name))}t.push(a)}n.playlist.replaceChildren(...t)}let g=N(),_=ze(),v;for(let e of Array.from(n.playlist.children)){let t=e,n=Number(t.dataset.index),r=Number.isInteger(n)&&n===g;t.classList.toggle(`selected`,r),t.classList.toggle(`playing`,r&&_),r&&(v=t)}g!==Je&&(Je=g,v?.scrollIntoView({block:`nearest`}))}function Qe(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(),$e(e)}),t.append(n)}return t}async function $e(e){try{let t=await fetch(j.url(`/api/source/remove`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({group:e})}),n=await t.json();B(t.ok?`Removed ${n.removed??0} tracks from ${e}.`:n.error??`that did not work`)}catch{B(`could not reach the server`)}}function et(){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 o=t.getContext(`2d`);if(k())O=be(O,D);else{let e=A.read();e.length>0&&(Oe.length!==25&&(Oe=_e(24,e.length)),D=ye(D,ve(e,Oe)),O=be(O,D))}if(o){let e=getComputedStyle(document.documentElement);xe(o,{width:t.width,height:t.height},D,O,{bar:e.getPropertyValue(`--green`).trim()||`#4af689`,peak:e.getPropertyValue(`--green-dim`).trim()||`#227a4a`,background:`transparent`})}if(ze()){n.glyphs.textContent=D.map(We).join(``);let[t,r]=k()?m.levels:A.levels();n.levels.textContent=Ue(t,r),n.elapsed.textContent=e(Re());let i=Le();!v&&i>0&&(n.seek.value=String(Math.round(Re()/i*1e3)))}requestAnimationFrame(et)}function R(e){n.video.hidden=!e,n.fullscreen.hidden=!e}function tt(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:Pe(),album:Fe(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void Ve()),navigator.mediaSession.setActionHandler(`pause`,()=>void Ve()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void F(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void F(-1)))}n.filter.addEventListener(`input`,()=>{qe=``,Ze()}),n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&P(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 F(-1)),n.next.addEventListener(`click`,()=>void F(1)),n.stop.addEventListener(`click`,()=>void He()),n.playPause.addEventListener(`click`,()=>void Ve());async function nt(e){if(o!==`remote`){_=`Connect to a server first: it fetches the link and plays it to you.`,I();return}_=`Reading ${e}…`,await x(async()=>{let t,n={};try{t=await fetch(j.url(`/api/links/play`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({url:e})}),n=await t.json().catch(()=>({}))}catch{_=`could not reach the server`;return}if(!t.ok||!n.channel){_=n.error??`that link would not play.`;return}await tn({id:n.channel,name:n.name||e,video:n.video!==!1},!0,{kind:`channel`,link:{url:e,extractor:n.extractor??``,download:n.download===!0,live:n.live===!0,video:n.video!==!1}})}),I()}n.linkForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.linkUrl.value.trim();t!==``&&nt(t)}),n.downloadNow.addEventListener(`click`,()=>{let e=S?.link;if(!e||o!==`remote`)return;let t=e.video?``:`&audio=1`;globalThis.open(j.url(`/api/links/download?url=${encodeURIComponent(e.url)}${t}`),`_blank`),_=`Fetching it through the server; your browser will save it when it arrives.`,I()}),n.goLiveNow.addEventListener(`click`,()=>{let e=je();e&&Me(e,n.goLiveNow)}),n.seek.addEventListener(`input`,()=>{v=!0}),n.seek.addEventListener(`change`,()=>{let e=Le();e>0&&A.seek(Number(n.seek.value)/1e3*e),v=!1}),n.volume.addEventListener(`input`,()=>{let e=Number(n.volume.value)/100;A.volume=e;try{localStorage.setItem(Ce,String(e))}catch{}});let rt=e=>{e.addEventListener(`change`,()=>{let t=ee(Array.from(e.files??[]));if(t.length===0){_=`Nothing playable in that selection.`,I();return}te(f),f=t,p=0,o=`local`,j.close(),_=``,P(0)})};rt(n.files),rt(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.remoteUrl.value,{base:r,key:i}=le(t);if(r===``){_=`That is not an address.`,I();return}(async()=>{h=`connecting`,I();let e=ge(r);if(e){h=`error`,g=e,_=e,o=`local`,I();return}if(await he(r,void 0,i)===null){h=`error`;let e=pe(r);g=e?`needs the server's name`:`not answering`,_=e||`Nothing answered at ${r}. If that is your machine, it is off or nixamp is not running on it; otherwise check the address.`,o=`local`,I();return}let n=await me(r,i);if(n){h=`error`,g=n,_=n,o=`local`,I();return}o=`remote`,_=``;try{localStorage.setItem(Se,t.trim())}catch{}j.connect(t),Z(),Tt(),Zt(!0),ft(),X(),I()})()});let it=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??[],ht(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} ${e.length===1?`server is`:`servers are`} on. Connect to one to browse its files and watch what is live on it. No account needed.`;for(let t of e){let e=document.createElement(`li`),r=document.createElement(`span`);r.className=`server-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=t.name;let a=document.createElement(`span`);a.className=`detail`;let o=[`${t.tracks.toLocaleString()} files to browse`];t.playing!==!1&&t.nowPlaying?o.push(`playing ${t.nowPlaying}`):o.push(`player idle`),t.code&&o.push(t.callers?`☎ ${t.code} · ${t.callers} on the phone`:`☎ ${t.code}`),a.textContent=o.join(` · `),r.append(i,a);let s=!!t.admin,u=(e,r=``)=>{c=e,l=r,n.remoteUrl.value=e?t.url:t.admin??t.url,n.directory.hidden=!0,n.remoteForm.requestSubmit()},d=document.createElement(`ul`);d.className=`server-lives`;for(let e of t.channels??[]){let n=document.createElement(`li`),r=document.createElement(`span`);r.className=`detail live`;let i=t.channelCodes?.[e]??``,a=t.channelCallers?.[e]??0;r.textContent=`● ${e}`+(i?` · ☎ ${i}${a?` · ${a} on the phone`:``}`:``);let o=document.createElement(`button`);o.type=`button`,o.className=`button`,o.textContent=`Play`,o.title=`Watch ${e}, live on ${t.name}`,o.addEventListener(`click`,()=>u(!0,`channel:${e}`)),n.append(r,o),d.append(n)}let f=document.createElement(`button`);f.type=`button`,f.className=`button`,f.textContent=`Viewer`,f.title=`Browse and watch. Changes nothing on the server.`,f.addEventListener(`click`,()=>u(!0));let p=document.createElement(`button`);if(p.type=`button`,p.className=`button`,p.textContent=`Admin`,p.disabled=!s,p.title=s?`Drive this server: what plays, what is live, what is on it.`:Y?`You do not administer this server.`:`Sign in as this server's owner to administer it.`,p.addEventListener(`click`,()=>u(!1)),e.append(r,f,p),Y&&e.append(vt(t.url,t.name)),t.ownerId&&Y&&t.ownerId!==Y&&e.append(Rt(t.ownerId,t.name)),d.childElementCount>0&&e.append(d),t.ownerId&&Y&&t.ownerId===Y){let r=document.createElement(`button`);r.type=`button`,r.className=`ghost`,r.textContent=`Take off the list`,r.addEventListener(`click`,e=>{e.stopPropagation(),r.disabled=!0,(async()=>{try{let e=await fetch(`/api/directory?id=${encodeURIComponent(t.id)}`,{method:`DELETE`}),r=await e.json().catch(()=>({}));n.directoryNote.textContent=e.ok?`${t.name} is off the list.`:r.error??`that did not work`}catch{n.directoryNote.textContent=`could not reach the directory`}finally{await it()}})()}),e.append(r)}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),it()}let z=null,at=e=>e===`cgnat`?`mobile or tailscale`:e===`private`?`your network`:e===`local`?`this machine`:e===`public`?`the internet`:e,ot=e=>e===`events`?`watching the panel`:e===`page`?`opened the page`:e===`media`?`playing a track`:e===`stream`?`listening live`:e,st=``,ct=``,lt=e=>{let t=e.map(e=>`${e.address}|${e.kind}|${e.track}|${Math.round(e.bytes/4096)}|${e.endedAt}`).join(`~`);if(t===st)return;st=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,``],[at(t.network),`network-${t.network}`],[ot(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 B(e){n.adminSaid.textContent=e,n.adminSaid.hidden=e===``}let ut=async()=>{try{let e=await fetch(j.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.`,lt(t.connections??[]),dt(t.publish??[],(t.channels??[]).map(e=>e.id)),dn(t.home??``,t.root??``),Z()}catch{n.adminNote.textContent=`lost touch with the server`}};function dt(e,t){n.publishPanel.hidden=e.length===0;let r=`${e.map(e=>`${e.id}=${e.url}`).join(`~`)}::${t.join(`,`)}`;if(r===ct)return;if(ct=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 ft=async()=>{if(o!==`remote`){n.adminPanel.hidden=!0,n.publishPanel.hidden=!0,z&&clearInterval(z),z=null;return}let e=!1,t=null,r=!1;try{let n=await fetch(j.url(`/api/admin`));if(n.ok){let i=await n.json();e=i.allowed===!0,t=i.as??null,r=i.claimed===!0}}catch{e=!1}if(c&&(e=!1),n.adminPanel.hidden=!e,z&&clearInterval(z),z=null,Z(),Tt(),X(),n.listenOnly.hidden=e,!e){n.listenOnly.textContent=r?`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.`,ut(),z=setInterval(()=>void ut(),2e3)};function pt(e,t,r){let i=(t||e).toLowerCase().replace(/[^a-z0-9_-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,40)||`s${Math.random().toString(16).slice(2,8)}`;B(`Starting ${t||e}…`),(async()=>{try{let a=await fetch(j.url(`/api/channels/${encodeURIComponent(i)}/pull`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({...r===void 0?{source:e}:{at:r},...t?{name:t}:{}})}),o=await a.json();if(!a.ok){B(o.error??`that did not work`);return}B(`${o.channel?.name||t||e} is on the air.`),n.adminSource.value=``,n.adminName.value=``,X(),Z()}catch{B(`could not reach the server`)}})()}n.adminRestream.addEventListener(`submit`,e=>{e.preventDefault();let t=n.adminSource.value.trim();t&&pt(t,n.adminName.value.trim())}),n.adminAdd.addEventListener(`click`,()=>{let e=n.adminSource.value.trim();if(!e)return;B(`Reading ${e}…`);let t=n.adminReplace.checked,r=n.adminName.value.trim();(async()=>{try{let i=await fetch(j.url(`/api/source`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({source:e,...r?{name:r}:{},...t?{replace:!0}:{}})}),a=await i.json();B(i.ok?t?`Now serving ${e}.`:a.added===0?`Everything there was already in the playlist.`:`Added ${a.added??0} tracks from ${e}.`:a.error??`that did not work`),i.ok&&(n.adminSource.value=``,n.adminName.value=``,X(),Z())}catch{B(`could not reach the server`)}})()});let mt=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`},ht=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 ${mt(e.endedAt)}`:`ended ${mt(e.endedAt)}`,r.append(i,a),t.append(r,Rt(e.ownerId,e.name)),n.recentList.append(t)}},V=new Set,H=e=>{try{return new URL(e).origin}catch{return e}},U=e=>[...V].some(t=>H(t)===H(e));async function gt(){if(!Y){V=new Set,n.favoritesPanel.hidden=!0,yt();return}try{let e=await fetch(`/api/v1/favorites`);if(!e.ok){n.favoritesPanel.hidden=!0;return}let t=(await e.json()).favorites??[];V=new Set(t.map(e=>e.url)),n.favoritesPanel.hidden=t.length===0,n.favoritesNote.textContent=`Servers you hearted. Connect to one, or let it go.`,n.favoritesList.replaceChildren(...t.map(e=>{let t=document.createElement(`li`),r=document.createElement(`span`);r.className=`server-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name||e.url.replace(/^https?:\/\//,``);let a=document.createElement(`span`);a.className=e.live?`detail live`:`detail`,a.textContent=e.live?[`● on now`,e.nowPlaying?`playing ${e.nowPlaying}`:``,e.channels.length>0?`live: ${e.channels.join(`, `)}`:``].filter(Boolean).join(` · `):`not on right now`,r.append(i,a);let o=document.createElement(`button`);return o.type=`button`,o.className=`button`,o.textContent=`Connect`,o.addEventListener(`click`,()=>{n.remoteUrl.value=e.url,n.remoteForm.requestSubmit()}),t.append(r,o,vt(e.url,e.name)),t}))}catch{n.favoritesPanel.hidden=!0}yt()}async function _t(e,t,n){try{if(!(n?await fetch(`/api/v1/favorites`,{method:`PUT`,headers:{"content-type":`application/json`},body:JSON.stringify({url:e,name:t})}):await fetch(`/api/v1/favorites?url=${encodeURIComponent(e)}`,{method:`DELETE`})).ok){_=n?`Could not save that favourite.`:`Could not remove that favourite.`,I();return}}catch{_=`could not reach nixamp.com`,I();return}if(n)V.add(e);else for(let t of[...V])H(t)===H(e)&&V.delete(t);await gt()}function vt(e,t){let n=document.createElement(`button`);n.type=`button`,n.className=`heart`;let r=()=>{let t=U(e);n.textContent=t?`♥`:`♡`,n.dataset.on=t?`yes`:`no`,n.title=t?`Remove from favourites`:`Add to favourites`,n.setAttribute(`aria-label`,n.title)};return r(),n.addEventListener(`click`,n=>{n.stopPropagation();let i=[...V].find(t=>H(t)===H(e))??e;_t(U(e)?i:e,t,!U(e)).then(r)}),n}function yt(){let e=o===`remote`?j.shareLink:``;if(n.favHere.hidden=!(Y&&e),n.favHere.hidden)return;let t=U(e);n.favHere.textContent=t?`♥`:`♡`,n.favHere.dataset.on=t?`yes`:`no`,n.favHere.title=t?`Remove this server from your favourites`:`Add this server to your favourites`,n.favHere.setAttribute(`aria-label`,n.favHere.title)}i(n.copyNow,`copy`),n.copyNow.addEventListener(`click`,()=>{$(A.source,n.copyNow,`✓`)}),n.favHere.addEventListener(`click`,()=>{let e=sn()||j.shareLink;if(!e)return;let t=[...V].find(t=>H(t)===H(e))??e;_t(U(e)?t:e,s||j.address,!U(e)).then(yt)});let W=[],G=null,K=null,bt=``,q=[],xt=0,St=null,Ct=0;function wt(e){if(!e)return`never`;let t=Math.max(0,Math.round((Date.now()-e)/1e3));if(t<90)return`just now`;let n=Math.round(t/60);if(n<90)return`${n} min ago`;let r=Math.round(n/60);return r<36?`${r} h ago`:`${Math.round(r/24)} d ago`}async function Tt(){if(o!==`remote`){n.catalogsPanel.hidden=!0;return}let e;try{e=await fetch(j.url(`/api/catalogs`))}catch{n.catalogsPanel.hidden=!0;return}if(!e.ok){n.catalogsPanel.hidden=!0;return}W=(await e.json().catch(()=>({}))).catalogs??[],G&&=W.find(e=>e.id===G?.id)??null,G||(K=null),n.catalogsPanel.hidden=!1,Et()}function Et(){let e=!n.adminPanel.hidden;n.catalogsForm.hidden=!e;let t=W.reduce((e,t)=>e+t.live,0),r=W.reduce((e,t)=>e+t.vod,0);n.catalogsNote.textContent=W.length===0?e?`No catalogs yet. Add an m3u list of channels or films.`:`No catalogs yet.`:`${W.length} ${W.length===1?`catalog`:`catalogs`} · ${t} live ${t===1?`channel`:`channels`} · ${r} on demand`,Dt();let i=G!==null&&K!==null;if(n.catalogsList.hidden=i,n.catalogsFilter.hidden=!i,n.catalogsEntries.hidden=!i,i){Mt();return}if(G){kt(G);return}n.catalogsList.replaceChildren(...W.map(t=>Ot(t,e)))}function Dt(){let e=G!==null;if(n.catalogsCrumbs.hidden=!e,!e)return;let t=(e,t,n)=>{if(n){let t=document.createElement(`span`);return t.className=`here`,t.textContent=e,t}let r=document.createElement(`button`);return r.type=`button`,r.textContent=e,r.addEventListener(`click`,t),r},r=()=>{let e=document.createElement(`span`);return e.textContent=`/`,e},i=[t(`All catalogs`,()=>{G=null,K=null,Et()},!1),r(),t(G?.name??``,()=>{K=null,Et()},K===null)];K!==null&&i.push(r(),t(K===``?`All groups`:K,()=>void 0,!0)),n.catalogsCrumbs.replaceChildren(...i)}function Ot(e,t){let n=document.createElement(`li`),r=document.createElement(`span`);r.className=`server-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name;let a=document.createElement(`span`);if(a.className=`detail`,a.textContent=[`${e.entries.toLocaleString()} ${e.entries===1?`entry`:`entries`}`,`${e.live.toLocaleString()} live`,`${e.vod.toLocaleString()} on demand`,`refreshed ${wt(e.refreshedAt)}`].join(` · `),r.append(i,a),t&&e.error){let t=document.createElement(`span`);t.className=`detail`,t.textContent=e.error,r.append(t)}let o=document.createElement(`button`);if(o.type=`button`,o.className=`button`,o.textContent=`Browse`,o.addEventListener(`click`,()=>{G=e,K=null,Et()}),n.append(r,o),t){let t=document.createElement(`button`);t.type=`button`,t.className=`ghost`,t.textContent=`Refresh`,t.title=`Read the list again`,t.addEventListener(`click`,()=>{Pt(e)});let r=document.createElement(`button`);r.type=`button`,r.className=`ghost`,r.textContent=`Remove`,r.title=`Take this catalog off the server`,r.addEventListener(`click`,()=>{confirm(`Remove ${e.name} from this server?`)&&Ft(e)}),n.append(t,r)}return n}async function kt(e){n.catalogsList.replaceChildren();let t=[];try{let n=await x(()=>fetch(j.url(`/api/catalogs/${encodeURIComponent(e.id)}/groups`)));if(!n.ok)throw Error(String(n.status));t=(await n.json()).groups??[]}catch{_=`Could not read the groups in ${e.name}.`,I();return}if(G?.id!==e.id||K!==null)return;let r=[At(`All groups`,``,e.entries,e.live,e.vod),...t.map(e=>At(e.name||`(no group)`,e.name,e.count,e.live,e.vod))];n.catalogsList.replaceChildren(...r)}function At(e,t,r,i,a){let o=document.createElement(`li`),s=document.createElement(`span`);s.className=`server-label`;let c=document.createElement(`span`);c.className=`name`,c.textContent=e;let l=document.createElement(`span`);l.className=`detail`,l.textContent=`${r.toLocaleString()} · ${i.toLocaleString()} live · ${a.toLocaleString()} on demand`,s.append(c,l);let u=document.createElement(`button`);return u.type=`button`,u.className=`button`,u.textContent=`Open`,u.addEventListener(`click`,()=>{K=t,bt=``,n.catalogsFilter.value=``,q=[],xt=0,Et(),jt(0)}),o.append(s,u),o}async function jt(e){let t=G,n=K;if(!t||n===null)return;let r=++Ct,i=new URLSearchParams({group:n,q:bt,offset:String(e),limit:`200`}),a;try{let e=await x(()=>fetch(j.url(`/api/catalogs/${encodeURIComponent(t.id)}/entries?${i}`)));if(!e.ok)throw Error(String(e.status));a=await e.json()}catch{_=`Could not read ${t.name}.`,I();return}r===Ct&&(xt=a.total??0,q=e===0?a.entries??[]:[...q,...a.entries??[]],Mt())}function Mt(){let t=G;if(!t)return;let r=q.map(n=>{let r=document.createElement(`li`);r.className=`row`;let a=document.createElement(`span`);a.className=`name`,a.textContent=n.title;let o=document.createElement(`span`);if(o.className=n.live?`catalog-tag catalog-live`:`catalog-tag`,o.textContent=n.live?`LIVE`:n.duration>0?e(n.duration):`VOD`,r.append(a,o),!n.live){let e=document.createElement(`button`);e.type=`button`,e.className=`row-copy`,i(e,`copy`),e.title=`Copy this entry's URL`,e.setAttribute(`aria-label`,`Copy the URL of ${n.title}`),e.addEventListener(`click`,r=>{r.stopPropagation();let i=`/api/catalogs/${encodeURIComponent(t.id)}/entries/${encodeURIComponent(n.id)}/stream`;$(j.url(i),e,`✓`)}),r.append(e)}return ke()&&r.append(Ne(()=>({kind:`entry`,catalog:{id:t.id,name:t.name},entry:n}),n.title)),r.addEventListener(`click`,()=>{Nt(t,n,r)}),r});if(q.length===0){let e=document.createElement(`li`);e.className=`group`;let t=document.createElement(`span`);t.className=`group-name`,t.textContent=bt?`Nothing called "${bt}" here.`:`Nothing in this group.`,e.append(t),r.push(e)}else if(q.length<xt){let e=document.createElement(`li`);e.className=`group`;let t=document.createElement(`button`);t.type=`button`,t.className=`ghost`,t.textContent=`Show more (${q.length.toLocaleString()} of ${xt.toLocaleString()})`,t.addEventListener(`click`,e=>{e.stopPropagation(),jt(q.length)}),e.append(t),r.push(e)}n.catalogsEntries.replaceChildren(...r)}async function Nt(e,t,n){n?.classList.add(`loading`),_=`Starting ${t.title}…`;let r={catalog:{id:e.id,name:e.name},entry:t};try{await x(async()=>{let n,i={};try{n=await fetch(j.url(`/api/catalogs/${encodeURIComponent(e.id)}/entries/${encodeURIComponent(t.id)}/play`),{method:`POST`}),i=await n.json().catch(()=>({}))}catch{_=`could not reach the server`;return}if(!n.ok){_=i.error??`${t.title} would not play.`;return}let a=i.name||t.title;if(i.kind===`live`&&i.channel){await tn({id:i.channel,name:a,video:!0},!0,{kind:`channel`,...r});return}if(i.kind===`vod`&&i.url){b=null,y=-1,S={kind:`vod`,...r},await A.load({title:a,artist:``,album:``,duration:0,url:j.url(i.url),video:!0,objectUrl:!1},!0),R(!0),_=`Playing ${a}.`;return}_=`${t.title} would not play.`})}finally{n?.classList.remove(`loading`),I()}}async function Pt(e){B(`Reading ${e.name} again…`);try{let t=await fetch(j.url(`/api/catalogs/${encodeURIComponent(e.id)}/refresh`),{method:`POST`}),n=await t.json().catch(()=>({}));B(t.ok?`${n.catalog?.name??e.name}: ${(n.catalog?.entries??0).toLocaleString()} entries.`:n.error??`that did not work`)}catch{B(`could not reach the server`)}Tt()}async function Ft(e){try{B((await fetch(j.url(`/api/catalogs/${encodeURIComponent(e.id)}`),{method:`DELETE`})).ok?`${e.name} is off the server.`:`that did not work`)}catch{B(`could not reach the server`)}G?.id===e.id&&(G=null,K=null),Tt()}n.catalogsForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.catalogSource.value.trim(),r=n.catalogName.value.trim();t&&(async()=>{B(`Reading ${r||t}…`);try{let e=await fetch(j.url(`/api/catalogs`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({source:t,name:r})}),i=await e.json().catch(()=>({}));if(!e.ok){B(i.error??`that did not work`);return}B(`${i.catalog?.name??r??t}: ${(i.catalog?.entries??0).toLocaleString()} entries.`),n.catalogSource.value=``,n.catalogName.value=``}catch{B(`could not reach the server`)}Tt()})()}),n.catalogsFilter.addEventListener(`input`,()=>{St&&clearTimeout(St),St=setTimeout(()=>{St=null,bt=n.catalogsFilter.value.trim(),jt(0)},250)});let It=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}/admin/${e.key}`:e.url,n.remoteForm.requestSubmit()}),he(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 It()}catch{s.disabled=!1}})()}),t.append(r,o,s),n.serversList.append(t)}}catch{n.serversPanel.hidden=!0}},Lt=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}},Rt=(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),Lt())}catch{}finally{n.disabled=!1}})()}),n},zt=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},Bt=()=>`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window,Vt=async()=>{if(!Bt())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:zt(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}},Ht=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{}},Ut=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`}},Wt=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=Bt()&&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 Vt();n.notifyWeb.checked=e,await Ut({wantsWeb:e});return}await Ht(),await Ut({wantsWeb:!1}),n.notifyNote.textContent=`Turn on “On this device” to be told here.`})()}),n.notifyEmail.addEventListener(`change`,()=>{Ut({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 Ut({wantsSms:n.notifySms.checked})})()}),n.notifyPhoneForm.addEventListener(`submit`,e=>{e.preventDefault(),Ut({phone:n.notifyPhone.value.trim()})});let J=!1,Y=``,Gt=e=>{let t=e!==null;n.notifyPanel.hidden=!t,t?(Wt(),Lt(),It()):(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.`:`Listening needs no account. Sign in to keep favourites, follow people, and publish.`,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`},Kt=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)}},qt=async()=>{try{let e=await fetch(`/api/v1/auth/me`),t=await e.json();Y=e.ok?t.account?.id??``:``,Gt(e.ok?t.account?.email??`you`:null)}catch{Y=``,Gt(null)}gt(),Jt()};function Jt(){if(De===``)return;let e=De;De=``,n.remoteUrl.value=e,n.remoteForm.requestSubmit()}n.accountToggle.addEventListener(`click`,()=>{J=!J,Gt(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=``,Gt(i.account?.email??t),ft(),Jt()}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=``,Gt(null),j.close(),y=-1,o=`local`,h=`idle`,g=``,n.remoteUrl.value=``,n.sharePanel.hidden=!0,n.publishPanel.hidden=!0,n.adminPanel.hidden=!0,n.onairPanel.hidden=!0,n.catalogsPanel.hidden=!0,n.listenOnly.hidden=!0,Zt(!1);try{localStorage.removeItem(Se)}catch{}_=`Signed out, and disconnected from the server.`,ft(),I()})()});try{let e=new URL(globalThis.location.href).searchParams,t=e.get(`url`)??``;t!==``&&(De=t,l=e.get(`play`)??``,u=Math.max(0,Number(e.get(`t`)??`0`)||0),n.remoteUrl.value=t,_=`Opening the stream you were sent…`,globalThis.history?.replaceState(null,``,globalThis.location.pathname))}catch{}Kt(),qt(),ft(),n.browse.addEventListener(`click`,()=>{if(!n.directory.hidden){n.directory.hidden=!0;return}it(),n.directory.scrollIntoView({behavior:`smooth`,block:`nearest`})}),n.disconnect.addEventListener(`click`,()=>{j.close(),n.listenOnly.hidden=!0,y=-1,n.sharePanel.hidden=!0,n.publishPanel.hidden=!0,n.adminPanel.hidden=!0,n.onairPanel.hidden=!0,n.onairPanel.dataset.title=`Live on this server`,n.catalogsPanel.hidden=!0,n.catalogsPanel.dataset.title=`Catalogs on this server`,s=``,d=``,yt(),Zt(!1),o=`local`,h=`idle`,g=``,I()});async function X(){if(o!==`remote`||j.shareLink===``){n.sharePanel.hidden=!0;return}n.sharePanel.hidden=!1;let e=sn(),t=globalThis.location.origin;n.shareLink.value=e===``?``: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 r=``;try{let e=await fetch(`/api/directory`);e.ok&&(r=(await e.json()).callIn??``)}catch{}let i=null;try{let r=await fetch(j.url(`/api/live/state`));r.ok&&(i=await r.json()),i?.url&&(e=i.url,d=i.url,n.shareLink.value=e.startsWith(`https://`)?`${t}/?url=${encodeURIComponent(e)}`:e)}catch{}if(i){if(de=i.live,w=i.live?i.code:``,T=r,n.liveControls.hidden=n.adminPanel.hidden||!i.possible,n.goLive.hidden=i.live,n.stopLive.hidden=!i.live,n.sharePhone.hidden=!1,!i.live){n.sharePhone.textContent=i.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(!r){n.sharePhone.textContent=`Listed. The code for the phone line is ${i.code}.`,n.shareSend.hidden=!1;return}n.sharePhone.replaceChildren(document.createTextNode(`To talk about it, call `),pn(r),document.createTextNode(` and key `),pn(i.code),document.createTextNode(`. That is a room with everyone else watching — not the stream itself.`)),n.shareSend.hidden=!1}}let Yt=``,Xt=null,Zt=e=>{Xt&&clearInterval(Xt),Xt=null,e&&(Xt=setInterval(()=>void Z(),6e3))};async function Z(){if(o!==`remote`){n.onairPanel.hidden=!0;return}let e;try{let t=await fetch(j.url(`/api/streams`));if(!t.ok){n.onairPanel.hidden=!0;return}e=await t.json(),e.server.name&&e.server.name!==s&&(s=e.server.name,n.onairPanel.dataset.title=`Live on ${s}`,n.catalogsPanel.dataset.title=`Catalogs on ${s}`,yt(),I())}catch{n.onairPanel.hidden=!0;return}n.onairPanel.hidden=!1,C=e,cn(e);let t=`${n.adminPanel.hidden?`view`:`drive`}:${JSON.stringify(e)}`;if(t===Yt)return;Yt=t;let r=e.restreams??[],i=e.channels.length+r.length;n.onairNote.textContent=i===0?`One stream, from this server's own files.`:`${i+1} streams: this server's own files, and ${i} more on it.`;let a=[],c=e.server.playing,l=!n.adminPanel.hidden;a.push(ln({title:e.server.name,detail:[c?`playing ${e.server.nowPlaying}`:e.server.nowPlaying?`stopped on ${e.server.nowPlaying}`:`nothing loaded`,`${e.server.tracks} track${e.server.tracks===1?``:`s`}`,e.server.code?`☎ ${e.server.code}`:`not listed`].join(` · `),playLabel:c?`Join live`:l?`Start the stream`:`Nothing playing`,onPlay:()=>{if(c){en(e.server.nowPlaying);return}l&&$t()},link:e.server.live?e.server.url:``,...c?{page:on(`live`)}:{},direct:c?j.url(`/api/live`):``}));for(let e of r)a.push(ln({title:e.name,detail:e.tracks===1?`re-streamed from the web`:`re-streamed from the web · ${e.tracks} tracks`,onPlay:()=>{P(e.at)},link:``,direct:j.media(e.at)}));for(let t of e.channels){let e=t.kind!==`audio`,n=j.url(`/api/channels/${encodeURIComponent(t.id)}`),r=[t.via===`pull`?`on the air · ${t.listeners} watching`:`live over ${t.via} · ${t.listeners} listening`];t.code&&r.push(`☎ ${t.code}`),t.redials&&r.push(`redialled ${t.redials}×`),l&&t.error&&r.push(t.error),a.push(ln({title:t.name,detail:r.join(` · `),onPlay:()=>{tn({id:t.id,name:t.name,video:e})},link:n,page:on(`channel:${t.id}`),direct:n,onRestart:l&&t.via===`pull`?()=>{rn(t.id,t.name)}:void 0,onStop:l?()=>{an(t.id,t.name)}:void 0}))}n.onairList.replaceChildren(...a)}async function Qt(){if(o===`remote`)try{if((await fetch(j.media(N(),0),{method:`GET`,headers:{range:`bytes=0-1`}})).status!==402)return;_=Y===``?`This stream is busy enough to be charging for. Sign in to nixamp.com to pay for a pass.`:`This stream is charging for a pass. Follow the payment prompt to keep listening.`,Y===``&&n.accountPanel.scrollIntoView({behavior:`smooth`,block:`center`}),I()}catch{}}async function $t(){B(`Starting the stream on the server…`);try{await j.send({type:`play`,index:Math.max(0,N())})}catch{B(`could not reach the server`);return}await en(m.tracks[N()]?.title??``),B(`Playing to the room. Anybody with the view link sees this.`),await Z()}async function en(e){y=-1,b=null,S={kind:`live`},await x(()=>A.load({title:e||`Live`,artist:``,album:``,duration:0,url:j.url(`/api/live`),video:!0,objectUrl:!1},!0)),R(!0),_=`Watching what this server is playing. Everyone here sees the same thing.`,I()}async function tn(e,t=!0,n){y=-1,b=e,t&&(ne=0),t&&(S=n??{kind:`channel`});let r=e.video&&Ae();await x(()=>A.load({title:e.name,artist:``,album:``,duration:0,url:j.url(r?`/api/channels/${encodeURIComponent(e.id)}/hls/index.m3u8`:`/api/channels/${encodeURIComponent(e.id)}`),video:e.video,objectUrl:!1},!0)),R(e.video),b===e&&(_=`Watching ${e.name}, live on this server.`),I()}function nn(){let e=b;return e?Ee?!0:ne>=5?(_=`${e.name} stopped, and did not come back.`,b=null,S=null,I(),!0):(ne+=1,_=`${e.name} started over; rejoining…`,I(),Ee=setTimeout(()=>{Ee=null,b===e&&tn(e,!1)},2e3),!0):!1}function Q(e){n.onairNote.textContent=e,B(e)}async function rn(e,t){Q(`Restarting ${t}…`);try{let n=await fetch(j.url(`/api/channels/${encodeURIComponent(e)}/restart`),{method:`POST`}),r=await n.json().catch(()=>({}));Q(n.ok?`${t} is dialling its source again.`:r.error??`that did not work`)}catch{Q(`could not reach the server`)}Yt=``,Z()}async function an(e,t){Q(`Taking ${t} off the air…`);try{let n=await fetch(j.url(`/api/channels/${encodeURIComponent(e)}`),{method:`DELETE`}),r=await n.json().catch(()=>({}));Q(n.ok?`${t} is off the air.`:r.error??`that did not work`)}catch{Q(`could not reach the server`)}b?.id===e&&(b=null,A.stop()),Yt=``,Z()}async function $(e,t,n=`Copied`){if(!e)return;let r=t.innerHTML;try{await navigator.clipboard.writeText(e)}catch{_=e,I();return}n===`✓`||n===`✓`?i(t,`check`):t.textContent=n,setTimeout(()=>{t.innerHTML=r},1200)}function on(e,t=0){let n=sn();if(n===``)return``;let r=globalThis.location.origin,i=t>1?`&t=${Math.floor(t)}`:``;return`${r}/?url=${encodeURIComponent(n)}&play=${encodeURIComponent(e)}${i}`}function sn(){if(d!==``)return d;let e=o===`remote`?j.shareLink:``;return/\/admin\//.test(e)?``:e}function cn(e){if(l===``)return;let t=l;if(t===`live`){l=``,e.server.playing?en(e.server.nowPlaying):(_=`Nothing is playing on this server right now.`,I());return}if(t.startsWith(`link:`)){l=``,nt(t.slice(5));return}let n=t.startsWith(`channel:`)?t.slice(8):``,r=e.channels.find(e=>e.id===n)??e.channels.find(e=>e.name===n);r&&(l=``,tn({id:r.id,name:r.name,video:r.kind!==`audio`}))}function ln(e){let t=document.createElement(`li`);t.className=`onair`;let n=document.createElement(`span`);n.className=`recent-label`;let r=document.createElement(`span`);r.className=`name`,r.textContent=e.title;let a=document.createElement(`span`);a.className=`detail`,a.textContent=e.detail,n.append(r,a);let o=document.createElement(`span`);o.className=`onair-actions`;let s=document.createElement(`button`);s.type=`button`,s.className=`button`,s.textContent=e.playLabel??`Play`,s.addEventListener(`click`,e.onPlay),o.append(s);let c=(e,t,n)=>{let r=document.createElement(`button`);return r.type=`button`,r.className=`icon`,i(r,e),r.title=t,r.setAttribute(`aria-label`,t),r.addEventListener(`click`,()=>n(r)),r};return(e.link||e.page)&&o.append(c(`link`,`Copy a link that opens this in the player`,t=>{let n=globalThis.location.origin;$(e.page??(e.link.startsWith(`https://`)?`${n}/?url=${encodeURIComponent(e.link)}`:e.link),t,`✓`)})),e.direct&&o.append(c(`copy`,`Copy the stream's own URL, for VLC or mpv`,t=>{$(e.direct??``,t,`✓`)})),e.onRestart&&o.append(c(`restart`,`Restart: dial the source again`,()=>e.onRestart?.())),e.onStop&&o.append(c(`remove`,`Remove: take it off the air`,()=>e.onStop?.())),t.append(n,o),t}let un=``;function dn(e,t){un=e;let r=e!==``&&t===e;n.loadHome.hidden=e===``,n.homeNote.hidden=e===``,e!==``&&(n.homeNote.textContent=r?`This server's own files: ${e}`:`This server's own files are ${e}, and are not in the playlist.`,n.loadHome.disabled=!1)}n.loadHome.addEventListener(`click`,()=>{un!==``&&(n.loadHome.disabled=!0,B(`Reading this server's files…`),(async()=>{try{let e=await fetch(j.url(`/api/source`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({source:un})}),t=await e.json();B(e.ok?t.added===0?`This server's files are already in the playlist.`:`Loaded ${t.added??0} of this server's own files.`:t.error??`that did not work`)}catch{B(`could not reach the server`)}finally{n.loadHome.disabled=!1}})())});let fn=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(j.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 X()}};n.goLive.addEventListener(`click`,()=>void fn(!0)),n.stopLive.addEventListener(`click`,()=>void fn(!1));function pn(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:sn()})}),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(we,n.listenHere.checked?`1`:`0`)}catch{}o===`remote`&&(async()=>{n.listenHere.checked?(await j.send({type:`stop`}),await Be(m.index)):(A.stop(),y=-1),I()})()}),document.addEventListener(`keydown`,e=>{let t=e.target;if(!(t&&/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))switch(e.key){case` `:e.preventDefault(),Ve();return;case`s`:He();return;case`n`:case`ArrowRight`:F(1);return;case`p`:case`ArrowLeft`:F(-1);return;case`ArrowDown`:e.preventDefault(),P(Math.min(M()-1,N()+1));return;case`ArrowUp`:e.preventDefault(),P(Math.max(0,N()-1));return}});let mn=null;globalThis.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),mn=e,n.install.hidden=!1}),n.install.addEventListener(`click`,()=>{mn?.prompt(),mn=null,n.install.hidden=!0});try{let e=localStorage.getItem(Ce);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),A.volume=Number(e));let t=localStorage.getItem(Se);t&&(n.remoteUrl.value=t),localStorage.getItem(we)===`0`&&(n.listenHere.checked=!1)}catch{}(async()=>{if(n.remoteUrl.value!==``)return;let e=globalThis.location.origin;if(await he(e)===null)return;let t=await fe(e);t&&t.trackCount!==0&&(n.remoteUrl.value=e,o=`remote`,_=``,j.connect(e),I())})(),(()=>{if(Te||De!==``)return;let e=()=>A.source!==``||A.playing||ce()||b!==null,t=async()=>{try{let e=await(await fetch(`/jingles/index.json`)).json();if(Array.isArray(e)&&e.length>0){let t=e[Math.floor(Math.random()*e.length)];if(typeof t==`string`)return`/jingles/${t}`}}catch{}return``},n=new Audio;n.volume=.7;let r=()=>{Te=!0},i=()=>{document.removeEventListener(`pointerdown`,i),document.removeEventListener(`keydown`,i),r(),setTimeout(()=>{e()||n.play().catch(()=>{})},150)};t().then(t=>{if(!(t===``||e()))return n.src=t,n.play().then(r,()=>{document.addEventListener(`pointerdown`,i,{once:!0}),document.addEventListener(`keydown`,i,{once:!0})})})})(),I(),requestAnimationFrame(et)}Ee(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});export{c as t};
|