nixamp 0.7.4 → 0.7.5
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/README.md +8 -2
- package/dist/admin.d.ts +2 -0
- package/dist/admin.js +32 -8
- package/dist/protocol.d.ts +8 -0
- package/dist/server.d.ts +61 -5
- package/dist/server.js +162 -16
- package/dist/share.js +6 -1
- package/dist/sources.d.ts +9 -0
- package/dist/sources.js +24 -0
- package/package.json +1 -1
- package/src/admin.ts +35 -10
- package/src/protocol.ts +8 -0
- package/src/server.ts +190 -20
- package/src/share.ts +5 -1
- package/src/sources.ts +21 -0
- package/web/dist/assets/{hls-3VKVEQE3-BirljKil.js → hls-3VKVEQE3-BsLZl7PK.js} +1 -1
- package/web/dist/assets/index-8D8K4vqw.css +1 -0
- package/web/dist/assets/index-a1aekKkP.js +1 -0
- package/web/dist/assets/{mpegts-Dd6YyA19.js → mpegts-DWMPccwQ.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Cl9mowBJ.js → mpegts-LO6RVLD6-CviHHfb1.js} +1 -1
- package/web/dist/index.html +17 -7
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-DSIDSSPF.css +0 -1
- package/web/dist/assets/index-U2odRmpd.js +0 -1
package/src/admin.ts
CHANGED
|
@@ -146,6 +146,9 @@ export async function admin(argv: string[]): Promise<void> {
|
|
|
146
146
|
let snapshot: Snapshot | null = null;
|
|
147
147
|
let error = "";
|
|
148
148
|
let restreaming = "";
|
|
149
|
+
// Which of the two things typing a source means. Adding is the ordinary one
|
|
150
|
+
// and has its own key; replacing throws the library away, so it has another.
|
|
151
|
+
let replacing = false;
|
|
149
152
|
let typing = false;
|
|
150
153
|
|
|
151
154
|
const app = await createApp({ theme: themes.matrix, title: "nixamp admin", quitKeys: ["ctrl+c"] });
|
|
@@ -164,24 +167,27 @@ export async function admin(argv: string[]): Promise<void> {
|
|
|
164
167
|
app.on("key", (event: KeyEvent) => {
|
|
165
168
|
const key = event.key;
|
|
166
169
|
if (typing) {
|
|
167
|
-
if (key === "escape") { typing = false; restreaming = ""; }
|
|
170
|
+
if (key === "escape") { typing = false; restreaming = ""; replacing = false; }
|
|
168
171
|
else if (key === "enter") {
|
|
169
172
|
const url = restreaming.trim();
|
|
173
|
+
const asReplacement = replacing;
|
|
170
174
|
typing = false;
|
|
171
175
|
restreaming = "";
|
|
172
|
-
|
|
176
|
+
replacing = false;
|
|
177
|
+
if (url) void restream(target, headers, url, asReplacement).then(() => refresh());
|
|
173
178
|
} else if (key === "backspace") restreaming = restreaming.slice(0, -1);
|
|
174
179
|
else restreaming += typed(key);
|
|
175
180
|
app.invalidate();
|
|
176
181
|
return;
|
|
177
182
|
}
|
|
178
183
|
if (key === "q") { app.quit(); return; }
|
|
179
|
-
if (key === "
|
|
184
|
+
if (key === "a") { typing = true; replacing = false; app.invalidate(); }
|
|
185
|
+
if (key === "r") { typing = true; replacing = true; app.invalidate(); }
|
|
180
186
|
});
|
|
181
187
|
|
|
182
188
|
app.on("exit", () => clearInterval(timer));
|
|
183
189
|
app.render(({ ui, theme }) => draw(ui, theme, {
|
|
184
|
-
url: target.url, report, snapshot, error, typing, restreaming,
|
|
190
|
+
url: target.url, report, snapshot, error, typing, restreaming, replacing,
|
|
185
191
|
links: target.links, key: target.key, source: target.source,
|
|
186
192
|
}));
|
|
187
193
|
|
|
@@ -189,13 +195,24 @@ export async function admin(argv: string[]): Promise<void> {
|
|
|
189
195
|
clearInterval(timer);
|
|
190
196
|
}
|
|
191
197
|
|
|
192
|
-
/**
|
|
193
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Hand the server something else to play.
|
|
200
|
+
*
|
|
201
|
+
* Two different asks down one route: adding puts an album on the end of the
|
|
202
|
+
* playlist, replacing points the server somewhere else entirely. The server
|
|
203
|
+
* adds unless told otherwise, so only the second one says anything.
|
|
204
|
+
*/
|
|
205
|
+
async function restream(
|
|
206
|
+
target: AdminOptions,
|
|
207
|
+
headers: Record<string, string>,
|
|
208
|
+
url: string,
|
|
209
|
+
replacing = false,
|
|
210
|
+
): Promise<void> {
|
|
194
211
|
try {
|
|
195
212
|
await fetch(`${target.url}/api/source`, {
|
|
196
213
|
method: "POST",
|
|
197
214
|
headers: { ...headers, "content-type": "application/json" },
|
|
198
|
-
body: JSON.stringify({ source: url }),
|
|
215
|
+
body: JSON.stringify({ source: url, ...(replacing ? { replace: true } : {}) }),
|
|
199
216
|
});
|
|
200
217
|
} catch {
|
|
201
218
|
// The next refresh reports the server being unreachable; this is not the
|
|
@@ -210,6 +227,8 @@ export interface View {
|
|
|
210
227
|
error: string;
|
|
211
228
|
typing: boolean;
|
|
212
229
|
restreaming: string;
|
|
230
|
+
/** Whether what is being typed replaces the playlist rather than joining it. */
|
|
231
|
+
replacing?: boolean;
|
|
213
232
|
/** Labelled addresses, and the key that makes them work. */
|
|
214
233
|
links: { label: string; url: string }[];
|
|
215
234
|
key: string | null;
|
|
@@ -296,15 +315,21 @@ export function draw(ui: Container, theme: Theme, view: View): void {
|
|
|
296
315
|
});
|
|
297
316
|
|
|
298
317
|
if (view.typing) {
|
|
299
|
-
ui.panel({
|
|
318
|
+
ui.panel({
|
|
319
|
+
title: view.replacing ? "Replace the playlist with a URL or a path" : "Add a URL or a path",
|
|
320
|
+
size: 4,
|
|
321
|
+
}, (p) => {
|
|
300
322
|
p.text(`${view.restreaming}_`, { fg: theme.accent });
|
|
301
|
-
p.label(
|
|
323
|
+
p.label(view.replacing
|
|
324
|
+
? "Enter drops this library and serves that instead. Escape forgets it."
|
|
325
|
+
: "Enter adds it to the playlist. Escape forgets it.");
|
|
302
326
|
});
|
|
303
327
|
}
|
|
304
328
|
|
|
305
329
|
ui.statusBar({
|
|
306
330
|
items: [
|
|
307
|
-
{ key: "
|
|
331
|
+
{ key: "a", label: "Add" },
|
|
332
|
+
{ key: "r", label: "Replace" },
|
|
308
333
|
{ key: "q", label: "Quit" },
|
|
309
334
|
],
|
|
310
335
|
right: [{ key: "", label: report ? `${report.connections.length} seen` : "connecting" }],
|
package/src/protocol.ts
CHANGED
|
@@ -21,6 +21,14 @@ export interface RemoteTrack {
|
|
|
21
21
|
* a browser could show played its soundtrack over a blank panel.
|
|
22
22
|
*/
|
|
23
23
|
video?: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* The source this track came in with, when it was not part of the library.
|
|
26
|
+
*
|
|
27
|
+
* Absent means it belongs to whatever this server was started on. Present
|
|
28
|
+
* means somebody added a folder or an album afterwards, and the name is what
|
|
29
|
+
* a client puts at the top of that block so the two are not one soup.
|
|
30
|
+
*/
|
|
31
|
+
group?: string;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
/** Everything a remote needs to draw the player. */
|
package/src/server.ts
CHANGED
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
type PaywallConfig,
|
|
60
60
|
paywallFromEnv,
|
|
61
61
|
} from "./paywall.ts";
|
|
62
|
-
import { isRemote, playsInBrowser } from "./sources.ts";
|
|
62
|
+
import { isRemote, playsInBrowser, sourceLabel } from "./sources.ts";
|
|
63
63
|
import { codecsOf, videoArgs } from "./audio.ts";
|
|
64
64
|
import {
|
|
65
65
|
allowedForListening,
|
|
@@ -389,6 +389,16 @@ export function safeJoin(rootDir: string, urlPath: string): string | null {
|
|
|
389
389
|
return full;
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
+
/**
|
|
393
|
+
* A track and the source it arrived with.
|
|
394
|
+
*
|
|
395
|
+
* The library a server was started on has no group: it is simply what this
|
|
396
|
+
* machine has. Anything added afterwards carries the name of the folder or
|
|
397
|
+
* album it came from, which is what lets a client draw the two apart instead
|
|
398
|
+
* of running them together.
|
|
399
|
+
*/
|
|
400
|
+
export type Loaded = Track & { group?: string };
|
|
401
|
+
|
|
392
402
|
/** What the HTTP layer needs from a player. Tests hand it a fake. */
|
|
393
403
|
export interface Engine {
|
|
394
404
|
/** `withTracks` false leaves the library out, for a frame that is only motion. */
|
|
@@ -398,11 +408,29 @@ export interface Engine {
|
|
|
398
408
|
/** Absolute path of a track, or undefined when the index is not one. */
|
|
399
409
|
trackPath(index: number): string | undefined;
|
|
400
410
|
/**
|
|
401
|
-
* Play something else instead
|
|
402
|
-
*
|
|
403
|
-
*
|
|
411
|
+
* Play something else instead of everything here.
|
|
412
|
+
*
|
|
413
|
+
* The big hammer, and no longer what adding a folder does: this is "point
|
|
414
|
+
* this server somewhere else", which throws the library away on purpose.
|
|
404
415
|
*/
|
|
405
416
|
replace(tracks: Track[], root: string): void;
|
|
417
|
+
/**
|
|
418
|
+
* Play something as well as everything here.
|
|
419
|
+
*
|
|
420
|
+
* What somebody means by putting a folder in a box: the album shows up at
|
|
421
|
+
* the bottom of the playlist under its own name, and the music that was
|
|
422
|
+
* already there is still there. Answers how many tracks were new.
|
|
423
|
+
*/
|
|
424
|
+
add(tracks: Track[], from: string): number;
|
|
425
|
+
/**
|
|
426
|
+
* Take an added source back out again, by the name `add` gave it.
|
|
427
|
+
*
|
|
428
|
+
* Nothing that came with the library can be dropped this way; the library is
|
|
429
|
+
* what the server is, and there is a command line for changing that.
|
|
430
|
+
*/
|
|
431
|
+
drop(group: string): number;
|
|
432
|
+
/** Every added source, in the order they were added. */
|
|
433
|
+
groups(): string[];
|
|
406
434
|
/**
|
|
407
435
|
* The same tracks, now with their tags.
|
|
408
436
|
*
|
|
@@ -416,7 +444,7 @@ export interface Engine {
|
|
|
416
444
|
stop(): void;
|
|
417
445
|
}
|
|
418
446
|
|
|
419
|
-
export function toRemoteTracks(tracks:
|
|
447
|
+
export function toRemoteTracks(tracks: Loaded[]): RemoteTrack[] {
|
|
420
448
|
return tracks.map((t) => ({
|
|
421
449
|
title: t.title,
|
|
422
450
|
artist: t.artist,
|
|
@@ -426,6 +454,9 @@ export function toRemoteTracks(tracks: Track[]): RemoteTrack[] {
|
|
|
426
454
|
// every track to the audio element -- a film's soundtrack over a blank
|
|
427
455
|
// panel, which is exactly what it looked like.
|
|
428
456
|
...(hasPicture(t.path) ? { video: true } : {}),
|
|
457
|
+
// Only for what was added; the library's own tracks say nothing, which is
|
|
458
|
+
// how a client knows they are the library.
|
|
459
|
+
...(t.group ? { group: t.group } : {}),
|
|
429
460
|
}));
|
|
430
461
|
}
|
|
431
462
|
|
|
@@ -463,7 +494,7 @@ export class PlayerEngine implements Engine {
|
|
|
463
494
|
};
|
|
464
495
|
|
|
465
496
|
constructor(
|
|
466
|
-
private tracks:
|
|
497
|
+
private tracks: Loaded[],
|
|
467
498
|
private root: string,
|
|
468
499
|
tools: Tools,
|
|
469
500
|
/** Frames a second pushed to remotes. */
|
|
@@ -637,13 +668,85 @@ export class PlayerEngine implements Engine {
|
|
|
637
668
|
this.push(true);
|
|
638
669
|
}
|
|
639
670
|
|
|
671
|
+
/**
|
|
672
|
+
* Load something as well as what is already here.
|
|
673
|
+
*
|
|
674
|
+
* Adding a folder used to be `replace`, so pointing a server at an album on
|
|
675
|
+
* the web threw away the music on its disk: the playlist you were looking at
|
|
676
|
+
* turned into somebody else's twenty-eight tracks, and clicking your own
|
|
677
|
+
* files played theirs. Nothing about playback changes here -- whatever was
|
|
678
|
+
* playing keeps playing, at the same index, because the new tracks go on the
|
|
679
|
+
* end.
|
|
680
|
+
*
|
|
681
|
+
* Paths already loaded are skipped, so adding the same album twice is not
|
|
682
|
+
* two copies of it.
|
|
683
|
+
*/
|
|
684
|
+
add(tracks: Track[], from: string): number {
|
|
685
|
+
const group = sourceLabel(from);
|
|
686
|
+
const known = new Set(this.tracks.map((track) => track.path));
|
|
687
|
+
const fresh = tracks.filter((track) => !known.has(track.path)).map((track) => ({ ...track, group }));
|
|
688
|
+
if (fresh.length === 0) return 0;
|
|
689
|
+
this.tracks = [...this.tracks, ...fresh];
|
|
690
|
+
// The list itself changed, so it has to ride this frame; a count nobody
|
|
691
|
+
// can index into is worse than no news at all.
|
|
692
|
+
this.push(true);
|
|
693
|
+
return fresh.length;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Take an added source back out.
|
|
698
|
+
*
|
|
699
|
+
* The track that is playing is followed rather than an index: removing an
|
|
700
|
+
* album from above the current track would otherwise slide the playlist out
|
|
701
|
+
* from under a listener mid-song. If the playing track is itself in what is
|
|
702
|
+
* being removed, playback stops -- there is nothing to keep playing.
|
|
703
|
+
*/
|
|
704
|
+
drop(group: string): number {
|
|
705
|
+
if (group === "") return 0;
|
|
706
|
+
const playingPath = this.tracks[this.state.index]?.path;
|
|
707
|
+
const kept = this.tracks.filter((track) => track.group !== group);
|
|
708
|
+
const removed = this.tracks.length - kept.length;
|
|
709
|
+
if (removed === 0) return 0;
|
|
710
|
+
this.tracks = kept;
|
|
711
|
+
const stillThere = kept.findIndex((track) => track.path === playingPath);
|
|
712
|
+
if (stillThere === -1) {
|
|
713
|
+
this.halt();
|
|
714
|
+
this.state.index = this.clamp(this.state.index);
|
|
715
|
+
} else {
|
|
716
|
+
this.state.index = stillThere;
|
|
717
|
+
}
|
|
718
|
+
this.push(true);
|
|
719
|
+
return removed;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
groups(): string[] {
|
|
723
|
+
const seen: string[] = [];
|
|
724
|
+
for (const track of this.tracks) {
|
|
725
|
+
if (track.group && !seen.includes(track.group)) seen.push(track.group);
|
|
726
|
+
}
|
|
727
|
+
return seen;
|
|
728
|
+
}
|
|
729
|
+
|
|
640
730
|
retag(tracks: Track[], root: string): void {
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
731
|
+
// Matched by path rather than by position, because the list is no longer
|
|
732
|
+
// required to be the one that was sent for tagging: somebody can add an
|
|
733
|
+
// album while a library's tags are still being read, and an exact-shape
|
|
734
|
+
// check would throw away every tag for it. Tags that describe tracks which
|
|
735
|
+
// are no longer here simply match nothing, which is the same protection
|
|
736
|
+
// the shape check was giving.
|
|
737
|
+
void root;
|
|
738
|
+
const byPath = new Map(tracks.map((track) => [track.path, track]));
|
|
739
|
+
let changed = false;
|
|
740
|
+
const merged = this.tracks.map((track) => {
|
|
741
|
+
const tagged = byPath.get(track.path);
|
|
742
|
+
if (!tagged || tagged === track) return track;
|
|
743
|
+
changed = true;
|
|
744
|
+
// The group is ours, not the tagger's: it knows what a track is called,
|
|
745
|
+
// not which pile it is in.
|
|
746
|
+
return { ...tagged, ...(track.group ? { group: track.group } : {}) };
|
|
747
|
+
});
|
|
748
|
+
if (!changed) return;
|
|
749
|
+
this.tracks = merged;
|
|
647
750
|
// No stop, no index reset: the only thing that changes is what the titles
|
|
648
751
|
// say, and every remote finds out because a snapshot goes out -- carrying
|
|
649
752
|
// the list, since the titles are the whole point of this one.
|
|
@@ -667,6 +770,15 @@ export class EmptyEngine implements Engine {
|
|
|
667
770
|
return undefined;
|
|
668
771
|
}
|
|
669
772
|
replace(): void {}
|
|
773
|
+
add(): number {
|
|
774
|
+
return 0;
|
|
775
|
+
}
|
|
776
|
+
drop(): number {
|
|
777
|
+
return 0;
|
|
778
|
+
}
|
|
779
|
+
groups(): string[] {
|
|
780
|
+
return [];
|
|
781
|
+
}
|
|
670
782
|
retag(): void {}
|
|
671
783
|
stop(): void {}
|
|
672
784
|
}
|
|
@@ -1098,7 +1210,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1098
1210
|
path !== "/api/directory" &&
|
|
1099
1211
|
!isSignInPath(path)
|
|
1100
1212
|
) {
|
|
1101
|
-
|
|
1213
|
+
let scope = scopeOf(keyFrom(request, url), key, listenKey);
|
|
1214
|
+
// A key is how somebody who was invited proves it. It is not the only way
|
|
1215
|
+
// to be allowed in: the person who owns this server is allowed in whether
|
|
1216
|
+
// or not they still have the link, and their nixamp.com session says who
|
|
1217
|
+
// they are. Without this, signing in as yourself and opening your own
|
|
1218
|
+
// server was refused, and the address of a machine you administer was
|
|
1219
|
+
// useless without a link you had to go and find.
|
|
1220
|
+
if (scope === null && options.owner) {
|
|
1221
|
+
const check = await options.owner.check(false, tokenFrom(request.headers));
|
|
1222
|
+
if (check.allowed) scope = "control";
|
|
1223
|
+
}
|
|
1102
1224
|
if (scope === null) {
|
|
1103
1225
|
// Counted, not because a 128-bit key falls to guessing, but because
|
|
1104
1226
|
// somebody hammering one should stop costing this server anything.
|
|
@@ -1109,7 +1231,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
1109
1231
|
response.end(JSON.stringify({ error: "too many attempts; wait a moment" }));
|
|
1110
1232
|
return;
|
|
1111
1233
|
}
|
|
1112
|
-
json(response, 401, {
|
|
1234
|
+
json(response, 401, {
|
|
1235
|
+
error: "this nixamp needs the key from its share link, or sign in as its owner",
|
|
1236
|
+
});
|
|
1113
1237
|
return;
|
|
1114
1238
|
}
|
|
1115
1239
|
if (scope === "listen" && !allowedForListening(path)) {
|
|
@@ -2030,16 +2154,50 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2030
2154
|
return;
|
|
2031
2155
|
}
|
|
2032
2156
|
|
|
2033
|
-
//
|
|
2034
|
-
//
|
|
2157
|
+
// Take an added source back out of the playlist. The library it was added
|
|
2158
|
+
// to is untouched -- there is no group name that names it.
|
|
2159
|
+
if (path === "/api/source/remove") {
|
|
2160
|
+
if (request.method !== "POST") {
|
|
2161
|
+
json(response, 405, { error: "POST only" });
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
let group = "";
|
|
2165
|
+
try {
|
|
2166
|
+
group = String((JSON.parse(await readBody(request)) as { group?: unknown }).group ?? "");
|
|
2167
|
+
} catch {
|
|
2168
|
+
json(response, 400, { error: "bad JSON" });
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
if (!group) {
|
|
2172
|
+
json(response, 400, { error: "no group given" });
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
const removed = engine.drop(group);
|
|
2176
|
+
if (removed === 0) {
|
|
2177
|
+
json(response, 404, { error: `nothing here came from ${group}` });
|
|
2178
|
+
return;
|
|
2179
|
+
}
|
|
2180
|
+
json(response, 200, { ...engine.snapshot(), removed, groups: engine.groups() });
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
// Hand the running server another source. The listeners stay connected;
|
|
2185
|
+
// by default they get more to listen to, and only an explicit `replace`
|
|
2186
|
+
// swaps what this server is for something else.
|
|
2035
2187
|
if (path === "/api/source") {
|
|
2036
2188
|
if (request.method !== "POST") {
|
|
2037
2189
|
json(response, 405, { error: "POST only" });
|
|
2038
2190
|
return;
|
|
2039
2191
|
}
|
|
2040
2192
|
let source = "";
|
|
2193
|
+
let replacing = false;
|
|
2041
2194
|
try {
|
|
2042
|
-
|
|
2195
|
+
const body = JSON.parse(await readBody(request)) as { source?: unknown; replace?: unknown };
|
|
2196
|
+
source = String(body.source ?? "");
|
|
2197
|
+
// Adding is what somebody means by putting a folder in a box, so it is
|
|
2198
|
+
// the default. Replacing is the much larger claim that this server now
|
|
2199
|
+
// serves that instead, so it is the one you have to ask for.
|
|
2200
|
+
replacing = body.replace === true;
|
|
2043
2201
|
} catch {
|
|
2044
2202
|
json(response, 400, { error: "bad JSON" });
|
|
2045
2203
|
return;
|
|
@@ -2054,8 +2212,20 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2054
2212
|
json(response, 422, { error: `nothing to play at ${source}` });
|
|
2055
2213
|
return;
|
|
2056
2214
|
}
|
|
2057
|
-
|
|
2058
|
-
|
|
2215
|
+
let added = tracks.length;
|
|
2216
|
+
if (replacing) {
|
|
2217
|
+
engine.replace(tracks, source);
|
|
2218
|
+
} else {
|
|
2219
|
+
added = engine.add(tracks, source);
|
|
2220
|
+
if (added === 0) {
|
|
2221
|
+
// Everything there was already here. Not an error -- the playlist
|
|
2222
|
+
// is exactly what the caller asked for -- but worth saying, so a
|
|
2223
|
+
// client can tell that apart from having added an album.
|
|
2224
|
+
json(response, 200, { ...engine.snapshot(), added: 0, groups: engine.groups() });
|
|
2225
|
+
return;
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
// Names now, tags later, here as much as at startup: loading a
|
|
2059
2229
|
// directory of five thousand files used to read every tag before it
|
|
2060
2230
|
// answered, with the event loop held the whole time.
|
|
2061
2231
|
if (options.tag) {
|
|
@@ -2064,7 +2234,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2064
2234
|
.then((tagged) => engine.retag(tagged, source))
|
|
2065
2235
|
.catch(() => {});
|
|
2066
2236
|
}
|
|
2067
|
-
json(response, 200, engine.snapshot());
|
|
2237
|
+
json(response, 200, { ...engine.snapshot(), added, replaced: replacing, groups: engine.groups() });
|
|
2068
2238
|
} catch (error) {
|
|
2069
2239
|
json(response, 422, { error: (error as Error).message.replace(/^nixamp: /, "") });
|
|
2070
2240
|
}
|
package/src/share.ts
CHANGED
|
@@ -199,7 +199,11 @@ export function scopeOf(offered: string | null, control: string | null, listen:
|
|
|
199
199
|
|
|
200
200
|
/** Paths a listen key may have. Everything else needs the control key. */
|
|
201
201
|
export function allowedForListening(path: string): boolean {
|
|
202
|
-
if (path === "/api/command"
|
|
202
|
+
if (path === "/api/command") return false;
|
|
203
|
+
// Prefix, not equality: everything under this changes what the server plays,
|
|
204
|
+
// and an exact check let a listen key reach /api/source/remove and delete an
|
|
205
|
+
// album out of somebody else's playlist.
|
|
206
|
+
if (path === "/api/source" || path.startsWith("/api/source/")) return false;
|
|
203
207
|
return true;
|
|
204
208
|
}
|
|
205
209
|
|
package/src/sources.ts
CHANGED
|
@@ -106,6 +106,27 @@ export function parsePls(text: string, base: string): Entry[] {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/** The last useful part of a path or URL, for when nothing named the track. */
|
|
109
|
+
/**
|
|
110
|
+
* What to call a whole source, as a heading over the tracks it brought.
|
|
111
|
+
*
|
|
112
|
+
* `nameOf` answers for a file; this answers for the thing a person added --
|
|
113
|
+
* usually the last segment either way, but a URL that is only a host has no
|
|
114
|
+
* segment to take, and "the album at that address" reads better as the host
|
|
115
|
+
* than as the whole URL repeated over every row.
|
|
116
|
+
*/
|
|
117
|
+
export function sourceLabel(source: string): string {
|
|
118
|
+
const trimmed = source.replace(/\/+$/, "");
|
|
119
|
+
if (trimmed === "") return source;
|
|
120
|
+
const named = nameOf(trimmed);
|
|
121
|
+
if (named !== trimmed && named !== "") return named;
|
|
122
|
+
if (!isRemote(trimmed)) return trimmed;
|
|
123
|
+
try {
|
|
124
|
+
return new URL(trimmed).host;
|
|
125
|
+
} catch {
|
|
126
|
+
return trimmed;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
109
130
|
export function nameOf(source: string): string {
|
|
110
131
|
const remote = isRemote(source);
|
|
111
132
|
const path = remote ? new URL(source).pathname : source;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-a1aekKkP.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}@media (max-width:720px){.split{grid-template-columns:1fr}}.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}.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-BsLZl7PK.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-CviHHfb1.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 O(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 k(e,t,n=0,r=``){return D(e,n>0?`/api/media/${t}?kbps=${Math.round(n)}`:`/api/media/${t}`,r)}function A(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 re=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 connected(){return this.source!==null}connect(e){let{base:t,key:n}=O(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=A(j(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=A(await t.json());n&&this.handlers.onSnapshot(n)}media(e,t=0){return k(this.base,e,t,this.key)}close(){this.source?.close(),this.source=null}};function j(e){try{return JSON.parse(e)}catch{return null}}async function ie(e,t,n=``){try{let r=await fetch(D(e,`/api/state`,n),{signal:t});return r.ok?A(await r.json()):null}catch{return null}}async function ae(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 M(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 oe(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 N=.14,P=.02;function se(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 ce(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 le(e,t,n=N){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function F(e,t,n=P){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function ue(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 I=`nixamp.remote`,L=`nixamp.volume`,R=`nixamp.listenHere`;function z(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function B(){let n={status:z(`status`),source:z(`source`),install:z(`install`),video:z(`video`),audio:z(`audio`),title:z(`title-line`),album:z(`album-line`),elapsed:z(`elapsed`),total:z(`total`),seek:z(`seek`),canvas:z(`spectrum`),glyphs:z(`glyphs`),levels:z(`levels`),playlist:z(`playlist`),playlistTitle:z(`playlist-panel`),note:z(`note`),files:z(`files`),folder:z(`folder`),remoteUrl:z(`remote-url`),remoteForm:z(`remote-form`),remoteState:z(`remote-state`),disconnect:z(`disconnect`),browse:z(`browse`),accountForm:z(`account-form`),accountEmail:z(`account-email`),accountPassword:z(`account-password`),accountSubmit:z(`account-submit`),accountToggle:z(`account-toggle`),accountProviders:z(`account-providers`),accountPanel:z(`account-panel`),accountElsewhere:z(`account-elsewhere`),accountSignOut:z(`account-signout`),accountNote:z(`account-note`),adminPanel:z(`admin-panel`),adminNote:z(`admin-note`),adminConnections:z(`admin-connections`),adminRestream:z(`admin-restream`),adminReplace:z(`admin-replace`),adminSource:z(`admin-source`),directory:z(`directory`),recentNote:z(`recent-note`),recentList:z(`recent-list`),followingNote:z(`following-note`),followingList:z(`following-list`),serversPanel:z(`servers-panel`),serversNote:z(`servers-note`),serversList:z(`servers-list`),notifyPanel:z(`notify-panel`),notifyNote:z(`notify-note`),notifyWeb:z(`notify-web`),notifyEmail:z(`notify-email`),notifySms:z(`notify-sms`),notifyPhone:z(`notify-phone`),notifyPhoneForm:z(`notify-phone-form`),notifyPhoneNote:z(`notify-phone-note`),directoryNote:z(`directory-note`),directoryList:z(`directory-list`),listenHere:z(`listen-here`),volume:z(`volume`),prev:z(`prev`),playPause:z(`play-pause`),stop:z(`stop`),next:z(`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=Array(24).fill(0),p=Array(24).fill(0),m=[],h=()=>r===`remote`&&!n.listenHere.checked,g=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:()=>j(1),onState:()=>B(),onError:e=>{l=e,B()}}),_=new re({onSnapshot:e=>{o=ne(o,e),h()&&(f=e.bars.length>0?e.bars:f,p=F(p,f)),B()},onStatus:(e,t)=>{s=e,c=t??``,B()}}),v=()=>r===`remote`?o.tracks.length:i.length,b=()=>r===`remote`?h()||d<0?o.index:Math.min(d,Math.max(0,o.tracks.length-1)):a,x=()=>{let e=r===`remote`?o.tracks[b()]:i[b()];return e?t(e):`Nothing loaded.`},S=()=>(r===`remote`?o.tracks[b()]:i[b()])?.album||`—`,C=()=>h()?o.tracks[b()]?.duration??0:g.duration,w=()=>h()?o.position:g.position,E=()=>h()?o.playing:g.playing;async function D(e){if(r===`remote`){if(h()){await _.send({type:`play`,index:e});return}await k(e);return}let t=i[e];t&&(a=e,await g.load(t,!0),W(t.video),G(),B())}async function k(e){let t=o.tracks[e];t&&(d=e,await g.load({title:t.title,artist:t.artist,album:t.album,duration:t.duration,url:_.media(e,0),video:t.video===!0,objectUrl:!1},!0),W(t.video===!0),G())}async function A(){if(h()){await _.send({type:`toggle`});return}v()!==0&&(g.playing?g.pause():g.position>0?await g.play():await D(b()),B())}async function j(e){let t=v();if(t!==0){if(h()){await _.send({type:e>0?`next`:`prev`});return}await D((b()+e+t)%t)}}async function N(){if(h()){await _.send({type:`stop`});return}g.stop(),f=Array(24).fill(0),p=[...f],B()}let P=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))];function B(){let t=v(),a=E();n.status.textContent=a?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=String(a),n.title.textContent=x(),n.album.textContent=S();let d=w(),p=C();n.elapsed.textContent=e(d),n.total.textContent=p>0?e(p):`--:--`,u||(n.seek.value=String(p>0?Math.round(d/p*1e3):0),n.seek.disabled=p<=0||h()),n.playPause.textContent=a?`❚❚`:`▶`,n.playPause.setAttribute(`aria-label`,a?`Pause`:`Play`),n.playlistTitle.dataset.title=`Playlist (${t})`,n.source.textContent=r===`remote`?`remote · ${_.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===``,de(),n.glyphs.textContent=f.map(P).join(``);let[y,b]=h()?o.levels:g.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(y*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(b*6)).padEnd(6,`·`)}`}let V=``,H=-1;function de(){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(fe(n.group)));let o=document.createElement(`li`);o.className=`row`,o.dataset.index=String(a);let s=document.createElement(`span`);s.className=`n`,s.textContent=String(a+1).padStart(2,` `);let c=document.createElement(`span`);c.className=`name`,c.textContent=n.name;let l=document.createElement(`span`);l.className=`time`,l.textContent=n.seconds>0?e(n.seconds):`--:--`,o.append(s,c,l),t.push(o)}),n.playlist.replaceChildren(...t)}let c=b(),l=E(),u;for(let e of Array.from(n.playlist.children)){let t=e,n=Number(t.dataset.index),r=Number.isInteger(n)&&n===c;t.classList.toggle(`selected`,r),t.classList.toggle(`playing`,r&&l),r&&(u=t)}c!==H&&(H=c,u?.scrollIntoView({block:`nearest`}))}function fe(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(),pe(e)}),t.append(n)}return t}async function pe(e){try{let t=await fetch(_.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(h())p=F(p,f);else{let e=g.read();e.length>0&&(m.length!==25&&(m=se(24,e.length)),f=le(f,ce(e,m)),p=F(p,f))}if(s){let e=getComputedStyle(document.documentElement);ue(s,{width:t.width,height:t.height},f,p,{bar:e.getPropertyValue(`--green`).trim()||`#4af689`,peak:e.getPropertyValue(`--green-dim`).trim()||`#227a4a`,background:`transparent`})}if(E()){n.glyphs.textContent=f.map(P).join(``);let[t,r]=h()?o.levels:g.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(t*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(r*6)).padEnd(6,`·`)}`,n.elapsed.textContent=e(w());let i=C();!u&&i>0&&(n.seek.value=String(Math.round(w()/i*1e3)))}requestAnimationFrame(U)}function W(e){n.video.hidden=!e}function G(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:x(),album:S(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void A()),navigator.mediaSession.setActionHandler(`pause`,()=>void A()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void j(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void j(-1)))}n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&D(n)}),n.prev.addEventListener(`click`,()=>void j(-1)),n.next.addEventListener(`click`,()=>void j(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=C();e>0&&g.seek(Number(n.seek.value)/1e3*e),u=!1}),n.volume.addEventListener(`input`,()=>{let e=Number(n.volume.value)/100;g.volume=e;try{localStorage.setItem(L,String(e))}catch{}});let me=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`,_.close(),l=``,D(0)})};me(n.files),me(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.remoteUrl.value,{base:i,key:a}=O(t);if(i===``){l=`That is not an address.`,B();return}(async()=>{s=`connecting`,B();let e=oe(i);if(e){s=`error`,c=e,l=e,r=`local`,B();return}if(await M(i,void 0,a)===null){s=`error`,c=`no nixamp answered there`,r=`local`,B();return}let n=await ae(i,a);if(n){s=`error`,c=n,l=n,r=`local`,B();return}r=`remote`,l=``;try{localStorage.setItem(I,t.trim())}catch{}_.connect(t),B()})()});let he=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??[],ye(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&&Z&&t.ownerId!==Z&&e.append(J(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),he()}let K=null,ge=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)}},_e=async()=>{try{let e=await fetch(`/api/connections`);if(!e.ok)return;let t=await e.json();n.adminNote.textContent=`${t.active??0} listening now.`,ge(t.connections??[])}catch{n.adminNote.textContent=`lost touch with the server`}},q=async()=>{let e=!1,t=null;try{let n=await fetch(_.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.`,_e(),K=setInterval(()=>void _e(),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(_.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 ve=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`},ye=e=>{n.recentList.replaceChildren();let t=Z?e.filter(e=>e.ownerId&&e.ownerId!==Z):[];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 ${ve(e.endedAt)}`:`ended ${ve(e.endedAt)}`,r.append(i,a),t.append(r,J(e.ownerId,e.name)),n.recentList.append(t)}},be=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(`a`);o.className=`button`,o.textContent=`Open`,o.href=e.key?`${e.url}/s/${e.key}`:e.url,o.rel=`noreferrer`;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 be()}catch{s.disabled=!1}})()}),t.append(r,o,s),n.serversList.append(t)}}catch{n.serversPanel.hidden=!0}},xe=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}},J=(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),xe())}catch{}finally{n.disabled=!1}})()}),n},Se=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},Ce=()=>`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window,we=async()=>{if(!Ce())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:Se(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}},Te=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{}},Y=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`}},Ee=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=Ce()&&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 we();n.notifyWeb.checked=e,await Y({wantsWeb:e});return}await Te(),await Y({wantsWeb:!1}),n.notifyNote.textContent=`Turn on “On this device” to be told here.`})()}),n.notifyEmail.addEventListener(`change`,()=>{Y({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 Y({wantsSms:n.notifySms.checked})})()}),n.notifyPhoneForm.addEventListener(`submit`,e=>{e.preventDefault(),Y({phone:n.notifyPhone.value.trim()})});let X=!1,Z=``,Q=e=>{let t=e!==null;n.notifyPanel.hidden=!t,t?(Ee(),xe(),be()):(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}.`:X?`Create an account on nixamp.com.`:`Sign in to nixamp.com to publish and get paid.`,n.accountSubmit.textContent=X?`Create account`:`Sign in`,n.accountToggle.textContent=X?`I have one`:`Create one`,n.accountPassword.autocomplete=X?`new-password`:`current-password`};n.accountToggle.addEventListener(`click`,()=>{X=!X,Q(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/${X?`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}Z=i.account?.id??``,n.accountPassword.value=``,Q(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{}Z=``,Q(null),q()})()}),(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)}})(),(async()=>{try{let e=await fetch(`/api/v1/auth/me`),t=await e.json();Z=e.ok?t.account?.id??``:``,Q(e.ok?t.account?.email??`you`:null)}catch{Z=``,Q(null)}})(),q(),n.browse.addEventListener(`click`,()=>{if(!n.directory.hidden){n.directory.hidden=!0;return}he(),n.directory.scrollIntoView({behavior:`smooth`,block:`nearest`})}),n.disconnect.addEventListener(`click`,()=>{_.close(),d=-1,r=`local`,s=`idle`,c=``,B()}),n.listenHere.addEventListener(`change`,()=>{try{localStorage.setItem(R,n.listenHere.checked?`1`:`0`)}catch{}r===`remote`&&(async()=>{n.listenHere.checked?(await _.send({type:`stop`}),await k(o.index)):(g.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`:j(1);return;case`p`:case`ArrowLeft`:j(-1);return;case`ArrowDown`:e.preventDefault(),D(Math.min(v()-1,b()+1));return;case`ArrowUp`:e.preventDefault(),D(Math.max(0,b()-1));return}});let $=null;globalThis.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),$=e,n.install.hidden=!1}),n.install.addEventListener(`click`,()=>{$?.prompt(),$=null,n.install.hidden=!0});try{let e=localStorage.getItem(L);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),g.volume=Number(e));let t=localStorage.getItem(I);t&&(n.remoteUrl.value=t),localStorage.getItem(R)===`0`&&(n.listenHere.checked=!1)}catch{}(async()=>{if(n.remoteUrl.value!==``)return;let e=globalThis.location.origin;if(await M(e)===null)return;let t=await ie(e);t&&t.trackCount!==0&&(n.remoteUrl.value=e,r=`remote`,l=``,_.connect(e),B())})(),B(),requestAnimationFrame(U)}B(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});export{c as t};
|