nixamp 0.7.5 → 0.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin.js +9 -1
- package/dist/audio.d.ts +11 -0
- package/dist/audio.js +10 -3
- package/dist/daemon.d.ts +20 -0
- package/dist/daemon.js +21 -1
- package/dist/invite.d.ts +6 -4
- package/dist/invite.js +9 -4
- package/dist/main.js +23 -3
- package/dist/partyline.d.ts +15 -10
- package/dist/partyline.js +19 -45
- package/dist/server.d.ts +48 -0
- package/dist/server.js +119 -27
- package/dist/session.js +3 -1
- package/dist/share.d.ts +10 -0
- package/dist/share.js +31 -1
- package/package.json +1 -1
- package/src/admin.ts +11 -1
- package/src/audio.ts +25 -4
- package/src/daemon.ts +36 -1
- package/src/invite.ts +10 -5
- package/src/main.ts +22 -3
- package/src/partyline.ts +20 -47
- package/src/server.ts +153 -30
- package/src/session.ts +5 -1
- package/src/share.ts +29 -1
- package/web/dist/assets/{hls-3VKVEQE3-BsLZl7PK.js → hls-3VKVEQE3-BM7Le4NN.js} +1 -1
- package/web/dist/assets/index-B5ijv1SY.css +1 -0
- package/web/dist/assets/index-CGXkk7_z.js +1 -0
- package/web/dist/assets/{mpegts-DWMPccwQ.js → mpegts-C6TLllAG.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CviHHfb1.js → mpegts-LO6RVLD6-B_K_M921.js} +1 -1
- package/web/dist/index.html +30 -10
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-8D8K4vqw.css +0 -1
- package/web/dist/assets/index-a1aekKkP.js +0 -1
package/src/partyline.ts
CHANGED
|
@@ -239,15 +239,6 @@ export class PartyLine {
|
|
|
239
239
|
this.reminders.set(code, set);
|
|
240
240
|
}
|
|
241
241
|
}
|
|
242
|
-
/**
|
|
243
|
-
* Legs listening to a stream, by its code.
|
|
244
|
-
*
|
|
245
|
-
* Separate from the rooms because a stream listener is not in a conference:
|
|
246
|
-
* they are a leg with an MP3 playing into it. Nothing else was counting
|
|
247
|
-
* them, so the directory had no way to say how many people were on the
|
|
248
|
-
* phone for a broadcast.
|
|
249
|
-
*/
|
|
250
|
-
private readonly streamLegs = new Map<string, Set<string>>();
|
|
251
242
|
private readonly key: ReturnType<typeof createPublicKey> | null;
|
|
252
243
|
private readonly fetch: typeof globalThis.fetch;
|
|
253
244
|
private readonly now: () => number;
|
|
@@ -414,6 +405,14 @@ export class PartyLine {
|
|
|
414
405
|
* room code still works: this line was a party line before it was a way into
|
|
415
406
|
* a broadcast, and a code that means nothing to the directory should still
|
|
416
407
|
* mean a room.
|
|
408
|
+
*
|
|
409
|
+
* Keying a stream's code puts you in a room with the other people watching
|
|
410
|
+
* it. It does not play the stream at you, which is what it used to do: this
|
|
411
|
+
* is the phone line beside a broadcast, the way a podcast has an 800 number
|
|
412
|
+
* -- the show is on your screen and the phone is where you talk about it.
|
|
413
|
+
* Playing the audio down the phone was both the worse half of the idea and
|
|
414
|
+
* the one that kept failing, because a share link answers a 302 and a cookie
|
|
415
|
+
* rather than an MP3.
|
|
417
416
|
*/
|
|
418
417
|
private async stream(leg: string, code: string): Promise<boolean> {
|
|
419
418
|
const streams = this.options.streams;
|
|
@@ -422,42 +421,13 @@ export class PartyLine {
|
|
|
422
421
|
const live = streams.liveByCode(code);
|
|
423
422
|
if (live !== undefined) {
|
|
424
423
|
const what = live.nowPlaying ? ` of ${live.nowPlaying}` : "";
|
|
425
|
-
|
|
426
|
-
// The share link is not playable. It answers 302 with a cookie and sends
|
|
427
|
-
// a browser to the player page; Telnyx fetches once with no cookie jar
|
|
428
|
-
// and gets a 401 in JSON. Playing it means a caller who is told "here it
|
|
429
|
-
// is" and then hears nothing at all, which is how this was found. Say
|
|
430
|
-
// what is true instead, and hang up rather than bill for silence.
|
|
431
|
-
if (!live.audio) {
|
|
432
|
-
await this.command(leg, "speak", {
|
|
433
|
-
payload:
|
|
434
|
-
`${live.name} is live right now${what}, but this stream cannot be played over the phone. ` +
|
|
435
|
-
"You can listen to it at nixamp dot com slash directory. Goodbye.",
|
|
436
|
-
voice: this.voice,
|
|
437
|
-
});
|
|
438
|
-
await this.command(leg, "hangup", {});
|
|
439
|
-
this.options.onEvent?.(` ${code} is live but announced no audio address; nothing to play.`);
|
|
440
|
-
return true;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
424
|
await this.command(leg, "speak", {
|
|
444
|
-
payload:
|
|
425
|
+
payload:
|
|
426
|
+
`You're on the line for ${live.name}${what}. ` +
|
|
427
|
+
"Everyone here is watching it too. Say hello.",
|
|
445
428
|
voice: this.voice,
|
|
446
429
|
});
|
|
447
|
-
|
|
448
|
-
// call, so listening by phone costs no audio handling here at all.
|
|
449
|
-
const playing = await this.command(leg, "playback_start", {
|
|
450
|
-
audio_url: live.audio,
|
|
451
|
-
loop: "infinity",
|
|
452
|
-
});
|
|
453
|
-
// Counted only once the audio is actually going. A leg we failed to
|
|
454
|
-
// start is not somebody listening, and the directory would be saying so.
|
|
455
|
-
if (playing) {
|
|
456
|
-
const legs = this.streamLegs.get(code) ?? new Set<string>();
|
|
457
|
-
legs.add(leg);
|
|
458
|
-
this.streamLegs.set(code, legs);
|
|
459
|
-
this.options.onEvent?.(` a caller is listening to ${code} (${legs.size} on the phone).`);
|
|
460
|
-
}
|
|
430
|
+
await this.join(leg, code);
|
|
461
431
|
return true;
|
|
462
432
|
}
|
|
463
433
|
|
|
@@ -608,16 +578,19 @@ export class PartyLine {
|
|
|
608
578
|
this.options.onEvent?.(` a caller joined a room (${room.callers} on the line).`);
|
|
609
579
|
}
|
|
610
580
|
|
|
611
|
-
/**
|
|
581
|
+
/**
|
|
582
|
+
* How many people are on the phone for a stream.
|
|
583
|
+
*
|
|
584
|
+
* The room's own count, now that a stream's code is a room like any other.
|
|
585
|
+
* It used to count legs with an MP3 playing into them, which is a thing that
|
|
586
|
+
* no longer happens.
|
|
587
|
+
*/
|
|
612
588
|
listenersOn(code: string): number {
|
|
613
|
-
return this.
|
|
589
|
+
return this.rooms.get(code)?.callers ?? 0;
|
|
614
590
|
}
|
|
615
591
|
|
|
616
592
|
/** A leg that hung up or was dropped, wherever it was. */
|
|
617
593
|
private release(leg: string): void {
|
|
618
|
-
for (const [code, legs] of this.streamLegs) {
|
|
619
|
-
if (legs.delete(leg) && legs.size === 0) this.streamLegs.delete(code);
|
|
620
|
-
}
|
|
621
594
|
const code = this.legRoom.get(leg);
|
|
622
595
|
this.legRoom.delete(leg);
|
|
623
596
|
if (code === undefined) return;
|
package/src/server.ts
CHANGED
|
@@ -397,7 +397,19 @@ export function safeJoin(rootDir: string, urlPath: string): string | null {
|
|
|
397
397
|
* album it came from, which is what lets a client draw the two apart instead
|
|
398
398
|
* of running them together.
|
|
399
399
|
*/
|
|
400
|
-
export type Loaded = Track & {
|
|
400
|
+
export type Loaded = Track & {
|
|
401
|
+
group?: string;
|
|
402
|
+
/**
|
|
403
|
+
* Whether this has a picture, when the name could not say.
|
|
404
|
+
*
|
|
405
|
+
* A file on disk is named `film.mkv` and that is answer enough. A live
|
|
406
|
+
* stream is `http://host/tipoffsport/KEY/301`, which says nothing at all --
|
|
407
|
+
* so it was treated as audio, transcoded with `-vn`, and arrived as a
|
|
408
|
+
* football match somebody could only listen to. Asked of ffprobe once, when
|
|
409
|
+
* the source is added, rather than guessed from a URL that has no opinion.
|
|
410
|
+
*/
|
|
411
|
+
picture?: boolean;
|
|
412
|
+
};
|
|
401
413
|
|
|
402
414
|
/** What the HTTP layer needs from a player. Tests hand it a fake. */
|
|
403
415
|
export interface Engine {
|
|
@@ -414,6 +426,8 @@ export interface Engine {
|
|
|
414
426
|
* this server somewhere else", which throws the library away on purpose.
|
|
415
427
|
*/
|
|
416
428
|
replace(tracks: Track[], root: string): void;
|
|
429
|
+
/** The library, arriving after the server was already listening. */
|
|
430
|
+
fill(tracks: Track[], root: string): void;
|
|
417
431
|
/**
|
|
418
432
|
* Play something as well as everything here.
|
|
419
433
|
*
|
|
@@ -453,7 +467,8 @@ export function toRemoteTracks(tracks: Loaded[]): RemoteTrack[] {
|
|
|
453
467
|
// Said out loud, because a remote cannot see the path and had been sending
|
|
454
468
|
// every track to the audio element -- a film's soundtrack over a blank
|
|
455
469
|
// panel, which is exactly what it looked like.
|
|
456
|
-
|
|
470
|
+
// The name when it says something, what ffprobe found when it does not.
|
|
471
|
+
...(t.picture ?? hasPicture(t.path) ? { video: true } : {}),
|
|
457
472
|
// Only for what was added; the library's own tracks say nothing, which is
|
|
458
473
|
// how a client knows they are the library.
|
|
459
474
|
...(t.group ? { group: t.group } : {}),
|
|
@@ -468,6 +483,23 @@ export function hasPicture(path: string): boolean {
|
|
|
468
483
|
return dot > 0 && PICTURE.has(path.slice(dot).toLowerCase());
|
|
469
484
|
}
|
|
470
485
|
|
|
486
|
+
/**
|
|
487
|
+
* Whether the name of a source tells us anything about what is inside it.
|
|
488
|
+
*
|
|
489
|
+
* A remote address with no extension -- an IPTV channel, a stream key, a
|
|
490
|
+
* redirect -- is the case where it does not, and the only way to find out is
|
|
491
|
+
* to look.
|
|
492
|
+
*/
|
|
493
|
+
export function nameSaysNothing(path: string): boolean {
|
|
494
|
+
if (!isRemote(path)) return false;
|
|
495
|
+
try {
|
|
496
|
+
const last = new URL(path).pathname.split("/").pop() ?? "";
|
|
497
|
+
return !last.includes(".");
|
|
498
|
+
} catch {
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
471
503
|
/**
|
|
472
504
|
* The headless player: the terminal app's engine without the terminal.
|
|
473
505
|
* One ffmpeg decodes, ffplay makes the sound, and every sample is measured on
|
|
@@ -727,6 +759,30 @@ export class PlayerEngine implements Engine {
|
|
|
727
759
|
return seen;
|
|
728
760
|
}
|
|
729
761
|
|
|
762
|
+
/**
|
|
763
|
+
* The library, arriving after the server was already listening.
|
|
764
|
+
*
|
|
765
|
+
* Walking a directory is the slow part of starting -- eighty thousand files
|
|
766
|
+
* under a home directory takes far longer than the fifteen seconds
|
|
767
|
+
* `nixamp daemon start` waits for the server to say it is up, so starting a
|
|
768
|
+
* daemon with no source given looked exactly like hanging and then failed
|
|
769
|
+
* about a server that was working. The port opens first now and this puts
|
|
770
|
+
* the library in behind it.
|
|
771
|
+
*
|
|
772
|
+
* Unlike `replace` it stops nothing and clears no listeners: whoever
|
|
773
|
+
* connected in the first second is still connected, and simply sees the
|
|
774
|
+
* playlist appear.
|
|
775
|
+
*/
|
|
776
|
+
fill(tracks: Loaded[], root: string): void {
|
|
777
|
+
// Something is already loaded, so this is a scan that finished after
|
|
778
|
+
// somebody pointed the server elsewhere. Theirs wins.
|
|
779
|
+
if (this.tracks.length > 0) return;
|
|
780
|
+
this.tracks = tracks;
|
|
781
|
+
this.root = root;
|
|
782
|
+
this.state.note = tracks.length === 0 ? `No audio files under ${root}.` : "";
|
|
783
|
+
this.push(true);
|
|
784
|
+
}
|
|
785
|
+
|
|
730
786
|
retag(tracks: Track[], root: string): void {
|
|
731
787
|
// Matched by path rather than by position, because the list is no longer
|
|
732
788
|
// required to be the one that was sent for tagging: somebody can add an
|
|
@@ -770,6 +826,7 @@ export class EmptyEngine implements Engine {
|
|
|
770
826
|
return undefined;
|
|
771
827
|
}
|
|
772
828
|
replace(): void {}
|
|
829
|
+
fill(): void {}
|
|
773
830
|
add(): number {
|
|
774
831
|
return 0;
|
|
775
832
|
}
|
|
@@ -793,6 +850,15 @@ const CORS: Record<string, string> = {
|
|
|
793
850
|
"access-control-max-age": "86400",
|
|
794
851
|
};
|
|
795
852
|
|
|
853
|
+
/**
|
|
854
|
+
* How many nameless addresses are worth an ffprobe when a source is added.
|
|
855
|
+
*
|
|
856
|
+
* One is the ordinary case -- somebody pasting a channel -- and a directory
|
|
857
|
+
* listing of thousands must not turn into thousands of probes for an answer
|
|
858
|
+
* that only changes which element a browser uses.
|
|
859
|
+
*/
|
|
860
|
+
const PROBE_BY_HAND = 8;
|
|
861
|
+
|
|
796
862
|
/** /api/v1/<provider>/oauth/start and .../callback, the house callback shape. */
|
|
797
863
|
const OAUTH_ROUTE = /^\/api\/v1\/([a-z0-9-]+)\/oauth\/(start|callback)$/;
|
|
798
864
|
|
|
@@ -891,6 +957,15 @@ export interface HandlerOptions {
|
|
|
891
957
|
broadcaster?: Broadcaster;
|
|
892
958
|
/** Where a broadcast should send, and what it should look like. */
|
|
893
959
|
broadcast?: () => { destinations: Destination[]; settings: EncoderSettings };
|
|
960
|
+
/**
|
|
961
|
+
* Where OBS should point, one entry per stream this server will accept.
|
|
962
|
+
*
|
|
963
|
+
* There is deliberately no single link. ffmpeg's RTMP listener serves one
|
|
964
|
+
* connection per process, so three people going live at once is three ports
|
|
965
|
+
* and three URLs -- and a panel offering one address for all of them would
|
|
966
|
+
* be offering an address that works exactly once.
|
|
967
|
+
*/
|
|
968
|
+
publishUrls?: () => { id: string; url: string }[];
|
|
894
969
|
/** Accounts, on the instance that keeps them. Only nixamp.com passes this. */
|
|
895
970
|
accounts?: Accounts;
|
|
896
971
|
/** Providers to sign in with, and the terminals waiting to be connected. */
|
|
@@ -2102,6 +2177,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2102
2177
|
active: tracker.active,
|
|
2103
2178
|
startedAt: started,
|
|
2104
2179
|
now: Date.now(),
|
|
2180
|
+
// Where to point OBS. Printed at startup since RTMP was added, which
|
|
2181
|
+
// is no use at all to somebody looking at the admin panel a day later.
|
|
2182
|
+
publish: options.publishUrls?.() ?? [],
|
|
2105
2183
|
});
|
|
2106
2184
|
return;
|
|
2107
2185
|
}
|
|
@@ -2207,11 +2285,28 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2207
2285
|
return;
|
|
2208
2286
|
}
|
|
2209
2287
|
try {
|
|
2210
|
-
|
|
2288
|
+
let tracks: Loaded[] = await options.load(source);
|
|
2211
2289
|
if (tracks.length === 0) {
|
|
2212
2290
|
json(response, 422, { error: `nothing to play at ${source}` });
|
|
2213
2291
|
return;
|
|
2214
2292
|
}
|
|
2293
|
+
// A handful of addresses whose names say nothing get asked what they
|
|
2294
|
+
// are, so a live channel arrives as a picture rather than as its own
|
|
2295
|
+
// soundtrack. Capped, because a playlist of five thousand of them is
|
|
2296
|
+
// five thousand ffprobes and the answer only matters for the few a
|
|
2297
|
+
// person adds by hand.
|
|
2298
|
+
const looked = await Promise.all(
|
|
2299
|
+
tracks.map(async (track, at) => {
|
|
2300
|
+
if (at >= PROBE_BY_HAND || !nameSaysNothing(track.path)) return track;
|
|
2301
|
+
const codecs = await codecsOf(
|
|
2302
|
+
{ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null },
|
|
2303
|
+
track.path,
|
|
2304
|
+
);
|
|
2305
|
+
return codecs.video === "" ? track : { ...track, picture: true };
|
|
2306
|
+
}),
|
|
2307
|
+
);
|
|
2308
|
+
tracks = looked;
|
|
2309
|
+
|
|
2215
2310
|
let added = tracks.length;
|
|
2216
2311
|
if (replacing) {
|
|
2217
2312
|
engine.replace(tracks, source);
|
|
@@ -2265,15 +2360,21 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2265
2360
|
|
|
2266
2361
|
if (playsInBrowser(file) && capKbps === 0) {
|
|
2267
2362
|
sendFile(request, response, file);
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2363
|
+
return;
|
|
2364
|
+
}
|
|
2365
|
+
// A film, or something whose name refuses to say. A live channel at
|
|
2366
|
+
// .../301 used to fall through to the audio branch and arrive as MP3
|
|
2367
|
+
// with `-vn` -- a match you could only listen to.
|
|
2368
|
+
if (hasPicture(file) || nameSaysNothing(file)) {
|
|
2369
|
+
// What ffprobe finds inside decides how little work it takes to keep
|
|
2370
|
+
// the picture, and whether there is a picture to keep at all.
|
|
2272
2371
|
const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2372
|
+
if (codecs.video !== "") {
|
|
2373
|
+
pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs, capKbps), "video/mp4");
|
|
2374
|
+
return;
|
|
2375
|
+
}
|
|
2276
2376
|
}
|
|
2377
|
+
transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
|
|
2277
2378
|
return;
|
|
2278
2379
|
}
|
|
2279
2380
|
|
|
@@ -2664,10 +2765,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
2664
2765
|
//
|
|
2665
2766
|
// So the filenames are enough to start: the server is up and answering in the
|
|
2666
2767
|
// time it takes to walk the directory, and the titles fill in behind it.
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
: new EmptyEngine(`No audio files under ${root}.`);
|
|
2768
|
+
// Empty on purpose. The walk happens below, once the port is open: it is
|
|
2769
|
+
// the slowest part of starting and nothing about it needs to happen first.
|
|
2770
|
+
const engine = new PlayerEngine([], root, tools);
|
|
2671
2771
|
|
|
2672
2772
|
const web = options.web !== null ? resolve(options.web) : defaultWebDir();
|
|
2673
2773
|
const key = options.key ? newKey() : null;
|
|
@@ -2885,11 +2985,17 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
2885
2985
|
})()
|
|
2886
2986
|
: undefined;
|
|
2887
2987
|
|
|
2988
|
+
// Filled in below, when the RTMP listeners are opened. Read through a
|
|
2989
|
+
// function so the handler sees the list rather than the empty array it was
|
|
2990
|
+
// built with.
|
|
2991
|
+
let publishUrls: { id: string; url: string }[] = [];
|
|
2992
|
+
|
|
2888
2993
|
const server = createServer(engine, {
|
|
2889
2994
|
web,
|
|
2890
2995
|
media: options.media,
|
|
2891
2996
|
owner,
|
|
2892
2997
|
channels,
|
|
2998
|
+
publishUrls: () => publishUrls,
|
|
2893
2999
|
...(ingest ? { ingest } : {}),
|
|
2894
3000
|
broadcaster,
|
|
2895
3001
|
broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
|
|
@@ -3015,20 +3121,30 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3015
3121
|
);
|
|
3016
3122
|
}
|
|
3017
3123
|
|
|
3018
|
-
//
|
|
3019
|
-
//
|
|
3020
|
-
//
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
.
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
});
|
|
3029
|
-
|
|
3124
|
+
// Names now, tags later, and both after the door is open. Not awaited:
|
|
3125
|
+
// nothing below needs the library, and a directory that takes a minute to
|
|
3126
|
+
// walk should cost nobody a minute of not being able to connect.
|
|
3127
|
+
void loadSource(tools, root, false)
|
|
3128
|
+
.then((found) => {
|
|
3129
|
+
engine.fill(found, root);
|
|
3130
|
+
if (found.length === 0) {
|
|
3131
|
+
console.log(`nixamp serve — no audio files under ${root}`);
|
|
3132
|
+
return;
|
|
3133
|
+
}
|
|
3134
|
+
console.log(`nixamp serve — ${found.length} tracks under ${root}`);
|
|
3135
|
+
if (isRemote(root)) return;
|
|
3136
|
+
return loadTagged(tools, root)
|
|
3137
|
+
.then((tagged) => engine.retag(tagged, root))
|
|
3138
|
+
.catch(() => {
|
|
3139
|
+
// Filenames are a working player. A failure here is worth nothing
|
|
3140
|
+
// but titles that stay as they are.
|
|
3141
|
+
});
|
|
3142
|
+
})
|
|
3143
|
+
.catch((error: unknown) => {
|
|
3144
|
+
console.log(`nixamp: could not read ${root}: ${(error as Error).message}`);
|
|
3145
|
+
});
|
|
3030
3146
|
|
|
3031
|
-
console.log(`nixamp serve —
|
|
3147
|
+
console.log(`nixamp serve — reading ${root}`);
|
|
3032
3148
|
console.log("");
|
|
3033
3149
|
|
|
3034
3150
|
const width = Math.max(...addresses.map((a) => a.label.length));
|
|
@@ -3083,9 +3199,14 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3083
3199
|
rtmp = new RtmpListeners(channels, tools.ffmpeg, listenKey ?? "live");
|
|
3084
3200
|
rtmp.listen(slots);
|
|
3085
3201
|
|
|
3202
|
+
publishUrls = slots.map((slot) => ({
|
|
3203
|
+
id: slot.id,
|
|
3204
|
+
url: `rtmp://${host}:${slot.port}/live/${listenKey ?? "live"}`,
|
|
3205
|
+
}));
|
|
3206
|
+
|
|
3086
3207
|
console.log(" Or publish from OBS, Larix or ffmpeg, one per URL:");
|
|
3087
|
-
for (const
|
|
3088
|
-
console.log(`
|
|
3208
|
+
for (const entry of publishUrls) {
|
|
3209
|
+
console.log(` ${entry.url} -> "${entry.id}"`);
|
|
3089
3210
|
}
|
|
3090
3211
|
}
|
|
3091
3212
|
if (destinations.length > 0) {
|
|
@@ -3149,7 +3270,9 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3149
3270
|
name: options.name || hostname(),
|
|
3150
3271
|
url: listen,
|
|
3151
3272
|
audio,
|
|
3152
|
-
|
|
3273
|
+
// Asked of the engine rather than a variable, because the library is
|
|
3274
|
+
// now read after the port opens and may still be arriving.
|
|
3275
|
+
tracks: engine.snapshot(false).trackCount,
|
|
3153
3276
|
// From `nixamp login`. The directory will not list a stream it cannot
|
|
3154
3277
|
// attribute to somebody, because a listing is now a phone code that
|
|
3155
3278
|
// costs money to answer.
|
package/src/session.ts
CHANGED
|
@@ -582,7 +582,11 @@ export async function servers(argv: string[], fetcher: typeof fetch = fetch): Pr
|
|
|
582
582
|
const daemon = await import("./daemon.ts");
|
|
583
583
|
const state = daemon.readState();
|
|
584
584
|
if (state === null) {
|
|
585
|
-
console.error(
|
|
585
|
+
console.error(
|
|
586
|
+
"nixamp: no daemon is running here.\n" +
|
|
587
|
+
" Start one: nixamp daemon start ~/Music\n" +
|
|
588
|
+
" Or give the share link of the one you mean: https://host:4321/s/KEY",
|
|
589
|
+
);
|
|
586
590
|
return 1;
|
|
587
591
|
}
|
|
588
592
|
// The address worth remembering is the one somebody else can open.
|
package/src/share.ts
CHANGED
|
@@ -128,6 +128,24 @@ export async function lookupPublicIp(send: typeof fetch = fetch, timeoutMs = 250
|
|
|
128
128
|
* somewhere else can open. It is labelled for what it is, because the key in
|
|
129
129
|
* the link is then the only thing between a stranger and the library.
|
|
130
130
|
*/
|
|
131
|
+
/**
|
|
132
|
+
* Whether a browser could ever verify a certificate for this address.
|
|
133
|
+
*
|
|
134
|
+
* A certificate is issued for a name. Handed an https link to a bare IP, a
|
|
135
|
+
* browser has nothing to match it against and refuses the connection before it
|
|
136
|
+
* asks anything -- and from the page it looks identical to a machine that is
|
|
137
|
+
* switched off. Offering such a link is offering one that cannot work, so the
|
|
138
|
+
* links are labelled and the ones that can work go first.
|
|
139
|
+
*/
|
|
140
|
+
export function certifiable(url: string): boolean {
|
|
141
|
+
if (!url.startsWith("https://")) return true;
|
|
142
|
+
try {
|
|
143
|
+
return !isIpAddress(new URL(url).hostname.replace(/^\[|\]$/g, ""));
|
|
144
|
+
} catch {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
131
149
|
export function reachableAddresses(
|
|
132
150
|
host: string,
|
|
133
151
|
port: number,
|
|
@@ -161,11 +179,21 @@ export function reachableAddresses(
|
|
|
161
179
|
}
|
|
162
180
|
const order = { private: 0, cgnat: 1, public: 2 } as const;
|
|
163
181
|
found.sort((x, y) => order[x.kind] - order[y.kind]);
|
|
164
|
-
|
|
182
|
+
const all = [
|
|
165
183
|
...told,
|
|
166
184
|
{ label: "here", url: `${scheme}://localhost:${port}` },
|
|
167
185
|
...found.map(({ label, url }) => ({ label, url })),
|
|
168
186
|
];
|
|
187
|
+
// Serving https, an address that is a bare IP cannot be verified by anyone,
|
|
188
|
+
// so it is said last and said differently rather than handed out as though
|
|
189
|
+
// it were a link somebody could use.
|
|
190
|
+
if (scheme !== "https") return all;
|
|
191
|
+
return [
|
|
192
|
+
...all.filter((entry) => certifiable(entry.url)),
|
|
193
|
+
...all
|
|
194
|
+
.filter((entry) => !certifiable(entry.url))
|
|
195
|
+
.map((entry) => ({ label: `${entry.label} (no certificate for an IP)`, url: entry.url })),
|
|
196
|
+
];
|
|
169
197
|
}
|
|
170
198
|
|
|
171
199
|
/** The full link, key and all. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-CGXkk7_z.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg:#080c09;--panel:#0c120e;--edge:#1d2c22;--green:#4af689;--green-dim:#227a4a;--fg:#cfe8d8;--muted:#6d8a79;--warn:#e8c35a;--accent:#7ef0c4;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}[hidden]{display:none!important}html,body{background:var(--bg);min-height:100%;color:var(--fg);margin:0;font:14px/1.45 ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace}body{background-image:repeating-linear-gradient(#4af68906 0 1px,#0000 1px 3px)}#app{max-width:1100px;padding:calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left));flex-direction:column;gap:10px;margin:0 auto;display:flex}.bar{flex-wrap:wrap;align-items:center;gap:10px;display:flex}.brand{color:var(--green);letter-spacing:.14em;font-weight:700}.status{color:var(--muted)}.status[data-playing=true]{color:var(--green)}.chip{color:var(--muted);border:1px solid var(--edge);text-overflow:ellipsis;white-space:nowrap;border-radius:999px;max-width:46vw;margin-left:auto;padding:1px 10px;font-size:12px;overflow:hidden}.panel{border:1px solid var(--edge);background:var(--panel);border-radius:6px;min-width:0;padding:14px 12px 12px;position:relative}.panel:before{content:attr(data-title);background:var(--panel);color:var(--green-dim);letter-spacing:.06em;padding:0 6px;font-size:12px;position:absolute;top:-.72em;left:10px}.split{grid-template-columns:1.3fr 1fr;gap:10px;display:grid}.split>.col-a{grid-column:1}.split>.col-b{grid-column:2}@media (max-width:720px){.split{grid-template-columns:1fr}.split>.col-a,.split>.col-b{grid-column:auto}}.track-title{color:var(--accent);text-overflow:ellipsis;white-space:nowrap;font-weight:700;overflow:hidden}.track-sub{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}#video{border:1px solid var(--edge);background:#000;border-radius:4px;width:100%;max-height:46vh;margin-bottom:8px}.scrub{align-items:center;gap:10px;margin-top:6px;display:flex}.time{color:var(--fg);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}input[type=range]{appearance:none;cursor:pointer;background:0 0;flex:1;min-width:0;height:14px}input[type=range]::-webkit-slider-runnable-track{background:linear-gradient(var(--edge), var(--edge));border:1px solid var(--edge);border-radius:3px;height:6px}input[type=range]::-moz-range-track{background:var(--edge);border-radius:3px;height:6px}input[type=range]::-webkit-slider-thumb{appearance:none;background:var(--green);border-radius:2px;width:10px;height:16px;margin-top:-6px}input[type=range]::-moz-range-thumb{background:var(--green);border:0;border-radius:2px;width:10px;height:16px}input[type=range]:disabled{opacity:.45;cursor:default}#spectrum{border:1px solid var(--edge);background:#060a07;border-radius:4px;width:100%;height:190px;display:block}.meters{align-items:baseline;gap:10px;margin-top:6px;display:flex;overflow:hidden}.glyphs{color:var(--green);letter-spacing:1px;white-space:nowrap;min-height:1.4em;overflow:hidden}.levelmeter{color:var(--accent);white-space:nowrap;margin-left:auto}.playlist{scrollbar-color:var(--green-dim) transparent;max-height:214px;margin:0;padding:0;list-style:none;overflow-y:auto}.row{cursor:pointer;white-space:nowrap;border-radius:3px;gap:8px;padding:2px 6px;display:flex}.row:hover{background:#142019}.row.selected{background:#16241c}.row.selected .name{color:var(--accent)}.row.playing .name{color:var(--green)}.group{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;border-top:1px solid var(--edge);align-items:center;gap:8px;margin-top:4px;padding:6px 6px 2px;font-size:11px;display:flex}.group:first-child{border-top:0;margin-top:0}.group-name{text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}.group-remove{border:1px solid var(--edge);color:var(--muted);cursor:pointer;background:0 0;border-radius:3px;flex:none;padding:0 6px;line-height:1.4}.group-remove:hover{color:var(--accent);border-color:var(--accent)}.row .n{color:var(--muted);text-align:right;flex:none;width:2.4em}.row .name{text-overflow:ellipsis;flex:1;overflow:hidden}.row .time{color:var(--muted);flex:none}.transport{border:1px solid var(--edge);background:var(--panel);border-radius:6px;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 10px;display:flex}button{font:inherit;color:var(--fg);border:1px solid var(--edge);cursor:pointer;background:#121a15;border-radius:4px;padding:6px 12px}button:hover{border-color:var(--green-dim);color:var(--green)}button:active{transform:translateY(1px)}button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid var(--green);outline-offset:2px}button.primary{color:var(--green);border-color:var(--green-dim);min-width:64px}button.ghost{color:var(--muted);background:0 0}.volume{flex:1;align-items:center;gap:8px;min-width:120px;max-width:220px;margin-left:auto;display:flex}.vol{color:var(--muted);letter-spacing:.08em;font-size:12px}.picker{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.button{border:1px solid var(--edge);cursor:pointer;color:var(--fg);background:#121a15;border-radius:4px;padding:6px 12px;display:inline-block}.button:hover{border-color:var(--green-dim);color:var(--green)}.button input[type=file]{display:none}#remote-url{font:inherit;min-width:12ch;color:var(--fg);border:1px solid var(--edge);background:#060a07;border-radius:4px;flex:1;padding:6px 10px}.check{color:var(--muted);align-items:center;gap:8px;margin-top:10px;display:flex}.check input{accent-color:var(--green)}.hint{color:var(--muted);margin:0 0 8px;font-size:13px}.hint code{color:var(--accent)}#remote-state[data-status=live]{color:var(--green)}#remote-state[data-status=connecting],#remote-state[data-status=error]{color:var(--warn)}.note{color:var(--warn);border-left:2px solid var(--warn);margin:0;padding-left:8px}.statusbar{color:var(--muted);border-top:1px solid var(--edge);flex-wrap:wrap;gap:14px;padding-top:8px;font-size:12px;display:flex}.statusbar b{color:var(--green);font-weight:700}.statusbar .spacer{flex:1}.statusbar a{color:var(--muted)}.directory{border-top:1px solid var(--line);margin-top:.6rem;padding-top:.6rem}.directory-list{max-height:12rem;margin:0;padding:0;list-style:none;overflow-y:auto}.directory-list li+li{margin-top:.3rem}.directory-list button{border:1px solid var(--line);color:inherit;font:inherit;cursor:pointer;background:0 0;border-radius:4px;padding:.4rem .5rem}.directory-list button:hover,.directory-list button:focus-visible{border-color:var(--accent);background:#ffffff0a}.directory-list .name{color:var(--accent);display:block}.directory-list .detail{opacity:.7;text-overflow:ellipsis;white-space:nowrap;font-size:.85em;display:block;overflow:hidden}.publish-list{margin:.3rem 0 0;padding:0;list-style:none}.publish-list li{align-items:center;gap:8px;margin-top:.3rem;display:flex}.publish-list .slot{color:var(--muted);flex:none;min-width:4.5em;font-size:12px}.publish-list input{flex:auto;min-width:0}.directory-list li.offline .recent-label{opacity:.55}.directory-list li.offline .detail{color:var(--warn)}.share-line{align-items:center;gap:8px;margin:.4rem 0;display:flex}.share-what{color:var(--muted);letter-spacing:.06em;text-transform:uppercase;flex:none;font-size:12px}.share-line input{flex:auto;min-width:0}#share-phone{color:var(--fg);font-size:1.05em}#share-phone b{color:var(--accent)}.admin-table{border-collapse:collapse;width:100%;max-height:14rem;margin:.4rem 0;font-size:.85em;display:block;overflow-y:auto}.admin-table th{text-align:left;opacity:.6;padding:.2rem .4rem .2rem 0;font-weight:400}.admin-table td{white-space:nowrap;text-overflow:ellipsis;max-width:12rem;padding:.2rem .4rem .2rem 0;overflow:hidden}.admin-table td.network-public{color:var(--warning,#e0b341)}.admin-table td.network-private{color:var(--success,#7fd18b)}.admin-table tr.ended{opacity:.45}body.route-directory .player-only{display:none}.directory-list li{align-items:stretch;gap:.4rem;display:flex}.directory-list li>:first-child{flex:auto;min-width:0}.directory-list li>button:first-child{text-align:left;width:100%}.directory-list li>.button,.directory-list li>.ghost{white-space:nowrap;flex:none;align-items:center;display:flex}.directory-list .follow{white-space:nowrap;width:auto;color:var(--muted);flex:none;padding-inline:.6rem}.directory-list .follow[data-following=yes]{border-color:var(--accent);color:var(--accent)}.toggle{color:var(--muted);cursor:pointer;align-items:center;gap:.35rem;display:inline-flex}.toggle input{accent-color:var(--accent)}.recent-label{border:1px solid var(--line);border-radius:4px;flex:auto;min-width:0;padding:.4rem .5rem}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();function e(e){if(!Number.isFinite(e)||e<0)return`--:--`;let t=Math.floor(e),n=Math.floor(t/60),r=t%60;return`${String(n).padStart(2,`0`)}:${String(r).padStart(2,`0`)}`}function t(e){return e.artist?`${e.artist} — ${e.title}`:e.title}function n(e){let t=e.split(`/`).pop()??e,n=t.lastIndexOf(`.`);return n>0?t.slice(0,n):t}var r=new Set([`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`,`avi`]);function i(e,t=``){if(t.startsWith(`video/`))return!0;if(t.startsWith(`audio/`))return!1;let n=e.lastIndexOf(`.`);return n>0&&r.has(e.slice(n+1).toLowerCase())}var a=`modulepreload`,o=function(e){return`/`+e},s={},c=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),c=i?.nonce||i?.getAttribute(`nonce`);function l(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function u(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=l(t.map(t=>{if(t=o(t,n),t=u(t),t in s)return;s[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:a,r||(i.as=`script`),i.crossOrigin=``,i.href=t,c&&i.setAttribute(`nonce`,c),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},l=[[/\.m3u8$/i,`hls`],[/\.(ts|mts|m2ts|mpegts)$/i,`mpegts`],[/\.(mp3|m4a|aac|oga|ogg|opus|wav|flac)$/i,`audio`],[/\.(mp4|m4v|webm|mov|ogv)$/i,`mp4`]],u=[[/mpegurl/i,`hls`],[/mp2t|mpeg-?ts/i,`mpegts`],[/^audio\//i,`audio`],[/^video\//i,`mp4`]];function d(e){if(e.kind)return e.kind;if(e.mimeType){for(let[t,n]of u)if(t.test(e.mimeType))return n}let t=f(e.src);for(let[e,n]of l)if(e.test(t))return n;return`unknown`}function f(e){try{return new URL(e,`https://placeholder.invalid`).pathname}catch{return e.split(/[?#]/)[0]??e}}function p(e=globalThis,t=null){let n=e.MediaSource!==void 0,r=!1;try{let e=t??(typeof document>`u`?null:document.createElement(`video`));r=e?e.canPlayType(`application/vnd.apple.mpegurl`)!==``||e.canPlayType(`application/x-mpegURL`)!==``:!1}catch{r=!1}return{mediaSource:n,nativeHls:r}}function m(e,t){let n=d(e);switch(n){case`hls`:return t.mediaSource?{engine:`hls`,kind:n}:t.nativeHls?{engine:`native`,kind:n}:{engine:`native`,kind:n,unplayable:`This browser cannot play HLS streams.`};case`mpegts`:return t.mediaSource?{engine:`mpegts`,kind:n}:{engine:`mpegts`,kind:n,unplayable:`This browser cannot play transport streams.`};default:return{engine:`native`,kind:n}}}async function h(e,t){let n=t.capabilities??p(),r=m({src:t.src,...t.kind?{kind:t.kind}:{},...t.mimeType?{mimeType:t.mimeType}:{}},n),i=()=>void 0,a={media:e,src:t.src,isTv:t.isTv??!1,live:t.live??r.kind===`mpegts`,onError:t.onError??i,onNotice:t.onNotice??i,...t.onReady?{onReady:t.onReady}:{}};if(r.unplayable)return t.onError?.(r.unplayable),{destroy:i,engine:r.engine,kind:r.kind,levels:()=>[],unplayable:r.unplayable};let o,s=t.engines?.[r.engine];if(s)o=await s(a);else if(r.engine===`hls`){let{createHlsEngine:e}=await c(async()=>{let{createHlsEngine:e}=await import(`./hls-3VKVEQE3-BM7Le4NN.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-B_K_M921.js`);return{createMpegtsEngine:e}},[]);o=await e(a,{withCredentials:t.withCredentials??!1,unplayableAdvice:t.unplayableAdvice??``})}else{let{createNativeEngine:e}=await c(async()=>{let{createNativeEngine:e}=await import(`./native-C7JTKWJH-BUyIoj0P.js`);return{createNativeEngine:e}},[]);o=await e(a)}return{destroy:()=>{o.destroy()},engine:r.engine,kind:r.kind,levels:o.levels,...o.setLevel?{setLevel:o.setLevel}:{},...o.currentLevel?{currentLevel:o.currentLevel}:{}}}var g=new Set([`mp3`,`flac`,`ogg`,`oga`,`opus`,`m4a`,`aac`,`wav`,`wma`,`aiff`,`aif`,`alac`,`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`]);function _(e,t=``){if(t.startsWith(`audio/`)||t.startsWith(`video/`))return!0;let n=e.lastIndexOf(`.`);return n>0&&g.has(e.slice(n+1).toLowerCase())}function v(e,t){return e.localeCompare(t,void 0,{numeric:!0,sensitivity:`base`})}function y(e){return e.filter(e=>_(e.name,e.type)).sort((e,t)=>v(b(e),b(t))).map(e=>({title:n(e.name),artist:``,album:x(b(e)),duration:0,url:URL.createObjectURL(e),video:i(e.name,e.type),objectUrl:!0}))}function b(e){return e.webkitRelativePath||e.name}function x(e){let t=e.split(`/`);return t.length>1?t[t.length-2]:``}function ee(e){for(let t of e)t.objectUrl&&URL.revokeObjectURL(t.url)}var S=2048;function C(e,t){return e||t===`hls`||t===`mpegts`}var te=class{elements;handlers;attached=null;context=null;analyser=null;wired=new WeakSet;active;frequencies=new Uint8Array;constructor(e,t){this.elements=e,this.handlers=t,this.active=e.audio;for(let t of[e.audio,e.video])t.crossOrigin=`anonymous`,t.addEventListener(`timeupdate`,()=>{t===this.active&&this.handlers.onTime(t.currentTime,Number.isFinite(t.duration)?t.duration:0)}),t.addEventListener(`loadedmetadata`,()=>{t===this.active&&this.handlers.onTime(t.currentTime,Number.isFinite(t.duration)?t.duration:0)}),t.addEventListener(`ended`,()=>{t===this.active&&this.handlers.onEnded()}),t.addEventListener(`play`,()=>{t===this.active&&this.handlers.onState(!0)}),t.addEventListener(`pause`,()=>{t===this.active&&this.handlers.onState(!1)}),t.addEventListener(`error`,()=>{t===this.active&&this.handlers.onError(w(t))})}get playing(){return!this.active.paused&&!this.active.ended}get position(){return this.active.currentTime}get duration(){return Number.isFinite(this.active.duration)?this.active.duration:0}get showingVideo(){return this.active===this.elements.video}ensureGraph(e){let t=globalThis.AudioContext??globalThis.webkitAudioContext;if(t){if(this.context??=new t,this.analyser||(this.analyser=this.context.createAnalyser(),this.analyser.fftSize=S,this.analyser.smoothingTimeConstant=.6,this.analyser.connect(this.context.destination),this.frequencies=new Uint8Array(this.analyser.frequencyBinCount)),!this.wired.has(e))try{this.context.createMediaElementSource(e).connect(this.analyser),this.wired.add(e)}catch{this.wired.add(e)}this.context.resume()}}read(){return this.analyser&&this.analyser.getByteFrequencyData(this.frequencies),this.frequencies}levels(){if(!this.analyser)return[0,0];let e=this.read(),t=0;for(let n of e)t+=n;let n=e.length===0?0:t/e.length/255;return[Math.min(1,n*2.2),Math.min(1,n*2.2)]}async load(e,t){let n=e.objectUrl?e.video?`mp4`:`audio`:d({src:e.url}),r=C(e.video,n)?this.elements.video:this.elements.audio;r!==this.active&&(this.active.pause(),this.active.removeAttribute(`src`),this.active.load(),this.active=r),this.attached?.destroy(),this.attached=null;try{this.attached=await h(this.active,{src:e.url,kind:n,unplayableAdvice:`VLC or mpv will play it; nixamp can only hand it to your browser.`,onError:e=>this.handlers.onError(e),onNotice:e=>{e&&this.handlers.onError(e)}})}catch(e){this.handlers.onError(e instanceof Error?e.message:`that would not play`);return}t&&await this.play()}async play(){this.ensureGraph(this.active);try{await this.active.play()}catch(e){this.handlers.onError(e instanceof Error?e.message:`playback was refused`)}}pause(){this.active.pause()}stop(){this.active.pause(),this.active.currentTime=0,this.attached?.destroy(),this.attached=null}seek(e){Number.isFinite(e)&&(this.active.currentTime=Math.max(0,e))}set volume(e){this.elements.audio.volume=e,this.elements.video.volume=e}get volume(){return this.active.volume}};function w(e){switch(e.error?.code){case MediaError.MEDIA_ERR_ABORTED:return`playback was aborted`;case MediaError.MEDIA_ERR_NETWORK:return`the network dropped mid-track`;case MediaError.MEDIA_ERR_DECODE:return`this browser could not decode that`;case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:return`this browser cannot play that format`;default:return`playback failed`}}function T(){return{revision:0,tracks:[],trackCount:0,index:0,playing:!1,position:0,bars:[],levels:[0,0],silent:!0,note:``,root:``}}function ne(e,t){return{...t,tracks:t.tracks??e.tracks}}function E(e){let t=e.trim();if(t===``)return``;/^https?:\/\//i.test(t)||(t=`http://${t}`);let n;try{n=new URL(t)}catch{return``}let r=n.pathname.replace(/\/+$/,``);return r=r.replace(/\/api(\/.*)?$/,``),`${n.origin}${r}`}function D(e,t,n=``){let r=`${e===``?``:E(e)}${t.startsWith(`/`)?t:`/${t}`}`;return n?`${r}${r.includes(`?`)?`&`:`?`}k=${encodeURIComponent(n)}`:r}function re(e){let t=e.trim();if(t===``)return{base:``,key:``};let n;try{n=new URL(/^https?:\/\//i.test(t)?t:`http://${t}`)}catch{return{base:``,key:``}}let r=/^\/s\/([^/]+)\/?$/.exec(n.pathname),i=r?.[1]??n.searchParams.get(`k`)??``;return r&&(n.pathname=`/`),n.searchParams.delete(`k`),{base:E(`${n.origin}${n.pathname}`),key:decodeURIComponent(i)}}function O(e,t,n=0,r=``){return D(e,n>0?`/api/media/${t}?kbps=${Math.round(n)}`:`/api/media/${t}`,r)}function k(e){if(typeof e!=`object`||!e)return null;let t=e,n=T(),r=(e,t)=>typeof e==`number`&&Number.isFinite(e)?e:t,i=Array.isArray(t.levels)?t.levels:[],a=Array.isArray(t.tracks)?t.tracks.map(e=>{let t=typeof e==`object`&&e?e:{};return{title:typeof t.title==`string`?t.title:`Untitled`,artist:typeof t.artist==`string`?t.artist:``,album:typeof t.album==`string`?t.album:``,duration:r(t.duration,0),...t.video===!0?{video:!0}:{},...typeof t.group==`string`&&t.group!==``?{group:t.group}:{}}}):void 0;return{revision:r(t.revision,0),...a?{tracks:a}:{},trackCount:r(t.trackCount,a?.length??0),index:r(t.index,0),playing:t.playing===!0,position:r(t.position,0),bars:Array.isArray(t.bars)?t.bars.map(e=>r(e,0)):[],levels:[r(i[0],0),r(i[1],0)],silent:t.silent===!0,note:typeof t.note==`string`?t.note:``,root:typeof t.root==`string`?t.root:n.root}}var ie=class{handlers;source=null;base=``;key=``;lastRevision=-1;constructor(e){this.handlers=e}get address(){return this.base}url(e){return D(this.base,e,this.key)}get shareLink(){return this.base===``?``:this.key===``?this.base:`${this.base}/s/${this.key}`}get connected(){return this.source!==null}connect(e){let{base:t,key:n}=re(e);this.close(),this.base=t,this.key=n,this.lastRevision=-1,this.handlers.onStatus(`connecting`);let r=new EventSource(D(t,`/api/events`,n));this.source=r,r.onopen=()=>this.handlers.onStatus(`live`),r.onmessage=e=>{let t=k(A(e.data));t&&(t.revision<this.lastRevision||(this.lastRevision=t.revision,this.handlers.onStatus(`live`),this.handlers.onSnapshot(t)))},r.onerror=()=>{this.handlers.onStatus(`error`,`reconnecting…`)}}async send(e){if(this.base===``&&!this.connected)return;let t=await fetch(D(this.base,`/api/command`,this.key),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify(e)});if(!t.ok){this.handlers.onStatus(`error`,`command refused (${t.status})`);return}let n=k(await t.json());n&&this.handlers.onSnapshot(n)}media(e,t=0){return O(this.base,e,t,this.key)}close(){this.source?.close(),this.source=null}};function A(e){try{return JSON.parse(e)}catch{return null}}async function ae(e,t,n=``){try{let r=await fetch(D(e,`/api/state`,n),{signal:t});return r.ok?k(await r.json()):null}catch{return null}}function oe(e){if(!/^https:\/\//i.test(e))return``;let t;try{t=new URL(e).hostname.replace(/^\[|\]$/g,``)}catch{return``}return/^\d{1,3}(\.\d{1,3}){3}$/.test(t)||t.includes(`:`)?`That is an https address for a bare IP, and a certificate is issued for a name — a browser refuses it before it asks anything. Use the server's name instead (the address it printed first), or connect over http.`:``}async function se(e,t=``,n){let r;try{r=await fetch(D(e,`/api/state`,t),{signal:n})}catch{return``}return r.ok?``:r.status===401?t===``?`That server needs its share link. Paste the whole link — the one ending in /s/… — or sign in as its owner.`:`That share link is not accepted by that server. It may have been restarted, which gives it a new one.`:r.status===403?`That link can listen but not drive this server.`:r.status===429?`That server is asking us to slow down. Try again in a moment.`:``}async function j(e,t,n=``){try{let r=await fetch(D(e,`/api/health`,n),{signal:t});if(!r.ok)return null;let i=await r.json();return i.name===`nixamp`?i.version??`unknown`:null}catch{return null}}function ce(e,t=globalThis.location?.protocol){return t!==`https:`||!/^http:\/\//i.test(e.trim())?``:`This page is https, and a browser refuses every request from an https page to an http one. Open that address directly, or give the server a certificate: nixamp serve --tls-cert cert.pem --tls-key key.pem.`}var M=.14,N=.02;function le(e,t){let n=[];for(let r=0;r<=e;r++){let i=r/e,a=Math.round(1*(t/1)**i),o=n[n.length-1];n.push(o===void 0?a:Math.max(a,o+1))}return n}function ue(e,t){let n=[];for(let r=0;r+1<t.length;r++){let i=Math.min(t[r],e.length),a=Math.min(Math.max(t[r+1],i+1),e.length),o=0,s=0;for(let t=i;t<a;t++)o+=e[t],s++;n.push(s===0?0:o/s/255)}return n}function de(e,t,n=M){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function P(e,t,n=N){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function fe(e,t,n,r,i){let{width:a,height:o}=t;if(a<=0||o<=0||n.length===0)return;e.clearRect(0,0,a,o);let s=a/n.length,c=Math.max(1,s*.72),l=Math.max(2,o*.012);e.fillStyle=i.bar,n.forEach((t,n)=>{let r=Math.max(1,t*(o-l*2));e.fillRect(n*s+(s-c)/2,o-r,c,r)}),e.fillStyle=i.peak,r.forEach((t,n)=>{let r=o-Math.max(1,t*(o-l*2))-l*2;e.fillRect(n*s+(s-c)/2,Math.max(0,r),c,l)})}var F=`nixamp.remote`,I=`nixamp.volume`,L=`nixamp.listenHere`;function R(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function z(){let n={status:R(`status`),source:R(`source`),install:R(`install`),video:R(`video`),audio:R(`audio`),title:R(`title-line`),album:R(`album-line`),elapsed:R(`elapsed`),total:R(`total`),seek:R(`seek`),canvas:R(`spectrum`),glyphs:R(`glyphs`),levels:R(`levels`),playlist:R(`playlist`),playlistTitle:R(`playlist-panel`),note:R(`note`),files:R(`files`),folder:R(`folder`),remoteUrl:R(`remote-url`),remoteForm:R(`remote-form`),remoteState:R(`remote-state`),disconnect:R(`disconnect`),browse:R(`browse`),accountForm:R(`account-form`),accountEmail:R(`account-email`),accountPassword:R(`account-password`),accountSubmit:R(`account-submit`),accountToggle:R(`account-toggle`),accountProviders:R(`account-providers`),accountPanel:R(`account-panel`),accountElsewhere:R(`account-elsewhere`),accountSignOut:R(`account-signout`),accountNote:R(`account-note`),adminPanel:R(`admin-panel`),adminNote:R(`admin-note`),adminConnections:R(`admin-connections`),publishNote:R(`publish-note`),publishList:R(`publish-list`),adminRestream:R(`admin-restream`),adminReplace:R(`admin-replace`),adminSource:R(`admin-source`),directory:R(`directory`),recentNote:R(`recent-note`),recentList:R(`recent-list`),followingNote:R(`following-note`),followingList:R(`following-list`),serversPanel:R(`servers-panel`),serversNote:R(`servers-note`),serversList:R(`servers-list`),notifyPanel:R(`notify-panel`),notifyNote:R(`notify-note`),notifyWeb:R(`notify-web`),notifyEmail:R(`notify-email`),notifySms:R(`notify-sms`),notifyPhone:R(`notify-phone`),notifyPhoneForm:R(`notify-phone-form`),notifyPhoneNote:R(`notify-phone-note`),directoryNote:R(`directory-note`),directoryList:R(`directory-list`),sharePanel:R(`share-panel`),shareNote:R(`share-note`),shareLink:R(`share-link`),shareCopy:R(`share-copy`),sharePhone:R(`share-phone`),shareSend:R(`share-send`),shareTo:R(`share-to`),listenHere:R(`listen-here`),volume:R(`volume`),prev:R(`prev`),playPause:R(`play-pause`),stop:R(`stop`),next:R(`next`)},r=`local`,i=[],a=0,o=T(),s=`idle`,c=``,l=`Pick files, or connect to a nixamp running somewhere else.`,u=!1,d=-1,f=``,p=Array(24).fill(0),m=Array(24).fill(0),h=[],g=()=>r===`remote`&&!n.listenHere.checked,_=new te({audio:n.audio,video:n.video},{onTime:(e,t)=>{let n=i[a];r===`local`&&n&&t>0&&n.duration!==t&&(n.duration=t),B()},onEnded:()=>M(1),onState:()=>B(),onError:e=>{l=e,B()}}),v=new ie({onSnapshot:e=>{o=ne(o,e),g()&&(p=e.bars.length>0?e.bars:p,m=P(m,p)),B()},onStatus:(e,t)=>{s=e,c=t??``,B()}}),b=()=>r===`remote`?o.tracks.length:i.length,x=()=>r===`remote`?g()||d<0?o.index:Math.min(d,Math.max(0,o.tracks.length-1)):a,S=()=>{let e=r===`remote`?o.tracks[x()]:i[x()];return e?t(e):`Nothing loaded.`},C=()=>(r===`remote`?o.tracks[x()]:i[x()])?.album||`—`,w=()=>g()?o.tracks[x()]?.duration??0:_.duration,E=()=>g()?o.position:_.position,D=()=>g()?o.playing:_.playing;async function O(e){if(r===`remote`){if(g()){await v.send({type:`play`,index:e});return}await k(e);return}let t=i[e];t&&(a=e,await _.load(t,!0),W(t.video),G(),B())}async function k(e){let t=o.tracks[e];t&&(d=e,await _.load({title:t.title,artist:t.artist,album:t.album,duration:t.duration,url:v.media(e,0),video:t.video===!0,objectUrl:!1},!0),W(t.video===!0),G())}async function A(){if(g()){await v.send({type:`toggle`});return}b()!==0&&(_.playing?_.pause():_.position>0?await _.play():await O(x()),B())}async function M(e){let t=b();if(t!==0){if(g()){await v.send({type:e>0?`next`:`prev`});return}await O((x()+e+t)%t)}}async function N(){if(g()){await v.send({type:`stop`});return}_.stop(),p=Array(24).fill(0),m=[...p],B()}let z=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))];function B(){let t=b(),a=D();n.status.textContent=a?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=String(a),n.title.textContent=S(),n.album.textContent=C();let d=E(),f=w();n.elapsed.textContent=e(d),n.total.textContent=f>0?e(f):`--:--`,u||(n.seek.value=String(f>0?Math.round(d/f*1e3):0),n.seek.disabled=f<=0||g()),n.playPause.textContent=a?`❚❚`:`▶`,n.playPause.setAttribute(`aria-label`,a?`Pause`:`Play`),n.playlistTitle.dataset.title=`Playlist (${t})`,n.source.textContent=r===`remote`?`remote · ${v.address.replace(/^https?:\/\//,``)||`—`}`:i.length>0?`local · ${i.length} files`:`no source`,n.remoteState.textContent=r===`remote`?`${s}${c?` — ${c}`:``}`:`not connected`,n.remoteState.dataset.status=r===`remote`?s:`idle`,n.disconnect.hidden=r!==`remote`;let m=r===`remote`&&o.note!==``?o.note:l;n.note.textContent=m,n.note.hidden=m===``,pe(),n.glyphs.textContent=p.map(z).join(``);let[h,y]=g()?o.levels:_.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(h*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(y*6)).padEnd(6,`·`)}`}let V=``,H=-1;function pe(){let a=r===`remote`?o.tracks.map(e=>({name:t(e),seconds:e.duration,group:e.group??``})):i.map(e=>({name:t(e),seconds:e.duration,group:``})),s=`${r}:${a.map(e=>`${e.name}@${e.seconds}@${e.group}`).join(`|`)}`;if(s!==V){V=s;let t=[],r=``,i=a.some(e=>e.group!==``);a.forEach((n,a)=>{n.group!==r&&(i||n.group!==``)&&(r=n.group,t.push(me(n.group)));let o=document.createElement(`li`);o.className=`row`,o.dataset.index=String(a);let s=document.createElement(`span`);s.className=`n`,s.textContent=String(a+1).padStart(2,` `);let c=document.createElement(`span`);c.className=`name`,c.textContent=n.name;let l=document.createElement(`span`);l.className=`time`,l.textContent=n.seconds>0?e(n.seconds):`--:--`,o.append(s,c,l),t.push(o)}),n.playlist.replaceChildren(...t)}let c=x(),l=D(),u;for(let e of Array.from(n.playlist.children)){let t=e,n=Number(t.dataset.index),r=Number.isInteger(n)&&n===c;t.classList.toggle(`selected`,r),t.classList.toggle(`playing`,r&&l),r&&(u=t)}c!==H&&(H=c,u?.scrollIntoView({block:`nearest`}))}function me(e){let t=document.createElement(`li`);t.className=`group`;let r=document.createElement(`span`);if(r.className=`group-name`,r.textContent=e===``?`This server's library`:e,t.append(r),e!==``&&!n.adminPanel.hidden){let n=document.createElement(`button`);n.type=`button`,n.className=`group-remove`,n.textContent=`×`,n.title=`Remove ${e} from the playlist`,n.setAttribute(`aria-label`,`Remove ${e} from the playlist`),n.addEventListener(`click`,t=>{t.stopPropagation(),he(e)}),t.append(n)}return t}async function he(e){try{let t=await fetch(v.url(`/api/source/remove`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({group:e})}),r=await t.json();n.adminNote.textContent=t.ok?`Removed ${r.removed??0} tracks from ${e}.`:r.error??`that did not work`}catch{n.adminNote.textContent=`could not reach the server`}}function U(){let t=n.canvas,r=Math.min(2,globalThis.devicePixelRatio||1),i=Math.round(t.clientWidth*r),a=Math.round(t.clientHeight*r);i>0&&a>0&&(t.width!==i||t.height!==a)&&(t.width=i,t.height=a);let s=t.getContext(`2d`);if(g())m=P(m,p);else{let e=_.read();e.length>0&&(h.length!==25&&(h=le(24,e.length)),p=de(p,ue(e,h)),m=P(m,p))}if(s){let e=getComputedStyle(document.documentElement);fe(s,{width:t.width,height:t.height},p,m,{bar:e.getPropertyValue(`--green`).trim()||`#4af689`,peak:e.getPropertyValue(`--green-dim`).trim()||`#227a4a`,background:`transparent`})}if(D()){n.glyphs.textContent=p.map(z).join(``);let[t,r]=g()?o.levels:_.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(t*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(r*6)).padEnd(6,`·`)}`,n.elapsed.textContent=e(E());let i=w();!u&&i>0&&(n.seek.value=String(Math.round(E()/i*1e3)))}requestAnimationFrame(U)}function W(e){n.video.hidden=!e}function G(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:S(),album:C(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void A()),navigator.mediaSession.setActionHandler(`pause`,()=>void A()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void M(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void M(-1)))}n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&O(n)}),n.prev.addEventListener(`click`,()=>void M(-1)),n.next.addEventListener(`click`,()=>void M(1)),n.stop.addEventListener(`click`,()=>void N()),n.playPause.addEventListener(`click`,()=>void A()),n.seek.addEventListener(`input`,()=>{u=!0}),n.seek.addEventListener(`change`,()=>{let e=w();e>0&&_.seek(Number(n.seek.value)/1e3*e),u=!1}),n.volume.addEventListener(`input`,()=>{let e=Number(n.volume.value)/100;_.volume=e;try{localStorage.setItem(I,String(e))}catch{}});let ge=e=>{e.addEventListener(`change`,()=>{let t=y(Array.from(e.files??[]));if(t.length===0){l=`Nothing playable in that selection.`,B();return}ee(i),i=t,a=0,r=`local`,v.close(),l=``,O(0)})};ge(n.files),ge(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.remoteUrl.value,{base:i,key:a}=re(t);if(i===``){l=`That is not an address.`,B();return}(async()=>{s=`connecting`,B();let e=ce(i);if(e){s=`error`,c=e,l=e,r=`local`,B();return}if(await j(i,void 0,a)===null){s=`error`;let e=oe(i);c=e?`needs the server's name`:`not answering`,l=e||`Nothing answered at ${i}. If that is your machine, it is off or nixamp is not running on it; otherwise check the address.`,r=`local`,B();return}let n=await se(i,a);if(n){s=`error`,c=n,l=n,r=`local`,B();return}r=`remote`,l=``;try{localStorage.setItem(F,t.trim())}catch{}v.connect(t),Pe(),B()})()});let _e=async()=>{n.directory.hidden=!1,n.directoryNote.textContent=`Looking for live streams…`,n.directoryList.replaceChildren();let e;try{let t=await fetch(`/api/directory`);if(!t.ok)throw Error(String(t.status));let n=await t.json();e=n.streams??[],Se(n.recent??[])}catch{n.directoryNote.textContent=`The directory is not answering. Type an address instead.`;return}if(e.length===0){n.directoryNote.textContent=`Nobody is streaming right now.`;return}n.directoryNote.textContent=`${e.length} live ${e.length===1?`stream`:`streams`}:`;for(let t of e){let e=document.createElement(`li`),r=document.createElement(`button`);r.type=`button`;let i=document.createElement(`span`);i.className=`name`,i.textContent=t.name;let a=document.createElement(`span`);a.className=`detail`;let o=[t.nowPlaying,`${t.tracks} tracks`].filter(Boolean);t.code&&o.push(t.callers?`☎ ${t.code} · ${t.callers} on the phone`:`☎ ${t.code}`),a.textContent=o.join(` · `),r.append(i,a),r.addEventListener(`click`,()=>{n.remoteUrl.value=t.url,n.directory.hidden=!0,n.remoteForm.requestSubmit()}),e.append(r),t.ownerId&&X&&t.ownerId!==X&&e.append(Te(t.ownerId,t.name)),n.directoryList.append(e)}};if(location.pathname.replace(/\/+$/,``)===`/directory`){document.body.classList.add(`route-directory`);let e=document.getElementById(`directory-back`);e&&(e.hidden=!1),_e()}let K=null,ve=e=>{n.adminConnections.replaceChildren();let t=document.createElement(`tr`);for(let e of[`Where`,`Network`,`Kind`,`Client`,`Track`,`Sent`]){let n=document.createElement(`th`);n.textContent=e,t.append(n)}n.adminConnections.append(t);for(let t of e.slice(0,40)){let e=document.createElement(`tr`);t.endedAt!==null&&(e.className=`ended`);let r=[[t.address,``],[t.network,`network-${t.network}`],[t.kind,``],[t.agent,``],[t.track||`—`,``],[`${Math.round(t.bytes/1024)} KiB`,``]];for(let[t,n]of r){let r=document.createElement(`td`);r.textContent=t,n&&(r.className=n),e.append(r)}n.adminConnections.append(e)}},ye=async()=>{try{let e=await fetch(v.url(`/api/connections`));if(!e.ok)return;let t=await e.json();n.adminNote.textContent=`${t.active??0} listening now.`,ve(t.connections??[]),be(t.publish??[])}catch{n.adminNote.textContent=`lost touch with the server`}};function be(e){if(n.publishNote.hidden=e.length===0,e.length===0){n.publishList.replaceChildren(),n.publishNote.hidden=!1,n.publishNote.textContent=`This server takes no RTMP. Start it with --rtmp-in 1935 to publish into it from OBS.`;return}n.publishNote.textContent=e.length===1?`Publish into this server from OBS, Larix or ffmpeg:`:`Publish into this server from OBS, Larix or ffmpeg. One URL per stream — ${e.length} at once:`,n.publishList.replaceChildren(...e.map(e=>{let t=document.createElement(`li`),n=document.createElement(`span`);n.className=`slot`,n.textContent=e.id;let r=document.createElement(`input`);r.type=`text`,r.readOnly=!0,r.value=e.url,r.setAttribute(`aria-label`,`RTMP URL for ${e.id}`);let i=document.createElement(`button`);return i.type=`button`,i.className=`ghost`,i.textContent=`Copy`,i.addEventListener(`click`,()=>{r.select(),navigator.clipboard?.writeText(e.url).catch(()=>{})}),t.append(n,r,i),t}))}let q=async()=>{let e=!1,t=null;try{let n=await fetch(v.url(`/api/admin`));if(n.ok){let r=await n.json();e=r.allowed===!0,t=r.as??null}}catch{e=!1}n.adminPanel.hidden=!e,K&&clearInterval(K),K=null,e&&(n.adminNote.textContent=t===`owner`?`You own this server.`:`You hold this server's control link.`,ye(),K=setInterval(()=>void ye(),2e3))};n.adminRestream.addEventListener(`submit`,e=>{e.preventDefault();let t=n.adminSource.value.trim();if(!t)return;let r=n.adminReplace.checked;(async()=>{try{let e=await fetch(v.url(`/api/source`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({source:t,...r?{replace:!0}:{}})}),i=await e.json();n.adminNote.textContent=e.ok?r?`Now serving ${t}.`:i.added===0?`Everything there was already in the playlist.`:`Added ${i.added??0} tracks from ${t}.`:i.error??`that did not work`,e.ok&&(n.adminSource.value=``)}catch{n.adminNote.textContent=`could not reach the server`}})()});let xe=e=>{let t=Math.max(1,Math.round((Date.now()-e)/6e4));if(t<60)return`${t} minute${t===1?``:`s`} ago`;let n=Math.round(t/60);return`${n} hour${n===1?``:`s`} ago`},Se=e=>{n.recentList.replaceChildren();let t=X?e.filter(e=>e.ownerId&&e.ownerId!==X):[];if(n.recentNote.hidden=t.length===0,t.length!==0)for(let e of t){let t=document.createElement(`li`),r=document.createElement(`span`);r.className=`recent-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name;let a=document.createElement(`span`);a.className=`detail`,a.textContent=e.nowPlaying?`${e.nowPlaying} · ended ${xe(e.endedAt)}`:`ended ${xe(e.endedAt)}`,r.append(i,a),t.append(r,Te(e.ownerId,e.name)),n.recentList.append(t)}},Ce=async()=>{n.serversList.replaceChildren();try{let e=await fetch(`/api/v1/servers`);if(!e.ok){n.serversPanel.hidden=!0;return}let t=(await e.json()).servers??[];n.serversPanel.hidden=!1,n.serversNote.textContent=t.length===0?"No servers yet. `nixamp server add --here` remembers the one you are running.":`The machines on your account. Open one, or forget it.`;for(let e of t){let t=document.createElement(`li`),r=document.createElement(`span`);r.className=`recent-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name;let a=document.createElement(`span`);a.className=`detail`,a.textContent=e.url,r.append(i,a);let o=document.createElement(`button`);o.type=`button`,o.className=`button`,o.textContent=`Open`,o.addEventListener(`click`,()=>{n.remoteUrl.value=e.key?`${e.url}/s/${e.key}`:e.url,n.remoteForm.requestSubmit()}),j(e.url).then(n=>{if(n!==null){a.textContent=`${e.url} · ${n}`;return}a.textContent=`${e.url} · not answering`,t.classList.add(`offline`),o.disabled=!0,o.title=`That machine is not answering. Start nixamp on it.`});let s=document.createElement(`button`);s.type=`button`,s.className=`ghost`,s.textContent=`Forget`,s.addEventListener(`click`,()=>{(async()=>{s.disabled=!0;try{await fetch(`/api/v1/servers/${encodeURIComponent(e.id)}`,{method:`DELETE`}),await Ce()}catch{s.disabled=!1}})()}),t.append(r,o,s),n.serversList.append(t)}}catch{n.serversPanel.hidden=!0}},we=async()=>{n.followingList.replaceChildren();try{let e=await fetch(`/api/v1/follows`);if(!e.ok){n.followingNote.hidden=!0;return}let t=(await e.json()).following??[];n.followingNote.hidden=t.length===0;for(let e of t){let t=document.createElement(`li`),r=document.createElement(`span`);r.className=`recent-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name||`a nixamp`;let a=document.createElement(`span`);a.className=`detail`,a.textContent=e.live?`live now`:`not streaming`,r.append(i,a);let o=document.createElement(`button`);o.type=`button`,o.className=`ghost follow`,o.textContent=`Unfollow`,o.addEventListener(`click`,()=>{(async()=>{o.disabled=!0;try{await fetch(`/api/v1/follows/${encodeURIComponent(e.id)}`,{method:`DELETE`}),t.remove(),n.followingList.children.length===0&&(n.followingNote.hidden=!0)}finally{o.disabled=!1}})()}),t.append(r,o),n.followingList.append(t)}}catch{n.followingNote.hidden=!0}},Te=(e,t)=>{let n=document.createElement(`button`);n.type=`button`,n.className=`ghost follow`,n.textContent=`Follow`,n.setAttribute(`aria-label`,`Follow ${t}`);let r=e=>{n.textContent=e?`Following`:`Follow`,n.dataset.following=e?`yes`:`no`};return(async()=>{try{let t=await fetch(`/api/v1/follows/${encodeURIComponent(e)}`);t.ok&&r((await t.json()).following===!0)}catch{}})(),n.addEventListener(`click`,()=>{(async()=>{let t=n.dataset.following===`yes`;n.disabled=!0;try{(await fetch(`/api/v1/follows/${encodeURIComponent(e)}`,{method:t?`DELETE`:`PUT`,headers:{"content-type":`application/json`},body:t?void 0:`{}`})).ok&&(r(!t),we())}catch{}finally{n.disabled=!1}})()}),n},Ee=e=>{let t=(e+`=`.repeat((4-e.length%4)%4)).replace(/-/g,`+`).replace(/_/g,`/`),n=atob(t),r=new Uint8Array(new ArrayBuffer(n.length));for(let e=0;e<n.length;e+=1)r[e]=n.charCodeAt(e);return r},De=()=>`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window,Oe=async()=>{if(!De())return n.notifyNote.textContent=`This browser cannot show notifications.`,!1;if(Notification.permission===`denied`)return n.notifyNote.textContent=`This browser is blocking notifications. Allow them in site settings first.`,!1;if(await Notification.requestPermission()!==`granted`)return n.notifyNote.textContent=`Not allowed, so nothing will be sent here.`,!1;try{let e=await navigator.serviceWorker.ready,{publicKey:t}=await(await fetch(`/api/v1/notify/key`)).json();if(!t)return n.notifyNote.textContent=`This server is not set up to send notifications.`,!1;let r=await e.pushManager.getSubscription()??await e.pushManager.subscribe({userVisibleOnly:!0,applicationServerKey:Ee(t)}),i=await fetch(`/api/v1/notify/subscribe`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify(r.toJSON())});if(!i.ok)throw Error(String(i.status));return n.notifyNote.textContent=`This device will be told.`,!0}catch{return n.notifyNote.textContent=`Could not set this device up.`,!1}},ke=async()=>{try{let e=await(await navigator.serviceWorker.ready).pushManager.getSubscription();if(!e)return;await fetch(`/api/v1/notify/subscribe?endpoint=${encodeURIComponent(e.endpoint)}`,{method:`DELETE`}),await e.unsubscribe()}catch{}},J=async e=>{try{let t=await fetch(`/api/v1/notify/prefs`,{method:`PUT`,headers:{"content-type":`application/json`},body:JSON.stringify(e)}),r=await t.json();n.notifyPhoneNote.textContent=t.ok?``:r.error??`that did not save`,t.ok&&typeof r.phone==`string`&&(n.notifyPhone.value=r.phone)}catch{n.notifyPhoneNote.textContent=`could not reach nixamp.com`}},Ae=async()=>{try{let e=await fetch(`/api/v1/notify/prefs`);if(!e.ok)return;let t=await e.json();n.notifyEmail.checked=t.wantsEmail!==!1,n.notifySms.checked=t.wantsSms===!0,n.notifyPhone.value=t.phone??``;let r=De()&&Notification.permission===`granted`?await(await navigator.serviceWorker.ready).pushManager.getSubscription()!==null:!1;n.notifyWeb.checked=t.wantsWeb!==!1&&r,n.notifyNote.textContent=r?`Get told when someone you follow goes live.`:`Turn on “On this device” to be told here.`}catch{}};n.notifyWeb.addEventListener(`change`,()=>{(async()=>{if(n.notifyWeb.checked){let e=await Oe();n.notifyWeb.checked=e,await J({wantsWeb:e});return}await ke(),await J({wantsWeb:!1}),n.notifyNote.textContent=`Turn on “On this device” to be told here.`})()}),n.notifyEmail.addEventListener(`change`,()=>{J({wantsEmail:n.notifyEmail.checked})}),n.notifySms.addEventListener(`change`,()=>{(async()=>{if(n.notifySms.checked&&!n.notifyPhone.value.trim()){n.notifyPhoneNote.textContent=`Add a phone number first.`,n.notifySms.checked=!1,n.notifyPhone.focus();return}await J({wantsSms:n.notifySms.checked})})()}),n.notifyPhoneForm.addEventListener(`submit`,e=>{e.preventDefault(),J({phone:n.notifyPhone.value.trim()})});let Y=!1,X=``,Z=e=>{let t=e!==null;n.notifyPanel.hidden=!t,t?(Ae(),we(),Ce()):(n.serversPanel.hidden=!0,n.followingNote.hidden=!0,n.followingList.replaceChildren(),n.recentNote.hidden=!0,n.recentList.replaceChildren()),n.accountForm.hidden=t,n.accountProviders.hidden=t||n.accountProviders.childElementCount===0,n.accountSignOut.hidden=!t,n.accountNote.textContent=t?`Signed in as ${e}.`:Y?`Create an account on nixamp.com.`:`Sign in to nixamp.com to publish and get paid.`,n.accountSubmit.textContent=Y?`Create account`:`Sign in`,n.accountToggle.textContent=Y?`I have one`:`Create one`,n.accountPassword.autocomplete=Y?`new-password`:`current-password`},je=async()=>{let e=[],t=!1;try{let n=await fetch(`/api/v1/auth/providers`);n.ok&&(t=!0,e=(await n.json()).providers??[])}catch{}n.accountProviders.replaceChildren(),n.accountProviders.hidden=e.length===0,n.accountPanel.hidden=!t,n.accountElsewhere.hidden=t;for(let t of e){let e=document.createElement(`a`);e.className=`button`,e.href=`/api/v1/${encodeURIComponent(t.id)}/oauth/start`,e.textContent=`Continue with ${t.name}`,n.accountProviders.append(e)}},Me=async()=>{try{let e=await fetch(`/api/v1/auth/me`),t=await e.json();X=e.ok?t.account?.id??``:``,Z(e.ok?t.account?.email??`you`:null)}catch{X=``,Z(null)}Ne()};function Ne(){if(f===``||X===``)return;let e=f;f=``,n.remoteUrl.value=e,n.remoteForm.requestSubmit()}n.accountToggle.addEventListener(`click`,()=>{Y=!Y,Z(null)}),n.accountForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.accountEmail.value.trim(),r=n.accountPassword.value;(async()=>{n.accountSubmit.disabled=!0;try{let e=await fetch(`/api/v1/auth/${Y?`signup`:`login`}`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({email:t,password:r})}),i=await e.json();if(!e.ok){n.accountNote.textContent=i.error??`that did not work`;return}X=i.account?.id??``,n.accountPassword.value=``,Z(i.account?.email??t),q()}catch{n.accountNote.textContent=`could not reach nixamp.com`}finally{n.accountSubmit.disabled=!1}})()}),n.accountSignOut.addEventListener(`click`,()=>{(async()=>{try{await fetch(`/api/v1/auth/logout`,{method:`POST`})}catch{}X=``,Z(null),q()})()});try{let e=new URL(globalThis.location.href).searchParams.get(`url`)??``;e!==``&&(f=e,n.remoteUrl.value=e,l=`Sign in to watch this stream.`,globalThis.history?.replaceState(null,``,globalThis.location.pathname))}catch{}je(),Me(),q(),n.browse.addEventListener(`click`,()=>{if(!n.directory.hidden){n.directory.hidden=!0;return}_e(),n.directory.scrollIntoView({behavior:`smooth`,block:`nearest`})}),n.disconnect.addEventListener(`click`,()=>{v.close(),d=-1,n.sharePanel.hidden=!0,r=`local`,s=`idle`,c=``,B()});let Pe=async()=>{if(r!==`remote`||v.shareLink===``){n.sharePanel.hidden=!0;return}n.sharePanel.hidden=!1;let e=v.shareLink,t=globalThis.location.origin;n.shareLink.value=e.startsWith(`https://`)?`${t}/?url=${encodeURIComponent(e)}`:e,n.shareNote.textContent=`Anyone with this link can watch. They sign in once, then it opens.`,n.sharePhone.hidden=!0,n.shareSend.hidden=!0;try{let t=await fetch(`/api/directory`);if(!t.ok)return;let r=await t.json(),i=new URL(e).origin,a=(r.streams??[]).find(e=>{try{return new URL(e.url).origin===i}catch{return!1}});if(!a||!r.callIn){n.sharePhone.hidden=!1,n.sharePhone.textContent=`Publish this stream (nixamp serve --announce) to get a phone number and a code for it.`;return}n.sharePhone.hidden=!1,n.sharePhone.innerHTML=``,n.sharePhone.append(document.createTextNode(`To talk about it, call `),Q(r.callIn),document.createTextNode(` and key `),Q(a.code),document.createTextNode(`. That is a room with everyone else watching — not the stream itself.`)),n.shareSend.hidden=!1}catch{}};function Q(e){let t=document.createElement(`b`);return t.textContent=e,t}n.shareCopy.addEventListener(`click`,()=>{n.shareLink.select(),navigator.clipboard?.writeText(n.shareLink.value).then(()=>{n.shareNote.textContent=`Copied. Send it to anybody.`},()=>{n.shareNote.textContent=`Copy it from the box above.`})}),n.shareSend.addEventListener(`submit`,e=>{e.preventDefault();let t=n.shareTo.value.trim();t!==``&&(async()=>{n.shareNote.textContent=`Sending…`;try{let e=await fetch(`/api/v1/invite`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({to:t,stream:v.shareLink})}),r=await e.json();n.shareNote.textContent=e.ok?`Sent to ${r.sent??t}.`:r.error??`that did not send`,e.ok&&(n.shareTo.value=``)}catch{n.shareNote.textContent=`could not send that`}})()}),n.listenHere.addEventListener(`change`,()=>{try{localStorage.setItem(L,n.listenHere.checked?`1`:`0`)}catch{}r===`remote`&&(async()=>{n.listenHere.checked?(await v.send({type:`stop`}),await k(o.index)):(_.stop(),d=-1),B()})()}),document.addEventListener(`keydown`,e=>{let t=e.target;if(!(t&&/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))switch(e.key){case` `:e.preventDefault(),A();return;case`s`:N();return;case`n`:case`ArrowRight`:M(1);return;case`p`:case`ArrowLeft`:M(-1);return;case`ArrowDown`:e.preventDefault(),O(Math.min(b()-1,x()+1));return;case`ArrowUp`:e.preventDefault(),O(Math.max(0,x()-1));return}});let $=null;globalThis.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),$=e,n.install.hidden=!1}),n.install.addEventListener(`click`,()=>{$?.prompt(),$=null,n.install.hidden=!0});try{let e=localStorage.getItem(I);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),_.volume=Number(e));let t=localStorage.getItem(F);t&&(n.remoteUrl.value=t),localStorage.getItem(L)===`0`&&(n.listenHere.checked=!1)}catch{}(async()=>{if(n.remoteUrl.value!==``)return;let e=globalThis.location.origin;if(await j(e)===null)return;let t=await ae(e);t&&t.trackCount!==0&&(n.remoteUrl.value=e,r=`remote`,l=``,v.connect(e),B())})(),B(),requestAnimationFrame(U)}z(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});export{c as t};
|