nixamp 0.7.3 → 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/src/admin.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  import { createApp, themes, type Container, type KeyEvent, type Theme } from "@profullstack/hqtui";
9
9
  import type { Color } from "@profullstack/hqtui";
10
10
  import type { Connection } from "./connections.ts";
11
- import { daemonUrl, readState } from "./daemon.ts";
11
+ import { daemonUrl, isLoopbackTls, readState } from "./daemon.ts";
12
12
  import { KEY_HEADER } from "./share.ts";
13
13
 
14
14
  interface Report {
@@ -59,8 +59,12 @@ export function resolveTarget(argv: string[]): AdminOptions {
59
59
  if (state === null) {
60
60
  throw new Error("nixamp: no daemon is running. Start one with `nixamp daemon start`, or pass --url.");
61
61
  }
62
+ const url_ = daemonUrl(state);
63
+ // Talking to our own daemon, whose certificate names somewhere else. Nothing
64
+ // is in the way on loopback, so there is nothing for a certificate to prove.
65
+ if (isLoopbackTls(url_)) process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0";
62
66
  return {
63
- url: daemonUrl(state),
67
+ url: url_,
64
68
  key: key ?? state.key,
65
69
  // A state file written before 0.5.3 has no list; loopback stands in.
66
70
  links: state.urls ?? [{ label: "here", url: daemonUrl(state) }],
@@ -142,6 +146,9 @@ export async function admin(argv: string[]): Promise<void> {
142
146
  let snapshot: Snapshot | null = null;
143
147
  let error = "";
144
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;
145
152
  let typing = false;
146
153
 
147
154
  const app = await createApp({ theme: themes.matrix, title: "nixamp admin", quitKeys: ["ctrl+c"] });
@@ -160,24 +167,27 @@ export async function admin(argv: string[]): Promise<void> {
160
167
  app.on("key", (event: KeyEvent) => {
161
168
  const key = event.key;
162
169
  if (typing) {
163
- if (key === "escape") { typing = false; restreaming = ""; }
170
+ if (key === "escape") { typing = false; restreaming = ""; replacing = false; }
164
171
  else if (key === "enter") {
165
172
  const url = restreaming.trim();
173
+ const asReplacement = replacing;
166
174
  typing = false;
167
175
  restreaming = "";
168
- if (url) void restream(target, headers, url).then(() => refresh());
176
+ replacing = false;
177
+ if (url) void restream(target, headers, url, asReplacement).then(() => refresh());
169
178
  } else if (key === "backspace") restreaming = restreaming.slice(0, -1);
170
179
  else restreaming += typed(key);
171
180
  app.invalidate();
172
181
  return;
173
182
  }
174
183
  if (key === "q") { app.quit(); return; }
175
- if (key === "r") { typing = true; app.invalidate(); }
184
+ if (key === "a") { typing = true; replacing = false; app.invalidate(); }
185
+ if (key === "r") { typing = true; replacing = true; app.invalidate(); }
176
186
  });
177
187
 
178
188
  app.on("exit", () => clearInterval(timer));
179
189
  app.render(({ ui, theme }) => draw(ui, theme, {
180
- url: target.url, report, snapshot, error, typing, restreaming,
190
+ url: target.url, report, snapshot, error, typing, restreaming, replacing,
181
191
  links: target.links, key: target.key, source: target.source,
182
192
  }));
183
193
 
@@ -185,13 +195,24 @@ export async function admin(argv: string[]): Promise<void> {
185
195
  clearInterval(timer);
186
196
  }
187
197
 
188
- /** Ask the server to play something else, which is what re-streaming is. */
189
- async function restream(target: AdminOptions, headers: Record<string, string>, url: string): Promise<void> {
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> {
190
211
  try {
191
212
  await fetch(`${target.url}/api/source`, {
192
213
  method: "POST",
193
214
  headers: { ...headers, "content-type": "application/json" },
194
- body: JSON.stringify({ source: url }),
215
+ body: JSON.stringify({ source: url, ...(replacing ? { replace: true } : {}) }),
195
216
  });
196
217
  } catch {
197
218
  // The next refresh reports the server being unreachable; this is not the
@@ -206,6 +227,8 @@ export interface View {
206
227
  error: string;
207
228
  typing: boolean;
208
229
  restreaming: string;
230
+ /** Whether what is being typed replaces the playlist rather than joining it. */
231
+ replacing?: boolean;
209
232
  /** Labelled addresses, and the key that makes them work. */
210
233
  links: { label: string; url: string }[];
211
234
  key: string | null;
@@ -292,15 +315,21 @@ export function draw(ui: Container, theme: Theme, view: View): void {
292
315
  });
293
316
 
294
317
  if (view.typing) {
295
- ui.panel({ title: "Re-stream a URL or a path", size: 4 }, (p) => {
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) => {
296
322
  p.text(`${view.restreaming}_`, { fg: theme.accent });
297
- p.label("Enter plays it here. Escape forgets it.");
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.");
298
326
  });
299
327
  }
300
328
 
301
329
  ui.statusBar({
302
330
  items: [
303
- { key: "r", label: "Re-stream" },
331
+ { key: "a", label: "Add" },
332
+ { key: "r", label: "Replace" },
304
333
  { key: "q", label: "Quit" },
305
334
  ],
306
335
  right: [{ key: "", label: report ? `${report.connections.length} seen` : "connecting" }],
package/src/daemon.ts CHANGED
@@ -95,8 +95,38 @@ export function status(): { running: boolean; state: DaemonState | null } {
95
95
 
96
96
  /** The URL an admin client should talk to. */
97
97
  export function daemonUrl(state: DaemonState): string {
98
+ // A daemon serving https has a certificate for a name, and loopback is not
99
+ // that name. Asking it for https://localhost fails verification even though
100
+ // it is the same process on the same machine, which is how turning TLS on
101
+ // silently broke `nixamp admin` and `nixamp attach`. Prefer the address it
102
+ // was told to publish, which is the one the certificate is actually for.
103
+ const told = state.urls?.find(
104
+ (entry) => entry.label === "on the internet" && entry.url.startsWith("https://"),
105
+ );
106
+ if (told) return told.url;
107
+
108
+ const scheme = state.urls?.[0]?.url.startsWith("https://") ? "https" : "http";
98
109
  const host = state.host === "0.0.0.0" || state.host === "::" ? "127.0.0.1" : state.host;
99
- return `http://${host.includes(":") ? `[${host}]` : host}:${state.port}`;
110
+ return `${scheme}://${host.includes(":") ? `[${host}]` : host}:${state.port}`;
111
+ }
112
+
113
+ /**
114
+ * Whether this address is our own machine over TLS with a certificate that
115
+ * cannot possibly name it.
116
+ *
117
+ * A certificate proves you reached the host you asked for. Asking for loopback
118
+ * proves that already: nothing is in the way to impersonate. So a daemon with a
119
+ * certificate for some public name is still reachable at 127.0.0.1, and a
120
+ * client refusing to talk to it is protecting nobody from anything.
121
+ */
122
+ export function isLoopbackTls(url: string): boolean {
123
+ try {
124
+ const parsed = new URL(url);
125
+ if (parsed.protocol !== "https:") return false;
126
+ return parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1";
127
+ } catch {
128
+ return false;
129
+ }
100
130
  }
101
131
 
102
132
  /**
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. Re-streaming is the whole reason the admin
402
- * view exists: point a running server at a URL without restarting it and
403
- * dropping every listener.
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: Track[]): RemoteTrack[] {
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: Track[],
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
- // Dropped rather than applied if the library moved underneath: somebody
642
- // re-streamed while the tagging was still running, and these tags describe
643
- // something nobody is playing any more.
644
- if (root !== this.root || tracks.length !== this.tracks.length) return;
645
- if (tracks.some((track, at) => track.path !== this.tracks[at]?.path)) return;
646
- this.tracks = tracks;
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
- const scope = scopeOf(keyFrom(request, url), key, listenKey);
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, { error: "this nixamp needs the key from its share link" });
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
- // Re-stream: hand the running server a different source. The listeners
2034
- // stay connected; what they are listening to changes under them.
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
- source = String((JSON.parse(await readBody(request)) as { source?: unknown }).source ?? "");
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
- engine.replace(tracks, source);
2058
- // Names now, tags later, here as much as at startup: re-streaming a
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" || path === "/api/source") return false;
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-U2odRmpd.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};
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};