nixamp 0.9.12 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audio.d.ts +3 -1
- package/dist/audio.js +11 -3
- package/dist/channels.d.ts +2 -2
- package/dist/channels.js +7 -3
- package/dist/directory.d.ts +30 -2
- package/dist/directory.js +55 -7
- package/dist/links.d.ts +62 -0
- package/dist/links.js +0 -0
- package/dist/server.d.ts +15 -1
- package/dist/server.js +159 -3
- package/dist/share.js +4 -2
- package/package.json +1 -1
- package/src/audio.ts +13 -3
- package/src/channels.ts +7 -2
- package/src/directory.ts +61 -8
- package/src/links.ts +0 -0
- package/src/server.ts +176 -3
- package/src/share.ts +4 -2
- package/web/dist/assets/{hls-3VKVEQE3-Dpc8MBeo.js → hls-3VKVEQE3-5T3M0hCY.js} +1 -1
- package/web/dist/assets/index-Bqujphpd.js +1 -0
- package/web/dist/assets/{index-dAV-kQL5.css → index-D9uHBJcW.css} +1 -1
- package/web/dist/assets/{mpegts-Coyqtirc.js → mpegts-DeEbMNtT.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CiVqI2jo.js → mpegts-LO6RVLD6-DUwEL0l2.js} +1 -1
- package/web/dist/index.html +13 -2
- package/web/dist/install.sh +23 -0
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-BeB8GvFX.js +0 -1
package/src/channels.ts
CHANGED
|
@@ -219,7 +219,7 @@ export class Channel {
|
|
|
219
219
|
* because you looked away, and a room where the picture depends on who is
|
|
220
220
|
* in it is not a room anybody can be invited to.
|
|
221
221
|
*/
|
|
222
|
-
pull(source: string, encode: string[], paced = true, stall = STALL): void {
|
|
222
|
+
pull(source: string, encode: string[], paced = true, stall = STALL, input: string[] = []): void {
|
|
223
223
|
this.stall = stall;
|
|
224
224
|
if (this.info.kind === "video") this.fragments = new Fragments();
|
|
225
225
|
const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
|
|
@@ -247,6 +247,10 @@ export class Channel {
|
|
|
247
247
|
// hour of film in ninety seconds and a room that cannot be in it
|
|
248
248
|
// together; a live source is already paced and loses nothing.
|
|
249
249
|
...(paced ? ["-re"] : []),
|
|
250
|
+
// What the source's site expects on the request: a user agent, a
|
|
251
|
+
// referer, a cookie. A link resolved by yt-dlp comes with these,
|
|
252
|
+
// and a CDN that got them from yt-dlp and not from us answers 403.
|
|
253
|
+
...input,
|
|
250
254
|
"-i", source,
|
|
251
255
|
...encode,
|
|
252
256
|
"pipe:1",
|
|
@@ -612,6 +616,7 @@ export class Channels {
|
|
|
612
616
|
kind: "audio" | "video",
|
|
613
617
|
paced = true,
|
|
614
618
|
stall = STALL,
|
|
619
|
+
input: string[] = [],
|
|
615
620
|
): Channel | null {
|
|
616
621
|
if (this.open.has(id)) return null;
|
|
617
622
|
const channel = new Channel(
|
|
@@ -630,7 +635,7 @@ export class Channels {
|
|
|
630
635
|
(gone) => this.open.delete(gone),
|
|
631
636
|
);
|
|
632
637
|
this.open.set(id, channel);
|
|
633
|
-
channel.pull(source, encode, paced, stall);
|
|
638
|
+
channel.pull(source, encode, paced, stall, input);
|
|
634
639
|
return channel;
|
|
635
640
|
}
|
|
636
641
|
|
package/src/directory.ts
CHANGED
|
@@ -73,6 +73,12 @@ export interface Listing {
|
|
|
73
73
|
playing: boolean;
|
|
74
74
|
/** The live channels on it, by name: what a visitor could actually watch. */
|
|
75
75
|
channels: string[];
|
|
76
|
+
/**
|
|
77
|
+
* A phone code for each of those channels, by name: its own room, so the
|
|
78
|
+
* people calling about one live are not put in with the people calling
|
|
79
|
+
* about another on the same server.
|
|
80
|
+
*/
|
|
81
|
+
channelCodes: Record<string, string>;
|
|
76
82
|
/** Set by the directory from the request, never by the publisher. */
|
|
77
83
|
updatedAt: number;
|
|
78
84
|
/** When this stream first announced itself: the "started at" a caller hears. */
|
|
@@ -283,6 +289,11 @@ export class Directory {
|
|
|
283
289
|
// listing always meant, and no channels is the honest empty list.
|
|
284
290
|
playing: announcement.playing ?? true,
|
|
285
291
|
channels: announcement.channels ?? [],
|
|
292
|
+
channelCodes: this.codesFor(
|
|
293
|
+
announcement.channels ?? [],
|
|
294
|
+
existing?.channelCodes ?? (previously && "channelCodes" in previously ? previously.channelCodes : undefined),
|
|
295
|
+
id,
|
|
296
|
+
),
|
|
286
297
|
updatedAt: this.now(),
|
|
287
298
|
// A stream that never stopped keeps its original start. One that did
|
|
288
299
|
// starts again now, because that is what a caller is being told about.
|
|
@@ -305,10 +316,55 @@ export class Directory {
|
|
|
305
316
|
return [...this.items.values()].sort((a, b) => b.updatedAt - a.updatedAt || a.id.localeCompare(b.id));
|
|
306
317
|
}
|
|
307
318
|
|
|
308
|
-
/**
|
|
319
|
+
/**
|
|
320
|
+
* The live stream on this code, if there is one.
|
|
321
|
+
*
|
|
322
|
+
* A channel's code answers as the channel: the same listing, named for the
|
|
323
|
+
* channel and playing nothing else, so the phone line says "the live room
|
|
324
|
+
* for FIBA World Cup" rather than the server's name and whatever its own
|
|
325
|
+
* player has on.
|
|
326
|
+
*/
|
|
309
327
|
liveByCode(code: string): Listing | undefined {
|
|
310
328
|
this.sweep();
|
|
311
|
-
|
|
329
|
+
const own = [...this.items.values()].find((item) => item.code === code);
|
|
330
|
+
if (own !== undefined) return own;
|
|
331
|
+
for (const item of this.items.values()) {
|
|
332
|
+
const channel = Object.entries(item.channelCodes).find(([, one]) => one === code)?.[0];
|
|
333
|
+
if (channel !== undefined) return { ...item, code, name: channel, nowPlaying: "" };
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* A code for each channel, kept while the channel stays on.
|
|
340
|
+
*
|
|
341
|
+
* One code per server meant every live on it shared a room: somebody
|
|
342
|
+
* calling about the basketball landed with the people talking about the
|
|
343
|
+
* film. Each live is its own room now, and a channel that is still there at
|
|
344
|
+
* the next heartbeat keeps the code it was given.
|
|
345
|
+
*/
|
|
346
|
+
private codesFor(channels: string[], had: Record<string, string> | undefined, own = ""): Record<string, string> {
|
|
347
|
+
const codes: Record<string, string> = {};
|
|
348
|
+
for (const name of channels) {
|
|
349
|
+
if (codes[name] !== undefined) continue;
|
|
350
|
+
const kept = had?.[name];
|
|
351
|
+
codes[name] = kept !== undefined && !this.taken(kept, codes, own) ? kept : this.freeCode(codes, own);
|
|
352
|
+
}
|
|
353
|
+
return codes;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Whether a code is somebody's already: a stream's own, a channel's on any
|
|
358
|
+
* stream, or a recently-ended stream's. A listing renewing itself is not
|
|
359
|
+
* "somebody else", or its channels would be re-coded every heartbeat.
|
|
360
|
+
*/
|
|
361
|
+
private taken(code: string, besides: Record<string, string> = {}, own = ""): boolean {
|
|
362
|
+
if (Object.values(besides).includes(code)) return true;
|
|
363
|
+
for (const item of this.items.values()) {
|
|
364
|
+
if (item.code === code) return true;
|
|
365
|
+
if (item.id !== own && Object.values(item.channelCodes).includes(code)) return true;
|
|
366
|
+
}
|
|
367
|
+
return [...this.ended.values()].some((i) => i.code === code);
|
|
312
368
|
}
|
|
313
369
|
|
|
314
370
|
/**
|
|
@@ -372,15 +428,12 @@ export class Directory {
|
|
|
372
428
|
this.mirror?.save(record);
|
|
373
429
|
}
|
|
374
430
|
|
|
375
|
-
/** A code no live and no recently-ended stream is using. */
|
|
376
|
-
private freeCode(): string {
|
|
431
|
+
/** A code no live stream, no channel on one, and no recently-ended stream is using. */
|
|
432
|
+
private freeCode(besides: Record<string, string> = {}, own = ""): string {
|
|
377
433
|
for (let tries = 0; tries < 40; tries += 1) {
|
|
378
434
|
const code = this.randomCode();
|
|
379
435
|
if (code.length !== 6) continue;
|
|
380
|
-
|
|
381
|
-
[...this.items.values()].some((i) => i.code === code) ||
|
|
382
|
-
[...this.ended.values()].some((i) => i.code === code);
|
|
383
|
-
if (!taken) return code;
|
|
436
|
+
if (!this.taken(code, besides, own)) return code;
|
|
384
437
|
}
|
|
385
438
|
return "";
|
|
386
439
|
}
|
package/src/links.ts
ADDED
|
Binary file
|
package/src/server.ts
CHANGED
|
@@ -51,6 +51,10 @@ 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 {
|
|
55
|
+
contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, playableLink, resolveLink, saveFormat,
|
|
56
|
+
type ResolvedLink,
|
|
57
|
+
} from "./links.ts";
|
|
54
58
|
import { CALL_IN_NUMBER, OPT_IN_PATH, optInPage } from "./optin.ts";
|
|
55
59
|
import pg from "pg";
|
|
56
60
|
import { Follows, phoneFrom } from "./follows.ts";
|
|
@@ -978,15 +982,50 @@ export async function pullChannel(
|
|
|
978
982
|
id: string,
|
|
979
983
|
name: string,
|
|
980
984
|
source: string,
|
|
985
|
+
input: string[] = [],
|
|
981
986
|
): Promise<Channel | null> {
|
|
982
|
-
const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source);
|
|
987
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source, input);
|
|
983
988
|
const kind = codecs.video === "" ? "audio" : "video";
|
|
984
989
|
const encode = kind === "video"
|
|
985
990
|
? videoArgs(codecs)
|
|
986
991
|
// No picture in it, so none is invented: MP3 is the thing every browser
|
|
987
992
|
// plays and the thing a listener can join halfway through.
|
|
988
993
|
: ["-vn", "-c:a", "libmp3lame", "-b:a", "192k", "-f", "mp3"];
|
|
989
|
-
return channels.pull(id, name, source, encode, kind);
|
|
994
|
+
return channels.pull(id, name, source, encode, kind, true, undefined, input);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** What a link resolves to, kept so a download can be named without asking twice. */
|
|
998
|
+
const links = new Map<string, ResolvedLink>();
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* A Netscape cookies file beside the state, if the operator has put one there.
|
|
1002
|
+
*
|
|
1003
|
+
* YouTube and Vimeo refuse a datacenter without a signed-in cookie; the
|
|
1004
|
+
* person who runs the server can export one from their browser and drop it
|
|
1005
|
+
* at ~/.local/state/nixamp/cookies.txt, and every link is asked for with it.
|
|
1006
|
+
*/
|
|
1007
|
+
export function cookiesFile(): string {
|
|
1008
|
+
const path = join(stateDir(), "cookies.txt");
|
|
1009
|
+
try {
|
|
1010
|
+
return statSync(path).isFile() ? path : "";
|
|
1011
|
+
} catch {
|
|
1012
|
+
return "";
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/** What the page is told about a link it asked to play. */
|
|
1017
|
+
function shownLink(channelId: string, link: ResolvedLink) {
|
|
1018
|
+
return {
|
|
1019
|
+
kind: "live",
|
|
1020
|
+
channel: channelId,
|
|
1021
|
+
name: link.title,
|
|
1022
|
+
live: link.live,
|
|
1023
|
+
video: link.video,
|
|
1024
|
+
duration: link.duration,
|
|
1025
|
+
extractor: link.extractor,
|
|
1026
|
+
// A live has no whole to keep; a bare file can be fetched by the browser itself.
|
|
1027
|
+
download: !link.live && link.extractor !== "direct",
|
|
1028
|
+
};
|
|
990
1029
|
}
|
|
991
1030
|
|
|
992
1031
|
export function liveOnes(engine: Engine): { name: string; at: number; tracks: number }[] {
|
|
@@ -1122,6 +1161,10 @@ export interface HandlerOptions {
|
|
|
1122
1161
|
ffmpeg?: string[];
|
|
1123
1162
|
/** Where ffprobe is, for asking what is inside a file before re-encoding it. */
|
|
1124
1163
|
ffprobe?: string[];
|
|
1164
|
+
/** yt-dlp, which turns a pasted page into a media address. Null when there is none. */
|
|
1165
|
+
ytdlp?: string[] | null;
|
|
1166
|
+
/** A Netscape cookies file for sites that want a signed-in browser, when there is one. */
|
|
1167
|
+
cookies?: string;
|
|
1125
1168
|
/** Who is listening, for the admin view. */
|
|
1126
1169
|
connections?: Connections;
|
|
1127
1170
|
/**
|
|
@@ -1177,7 +1220,11 @@ export interface HandlerOptions {
|
|
|
1177
1220
|
* and starting it. It is an action now, because that is what it is.
|
|
1178
1221
|
*/
|
|
1179
1222
|
live?: {
|
|
1180
|
-
status: () => {
|
|
1223
|
+
status: () => {
|
|
1224
|
+
live: boolean; code: string; name: string; url: string; possible: boolean;
|
|
1225
|
+
/** A phone code per live channel, by name, as the directory assigned them. */
|
|
1226
|
+
channelCodes?: Record<string, string>;
|
|
1227
|
+
};
|
|
1181
1228
|
start: () => Promise<{ live: boolean; code: string; name: string; url: string; error?: string }>;
|
|
1182
1229
|
stop: () => Promise<void>;
|
|
1183
1230
|
/**
|
|
@@ -2363,6 +2410,11 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2363
2410
|
const streams = options.directory.list().map(({ admin, ...stream }) => ({
|
|
2364
2411
|
...stream,
|
|
2365
2412
|
callers: onThePhone ? onThePhone.listenersOn(stream.code) : 0,
|
|
2413
|
+
// And on the phone for each live on it, by name: every live is its
|
|
2414
|
+
// own room, so each has its own count.
|
|
2415
|
+
channelCallers: Object.fromEntries(
|
|
2416
|
+
Object.entries(stream.channelCodes).map(([name, code]) => [name, onThePhone ? onThePhone.listenersOn(code) : 0]),
|
|
2417
|
+
),
|
|
2366
2418
|
...(admin && me !== null && stream.ownerId === me.id ? { admin } : {}),
|
|
2367
2419
|
}));
|
|
2368
2420
|
// Recently ended too, because following exists to hear about
|
|
@@ -2533,6 +2585,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2533
2585
|
// Whether it has a picture, so the page puts it in the element
|
|
2534
2586
|
// that can show one. Never the source: that is the owner's.
|
|
2535
2587
|
kind: one.kind ?? "audio",
|
|
2588
|
+
// Its own room on the phone line, as the directory assigned it;
|
|
2589
|
+
// empty until the next heartbeat has told the directory it is on.
|
|
2590
|
+
code: state?.channelCodes?.[one.name] ?? "",
|
|
2536
2591
|
// How it has been going, for whoever may do something about it.
|
|
2537
2592
|
redials: one.redials ?? 0,
|
|
2538
2593
|
error: one.error ?? "",
|
|
@@ -2721,6 +2776,118 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2721
2776
|
return;
|
|
2722
2777
|
}
|
|
2723
2778
|
|
|
2779
|
+
// --- any link, played -------------------------------------------------
|
|
2780
|
+
//
|
|
2781
|
+
// Paste a page -- YouTube, a podcast, SoundCloud, a TikTok live -- and the
|
|
2782
|
+
// server works out where the media is and plays it as a channel of its
|
|
2783
|
+
// own, the way a catalog entry is played: started for whoever asked,
|
|
2784
|
+
// stopped a minute after the last viewer leaves. Open to anyone holding
|
|
2785
|
+
// the link, like picking something from a catalog.
|
|
2786
|
+
if (path === "/api/links/play" && request.method === "POST") {
|
|
2787
|
+
let body: { url?: unknown } = {};
|
|
2788
|
+
try {
|
|
2789
|
+
body = JSON.parse(await readBody(request)) as typeof body;
|
|
2790
|
+
} catch {
|
|
2791
|
+
json(response, 400, { error: "bad JSON" });
|
|
2792
|
+
return;
|
|
2793
|
+
}
|
|
2794
|
+
const link = playableLink(body.url);
|
|
2795
|
+
if (link === "") {
|
|
2796
|
+
json(response, 400, { error: "that is not a link this can play" });
|
|
2797
|
+
return;
|
|
2798
|
+
}
|
|
2799
|
+
if (!options.channels) {
|
|
2800
|
+
json(response, 503, { error: "this server cannot carry channels" });
|
|
2801
|
+
return;
|
|
2802
|
+
}
|
|
2803
|
+
const channelId = linkChannelId(link);
|
|
2804
|
+
const known = links.get(link);
|
|
2805
|
+
if (options.channels.has(channelId) && known) {
|
|
2806
|
+
json(response, 200, shownLink(channelId, known));
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2809
|
+
if (!options.channels.has(channelId) && options.channels.ephemeralCount >= MAX_ON_DEMAND) {
|
|
2810
|
+
json(response, 429, { error: `this server is already carrying ${MAX_ON_DEMAND} channels on demand; try again in a minute` });
|
|
2811
|
+
return;
|
|
2812
|
+
}
|
|
2813
|
+
const resolved = known ?? await resolveLink(options.ytdlp ?? null, link, { cookies: options.cookies ?? "" });
|
|
2814
|
+
if ("error" in resolved) {
|
|
2815
|
+
json(response, 422, { error: resolved.error });
|
|
2816
|
+
return;
|
|
2817
|
+
}
|
|
2818
|
+
links.set(link, resolved);
|
|
2819
|
+
if (!options.channels.has(channelId)) {
|
|
2820
|
+
const started = await pullChannel(
|
|
2821
|
+
options.channels, options.ffprobe ?? ["ffprobe"], channelId, resolved.title, resolved.media,
|
|
2822
|
+
inputArgsFor(resolved.headers),
|
|
2823
|
+
);
|
|
2824
|
+
if (!started) {
|
|
2825
|
+
json(response, 409, { error: "that link is already starting" });
|
|
2826
|
+
return;
|
|
2827
|
+
}
|
|
2828
|
+
options.channels.ephemeral(channelId);
|
|
2829
|
+
}
|
|
2830
|
+
json(response, 200, shownLink(channelId, resolved));
|
|
2831
|
+
return;
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
// The whole thing, to keep. The server fetches it through yt-dlp and
|
|
2835
|
+
// hands the bytes straight on as a download, so the person's own machine
|
|
2836
|
+
// ends up with the file and the server keeps nothing. A live stream has
|
|
2837
|
+
// no whole to hand over.
|
|
2838
|
+
if (path === "/api/links/download") {
|
|
2839
|
+
const link = playableLink(url.searchParams.get("url"));
|
|
2840
|
+
if (link === "") {
|
|
2841
|
+
json(response, 400, { error: "that is not a link this can fetch" });
|
|
2842
|
+
return;
|
|
2843
|
+
}
|
|
2844
|
+
if (!options.ytdlp || options.ytdlp.length === 0) {
|
|
2845
|
+
json(response, 503, { error: "this server has no yt-dlp to fetch with" });
|
|
2846
|
+
return;
|
|
2847
|
+
}
|
|
2848
|
+
const resolved = links.get(link) ?? await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "" });
|
|
2849
|
+
if ("error" in resolved) {
|
|
2850
|
+
json(response, 422, { error: resolved.error });
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2853
|
+
links.set(link, resolved);
|
|
2854
|
+
if (resolved.live) {
|
|
2855
|
+
json(response, 409, { error: "that is live; there is no whole file to download yet" });
|
|
2856
|
+
return;
|
|
2857
|
+
}
|
|
2858
|
+
const audioOnly = url.searchParams.get("audio") === "1" || !resolved.video;
|
|
2859
|
+
// Named for the format the download will actually take, which is not
|
|
2860
|
+
// always the one played: a track played from an HLS playlist is saved
|
|
2861
|
+
// as the plain MP3 the site also offers, and ".m4a" on an MP3 is a
|
|
2862
|
+
// file nothing will open.
|
|
2863
|
+
const saved = await resolveLink(options.ytdlp, link, { cookies: options.cookies ?? "", format: saveFormat(audioOnly) });
|
|
2864
|
+
const fileName = fileNameFor("error" in saved ? resolved : { ...resolved, ext: saved.ext || resolved.ext }, audioOnly);
|
|
2865
|
+
const [command, ...prefix] = options.ytdlp as [string, ...string[]];
|
|
2866
|
+
const child = spawn(command, [...prefix, ...downloadArgs(link, audioOnly, options.cookies ?? "")], {
|
|
2867
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2868
|
+
});
|
|
2869
|
+
response.writeHead(200, {
|
|
2870
|
+
"content-type": contentTypeFor(fileName),
|
|
2871
|
+
"content-disposition": `attachment; filename="${fileName.replace(/["\\]/g, "")}"; filename*=UTF-8''${encodeURIComponent(fileName)}`,
|
|
2872
|
+
"cache-control": "no-store",
|
|
2873
|
+
});
|
|
2874
|
+
child.stdout?.pipe(response);
|
|
2875
|
+
let complaint = "";
|
|
2876
|
+
child.stderr?.on("data", (chunk: Buffer) => {
|
|
2877
|
+
if (complaint.length < 20_000) complaint += chunk.toString("utf8");
|
|
2878
|
+
});
|
|
2879
|
+
child.on("error", () => response.end());
|
|
2880
|
+
child.on("close", (code) => {
|
|
2881
|
+
if (code !== 0) console.log(` a download of ${link} failed: ${complaint.split("\n").filter((one) => one.startsWith("ERROR")).pop() ?? code}`);
|
|
2882
|
+
response.end();
|
|
2883
|
+
});
|
|
2884
|
+
// The person closed the tab: stop fetching what nobody will keep.
|
|
2885
|
+
response.on("close", () => {
|
|
2886
|
+
if (child.exitCode === null) child.kill("SIGKILL");
|
|
2887
|
+
});
|
|
2888
|
+
return;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2724
2891
|
// A channel is one publisher and everybody listening to them. Two or three
|
|
2725
2892
|
// devices can publish at once, each to their own channel, and a listener
|
|
2726
2893
|
// picks which to hear.
|
|
@@ -4163,6 +4330,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
4163
4330
|
status: () => ({
|
|
4164
4331
|
live: publisher !== null,
|
|
4165
4332
|
code: listing?.code ?? "",
|
|
4333
|
+
channelCodes: listing?.channelCodes ?? {},
|
|
4166
4334
|
name: listing?.name ?? (options.name || hostname()),
|
|
4167
4335
|
url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey, false) : ""),
|
|
4168
4336
|
// Whether going live is even possible here. A laptop behind a router
|
|
@@ -4210,6 +4378,11 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
4210
4378
|
paywall,
|
|
4211
4379
|
ffmpeg: tools.ffmpeg,
|
|
4212
4380
|
ffprobe: tools.ffprobe,
|
|
4381
|
+
// For a pasted link: where its media is, and the whole of it to keep.
|
|
4382
|
+
// A cookie jar beside the state, when the operator has put one there,
|
|
4383
|
+
// for the sites that will not talk to a datacenter without one.
|
|
4384
|
+
ytdlp: tools.ytdlp ?? null,
|
|
4385
|
+
cookies: cookiesFile(),
|
|
4213
4386
|
...(tls ? { tls } : {}),
|
|
4214
4387
|
// Untagged, so a directory of five thousand files answers at once; the
|
|
4215
4388
|
// tags follow through `tag` below.
|
package/src/share.ts
CHANGED
|
@@ -354,8 +354,10 @@ export function allowedForListening(path: string): boolean {
|
|
|
354
354
|
if (path === "/api/source" || path.startsWith("/api/source/")) return false;
|
|
355
355
|
// Listing this machine in a public directory is not listening to it. The
|
|
356
356
|
// listen address itself, /api/live, stays open: that is the thing a listen
|
|
357
|
-
// key is for
|
|
358
|
-
|
|
357
|
+
// key is for -- and so is knowing the phone code, which /api/live/state
|
|
358
|
+
// answers: a joiner is told the number to call and the code to key, and
|
|
359
|
+
// the code is in the public directory anyway.
|
|
360
|
+
if (path === "/api/live/start" || path === "/api/live/stop") return false;
|
|
359
361
|
return true;
|
|
360
362
|
}
|
|
361
363
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-Bqujphpd.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-5T3M0hCY.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-DUwEL0l2.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(){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 je(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 dn(!0),await X(),Z();let i=an(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 Me(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(),je(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:()=>{tn()||F(1)},onState:()=>I(),onBusy:e=>{ae!==e&&(ae=e,I())},onError:e=>{tn()||(_=e,I(),Zt())}}),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;ze(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,Ne=()=>{if(b)return b.name;let e=o===`remote`?m.tracks[N()]:f[N()];return e?t(e):`Nothing loaded.`},Pe=()=>b?`live on this server`:S?.kind===`live`&&o===`remote`?`live on ${s||`this server`}`:(o===`remote`?m.tracks[N()]:f[N()])?.album||`—`;function Fe(){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||Ne(),r=[b?`Live on ${e}: `:`Live from ${e}, now playing: `,fn(t)],i=b?C?.channels.find(e=>e.id===b?.id)?.code??``:de?w:``;i&&r.push(`. To talk about it, call `,fn(T||`the line`),` and key `,fn(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 Ie=()=>k()?m.tracks[N()]?.duration??0:A.duration,Le=()=>k()?m.position:A.position,Re=()=>k()?m.playing:A.playing;async function P(e){if(o===`remote`){if(k()){await j.send({type:`play`,index:e});return}await ze(e);return}let t=f[e];t&&(p=e,b=null,S={kind:`file`},await x(()=>A.load(t,!0)),R(t.video),et(),I())}async function ze(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),et())}async function Be(){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 Ve(){if(k()){await j.send({type:`stop`});return}b=null,S=null,A.stop(),D=Array(24).fill(0),O=[...D],I()}let He=(e,t)=>`L${`▮`.repeat(Math.round(e*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(t*6)).padEnd(6,`·`)}`,Ue=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))],We=``;function Ge(){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===We)return;We=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=Re(),i=ce();n.status.textContent=i?`LOADING`:r?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=i?`loading`:String(r),n.title.textContent=Ne(),Fe(),Ge(),n.goLiveNow.hidden=!ke()||Ae()===null;let c=r?`${Ne()} · ${a}`:a;document.title!==c&&(document.title=c),n.copyNow.hidden=A.source===``,n.downloadNow.hidden=!(b&&S?.link?.download),n.album.textContent=Pe();let l=Le(),u=Ie();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===``,Xe(),n.glyphs.textContent=D.map(Ue).join(``);let[p,ee]=k()?m.levels:A.levels();n.levels.textContent=He(p,ee)}let Ke=``,qe=-1,L=``;function Je(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,Ke=``,Xe()}),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 Ye(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}`,Ke=``,Xe()}),n}function Xe(){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!==Ke){Ke=h,Je(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(Ye(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(Ze(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(),$(an(`track:${n.index}`,y===n.index?A.position:0),e,`✓`)}),a.append(e),ke()&&a.append(Me(()=>({kind:`track`,index:n.index,name:n.name}),n.name))}t.push(a)}n.playlist.replaceChildren(...t)}let g=N(),_=Re(),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!==qe&&(qe=g,v?.scrollIntoView({block:`nearest`}))}function Ze(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(),Qe(e)}),t.append(n)}return t}async function Qe(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 $e(){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(Re()){n.glyphs.textContent=D.map(Ue).join(``);let[t,r]=k()?m.levels:A.levels();n.levels.textContent=He(t,r),n.elapsed.textContent=e(Le());let i=Ie();!v&&i>0&&(n.seek.value=String(Math.round(Le()/i*1e3)))}requestAnimationFrame($e)}function R(e){n.video.hidden=!e,n.fullscreen.hidden=!e}function et(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:Ne(),album:Pe(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void Be()),navigator.mediaSession.setActionHandler(`pause`,()=>void Be()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void F(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void F(-1)))}n.filter.addEventListener(`input`,()=>{Ke=``,Xe()}),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 Ve()),n.playPause.addEventListener(`click`,()=>void Be());async function tt(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 en({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!==``&&tt(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=Ae();e&&je(e,n.goLiveNow)}),n.seek.addEventListener(`input`,()=>{v=!0}),n.seek.addEventListener(`change`,()=>{let e=Ie();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 nt=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)})};nt(n.files),nt(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(),wt(),Xt(!0),dt(),X(),I()})()});let rt=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??[],mt(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(_t(t.url,t.name)),t.ownerId&&Y&&t.ownerId!==Y&&e.append(Lt(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 rt()}})()}),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),rt()}let z=null,it=e=>e===`cgnat`?`mobile or tailscale`:e===`private`?`your network`:e===`local`?`this machine`:e===`public`?`the internet`:e,at=e=>e===`events`?`watching the panel`:e===`page`?`opened the page`:e===`media`?`playing a track`:e===`stream`?`listening live`:e,ot=``,st=``,ct=e=>{let t=e.map(e=>`${e.address}|${e.kind}|${e.track}|${Math.round(e.bytes/4096)}|${e.endedAt}`).join(`~`);if(t===ot)return;ot=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,``],[it(t.network),`network-${t.network}`],[at(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 lt=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.`,ct(t.connections??[]),ut(t.publish??[],(t.channels??[]).map(e=>e.id)),un(t.home??``,t.root??``),Z()}catch{n.adminNote.textContent=`lost touch with the server`}};function ut(e,t){n.publishPanel.hidden=e.length===0;let r=`${e.map(e=>`${e.id}=${e.url}`).join(`~`)}::${t.join(`,`)}`;if(r===st)return;if(st=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 dt=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(),wt(),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.`,lt(),z=setInterval(()=>void lt(),2e3)};function ft(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&&ft(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 pt=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`},mt=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 ${pt(e.endedAt)}`:`ended ${pt(e.endedAt)}`,r.append(i,a),t.append(r,Lt(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 ht(){if(!Y){V=new Set,n.favoritesPanel.hidden=!0,vt();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,_t(e.url,e.name)),t}))}catch{n.favoritesPanel.hidden=!0}vt()}async function gt(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 ht()}function _t(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;gt(U(e)?i:e,t,!U(e)).then(r)}),n}function vt(){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=on()||j.shareLink;if(!e)return;let t=[...V].find(t=>H(t)===H(e))??e;gt(U(e)?t:e,s||j.address,!U(e)).then(vt)});let W=[],G=null,K=null,yt=``,q=[],bt=0,xt=null,St=0;function Ct(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 wt(){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,Tt()}function Tt(){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`,Et();let i=G!==null&&K!==null;if(n.catalogsList.hidden=i,n.catalogsFilter.hidden=!i,n.catalogsEntries.hidden=!i,i){jt();return}if(G){Ot(G);return}n.catalogsList.replaceChildren(...W.map(t=>Dt(t,e)))}function Et(){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,Tt()},!1),r(),t(G?.name??``,()=>{K=null,Tt()},K===null)];K!==null&&i.push(r(),t(K===``?`All groups`:K,()=>void 0,!0)),n.catalogsCrumbs.replaceChildren(...i)}function Dt(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 ${Ct(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,Tt()}),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`,()=>{Nt(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?`)&&Pt(e)}),n.append(t,r)}return n}async function Ot(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=[kt(`All groups`,``,e.entries,e.live,e.vod),...t.map(e=>kt(e.name||`(no group)`,e.name,e.count,e.live,e.vod))];n.catalogsList.replaceChildren(...r)}function kt(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,yt=``,n.catalogsFilter.value=``,q=[],bt=0,Tt(),At(0)}),o.append(s,u),o}async function At(e){let t=G,n=K;if(!t||n===null)return;let r=++St,i=new URLSearchParams({group:n,q:yt,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===St&&(bt=a.total??0,q=e===0?a.entries??[]:[...q,...a.entries??[]],jt())}function jt(){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(Me(()=>({kind:`entry`,catalog:{id:t.id,name:t.name},entry:n}),n.title)),r.addEventListener(`click`,()=>{Mt(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=yt?`Nothing called "${yt}" here.`:`Nothing in this group.`,e.append(t),r.push(e)}else if(q.length<bt){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 ${bt.toLocaleString()})`,t.addEventListener(`click`,e=>{e.stopPropagation(),At(q.length)}),e.append(t),r.push(e)}n.catalogsEntries.replaceChildren(...r)}async function Mt(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 en({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 Nt(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`)}wt()}async function Pt(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),wt()}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`)}wt()})()}),n.catalogsFilter.addEventListener(`input`,()=>{xt&&clearTimeout(xt),xt=setTimeout(()=>{xt=null,yt=n.catalogsFilter.value.trim(),At(0)},250)});let Ft=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 Ft()}catch{s.disabled=!1}})()}),t.append(r,o,s),n.serversList.append(t)}}catch{n.serversPanel.hidden=!0}},It=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}},Lt=(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),It())}catch{}finally{n.disabled=!1}})()}),n},Rt=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},zt=()=>`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window,Bt=async()=>{if(!zt())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:Rt(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}},Vt=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{}},Ht=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`}},Ut=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=zt()&&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 Bt();n.notifyWeb.checked=e,await Ht({wantsWeb:e});return}await Vt(),await Ht({wantsWeb:!1}),n.notifyNote.textContent=`Turn on “On this device” to be told here.`})()}),n.notifyEmail.addEventListener(`change`,()=>{Ht({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 Ht({wantsSms:n.notifySms.checked})})()}),n.notifyPhoneForm.addEventListener(`submit`,e=>{e.preventDefault(),Ht({phone:n.notifyPhone.value.trim()})});let J=!1,Y=``,Wt=e=>{let t=e!==null;n.notifyPanel.hidden=!t,t?(Ut(),It(),Ft()):(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`},Gt=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)}},Kt=async()=>{try{let e=await fetch(`/api/v1/auth/me`),t=await e.json();Y=e.ok?t.account?.id??``:``,Wt(e.ok?t.account?.email??`you`:null)}catch{Y=``,Wt(null)}ht(),qt()};function qt(){if(De===``)return;let e=De;De=``,n.remoteUrl.value=e,n.remoteForm.requestSubmit()}n.accountToggle.addEventListener(`click`,()=>{J=!J,Wt(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=``,Wt(i.account?.email??t),dt(),qt()}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=``,Wt(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,Xt(!1);try{localStorage.removeItem(Se)}catch{}_=`Signed out, and disconnected from the server.`,dt(),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{}Gt(),Kt(),dt(),n.browse.addEventListener(`click`,()=>{if(!n.directory.hidden){n.directory.hidden=!0;return}rt(),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=``,vt(),Xt(!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=on(),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 `),fn(r),document.createTextNode(` and key `),fn(i.code),document.createTextNode(`. That is a room with everyone else watching — not the stream itself.`)),n.shareSend.hidden=!1}}let Jt=``,Yt=null,Xt=e=>{Yt&&clearInterval(Yt),Yt=null,e&&(Yt=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}`,vt(),I())}catch{n.onairPanel.hidden=!0;return}n.onairPanel.hidden=!1,C=e,sn(e);let t=`${n.adminPanel.hidden?`view`:`drive`}:${JSON.stringify(e)}`;if(t===Jt)return;Jt=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(cn({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){$t(e.server.nowPlaying);return}l&&Qt()},link:e.server.live?e.server.url:``,...c?{page:an(`live`)}:{},direct:c?j.url(`/api/live`):``}));for(let e of r)a.push(cn({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(cn({title:t.name,detail:r.join(` · `),onPlay:()=>{en({id:t.id,name:t.name,video:e})},link:n,page:an(`channel:${t.id}`),direct:n,onRestart:l&&t.via===`pull`?()=>{nn(t.id,t.name)}:void 0,onStop:l?()=>{rn(t.id,t.name)}:void 0}))}n.onairList.replaceChildren(...a)}async function Zt(){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 Qt(){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 $t(m.tracks[N()]?.title??``),B(`Playing to the room. Anybody with the view link sees this.`),await Z()}async function $t(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 en(e,t=!0,n){y=-1,b=e,t&&(ne=0),t&&(S=n??{kind:`channel`}),await x(()=>A.load({title:e.name,artist:``,album:``,duration:0,url:j.url(`/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 tn(){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&&en(e,!1)},2e3),!0):!1}function Q(e){n.onairNote.textContent=e,B(e)}async function nn(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`)}Jt=``,Z()}async function rn(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()),Jt=``,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 an(e,t=0){let n=on();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 on(){if(d!==``)return d;let e=o===`remote`?j.shareLink:``;return/\/admin\//.test(e)?``:e}function sn(e){if(l===``)return;let t=l;if(t===`live`){l=``,e.server.playing?$t(e.server.nowPlaying):(_=`Nothing is playing on this server right now.`,I());return}if(t.startsWith(`link:`)){l=``,tt(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=``,en({id:r.id,name:r.name,video:r.kind!==`audio`}))}function cn(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 ln=``;function un(e,t){ln=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`,()=>{ln!==``&&(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:ln})}),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 dn=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 dn(!0)),n.stopLive.addEventListener(`click`,()=>void dn(!1));function fn(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:on()})}),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 ze(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(),Be();return;case`s`:Ve();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 pn=null;globalThis.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),pn=e,n.install.hidden=!1}),n.install.addEventListener(`click`,()=>{pn?.prompt(),pn=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($e)}Ee(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});export{c as t};
|