nixamp 0.7.7 → 0.7.9

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/server.ts CHANGED
@@ -43,6 +43,7 @@ import {
43
43
  SignIn,
44
44
  } from "./oauth.ts";
45
45
  import { needsAdmin, Owner } from "./owner.ts";
46
+ import { stateDir } from "./daemon.ts";
46
47
  import { readSession } from "./session.ts";
47
48
  import { Directory, ENDED_TTL_MS, parseAnnouncement, type Listing } from "./directory.ts";
48
49
  import { PartyLine, telnyxSms } from "./partyline.ts";
@@ -65,7 +66,9 @@ import {
65
66
  allowedForListening,
66
67
  elevate,
67
68
  firewallInUse,
69
+ certifiable,
68
70
  keyCookie,
71
+ rememberedKeys,
69
72
  keyFrom,
70
73
  keysMatch,
71
74
  lookupPublicIp,
@@ -106,6 +109,8 @@ export interface ServeOptions {
106
109
  * port, which is what the public deployment wants and no private one does.
107
110
  */
108
111
  key: boolean;
112
+ /** Mint a new share key rather than reusing the one this port had. */
113
+ newKey: boolean;
109
114
  /**
110
115
  * Ask the local firewall to let the port through, and put it back on the way
111
116
  * out. Off by default because it changes the machine, not just this process.
@@ -198,6 +203,7 @@ export function parseServeArgs(argv: string[]): ServeOptions {
198
203
  web: null,
199
204
  media: true,
200
205
  key: true,
206
+ newKey: false,
201
207
  openPort: false,
202
208
  announce: false,
203
209
  directory: false,
@@ -270,6 +276,8 @@ export function parseServeArgs(argv: string[]): ServeOptions {
270
276
  options.name = value();
271
277
  } else if (arg === "--owner") {
272
278
  options.owner = value();
279
+ } else if (arg === "--new-key") {
280
+ options.newKey = true;
273
281
  } else if (arg === "--ingest") {
274
282
  options.ingest = true;
275
283
  } else if (arg === "--rtmp-streams") {
@@ -957,6 +965,19 @@ export interface HandlerOptions {
957
965
  broadcaster?: Broadcaster;
958
966
  /** Where a broadcast should send, and what it should look like. */
959
967
  broadcast?: () => { destinations: Destination[]; settings: EncoderSettings };
968
+ /**
969
+ * Going live: whether this server is listed, and how to change that.
970
+ *
971
+ * Listing used to be a question asked once at startup and never again, so a
972
+ * server started with --no-publish had no listing, no phone code, and no
973
+ * link to hand anybody -- and no way to change its mind short of stopping
974
+ * and starting it. It is an action now, because that is what it is.
975
+ */
976
+ live?: {
977
+ status: () => { live: boolean; code: string; name: string; url: string; possible: boolean };
978
+ start: () => Promise<{ live: boolean; code: string; name: string; url: string; error?: string }>;
979
+ stop: () => Promise<void>;
980
+ };
960
981
  /**
961
982
  * Where OBS should point, one entry per stream this server will accept.
962
983
  *
@@ -2171,6 +2192,37 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2171
2192
 
2172
2193
  // Everything the admin view draws, in one request: who is connected, and
2173
2194
  // what this server is.
2195
+ // Going live, and coming back off. Admin-gated by ADMIN_PATHS, because
2196
+ // listing somebody's machine in a public directory is not a thing a
2197
+ // listener gets to do.
2198
+ if (path === "/api/live/state") {
2199
+ if (!options.live) {
2200
+ json(response, 200, { live: false, possible: false, code: "", name: "", url: "" });
2201
+ return;
2202
+ }
2203
+ json(response, 200, options.live.status());
2204
+ return;
2205
+ }
2206
+
2207
+ if (path === "/api/live/start" || path === "/api/live/stop") {
2208
+ if (request.method !== "POST") {
2209
+ json(response, 405, { error: "POST only" });
2210
+ return;
2211
+ }
2212
+ if (!options.live) {
2213
+ json(response, 409, { error: "this nixamp cannot be listed; it has no address the world can reach" });
2214
+ return;
2215
+ }
2216
+ if (path === "/api/live/stop") {
2217
+ await options.live.stop();
2218
+ json(response, 200, options.live.status());
2219
+ return;
2220
+ }
2221
+ const started = await options.live.start();
2222
+ json(response, started.error ? 502 : 200, started);
2223
+ return;
2224
+ }
2225
+
2174
2226
  if (path === "/api/connections") {
2175
2227
  json(response, 200, {
2176
2228
  connections: tracker.list(),
@@ -2770,10 +2822,15 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2770
2822
  const engine = new PlayerEngine([], root, tools);
2771
2823
 
2772
2824
  const web = options.web !== null ? resolve(options.web) : defaultWebDir();
2773
- const key = options.key ? newKey() : null;
2774
- // Minted whether or not it is published, so `nixamp admin` and the operator
2825
+ // The same keys this port used last time, so a link somebody was given
2826
+ // still works after a restart -- and a server is restarted to pick up a new
2827
+ // version, which is to say often. `--new-key` mints a fresh pair and forgets
2828
+ // the old one, which is the way to revoke a link that got out.
2829
+ const remembered = options.key ? rememberedKeys(stateDir(), options.port, options.newKey) : null;
2830
+ const key = remembered?.key ?? null;
2831
+ // Kept whether or not it is published, so `nixamp admin` and the operator
2775
2832
  // both have a link they can hand out without handing over the controls.
2776
- const listenKey = key === null ? null : newKey();
2833
+ const listenKey = remembered?.listenKey ?? null;
2777
2834
  // Configuration can arrive from the directory later, so it is a box the
2778
2835
  // paywall reads rather than a value it was handed once.
2779
2836
  let paywallConfig: PaywallConfig = { ...paywallFromEnv(), enabled: options.x402 || paywallFromEnv().enabled };
@@ -2990,12 +3047,55 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2990
3047
  // built with.
2991
3048
  let publishUrls: { id: string; url: string }[] = [];
2992
3049
 
3050
+ // Declared up here, not beside the publishing below: the port opens before
3051
+ // that code runs, so an admin asking to go live in the first moments would
3052
+ // otherwise reach a binding that has not been initialised.
3053
+ let publisher: Publisher | null = null;
3054
+ let listing: Listing | null = null;
3055
+ let publishable_: { label: string; url: string } | undefined;
3056
+
2993
3057
  const server = createServer(engine, {
2994
3058
  web,
2995
3059
  media: options.media,
2996
3060
  owner,
2997
3061
  channels,
2998
3062
  publishUrls: () => publishUrls,
3063
+ live: {
3064
+ status: () => ({
3065
+ live: publisher !== null,
3066
+ code: listing?.code ?? "",
3067
+ name: listing?.name ?? (options.name || hostname()),
3068
+ url: listing?.url ?? (publishable_ ? shareLink(publishable_.url, listenKey) : ""),
3069
+ // Whether going live is even possible here. A laptop behind a router
3070
+ // with no address the world can reach cannot be listed, and a button
3071
+ // that could only fail is worse than one that is not offered.
3072
+ possible: publishable_ !== undefined,
3073
+ }),
3074
+ start: async () => {
3075
+ if (publisher === null) publisher = makePublisher();
3076
+ if (publisher === null) {
3077
+ return { live: false, code: "", name: "", url: "", error: "this machine has no address the world can reach" };
3078
+ }
3079
+ const first = await publisher.start();
3080
+ if (first === null) {
3081
+ // Nothing was listed, so nothing should claim to be: a publisher
3082
+ // left running here would heartbeat at a directory that refused it.
3083
+ await publisher.stop();
3084
+ publisher = null;
3085
+ return {
3086
+ live: false, code: "", name: "", url: "",
3087
+ error: `${DEFAULT_DIRECTORY} would not list this stream. Run \`nixamp login\` on that machine.`,
3088
+ };
3089
+ }
3090
+ listing = first;
3091
+ return { live: true, code: first.code, name: first.name, url: first.url };
3092
+ },
3093
+ stop: async () => {
3094
+ await publisher?.stop();
3095
+ publisher = null;
3096
+ listing = null;
3097
+ },
3098
+ },
2999
3099
  ...(ingest ? { ingest } : {}),
3000
3100
  broadcaster,
3001
3101
  broadcast: () => ({ destinations, settings: DEFAULT_ENCODER }),
@@ -3250,22 +3350,28 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3250
3350
 
3251
3351
  // The listing carries the listen link, and only ever a public address: an
3252
3352
  // entry pointing at 192.168.1.5 is one nobody outside that house can open.
3253
- const publishable_ = addresses.find((a) => a.label === "on the internet")
3353
+ // Never a bare IP over https: a certificate is issued for a name, so a
3354
+ // listing pointing at one is a listing nobody can open.
3355
+ publishable_ = addresses.find((a) => a.label === "on the internet" && certifiable(a.url))
3356
+ ?? addresses.find((a) => a.label === "on tailscale" && certifiable(a.url))
3357
+ ?? addresses.find((a) => a.label === "on the internet")
3254
3358
  ?? addresses.find((a) => a.label === "on tailscale");
3255
- let publisher: Publisher | null = null;
3256
3359
 
3257
- if (options.publish !== "no" && publishable_) {
3360
+ /**
3361
+ * Make a publisher for this server. Called at startup when the operator says
3362
+ * yes, and again whenever somebody goes live from the admin panel.
3363
+ *
3364
+ * A declaration rather than an assignment, so it exists from the moment the
3365
+ * function is entered -- the port is open well before this line is reached.
3366
+ */
3367
+ function makePublisher(): Publisher | null {
3368
+ if (!publishable_) return null;
3258
3369
  const listen = shareLink(publishable_.url, listenKey);
3259
3370
  // Announced next to the listen link, not instead of it: one is for a person
3260
3371
  // with a browser, the other for the phone line and anything else that is
3261
3372
  // handed one address and expected to play it.
3262
3373
  const audio = audioLink(publishable_.url, listenKey);
3263
- const wanted = options.publish === "yes"
3264
- ? true
3265
- : await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
3266
-
3267
- if (wanted) {
3268
- publisher = new Publisher({
3374
+ return new Publisher({
3269
3375
  directory: DEFAULT_DIRECTORY,
3270
3376
  name: options.name || hostname(),
3271
3377
  url: listen,
@@ -3294,8 +3400,18 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
3294
3400
  ? ` nixamp.com turned paid listening on: $${(next.priceCents / 100).toFixed(2)} for ${next.passMinutes} minutes, over ${FREE_LISTENERS} listeners.`
3295
3401
  : " nixamp.com turned paid listening off.");
3296
3402
  },
3297
- });
3298
- const listing = await publisher.start();
3403
+ });
3404
+ }
3405
+
3406
+ if (options.publish !== "no" && publishable_) {
3407
+ const listen = shareLink(publishable_.url, listenKey);
3408
+ const wanted = options.publish === "yes"
3409
+ ? true
3410
+ : await confirm(`\n List this stream at ${DEFAULT_DIRECTORY}/directory so anyone can find it?\n It publishes ${listen} — listen only, not the controls.`);
3411
+
3412
+ if (wanted) {
3413
+ publisher = makePublisher();
3414
+ listing = (await publisher?.start()) ?? null;
3299
3415
  console.log("");
3300
3416
  console.log(listing
3301
3417
  ? ` Listed at ${DEFAULT_DIRECTORY}/directory as "${listing.name}". It leaves the list when this stops.`
package/src/share.ts CHANGED
@@ -11,6 +11,8 @@
11
11
  * every EventSource and every `<audio src>` on its own.
12
12
  */
13
13
  import { randomBytes, timingSafeEqual } from "node:crypto";
14
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { dirname } from "node:path";
14
16
  import type { IncomingMessage } from "node:http";
15
17
  import { networkInterfaces } from "node:os";
16
18
 
@@ -37,6 +39,79 @@ export function newKey(): string {
37
39
  return randomBytes(16).toString("base64url");
38
40
  }
39
41
 
42
+ /** The pair of keys a server hands out: one that drives, one that only hears. */
43
+ export interface KeyPair {
44
+ key: string;
45
+ listenKey: string;
46
+ }
47
+
48
+ /**
49
+ * The keys this port used last time, or a new pair remembered for next time.
50
+ *
51
+ * Keys used to be minted on every start, so every link anybody had been given
52
+ * died the moment the server was restarted -- and a server gets restarted to
53
+ * pick up a new version, which is to say often. A link you cannot rely on is
54
+ * not a link you can share, which was most of why sharing did not feel like it
55
+ * worked.
56
+ *
57
+ * Kept per port, because two servers on one machine are two different
58
+ * audiences, and a single remembered key would hand each of them the other's.
59
+ *
60
+ * The trade is that a key which never changes is a key that stays valid if it
61
+ * leaks, so `fresh` mints a new pair and forgets the old one -- which is what
62
+ * `--new-key` is for.
63
+ */
64
+ export function rememberedKeys(
65
+ dir: string,
66
+ port: number,
67
+ fresh = false,
68
+ io: {
69
+ read: (path: string) => string | null;
70
+ write: (path: string, body: string) => void;
71
+ } = defaultKeyStore,
72
+ ): KeyPair {
73
+ const path = `${dir}/keys.json`;
74
+ let all: Record<string, KeyPair> = {};
75
+ const existing = io.read(path);
76
+ if (existing !== null) {
77
+ try {
78
+ const parsed = JSON.parse(existing) as Record<string, KeyPair>;
79
+ if (parsed && typeof parsed === "object") all = parsed;
80
+ } catch {
81
+ // A file we cannot read is a file we replace. Losing a key costs a link;
82
+ // refusing to start costs the whole server.
83
+ }
84
+ }
85
+
86
+ const held = all[String(port)];
87
+ if (!fresh && held && typeof held.key === "string" && typeof held.listenKey === "string") return held;
88
+
89
+ const minted: KeyPair = { key: newKey(), listenKey: newKey() };
90
+ all[String(port)] = minted;
91
+ try {
92
+ io.write(path, JSON.stringify(all, null, 2));
93
+ } catch {
94
+ // Unwritable state is a key that will not survive a restart, which is how
95
+ // it behaved before this existed. Not a reason to refuse to serve.
96
+ }
97
+ return minted;
98
+ }
99
+
100
+ const defaultKeyStore = {
101
+ read: (path: string): string | null => {
102
+ try {
103
+ return readFileSync(path, "utf8");
104
+ } catch {
105
+ return null;
106
+ }
107
+ },
108
+ write: (path: string, body: string): void => {
109
+ mkdirSync(dirname(path), { recursive: true });
110
+ // Readable only by its owner: it is the password to this server.
111
+ writeFileSync(path, body, { mode: 0o600 });
112
+ },
113
+ };
114
+
40
115
  /** Compare without leaking where two keys first differ. */
41
116
  export function keysMatch(a: string, b: string): boolean {
42
117
  const left = Buffer.from(a);
@@ -232,6 +307,10 @@ export function allowedForListening(path: string): boolean {
232
307
  // and an exact check let a listen key reach /api/source/remove and delete an
233
308
  // album out of somebody else's playlist.
234
309
  if (path === "/api/source" || path.startsWith("/api/source/")) return false;
310
+ // Listing this machine in a public directory is not listening to it. The
311
+ // listen address itself, /api/live, stays open: that is the thing a listen
312
+ // key is for.
313
+ if (path === "/api/live/state" || path === "/api/live/start" || path === "/api/live/stop") return false;
235
314
  return true;
236
315
  }
237
316
 
@@ -1 +1 @@
1
- import{t as e}from"./index-CGXkk7_z.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
1
+ import{t as e}from"./index-CnneSQil.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
@@ -0,0 +1 @@
1
+ (function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();function e(e){if(!Number.isFinite(e)||e<0)return`--:--`;let t=Math.floor(e),n=Math.floor(t/60),r=t%60;return`${String(n).padStart(2,`0`)}:${String(r).padStart(2,`0`)}`}function t(e){return e.artist?`${e.artist} — ${e.title}`:e.title}function n(e){let t=e.split(`/`).pop()??e,n=t.lastIndexOf(`.`);return n>0?t.slice(0,n):t}var r=new Set([`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`,`avi`]);function i(e,t=``){if(t.startsWith(`video/`))return!0;if(t.startsWith(`audio/`))return!1;let n=e.lastIndexOf(`.`);return n>0&&r.has(e.slice(n+1).toLowerCase())}var a=`modulepreload`,o=function(e){return`/`+e},s={},c=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),c=i?.nonce||i?.getAttribute(`nonce`);function l(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}function u(e){return import.meta.resolve?import.meta.resolve(e):new URL(e,import.meta.url).href}r=l(t.map(t=>{if(t=o(t,n),t=u(t),t in s)return;s[t]=!0;let r=t.endsWith(`.css`);for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}let i=document.createElement(`link`);if(i.rel=r?`stylesheet`:a,r||(i.as=`script`),i.crossOrigin=``,i.href=t,c&&i.setAttribute(`nonce`,c),document.head.appendChild(i),r)return new Promise((e,n)=>{i.addEventListener(`load`,e),i.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},l=[[/\.m3u8$/i,`hls`],[/\.(ts|mts|m2ts|mpegts)$/i,`mpegts`],[/\.(mp3|m4a|aac|oga|ogg|opus|wav|flac)$/i,`audio`],[/\.(mp4|m4v|webm|mov|ogv)$/i,`mp4`]],u=[[/mpegurl/i,`hls`],[/mp2t|mpeg-?ts/i,`mpegts`],[/^audio\//i,`audio`],[/^video\//i,`mp4`]];function d(e){if(e.kind)return e.kind;if(e.mimeType){for(let[t,n]of u)if(t.test(e.mimeType))return n}let t=f(e.src);for(let[e,n]of l)if(e.test(t))return n;return`unknown`}function f(e){try{return new URL(e,`https://placeholder.invalid`).pathname}catch{return e.split(/[?#]/)[0]??e}}function p(e=globalThis,t=null){let n=e.MediaSource!==void 0,r=!1;try{let e=t??(typeof document>`u`?null:document.createElement(`video`));r=e?e.canPlayType(`application/vnd.apple.mpegurl`)!==``||e.canPlayType(`application/x-mpegURL`)!==``:!1}catch{r=!1}return{mediaSource:n,nativeHls:r}}function m(e,t){let n=d(e);switch(n){case`hls`:return t.mediaSource?{engine:`hls`,kind:n}:t.nativeHls?{engine:`native`,kind:n}:{engine:`native`,kind:n,unplayable:`This browser cannot play HLS streams.`};case`mpegts`:return t.mediaSource?{engine:`mpegts`,kind:n}:{engine:`mpegts`,kind:n,unplayable:`This browser cannot play transport streams.`};default:return{engine:`native`,kind:n}}}async function h(e,t){let n=t.capabilities??p(),r=m({src:t.src,...t.kind?{kind:t.kind}:{},...t.mimeType?{mimeType:t.mimeType}:{}},n),i=()=>void 0,a={media:e,src:t.src,isTv:t.isTv??!1,live:t.live??r.kind===`mpegts`,onError:t.onError??i,onNotice:t.onNotice??i,...t.onReady?{onReady:t.onReady}:{}};if(r.unplayable)return t.onError?.(r.unplayable),{destroy:i,engine:r.engine,kind:r.kind,levels:()=>[],unplayable:r.unplayable};let o,s=t.engines?.[r.engine];if(s)o=await s(a);else if(r.engine===`hls`){let{createHlsEngine:e}=await c(async()=>{let{createHlsEngine:e}=await import(`./hls-3VKVEQE3-pB7DqkTk.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-Ckppbviz.js`);return{createMpegtsEngine:e}},[]);o=await e(a,{withCredentials:t.withCredentials??!1,unplayableAdvice:t.unplayableAdvice??``})}else{let{createNativeEngine:e}=await c(async()=>{let{createNativeEngine:e}=await import(`./native-C7JTKWJH-BUyIoj0P.js`);return{createNativeEngine:e}},[]);o=await e(a)}return{destroy:()=>{o.destroy()},engine:r.engine,kind:r.kind,levels:o.levels,...o.setLevel?{setLevel:o.setLevel}:{},...o.currentLevel?{currentLevel:o.currentLevel}:{}}}var g=new Set([`mp3`,`flac`,`ogg`,`oga`,`opus`,`m4a`,`aac`,`wav`,`wma`,`aiff`,`aif`,`alac`,`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`]);function _(e,t=``){if(t.startsWith(`audio/`)||t.startsWith(`video/`))return!0;let n=e.lastIndexOf(`.`);return n>0&&g.has(e.slice(n+1).toLowerCase())}function v(e,t){return e.localeCompare(t,void 0,{numeric:!0,sensitivity:`base`})}function y(e){return e.filter(e=>_(e.name,e.type)).sort((e,t)=>v(b(e),b(t))).map(e=>({title:n(e.name),artist:``,album:x(b(e)),duration:0,url:URL.createObjectURL(e),video:i(e.name,e.type),objectUrl:!0}))}function b(e){return e.webkitRelativePath||e.name}function x(e){let t=e.split(`/`);return t.length>1?t[t.length-2]:``}function ee(e){for(let t of e)t.objectUrl&&URL.revokeObjectURL(t.url)}var S=2048;function C(e,t){return e||t===`hls`||t===`mpegts`}var te=class{elements;handlers;attached=null;context=null;analyser=null;wired=new WeakSet;active;frequencies=new Uint8Array;constructor(e,t){this.elements=e,this.handlers=t,this.active=e.audio;for(let t of[e.audio,e.video])t.crossOrigin=`anonymous`,t.addEventListener(`timeupdate`,()=>{t===this.active&&this.handlers.onTime(t.currentTime,Number.isFinite(t.duration)?t.duration:0)}),t.addEventListener(`loadedmetadata`,()=>{t===this.active&&this.handlers.onTime(t.currentTime,Number.isFinite(t.duration)?t.duration:0)}),t.addEventListener(`ended`,()=>{t===this.active&&this.handlers.onEnded()}),t.addEventListener(`play`,()=>{t===this.active&&this.handlers.onState(!0)}),t.addEventListener(`pause`,()=>{t===this.active&&this.handlers.onState(!1)}),t.addEventListener(`error`,()=>{t===this.active&&this.handlers.onError(w(t))})}get playing(){return!this.active.paused&&!this.active.ended}get position(){return this.active.currentTime}get duration(){return Number.isFinite(this.active.duration)?this.active.duration:0}get showingVideo(){return this.active===this.elements.video}ensureGraph(e){let t=globalThis.AudioContext??globalThis.webkitAudioContext;if(t){if(this.context??=new t,this.analyser||(this.analyser=this.context.createAnalyser(),this.analyser.fftSize=S,this.analyser.smoothingTimeConstant=.6,this.analyser.connect(this.context.destination),this.frequencies=new Uint8Array(this.analyser.frequencyBinCount)),!this.wired.has(e))try{this.context.createMediaElementSource(e).connect(this.analyser),this.wired.add(e)}catch{this.wired.add(e)}this.context.resume()}}read(){return this.analyser&&this.analyser.getByteFrequencyData(this.frequencies),this.frequencies}levels(){if(!this.analyser)return[0,0];let e=this.read(),t=0;for(let n of e)t+=n;let n=e.length===0?0:t/e.length/255;return[Math.min(1,n*2.2),Math.min(1,n*2.2)]}async load(e,t){let n=e.objectUrl?e.video?`mp4`:`audio`:d({src:e.url}),r=C(e.video,n)?this.elements.video:this.elements.audio;r!==this.active&&(this.active.pause(),this.active.removeAttribute(`src`),this.active.load(),this.active=r),this.attached?.destroy(),this.attached=null;try{this.attached=await h(this.active,{src:e.url,kind:n,unplayableAdvice:`VLC or mpv will play it; nixamp can only hand it to your browser.`,onError:e=>this.handlers.onError(e),onNotice:e=>{e&&this.handlers.onError(e)}})}catch(e){this.handlers.onError(e instanceof Error?e.message:`that would not play`);return}t&&await this.play()}async play(){this.ensureGraph(this.active);try{await this.active.play()}catch(e){this.handlers.onError(e instanceof Error?e.message:`playback was refused`)}}pause(){this.active.pause()}stop(){this.active.pause(),this.active.currentTime=0,this.attached?.destroy(),this.attached=null}seek(e){Number.isFinite(e)&&(this.active.currentTime=Math.max(0,e))}set volume(e){this.elements.audio.volume=e,this.elements.video.volume=e}get volume(){return this.active.volume}};function w(e){switch(e.error?.code){case MediaError.MEDIA_ERR_ABORTED:return`playback was aborted`;case MediaError.MEDIA_ERR_NETWORK:return`the network dropped mid-track`;case MediaError.MEDIA_ERR_DECODE:return`this browser could not decode that`;case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:return`this browser cannot play that format`;default:return`playback failed`}}function T(){return{revision:0,tracks:[],trackCount:0,index:0,playing:!1,position:0,bars:[],levels:[0,0],silent:!0,note:``,root:``}}function ne(e,t){return{...t,tracks:t.tracks??e.tracks}}function E(e){let t=e.trim();if(t===``)return``;/^https?:\/\//i.test(t)||(t=`http://${t}`);let n;try{n=new URL(t)}catch{return``}let r=n.pathname.replace(/\/+$/,``);return r=r.replace(/\/api(\/.*)?$/,``),`${n.origin}${r}`}function D(e,t,n=``){let r=`${e===``?``:E(e)}${t.startsWith(`/`)?t:`/${t}`}`;return n?`${r}${r.includes(`?`)?`&`:`?`}k=${encodeURIComponent(n)}`:r}function re(e){let t=e.trim();if(t===``)return{base:``,key:``};let n;try{n=new URL(/^https?:\/\//i.test(t)?t:`http://${t}`)}catch{return{base:``,key:``}}let r=/^\/s\/([^/]+)\/?$/.exec(n.pathname),i=r?.[1]??n.searchParams.get(`k`)??``;return r&&(n.pathname=`/`),n.searchParams.delete(`k`),{base:E(`${n.origin}${n.pathname}`),key:decodeURIComponent(i)}}function O(e,t,n=0,r=``){return D(e,n>0?`/api/media/${t}?kbps=${Math.round(n)}`:`/api/media/${t}`,r)}function k(e){if(typeof e!=`object`||!e)return null;let t=e,n=T(),r=(e,t)=>typeof e==`number`&&Number.isFinite(e)?e:t,i=Array.isArray(t.levels)?t.levels:[],a=Array.isArray(t.tracks)?t.tracks.map(e=>{let t=typeof e==`object`&&e?e:{};return{title:typeof t.title==`string`?t.title:`Untitled`,artist:typeof t.artist==`string`?t.artist:``,album:typeof t.album==`string`?t.album:``,duration:r(t.duration,0),...t.video===!0?{video:!0}:{},...typeof t.group==`string`&&t.group!==``?{group:t.group}:{}}}):void 0;return{revision:r(t.revision,0),...a?{tracks:a}:{},trackCount:r(t.trackCount,a?.length??0),index:r(t.index,0),playing:t.playing===!0,position:r(t.position,0),bars:Array.isArray(t.bars)?t.bars.map(e=>r(e,0)):[],levels:[r(i[0],0),r(i[1],0)],silent:t.silent===!0,note:typeof t.note==`string`?t.note:``,root:typeof t.root==`string`?t.root:n.root}}var ie=class{handlers;source=null;base=``;key=``;lastRevision=-1;constructor(e){this.handlers=e}get address(){return this.base}url(e){return D(this.base,e,this.key)}get shareLink(){return this.base===``?``:this.key===``?this.base:`${this.base}/s/${this.key}`}get connected(){return this.source!==null}connect(e){let{base:t,key:n}=re(e);this.close(),this.base=t,this.key=n,this.lastRevision=-1,this.handlers.onStatus(`connecting`);let r=new EventSource(D(t,`/api/events`,n));this.source=r,r.onopen=()=>this.handlers.onStatus(`live`),r.onmessage=e=>{let t=k(A(e.data));t&&(t.revision<this.lastRevision||(this.lastRevision=t.revision,this.handlers.onStatus(`live`),this.handlers.onSnapshot(t)))},r.onerror=()=>{this.handlers.onStatus(`error`,`reconnecting…`)}}async send(e){if(this.base===``&&!this.connected)return;let t=await fetch(D(this.base,`/api/command`,this.key),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify(e)});if(!t.ok){this.handlers.onStatus(`error`,`command refused (${t.status})`);return}let n=k(await t.json());n&&this.handlers.onSnapshot(n)}media(e,t=0){return O(this.base,e,t,this.key)}close(){this.source?.close(),this.source=null}};function A(e){try{return JSON.parse(e)}catch{return null}}async function ae(e,t,n=``){try{let r=await fetch(D(e,`/api/state`,n),{signal:t});return r.ok?k(await r.json()):null}catch{return null}}function oe(e){if(!/^https:\/\//i.test(e))return``;let t;try{t=new URL(e).hostname.replace(/^\[|\]$/g,``)}catch{return``}return/^\d{1,3}(\.\d{1,3}){3}$/.test(t)||t.includes(`:`)?`That is an https address for a bare IP, and a certificate is issued for a name — a browser refuses it before it asks anything. Use the server's name instead (the address it printed first), or connect over http.`:``}async function se(e,t=``,n){let r;try{r=await fetch(D(e,`/api/state`,t),{signal:n})}catch{return``}return r.ok?``:r.status===401?t===``?`That server needs its share link. Paste the whole link — the one ending in /s/… — or sign in as its owner.`:`That share link is not accepted by that server. It may have been restarted, which gives it a new one.`:r.status===403?`That link can listen but not drive this server.`:r.status===429?`That server is asking us to slow down. Try again in a moment.`:``}async function j(e,t,n=``){try{let r=await fetch(D(e,`/api/health`,n),{signal:t});if(!r.ok)return null;let i=await r.json();return i.name===`nixamp`?i.version??`unknown`:null}catch{return null}}function ce(e,t=globalThis.location?.protocol){return t!==`https:`||!/^http:\/\//i.test(e.trim())?``:`This page is https, and a browser refuses every request from an https page to an http one. Open that address directly, or give the server a certificate: nixamp serve --tls-cert cert.pem --tls-key key.pem.`}var M=.14,N=.02;function le(e,t){let n=[];for(let r=0;r<=e;r++){let i=r/e,a=Math.round(1*(t/1)**i),o=n[n.length-1];n.push(o===void 0?a:Math.max(a,o+1))}return n}function ue(e,t){let n=[];for(let r=0;r+1<t.length;r++){let i=Math.min(t[r],e.length),a=Math.min(Math.max(t[r+1],i+1),e.length),o=0,s=0;for(let t=i;t<a;t++)o+=e[t],s++;n.push(s===0?0:o/s/255)}return n}function de(e,t,n=M){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function P(e,t,n=N){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function fe(e,t,n,r,i){let{width:a,height:o}=t;if(a<=0||o<=0||n.length===0)return;e.clearRect(0,0,a,o);let s=a/n.length,c=Math.max(1,s*.72),l=Math.max(2,o*.012);e.fillStyle=i.bar,n.forEach((t,n)=>{let r=Math.max(1,t*(o-l*2));e.fillRect(n*s+(s-c)/2,o-r,c,r)}),e.fillStyle=i.peak,r.forEach((t,n)=>{let r=o-Math.max(1,t*(o-l*2))-l*2;e.fillRect(n*s+(s-c)/2,Math.max(0,r),c,l)})}var F=`nixamp.remote`,I=`nixamp.volume`,L=`nixamp.listenHere`;function R(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function z(){let n={status:R(`status`),source:R(`source`),install:R(`install`),video:R(`video`),audio:R(`audio`),title:R(`title-line`),album:R(`album-line`),elapsed:R(`elapsed`),total:R(`total`),seek:R(`seek`),canvas:R(`spectrum`),glyphs:R(`glyphs`),levels:R(`levels`),playlist:R(`playlist`),playlistTitle:R(`playlist-panel`),note:R(`note`),files:R(`files`),folder:R(`folder`),remoteUrl:R(`remote-url`),remoteForm:R(`remote-form`),remoteState:R(`remote-state`),disconnect:R(`disconnect`),browse:R(`browse`),accountForm:R(`account-form`),accountEmail:R(`account-email`),accountPassword:R(`account-password`),accountSubmit:R(`account-submit`),accountToggle:R(`account-toggle`),accountProviders:R(`account-providers`),accountPanel:R(`account-panel`),accountElsewhere:R(`account-elsewhere`),accountSignOut:R(`account-signout`),accountNote:R(`account-note`),adminPanel:R(`admin-panel`),adminNote:R(`admin-note`),adminConnections:R(`admin-connections`),publishNote:R(`publish-note`),publishList:R(`publish-list`),adminRestream:R(`admin-restream`),adminReplace:R(`admin-replace`),adminSource:R(`admin-source`),directory:R(`directory`),recentNote:R(`recent-note`),recentList:R(`recent-list`),followingNote:R(`following-note`),followingList:R(`following-list`),serversPanel:R(`servers-panel`),serversNote:R(`servers-note`),serversList:R(`servers-list`),notifyPanel:R(`notify-panel`),notifyNote:R(`notify-note`),notifyWeb:R(`notify-web`),notifyEmail:R(`notify-email`),notifySms:R(`notify-sms`),notifyPhone:R(`notify-phone`),notifyPhoneForm:R(`notify-phone-form`),notifyPhoneNote:R(`notify-phone-note`),directoryNote:R(`directory-note`),directoryList:R(`directory-list`),sharePanel:R(`share-panel`),shareNote:R(`share-note`),shareLink:R(`share-link`),shareCopy:R(`share-copy`),sharePhone:R(`share-phone`),shareSend:R(`share-send`),liveControls:R(`live-controls`),goLive:R(`go-live`),stopLive:R(`stop-live`),shareTo:R(`share-to`),listenHere:R(`listen-here`),volume:R(`volume`),prev:R(`prev`),playPause:R(`play-pause`),stop:R(`stop`),next:R(`next`)},r=`local`,i=[],a=0,o=T(),s=`idle`,c=``,l=`Pick files, or connect to a nixamp running somewhere else.`,u=!1,d=-1,f=``,p=Array(24).fill(0),m=Array(24).fill(0),h=[],g=()=>r===`remote`&&!n.listenHere.checked,_=new te({audio:n.audio,video:n.video},{onTime:(e,t)=>{let n=i[a];r===`local`&&n&&t>0&&n.duration!==t&&(n.duration=t),B()},onEnded:()=>M(1),onState:()=>B(),onError:e=>{l=e,B()}}),v=new ie({onSnapshot:e=>{o=ne(o,e),g()&&(p=e.bars.length>0?e.bars:p,m=P(m,p)),B()},onStatus:(e,t)=>{s=e,c=t??``,B()}}),b=()=>r===`remote`?o.tracks.length:i.length,x=()=>r===`remote`?g()||d<0?o.index:Math.min(d,Math.max(0,o.tracks.length-1)):a,S=()=>{let e=r===`remote`?o.tracks[x()]:i[x()];return e?t(e):`Nothing loaded.`},C=()=>(r===`remote`?o.tracks[x()]:i[x()])?.album||`—`,w=()=>g()?o.tracks[x()]?.duration??0:_.duration,E=()=>g()?o.position:_.position,D=()=>g()?o.playing:_.playing;async function O(e){if(r===`remote`){if(g()){await v.send({type:`play`,index:e});return}await k(e);return}let t=i[e];t&&(a=e,await _.load(t,!0),W(t.video),G(),B())}async function k(e){let t=o.tracks[e];t&&(d=e,await _.load({title:t.title,artist:t.artist,album:t.album,duration:t.duration,url:v.media(e,0),video:t.video===!0,objectUrl:!1},!0),W(t.video===!0),G())}async function A(){if(g()){await v.send({type:`toggle`});return}b()!==0&&(_.playing?_.pause():_.position>0?await _.play():await O(x()),B())}async function M(e){let t=b();if(t!==0){if(g()){await v.send({type:e>0?`next`:`prev`});return}await O((x()+e+t)%t)}}async function N(){if(g()){await v.send({type:`stop`});return}_.stop(),p=Array(24).fill(0),m=[...p],B()}let z=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))];function B(){let t=b(),a=D();n.status.textContent=a?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=String(a),n.title.textContent=S(),n.album.textContent=C();let d=E(),f=w();n.elapsed.textContent=e(d),n.total.textContent=f>0?e(f):`--:--`,u||(n.seek.value=String(f>0?Math.round(d/f*1e3):0),n.seek.disabled=f<=0||g()),n.playPause.textContent=a?`❚❚`:`▶`,n.playPause.setAttribute(`aria-label`,a?`Pause`:`Play`),n.playlistTitle.dataset.title=`Playlist (${t})`,n.source.textContent=r===`remote`?`remote · ${v.address.replace(/^https?:\/\//,``)||`—`}`:i.length>0?`local · ${i.length} files`:`no source`,n.remoteState.textContent=r===`remote`?`${s}${c?` — ${c}`:``}`:`not connected`,n.remoteState.dataset.status=r===`remote`?s:`idle`,n.disconnect.hidden=r!==`remote`;let m=r===`remote`&&o.note!==``?o.note:l;n.note.textContent=m,n.note.hidden=m===``,pe(),n.glyphs.textContent=p.map(z).join(``);let[h,y]=g()?o.levels:_.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(h*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(y*6)).padEnd(6,`·`)}`}let V=``,H=-1;function pe(){let a=r===`remote`?o.tracks.map(e=>({name:t(e),seconds:e.duration,group:e.group??``})):i.map(e=>({name:t(e),seconds:e.duration,group:``})),s=`${r}:${a.map(e=>`${e.name}@${e.seconds}@${e.group}`).join(`|`)}`;if(s!==V){V=s;let t=[],r=``,i=a.some(e=>e.group!==``);a.forEach((n,a)=>{n.group!==r&&(i||n.group!==``)&&(r=n.group,t.push(me(n.group)));let o=document.createElement(`li`);o.className=`row`,o.dataset.index=String(a);let s=document.createElement(`span`);s.className=`n`,s.textContent=String(a+1).padStart(2,` `);let c=document.createElement(`span`);c.className=`name`,c.textContent=n.name;let l=document.createElement(`span`);l.className=`time`,l.textContent=n.seconds>0?e(n.seconds):`--:--`,o.append(s,c,l),t.push(o)}),n.playlist.replaceChildren(...t)}let c=x(),l=D(),u;for(let e of Array.from(n.playlist.children)){let t=e,n=Number(t.dataset.index),r=Number.isInteger(n)&&n===c;t.classList.toggle(`selected`,r),t.classList.toggle(`playing`,r&&l),r&&(u=t)}c!==H&&(H=c,u?.scrollIntoView({block:`nearest`}))}function me(e){let t=document.createElement(`li`);t.className=`group`;let r=document.createElement(`span`);if(r.className=`group-name`,r.textContent=e===``?`This server's library`:e,t.append(r),e!==``&&!n.adminPanel.hidden){let n=document.createElement(`button`);n.type=`button`,n.className=`group-remove`,n.textContent=`×`,n.title=`Remove ${e} from the playlist`,n.setAttribute(`aria-label`,`Remove ${e} from the playlist`),n.addEventListener(`click`,t=>{t.stopPropagation(),he(e)}),t.append(n)}return t}async function he(e){try{let t=await fetch(v.url(`/api/source/remove`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({group:e})}),r=await t.json();n.adminNote.textContent=t.ok?`Removed ${r.removed??0} tracks from ${e}.`:r.error??`that did not work`}catch{n.adminNote.textContent=`could not reach the server`}}function U(){let t=n.canvas,r=Math.min(2,globalThis.devicePixelRatio||1),i=Math.round(t.clientWidth*r),a=Math.round(t.clientHeight*r);i>0&&a>0&&(t.width!==i||t.height!==a)&&(t.width=i,t.height=a);let s=t.getContext(`2d`);if(g())m=P(m,p);else{let e=_.read();e.length>0&&(h.length!==25&&(h=le(24,e.length)),p=de(p,ue(e,h)),m=P(m,p))}if(s){let e=getComputedStyle(document.documentElement);fe(s,{width:t.width,height:t.height},p,m,{bar:e.getPropertyValue(`--green`).trim()||`#4af689`,peak:e.getPropertyValue(`--green-dim`).trim()||`#227a4a`,background:`transparent`})}if(D()){n.glyphs.textContent=p.map(z).join(``);let[t,r]=g()?o.levels:_.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(t*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(r*6)).padEnd(6,`·`)}`,n.elapsed.textContent=e(E());let i=w();!u&&i>0&&(n.seek.value=String(Math.round(E()/i*1e3)))}requestAnimationFrame(U)}function W(e){n.video.hidden=!e}function G(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:S(),album:C(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void A()),navigator.mediaSession.setActionHandler(`pause`,()=>void A()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void M(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void M(-1)))}n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&O(n)}),n.prev.addEventListener(`click`,()=>void M(-1)),n.next.addEventListener(`click`,()=>void M(1)),n.stop.addEventListener(`click`,()=>void N()),n.playPause.addEventListener(`click`,()=>void A()),n.seek.addEventListener(`input`,()=>{u=!0}),n.seek.addEventListener(`change`,()=>{let e=w();e>0&&_.seek(Number(n.seek.value)/1e3*e),u=!1}),n.volume.addEventListener(`input`,()=>{let e=Number(n.volume.value)/100;_.volume=e;try{localStorage.setItem(I,String(e))}catch{}});let ge=e=>{e.addEventListener(`change`,()=>{let t=y(Array.from(e.files??[]));if(t.length===0){l=`Nothing playable in that selection.`,B();return}ee(i),i=t,a=0,r=`local`,v.close(),l=``,O(0)})};ge(n.files),ge(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.remoteUrl.value,{base:i,key:a}=re(t);if(i===``){l=`That is not an address.`,B();return}(async()=>{s=`connecting`,B();let e=ce(i);if(e){s=`error`,c=e,l=e,r=`local`,B();return}if(await j(i,void 0,a)===null){s=`error`;let e=oe(i);c=e?`needs the server's name`:`not answering`,l=e||`Nothing answered at ${i}. If that is your machine, it is off or nixamp is not running on it; otherwise check the address.`,r=`local`,B();return}let n=await se(i,a);if(n){s=`error`,c=n,l=n,r=`local`,B();return}r=`remote`,l=``;try{localStorage.setItem(F,t.trim())}catch{}v.connect(t),Q(),B()})()});let _e=async()=>{n.directory.hidden=!1,n.directoryNote.textContent=`Looking for live streams…`,n.directoryList.replaceChildren();let e;try{let t=await fetch(`/api/directory`);if(!t.ok)throw Error(String(t.status));let n=await t.json();e=n.streams??[],Se(n.recent??[])}catch{n.directoryNote.textContent=`The directory is not answering. Type an address instead.`;return}if(e.length===0){n.directoryNote.textContent=`Nobody is streaming right now.`;return}n.directoryNote.textContent=`${e.length} live ${e.length===1?`stream`:`streams`}:`;for(let t of e){let e=document.createElement(`li`),r=document.createElement(`button`);r.type=`button`;let i=document.createElement(`span`);i.className=`name`,i.textContent=t.name;let a=document.createElement(`span`);a.className=`detail`;let o=[t.nowPlaying,`${t.tracks} tracks`].filter(Boolean);t.code&&o.push(t.callers?`☎ ${t.code} · ${t.callers} on the phone`:`☎ ${t.code}`),a.textContent=o.join(` · `),r.append(i,a),r.addEventListener(`click`,()=>{n.remoteUrl.value=t.url,n.directory.hidden=!0,n.remoteForm.requestSubmit()}),e.append(r),t.ownerId&&X&&t.ownerId!==X&&e.append(Te(t.ownerId,t.name)),n.directoryList.append(e)}};if(location.pathname.replace(/\/+$/,``)===`/directory`){document.body.classList.add(`route-directory`);let e=document.getElementById(`directory-back`);e&&(e.hidden=!1),_e()}let K=null,ve=e=>{n.adminConnections.replaceChildren();let t=document.createElement(`tr`);for(let e of[`Where`,`Network`,`Kind`,`Client`,`Track`,`Sent`]){let n=document.createElement(`th`);n.textContent=e,t.append(n)}n.adminConnections.append(t);for(let t of e.slice(0,40)){let e=document.createElement(`tr`);t.endedAt!==null&&(e.className=`ended`);let r=[[t.address,``],[t.network,`network-${t.network}`],[t.kind,``],[t.agent,``],[t.track||`—`,``],[`${Math.round(t.bytes/1024)} KiB`,``]];for(let[t,n]of r){let r=document.createElement(`td`);r.textContent=t,n&&(r.className=n),e.append(r)}n.adminConnections.append(e)}},ye=async()=>{try{let e=await fetch(v.url(`/api/connections`));if(!e.ok)return;let t=await e.json();n.adminNote.textContent=`${t.active??0} listening now.`,ve(t.connections??[]),be(t.publish??[])}catch{n.adminNote.textContent=`lost touch with the server`}};function be(e){if(n.publishNote.hidden=e.length===0,e.length===0){n.publishList.replaceChildren(),n.publishNote.hidden=!1,n.publishNote.textContent=`This server takes no RTMP. Start it with --rtmp-in 1935 to publish into it from OBS.`;return}n.publishNote.textContent=e.length===1?`Publish into this server from OBS, Larix or ffmpeg:`:`Publish into this server from OBS, Larix or ffmpeg. One URL per stream — ${e.length} at once:`,n.publishList.replaceChildren(...e.map(e=>{let t=document.createElement(`li`),n=document.createElement(`span`);n.className=`slot`,n.textContent=e.id;let r=document.createElement(`input`);r.type=`text`,r.readOnly=!0,r.value=e.url,r.setAttribute(`aria-label`,`RTMP URL for ${e.id}`);let i=document.createElement(`button`);return i.type=`button`,i.className=`ghost`,i.textContent=`Copy`,i.addEventListener(`click`,()=>{r.select(),navigator.clipboard?.writeText(e.url).catch(()=>{})}),t.append(n,r,i),t}))}let q=async()=>{let e=!1,t=null;try{let n=await fetch(v.url(`/api/admin`));if(n.ok){let r=await n.json();e=r.allowed===!0,t=r.as??null}}catch{e=!1}n.adminPanel.hidden=!e,K&&clearInterval(K),K=null,Q(),e&&(n.adminNote.textContent=t===`owner`?`You own this server.`:`You hold this server's control link.`,ye(),K=setInterval(()=>void ye(),2e3))};n.adminRestream.addEventListener(`submit`,e=>{e.preventDefault();let t=n.adminSource.value.trim();if(!t)return;let r=n.adminReplace.checked;(async()=>{try{let e=await fetch(v.url(`/api/source`),{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({source:t,...r?{replace:!0}:{}})}),i=await e.json();n.adminNote.textContent=e.ok?r?`Now serving ${t}.`:i.added===0?`Everything there was already in the playlist.`:`Added ${i.added??0} tracks from ${t}.`:i.error??`that did not work`,e.ok&&(n.adminSource.value=``)}catch{n.adminNote.textContent=`could not reach the server`}})()});let xe=e=>{let t=Math.max(1,Math.round((Date.now()-e)/6e4));if(t<60)return`${t} minute${t===1?``:`s`} ago`;let n=Math.round(t/60);return`${n} hour${n===1?``:`s`} ago`},Se=e=>{n.recentList.replaceChildren();let t=X?e.filter(e=>e.ownerId&&e.ownerId!==X):[];if(n.recentNote.hidden=t.length===0,t.length!==0)for(let e of t){let t=document.createElement(`li`),r=document.createElement(`span`);r.className=`recent-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name;let a=document.createElement(`span`);a.className=`detail`,a.textContent=e.nowPlaying?`${e.nowPlaying} · ended ${xe(e.endedAt)}`:`ended ${xe(e.endedAt)}`,r.append(i,a),t.append(r,Te(e.ownerId,e.name)),n.recentList.append(t)}},Ce=async()=>{n.serversList.replaceChildren();try{let e=await fetch(`/api/v1/servers`);if(!e.ok){n.serversPanel.hidden=!0;return}let t=(await e.json()).servers??[];n.serversPanel.hidden=!1,n.serversNote.textContent=t.length===0?"No servers yet. `nixamp server add --here` remembers the one you are running.":`The machines on your account. Open one, or forget it.`;for(let e of t){let t=document.createElement(`li`),r=document.createElement(`span`);r.className=`recent-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name;let a=document.createElement(`span`);a.className=`detail`,a.textContent=e.url,r.append(i,a);let o=document.createElement(`button`);o.type=`button`,o.className=`button`,o.textContent=`Open`,o.addEventListener(`click`,()=>{n.remoteUrl.value=e.key?`${e.url}/s/${e.key}`:e.url,n.remoteForm.requestSubmit()}),j(e.url).then(n=>{if(n!==null){a.textContent=`${e.url} · ${n}`;return}a.textContent=`${e.url} · not answering`,t.classList.add(`offline`),o.disabled=!0,o.title=`That machine is not answering. Start nixamp on it.`});let s=document.createElement(`button`);s.type=`button`,s.className=`ghost`,s.textContent=`Forget`,s.addEventListener(`click`,()=>{(async()=>{s.disabled=!0;try{await fetch(`/api/v1/servers/${encodeURIComponent(e.id)}`,{method:`DELETE`}),await Ce()}catch{s.disabled=!1}})()}),t.append(r,o,s),n.serversList.append(t)}}catch{n.serversPanel.hidden=!0}},we=async()=>{n.followingList.replaceChildren();try{let e=await fetch(`/api/v1/follows`);if(!e.ok){n.followingNote.hidden=!0;return}let t=(await e.json()).following??[];n.followingNote.hidden=t.length===0;for(let e of t){let t=document.createElement(`li`),r=document.createElement(`span`);r.className=`recent-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name||`a nixamp`;let a=document.createElement(`span`);a.className=`detail`,a.textContent=e.live?`live now`:`not streaming`,r.append(i,a);let o=document.createElement(`button`);o.type=`button`,o.className=`ghost follow`,o.textContent=`Unfollow`,o.addEventListener(`click`,()=>{(async()=>{o.disabled=!0;try{await fetch(`/api/v1/follows/${encodeURIComponent(e.id)}`,{method:`DELETE`}),t.remove(),n.followingList.children.length===0&&(n.followingNote.hidden=!0)}finally{o.disabled=!1}})()}),t.append(r,o),n.followingList.append(t)}}catch{n.followingNote.hidden=!0}},Te=(e,t)=>{let n=document.createElement(`button`);n.type=`button`,n.className=`ghost follow`,n.textContent=`Follow`,n.setAttribute(`aria-label`,`Follow ${t}`);let r=e=>{n.textContent=e?`Following`:`Follow`,n.dataset.following=e?`yes`:`no`};return(async()=>{try{let t=await fetch(`/api/v1/follows/${encodeURIComponent(e)}`);t.ok&&r((await t.json()).following===!0)}catch{}})(),n.addEventListener(`click`,()=>{(async()=>{let t=n.dataset.following===`yes`;n.disabled=!0;try{(await fetch(`/api/v1/follows/${encodeURIComponent(e)}`,{method:t?`DELETE`:`PUT`,headers:{"content-type":`application/json`},body:t?void 0:`{}`})).ok&&(r(!t),we())}catch{}finally{n.disabled=!1}})()}),n},Ee=e=>{let t=(e+`=`.repeat((4-e.length%4)%4)).replace(/-/g,`+`).replace(/_/g,`/`),n=atob(t),r=new Uint8Array(new ArrayBuffer(n.length));for(let e=0;e<n.length;e+=1)r[e]=n.charCodeAt(e);return r},De=()=>`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window,Oe=async()=>{if(!De())return n.notifyNote.textContent=`This browser cannot show notifications.`,!1;if(Notification.permission===`denied`)return n.notifyNote.textContent=`This browser is blocking notifications. Allow them in site settings first.`,!1;if(await Notification.requestPermission()!==`granted`)return n.notifyNote.textContent=`Not allowed, so nothing will be sent here.`,!1;try{let e=await navigator.serviceWorker.ready,{publicKey:t}=await(await fetch(`/api/v1/notify/key`)).json();if(!t)return n.notifyNote.textContent=`This server is not set up to send notifications.`,!1;let r=await e.pushManager.getSubscription()??await e.pushManager.subscribe({userVisibleOnly:!0,applicationServerKey:Ee(t)}),i=await fetch(`/api/v1/notify/subscribe`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify(r.toJSON())});if(!i.ok)throw Error(String(i.status));return n.notifyNote.textContent=`This device will be told.`,!0}catch{return n.notifyNote.textContent=`Could not set this device up.`,!1}},ke=async()=>{try{let e=await(await navigator.serviceWorker.ready).pushManager.getSubscription();if(!e)return;await fetch(`/api/v1/notify/subscribe?endpoint=${encodeURIComponent(e.endpoint)}`,{method:`DELETE`}),await e.unsubscribe()}catch{}},J=async e=>{try{let t=await fetch(`/api/v1/notify/prefs`,{method:`PUT`,headers:{"content-type":`application/json`},body:JSON.stringify(e)}),r=await t.json();n.notifyPhoneNote.textContent=t.ok?``:r.error??`that did not save`,t.ok&&typeof r.phone==`string`&&(n.notifyPhone.value=r.phone)}catch{n.notifyPhoneNote.textContent=`could not reach nixamp.com`}},Ae=async()=>{try{let e=await fetch(`/api/v1/notify/prefs`);if(!e.ok)return;let t=await e.json();n.notifyEmail.checked=t.wantsEmail!==!1,n.notifySms.checked=t.wantsSms===!0,n.notifyPhone.value=t.phone??``;let r=De()&&Notification.permission===`granted`?await(await navigator.serviceWorker.ready).pushManager.getSubscription()!==null:!1;n.notifyWeb.checked=t.wantsWeb!==!1&&r,n.notifyNote.textContent=r?`Get told when someone you follow goes live.`:`Turn on “On this device” to be told here.`}catch{}};n.notifyWeb.addEventListener(`change`,()=>{(async()=>{if(n.notifyWeb.checked){let e=await Oe();n.notifyWeb.checked=e,await J({wantsWeb:e});return}await ke(),await J({wantsWeb:!1}),n.notifyNote.textContent=`Turn on “On this device” to be told here.`})()}),n.notifyEmail.addEventListener(`change`,()=>{J({wantsEmail:n.notifyEmail.checked})}),n.notifySms.addEventListener(`change`,()=>{(async()=>{if(n.notifySms.checked&&!n.notifyPhone.value.trim()){n.notifyPhoneNote.textContent=`Add a phone number first.`,n.notifySms.checked=!1,n.notifyPhone.focus();return}await J({wantsSms:n.notifySms.checked})})()}),n.notifyPhoneForm.addEventListener(`submit`,e=>{e.preventDefault(),J({phone:n.notifyPhone.value.trim()})});let Y=!1,X=``,Z=e=>{let t=e!==null;n.notifyPanel.hidden=!t,t?(Ae(),we(),Ce()):(n.serversPanel.hidden=!0,n.followingNote.hidden=!0,n.followingList.replaceChildren(),n.recentNote.hidden=!0,n.recentList.replaceChildren()),n.accountForm.hidden=t,n.accountProviders.hidden=t||n.accountProviders.childElementCount===0,n.accountSignOut.hidden=!t,n.accountNote.textContent=t?`Signed in as ${e}.`:Y?`Create an account on nixamp.com.`:`Sign in to nixamp.com to publish and get paid.`,n.accountSubmit.textContent=Y?`Create account`:`Sign in`,n.accountToggle.textContent=Y?`I have one`:`Create one`,n.accountPassword.autocomplete=Y?`new-password`:`current-password`},je=async()=>{let e=[],t=!1;try{let n=await fetch(`/api/v1/auth/providers`);n.ok&&(t=!0,e=(await n.json()).providers??[])}catch{}n.accountProviders.replaceChildren(),n.accountProviders.hidden=e.length===0,n.accountPanel.hidden=!t,n.accountElsewhere.hidden=t;for(let t of e){let e=document.createElement(`a`);e.className=`button`,e.href=`/api/v1/${encodeURIComponent(t.id)}/oauth/start`,e.textContent=`Continue with ${t.name}`,n.accountProviders.append(e)}},Me=async()=>{try{let e=await fetch(`/api/v1/auth/me`),t=await e.json();X=e.ok?t.account?.id??``:``,Z(e.ok?t.account?.email??`you`:null)}catch{X=``,Z(null)}Ne()};function Ne(){if(f===``||X===``)return;let e=f;f=``,n.remoteUrl.value=e,n.remoteForm.requestSubmit()}n.accountToggle.addEventListener(`click`,()=>{Y=!Y,Z(null)}),n.accountForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.accountEmail.value.trim(),r=n.accountPassword.value;(async()=>{n.accountSubmit.disabled=!0;try{let e=await fetch(`/api/v1/auth/${Y?`signup`:`login`}`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({email:t,password:r})}),i=await e.json();if(!e.ok){n.accountNote.textContent=i.error??`that did not work`;return}X=i.account?.id??``,n.accountPassword.value=``,Z(i.account?.email??t),q()}catch{n.accountNote.textContent=`could not reach nixamp.com`}finally{n.accountSubmit.disabled=!1}})()}),n.accountSignOut.addEventListener(`click`,()=>{(async()=>{try{await fetch(`/api/v1/auth/logout`,{method:`POST`})}catch{}X=``,Z(null),q()})()});try{let e=new URL(globalThis.location.href).searchParams.get(`url`)??``;e!==``&&(f=e,n.remoteUrl.value=e,l=`Sign in to watch this stream.`,globalThis.history?.replaceState(null,``,globalThis.location.pathname))}catch{}je(),Me(),q(),n.browse.addEventListener(`click`,()=>{if(!n.directory.hidden){n.directory.hidden=!0;return}_e(),n.directory.scrollIntoView({behavior:`smooth`,block:`nearest`})}),n.disconnect.addEventListener(`click`,()=>{v.close(),d=-1,n.sharePanel.hidden=!0,r=`local`,s=`idle`,c=``,B()});async function Q(){if(r!==`remote`||v.shareLink===``){n.sharePanel.hidden=!0;return}n.sharePanel.hidden=!1;let e=v.shareLink,t=globalThis.location.origin;n.shareLink.value=e.startsWith(`https://`)?`${t}/?url=${encodeURIComponent(e)}`:e,n.shareNote.textContent=`Anyone with this link can watch. They sign in once, then it opens.`,n.sharePhone.hidden=!0,n.shareSend.hidden=!0,n.liveControls.hidden=!0;let i=``;try{let e=await fetch(`/api/directory`);e.ok&&(i=(await e.json()).callIn??``)}catch{}let a=null;try{let e=await fetch(v.url(`/api/live/state`));e.ok&&(a=await e.json())}catch{}if(a){if(n.liveControls.hidden=n.adminPanel.hidden||!a.possible,n.goLive.hidden=a.live,n.stopLive.hidden=!a.live,n.sharePhone.hidden=!1,!a.live){n.sharePhone.textContent=a.possible?`Not listed yet. Go live to get a phone number and a code anyone can call.`:`This machine has no address the world can reach, so it cannot be listed.`;return}if(!i){n.sharePhone.textContent=`Listed. The code for the phone line is ${a.code}.`,n.shareSend.hidden=!1;return}n.sharePhone.replaceChildren(document.createTextNode(`To talk about it, call `),Fe(i),document.createTextNode(` and key `),Fe(a.code),document.createTextNode(`. That is a room with everyone else watching — not the stream itself.`)),n.shareSend.hidden=!1}}let Pe=async e=>{n.goLive.disabled=!0,n.stopLive.disabled=!0,n.shareNote.textContent=e?`Going live…`:`Taking it off the list…`;try{let t=await fetch(v.url(e?`/api/live/start`:`/api/live/stop`),{method:`POST`}),r=await t.json();n.shareNote.textContent=t.ok?e?`Live. Anyone can call and key ${r.code??``} to talk about it.`:`Taken off the list. The link still works for anybody who has it.`:r.error??`that did not work`}catch{n.shareNote.textContent=`could not reach the server`}finally{n.goLive.disabled=!1,n.stopLive.disabled=!1,await Q()}};n.goLive.addEventListener(`click`,()=>void Pe(!0)),n.stopLive.addEventListener(`click`,()=>void Pe(!1));function Fe(e){let t=document.createElement(`b`);return t.textContent=e,t}n.shareCopy.addEventListener(`click`,()=>{n.shareLink.select(),navigator.clipboard?.writeText(n.shareLink.value).then(()=>{n.shareNote.textContent=`Copied. Send it to anybody.`},()=>{n.shareNote.textContent=`Copy it from the box above.`})}),n.shareSend.addEventListener(`submit`,e=>{e.preventDefault();let t=n.shareTo.value.trim();t!==``&&(async()=>{n.shareNote.textContent=`Sending…`;try{let e=await fetch(`/api/v1/invite`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({to:t,stream:v.shareLink})}),r=await e.json();n.shareNote.textContent=e.ok?`Sent to ${r.sent??t}.`:r.error??`that did not send`,e.ok&&(n.shareTo.value=``)}catch{n.shareNote.textContent=`could not send that`}})()}),n.listenHere.addEventListener(`change`,()=>{try{localStorage.setItem(L,n.listenHere.checked?`1`:`0`)}catch{}r===`remote`&&(async()=>{n.listenHere.checked?(await v.send({type:`stop`}),await k(o.index)):(_.stop(),d=-1),B()})()}),document.addEventListener(`keydown`,e=>{let t=e.target;if(!(t&&/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))switch(e.key){case` `:e.preventDefault(),A();return;case`s`:N();return;case`n`:case`ArrowRight`:M(1);return;case`p`:case`ArrowLeft`:M(-1);return;case`ArrowDown`:e.preventDefault(),O(Math.min(b()-1,x()+1));return;case`ArrowUp`:e.preventDefault(),O(Math.max(0,x()-1));return}});let $=null;globalThis.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),$=e,n.install.hidden=!1}),n.install.addEventListener(`click`,()=>{$?.prompt(),$=null,n.install.hidden=!0});try{let e=localStorage.getItem(I);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),_.volume=Number(e));let t=localStorage.getItem(F);t&&(n.remoteUrl.value=t),localStorage.getItem(L)===`0`&&(n.listenHere.checked=!1)}catch{}(async()=>{if(n.remoteUrl.value!==``)return;let e=globalThis.location.origin;if(await j(e)===null)return;let t=await ae(e);t&&t.trackCount!==0&&(n.remoteUrl.value=e,r=`remote`,l=``,v.connect(e),B())})(),B(),requestAnimationFrame(U)}z(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});export{c as t};