nixamp 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +171 -0
- package/dist/accounts.d.ts +54 -0
- package/dist/accounts.js +160 -0
- package/dist/broadcast.d.ts +96 -0
- package/dist/broadcast.js +193 -0
- package/dist/channels.d.ts +94 -0
- package/dist/channels.js +235 -0
- package/dist/connections.d.ts +6 -0
- package/dist/connections.js +13 -0
- package/dist/directory.d.ts +63 -0
- package/dist/directory.js +111 -0
- package/dist/ingest.d.ts +80 -0
- package/dist/ingest.js +252 -0
- package/dist/main.js +21 -0
- package/dist/manage.js +2 -1
- package/dist/owner.d.ts +53 -0
- package/dist/owner.js +96 -0
- package/dist/paywall.d.ts +60 -0
- package/dist/paywall.js +162 -0
- package/dist/publish.d.ts +36 -0
- package/dist/publish.js +90 -0
- package/dist/rtmp-in.d.ts +22 -0
- package/dist/rtmp-in.js +79 -0
- package/dist/server.d.ts +79 -0
- package/dist/server.js +609 -10
- package/dist/session.d.ts +29 -0
- package/dist/session.js +184 -0
- package/dist/share.d.ts +16 -0
- package/dist/share.js +19 -0
- package/package.json +5 -2
- package/src/accounts.ts +193 -0
- package/src/broadcast.ts +264 -0
- package/src/channels.ts +281 -0
- package/src/connections.ts +13 -0
- package/src/directory.ts +135 -0
- package/src/ingest.ts +297 -0
- package/src/main.ts +21 -0
- package/src/manage.ts +2 -1
- package/src/owner.ts +113 -0
- package/src/paywall.ts +198 -0
- package/src/publish.ts +101 -0
- package/src/rtmp-in.ts +90 -0
- package/src/server.ts +702 -10
- package/src/session.ts +209 -0
- package/src/share.ts +27 -0
- package/src/types/auth-system.d.ts +77 -0
- package/web/dist/assets/{index-BGKWWaIx.css → index-0wAv50Ay.css} +1 -1
- package/web/dist/assets/index-WYJ6R4uF.js +1 -0
- package/web/dist/index.html +37 -6
- package/web/dist/sw.js +3 -3
- package/web/dist/assets/index-Dhja5wxB.js +0 -1
package/src/session.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Being signed in, from a terminal.
|
|
3
|
+
*
|
|
4
|
+
* `nixamp login` asks for an address and a password, and keeps the token it
|
|
5
|
+
* gets back beside the daemon's state. The desktop app bundles this same CLI,
|
|
6
|
+
* so signing in there and signing in here are the same thing on disk.
|
|
7
|
+
*
|
|
8
|
+
* The password is read with the echo turned off and is never written down: the
|
|
9
|
+
* token is what is kept, and it can be revoked without changing anything the
|
|
10
|
+
* person has to remember.
|
|
11
|
+
*/
|
|
12
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { createInterface } from "node:readline/promises";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { stateDir } from "./daemon.ts";
|
|
16
|
+
import { DEFAULT_DIRECTORY } from "./directory.ts";
|
|
17
|
+
|
|
18
|
+
export interface Session {
|
|
19
|
+
site: string;
|
|
20
|
+
email: string;
|
|
21
|
+
token: string;
|
|
22
|
+
signedInAt: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function sessionPath(): string {
|
|
26
|
+
return join(stateDir(), "session.json");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readSession(): Session | null {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(readFileSync(sessionPath(), "utf8")) as Session;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function writeSession(session: Session): void {
|
|
38
|
+
const path = sessionPath();
|
|
39
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
40
|
+
writeFileSync(path, `${JSON.stringify(session, null, 2)}\n`);
|
|
41
|
+
// A bearer token is as good as the password for as long as it lives, so it
|
|
42
|
+
// is not left readable by everyone with an account on the machine.
|
|
43
|
+
chmodSync(path, 0o600);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function clearSession(): void {
|
|
47
|
+
rmSync(sessionPath(), { force: true });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Ask without echoing. Node has no "read a password" call, so the terminal is
|
|
52
|
+
* put in raw mode and the keystrokes are collected by hand.
|
|
53
|
+
*/
|
|
54
|
+
export async function askSecret(prompt: string): Promise<string> {
|
|
55
|
+
const input = process.stdin;
|
|
56
|
+
if (!input.isTTY) {
|
|
57
|
+
// A pipe has no echo to turn off, and reading a line is what a script
|
|
58
|
+
// wants anyway.
|
|
59
|
+
const rl = createInterface({ input, output: process.stdout });
|
|
60
|
+
try {
|
|
61
|
+
return await rl.question("");
|
|
62
|
+
} finally {
|
|
63
|
+
rl.close();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
process.stdout.write(prompt);
|
|
68
|
+
input.setRawMode(true);
|
|
69
|
+
input.resume();
|
|
70
|
+
input.setEncoding("utf8");
|
|
71
|
+
|
|
72
|
+
return new Promise<string>((done) => {
|
|
73
|
+
let typed = "";
|
|
74
|
+
const onData = (key: string): void => {
|
|
75
|
+
switch (key) {
|
|
76
|
+
case "\u0003": // ctrl-c
|
|
77
|
+
input.setRawMode(false);
|
|
78
|
+
input.pause();
|
|
79
|
+
process.stdout.write("\n");
|
|
80
|
+
process.exit(130);
|
|
81
|
+
return;
|
|
82
|
+
case "\r":
|
|
83
|
+
case "\n":
|
|
84
|
+
case "\u0004": // ctrl-d
|
|
85
|
+
input.setRawMode(false);
|
|
86
|
+
input.pause();
|
|
87
|
+
input.off("data", onData);
|
|
88
|
+
process.stdout.write("\n");
|
|
89
|
+
done(typed);
|
|
90
|
+
return;
|
|
91
|
+
case "\u007f": // backspace
|
|
92
|
+
case "\b":
|
|
93
|
+
typed = typed.slice(0, -1);
|
|
94
|
+
return;
|
|
95
|
+
default:
|
|
96
|
+
// One printable character. An arrow key arrives as a whole escape
|
|
97
|
+
// sequence, which would otherwise be appended as several characters
|
|
98
|
+
// of password nobody typed.
|
|
99
|
+
if (key.length === 1 && key >= " " && key !== "\u007f") typed += key;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
input.on("data", onData);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function ask(prompt: string): Promise<string> {
|
|
107
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
108
|
+
try {
|
|
109
|
+
return (await rl.question(prompt)).trim();
|
|
110
|
+
} finally {
|
|
111
|
+
rl.close();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface LoginOptions {
|
|
116
|
+
site: string;
|
|
117
|
+
email: string;
|
|
118
|
+
/** Create the account rather than signing in to one. */
|
|
119
|
+
signUp: boolean;
|
|
120
|
+
fetcher?: typeof fetch;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Read the flags `nixamp login` accepts. */
|
|
124
|
+
export function parseLoginArgs(argv: string[]): LoginOptions {
|
|
125
|
+
const at = (flag: string): string | undefined => {
|
|
126
|
+
const index = argv.indexOf(flag);
|
|
127
|
+
return index === -1 ? undefined : argv[index + 1];
|
|
128
|
+
};
|
|
129
|
+
return {
|
|
130
|
+
site: (at("--site") ?? DEFAULT_DIRECTORY).replace(/\/+$/, ""),
|
|
131
|
+
email: at("--email") ?? argv.find((a) => !a.startsWith("-") && a.includes("@")) ?? "",
|
|
132
|
+
signUp: argv.includes("--signup") || argv.includes("--sign-up"),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `nixamp login` / `nixamp signup`. */
|
|
137
|
+
export async function login(argv: string[]): Promise<number> {
|
|
138
|
+
const options = parseLoginArgs(argv);
|
|
139
|
+
const send = options.fetcher ?? fetch;
|
|
140
|
+
|
|
141
|
+
const email = options.email || (await ask("Email: "));
|
|
142
|
+
if (!email) {
|
|
143
|
+
console.error("nixamp: no email given");
|
|
144
|
+
return 64;
|
|
145
|
+
}
|
|
146
|
+
const password = await askSecret("Password: ");
|
|
147
|
+
if (!password) {
|
|
148
|
+
console.error("nixamp: no password given");
|
|
149
|
+
return 64;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const where = `${options.site}/api/v1/auth/${options.signUp ? "signup" : "login"}`;
|
|
153
|
+
let answer: Response;
|
|
154
|
+
try {
|
|
155
|
+
answer = await send(where, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
headers: { "content-type": "application/json" },
|
|
158
|
+
body: JSON.stringify({ email, password }),
|
|
159
|
+
});
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.error(`nixamp: could not reach ${options.site}: ${(error as Error).message}`);
|
|
162
|
+
return 69;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const body = (await answer.json().catch(() => ({}))) as { token?: string; error?: string };
|
|
166
|
+
if (!answer.ok || !body.token) {
|
|
167
|
+
console.error(`nixamp: ${body.error ?? `signing in failed (${answer.status})`}`);
|
|
168
|
+
return 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
writeSession({ site: options.site, email, token: body.token, signedInAt: Date.now() });
|
|
172
|
+
console.log(`Signed in to ${options.site} as ${email}.`);
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function logout(): number {
|
|
177
|
+
const session = readSession();
|
|
178
|
+
clearSession();
|
|
179
|
+
console.log(session ? `Signed out of ${session.site}.` : "nixamp: you were not signed in.");
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** `nixamp whoami`, which asks the server rather than trusting the file. */
|
|
184
|
+
export async function whoami(fetcher: typeof fetch = fetch): Promise<number> {
|
|
185
|
+
const session = readSession();
|
|
186
|
+
if (session === null) {
|
|
187
|
+
console.log("nixamp: not signed in. Try `nixamp login`.");
|
|
188
|
+
return 1;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const answer = await fetcher(`${session.site}/api/v1/auth/me`, {
|
|
192
|
+
headers: { authorization: `Bearer ${session.token}` },
|
|
193
|
+
});
|
|
194
|
+
if (!answer.ok) {
|
|
195
|
+
// The token outlived its welcome, which is worth saying plainly rather
|
|
196
|
+
// than leaving a stale file to confuse the next command.
|
|
197
|
+
console.log(`nixamp: signed in as ${session.email}, but ${session.site} no longer accepts it.`);
|
|
198
|
+
console.log(" Run `nixamp login` again.");
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
const body = (await answer.json()) as { account?: { email?: string } };
|
|
202
|
+
console.log(`${body.account?.email ?? session.email} at ${session.site}`);
|
|
203
|
+
return 0;
|
|
204
|
+
} catch {
|
|
205
|
+
// Offline is not signed out: the token is still good, we just cannot ask.
|
|
206
|
+
console.log(`${session.email} at ${session.site} (could not reach it to check)`);
|
|
207
|
+
return 0;
|
|
208
|
+
}
|
|
209
|
+
}
|
package/src/share.ts
CHANGED
|
@@ -14,6 +14,16 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
|
14
14
|
import type { IncomingMessage } from "node:http";
|
|
15
15
|
import { networkInterfaces } from "node:os";
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Two keys, two scopes.
|
|
19
|
+
*
|
|
20
|
+
* The full key drives the player: it can skip, stop, and point the server at a
|
|
21
|
+
* different source. The listen key can only hear it. A stream published to the
|
|
22
|
+
* public directory hands out the listen key, because a link that lets a
|
|
23
|
+
* stranger pause your music is not a link you can publish.
|
|
24
|
+
*/
|
|
25
|
+
export type Scope = "control" | "listen";
|
|
26
|
+
|
|
17
27
|
/** The cookie, and the query parameter that sets it. */
|
|
18
28
|
export const KEY_COOKIE = "nixamp_key";
|
|
19
29
|
export const KEY_QUERY = "k";
|
|
@@ -116,6 +126,23 @@ export function shareLink(base: string, key: string | null): string {
|
|
|
116
126
|
return key === null ? base : `${base}/s/${key}`;
|
|
117
127
|
}
|
|
118
128
|
|
|
129
|
+
/**
|
|
130
|
+
* What a key is allowed to do. An unknown key is allowed nothing, which is the
|
|
131
|
+
* same answer as no key at all.
|
|
132
|
+
*/
|
|
133
|
+
export function scopeOf(offered: string | null, control: string | null, listen: string | null): Scope | null {
|
|
134
|
+
if (offered === null) return null;
|
|
135
|
+
if (control !== null && keysMatch(offered, control)) return "control";
|
|
136
|
+
if (listen !== null && keysMatch(offered, listen)) return "listen";
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Paths a listen key may have. Everything else needs the control key. */
|
|
141
|
+
export function allowedForListening(path: string): boolean {
|
|
142
|
+
if (path === "/api/command" || path === "/api/source") return false;
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
119
146
|
/** How to run a command, so the tests never touch a real firewall. */
|
|
120
147
|
export interface Runner {
|
|
121
148
|
read(path: string): string | null;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for `@profullstack/auth-system`, which ships none.
|
|
3
|
+
*
|
|
4
|
+
* Only the surface nixamp uses, written from its source rather than guessed:
|
|
5
|
+
* `register` and `login` resolve `{ success, user, tokens }` and THROW on a bad
|
|
6
|
+
* password or a taken address, while `validateToken` resolves the claims
|
|
7
|
+
* directly. The two shapes differ, so they are typed differently here rather
|
|
8
|
+
* than smoothed over.
|
|
9
|
+
*/
|
|
10
|
+
declare module "@profullstack/auth-system" {
|
|
11
|
+
export interface AuthUser {
|
|
12
|
+
id: string;
|
|
13
|
+
email: string;
|
|
14
|
+
profile?: Record<string, unknown>;
|
|
15
|
+
emailVerified?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AuthTokens {
|
|
19
|
+
accessToken: string;
|
|
20
|
+
refreshToken?: string;
|
|
21
|
+
expiresIn?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AuthResponse {
|
|
25
|
+
success: boolean;
|
|
26
|
+
message?: string;
|
|
27
|
+
user: AuthUser;
|
|
28
|
+
tokens: AuthTokens;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What `validateToken` resolves to: the claims, with no wrapper. */
|
|
32
|
+
export interface AuthClaims {
|
|
33
|
+
userId: string;
|
|
34
|
+
email: string;
|
|
35
|
+
profile?: Record<string, unknown>;
|
|
36
|
+
emailVerified?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface AuthSystem {
|
|
40
|
+
/** Throws when the address is already registered. */
|
|
41
|
+
register(input: { email: string; password: string; profile?: Record<string, unknown> }): Promise<AuthResponse>;
|
|
42
|
+
/** Throws `Invalid email or password` rather than resolving success: false. */
|
|
43
|
+
login(input: { email: string; password: string }): Promise<AuthResponse>;
|
|
44
|
+
validateToken(token: string): Promise<AuthClaims>;
|
|
45
|
+
logout(refreshToken?: string, accessToken?: string): Promise<{ success: boolean }>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PostgresAdapterOptions {
|
|
49
|
+
pool?: unknown;
|
|
50
|
+
connectionString?: string;
|
|
51
|
+
host?: string;
|
|
52
|
+
port?: number;
|
|
53
|
+
database?: string;
|
|
54
|
+
user?: string;
|
|
55
|
+
password?: string;
|
|
56
|
+
usersTable?: string;
|
|
57
|
+
tokensTable?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class PostgresAdapter {
|
|
61
|
+
constructor(options?: PostgresAdapterOptions);
|
|
62
|
+
initialize(): Promise<void>;
|
|
63
|
+
close(): Promise<void>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class MemoryAdapter {
|
|
67
|
+
constructor();
|
|
68
|
+
clear(): Promise<void>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createAuthSystem(options?: {
|
|
72
|
+
adapter?: unknown;
|
|
73
|
+
jwtSecret?: string;
|
|
74
|
+
accessTokenExpiry?: string | number;
|
|
75
|
+
refreshTokenExpiry?: string | number;
|
|
76
|
+
}): AuthSystem;
|
|
77
|
+
}
|
|
@@ -1 +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}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:12px 12px calc(12px + env(safe-area-inset-bottom));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;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)}.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;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;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)}
|
|
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}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:12px 12px calc(12px + env(safe-area-inset-bottom));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;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)}.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;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;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{text-align:left;border:1px solid var(--line);width:100%;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}
|
|
@@ -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=new Set([`mp3`,`flac`,`ogg`,`oga`,`opus`,`m4a`,`aac`,`wav`,`wma`,`aiff`,`aif`,`alac`,`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`]);function o(e,t=``){if(t.startsWith(`audio/`)||t.startsWith(`video/`))return!0;let n=e.lastIndexOf(`.`);return n>0&&a.has(e.slice(n+1).toLowerCase())}function s(e,t){return e.localeCompare(t,void 0,{numeric:!0,sensitivity:`base`})}function c(e){return e.filter(e=>o(e.name,e.type)).sort((e,t)=>s(l(e),l(t))).map(e=>({title:n(e.name),artist:``,album:u(l(e)),duration:0,url:URL.createObjectURL(e),video:i(e.name,e.type),objectUrl:!0}))}function l(e){return e.webkitRelativePath||e.name}function u(e){let t=e.split(`/`);return t.length>1?t[t.length-2]:``}function d(e){for(let t of e)t.objectUrl&&URL.revokeObjectURL(t.url)}var f=2048,p=class{elements;handlers;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(m(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=f,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.video?this.elements.video:this.elements.audio;n!==this.active&&(this.active.pause(),this.active.removeAttribute(`src`),this.active.load(),this.active=n),this.active.src=e.url,this.active.load(),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}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 m(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 h(){return{revision:0,tracks:[],index:0,playing:!1,position:0,bars:[],levels:[0,0],silent:!0,note:``,root:``}}function g(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 _(e,t){return`${e===``?``:g(e)}${t.startsWith(`/`)?t:`/${t}`}`}function v(e,t){return _(e,`/api/media/${t}`)}function y(e){if(typeof e!=`object`||!e)return null;let t=e;if(!Array.isArray(t.tracks))return null;let n=h(),r=(e,t)=>typeof e==`number`&&Number.isFinite(e)?e:t,i=Array.isArray(t.levels)?t.levels:[];return{revision:r(t.revision,0),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)}}),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 ee=class{handlers;source=null;base=``;lastRevision=-1;constructor(e){this.handlers=e}get address(){return this.base}get connected(){return this.source!==null}connect(e){let t=g(e);this.close(),this.base=t,this.lastRevision=-1,this.handlers.onStatus(`connecting`);let n=new EventSource(_(t,`/api/events`));this.source=n,n.onopen=()=>this.handlers.onStatus(`live`),n.onmessage=e=>{let t=y(b(e.data));t&&(t.revision<this.lastRevision||(this.lastRevision=t.revision,this.handlers.onStatus(`live`),this.handlers.onSnapshot(t)))},n.onerror=()=>{this.handlers.onStatus(`error`,`reconnecting…`)}}async send(e){if(this.base===``&&!this.connected)return;let t=await fetch(_(this.base,`/api/command`),{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=y(await t.json());n&&this.handlers.onSnapshot(n)}media(e){return v(this.base,e)}close(){this.source?.close(),this.source=null}};function b(e){try{return JSON.parse(e)}catch{return null}}async function te(e,t){try{let n=await fetch(_(e,`/api/state`),{signal:t});return n.ok?y(await n.json()):null}catch{return null}}async function x(e,t){try{let n=await fetch(_(e,`/api/health`),{signal:t});if(!n.ok)return null;let r=await n.json();return r.name===`nixamp`?r.version??`unknown`:null}catch{return null}}var S=.14,C=.02;function ne(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 re(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 w(e,t,n=S){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function T(e,t,n=C){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function E(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 D=`nixamp.remote`,O=`nixamp.volume`;function k(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function A(){let n={status:k(`status`),source:k(`source`),install:k(`install`),video:k(`video`),audio:k(`audio`),title:k(`title-line`),album:k(`album-line`),elapsed:k(`elapsed`),total:k(`total`),seek:k(`seek`),canvas:k(`spectrum`),glyphs:k(`glyphs`),levels:k(`levels`),playlist:k(`playlist`),playlistTitle:k(`playlist-panel`),note:k(`note`),files:k(`files`),folder:k(`folder`),remoteUrl:k(`remote-url`),remoteForm:k(`remote-form`),remoteState:k(`remote-state`),disconnect:k(`disconnect`),browse:k(`browse`),accountForm:k(`account-form`),accountEmail:k(`account-email`),accountPassword:k(`account-password`),accountSubmit:k(`account-submit`),accountToggle:k(`account-toggle`),accountSignOut:k(`account-signout`),accountNote:k(`account-note`),adminPanel:k(`admin-panel`),adminNote:k(`admin-note`),adminConnections:k(`admin-connections`),adminRestream:k(`admin-restream`),adminSource:k(`admin-source`),directory:k(`directory`),directoryNote:k(`directory-note`),directoryList:k(`directory-list`),listenHere:k(`listen-here`),volume:k(`volume`),prev:k(`prev`),playPause:k(`play-pause`),stop:k(`stop`),next:k(`next`)},r=`local`,i=[],a=0,o=h(),s=`idle`,l=``,u=`Pick files, or connect to a nixamp running somewhere else.`,f=!1,m=Array(24).fill(0),_=Array(24).fill(0),v=[],y=()=>r===`remote`&&!n.listenHere.checked,b=new p({audio:n.audio,video:n.video},{onTime:(e,t)=>{let n=i[a];r===`local`&&n&&t>0&&n.duration!==t&&(n.duration=t),H()},onEnded:()=>z(1),onState:()=>H(),onError:e=>{u=e,H()}}),S=new ee({onSnapshot:e=>{o=e,y()&&(m=e.bars.length>0?e.bars:m,_=T(_,m)),H()},onStatus:(e,t)=>{s=e,l=t??``,H()}}),C=()=>r===`remote`?o.tracks.length:i.length,A=()=>r===`remote`?o.index:a,j=()=>{if(r===`remote`){let e=o.tracks[o.index];return e?t(e):`Nothing loaded.`}let e=i[a];return e?t(e):`Nothing loaded.`},M=()=>(r===`remote`?o.tracks[o.index]:i[a])?.album||`—`,N=()=>y()?o.tracks[o.index]?.duration??0:b.duration,P=()=>y()?o.position:b.position,F=()=>y()?o.playing:b.playing;async function I(e){if(r===`remote`){if(y()){await S.send({type:`play`,index:e});return}await S.send({type:`select`,index:e}),await L(e);return}let t=i[e];t&&(a=e,await b.load(t,!0),ae(t.video),G(),H())}async function L(e){let t=o.tracks[e];t&&(await b.load({title:t.title,artist:t.artist,album:t.album,duration:t.duration,url:S.media(e),video:!1,objectUrl:!1},!0),G())}async function R(){if(y()){await S.send({type:`toggle`});return}C()!==0&&(b.playing?b.pause():b.position>0?await b.play():await I(A()),H())}async function z(e){let t=C();if(t!==0){if(y()){await S.send({type:e>0?`next`:`prev`});return}await I((A()+e+t)%t)}}async function B(){if(y()){await S.send({type:`stop`});return}b.stop(),m=Array(24).fill(0),_=[...m],H()}let V=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))];function H(){let t=C(),a=F();n.status.textContent=a?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=String(a),n.title.textContent=j(),n.album.textContent=M();let c=P(),d=N();n.elapsed.textContent=e(c),n.total.textContent=d>0?e(d):`--:--`,f||(n.seek.value=String(d>0?Math.round(c/d*1e3):0),n.seek.disabled=d<=0||y()),n.playPause.textContent=a?`❚❚`:`▶`,n.playPause.setAttribute(`aria-label`,a?`Pause`:`Play`),n.playlistTitle.dataset.title=`Playlist (${t})`,n.source.textContent=r===`remote`?`remote · ${S.address.replace(/^https?:\/\//,``)||`—`}`:i.length>0?`local · ${i.length} files`:`no source`,n.remoteState.textContent=r===`remote`?`${s}${l?` — ${l}`:``}`:`not connected`,n.remoteState.dataset.status=r===`remote`?s:`idle`,n.disconnect.hidden=r!==`remote`;let p=r===`remote`&&o.note!==``?o.note:u;n.note.textContent=p,n.note.hidden=p===``,ie(),n.glyphs.textContent=m.map(V).join(``);let[h,g]=y()?o.levels:b.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(h*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(g*6)).padEnd(6,`·`)}`}let U=``;function ie(){let a=r===`remote`?o.tracks.map(e=>[t(e),e.duration]):i.map(e=>[t(e),e.duration]),s=`${r}:${a.map(([e,t])=>`${e}@${t}`).join(`|`)}`;s!==U&&(U=s,n.playlist.replaceChildren(...a.map(([t,n],r)=>{let i=document.createElement(`li`);i.className=`row`,i.dataset.index=String(r);let a=document.createElement(`span`);a.className=`n`,a.textContent=String(r+1).padStart(2,` `);let o=document.createElement(`span`);o.className=`name`,o.textContent=t;let s=document.createElement(`span`);return s.className=`time`,s.textContent=n>0?e(n):`--:--`,i.append(a,o,s),i})));let c=A(),l=F();Array.from(n.playlist.children).forEach((e,t)=>{let n=e;n.classList.toggle(`selected`,t===c),n.classList.toggle(`playing`,t===c&&l)}),n.playlist.children[c]?.scrollIntoView({block:`nearest`})}function W(){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(y())_=T(_,m);else{let e=b.read();e.length>0&&(v.length!==25&&(v=ne(24,e.length)),m=w(m,re(e,v)),_=T(_,m))}if(s){let e=getComputedStyle(document.documentElement);E(s,{width:t.width,height:t.height},m,_,{bar:e.getPropertyValue(`--green`).trim()||`#4af689`,peak:e.getPropertyValue(`--green-dim`).trim()||`#227a4a`,background:`transparent`})}if(F()){n.glyphs.textContent=m.map(V).join(``);let[t,r]=y()?o.levels:b.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(t*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(r*6)).padEnd(6,`·`)}`,n.elapsed.textContent=e(P());let i=N();!f&&i>0&&(n.seek.value=String(Math.round(P()/i*1e3)))}requestAnimationFrame(W)}function ae(e){n.video.hidden=!e}function G(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:j(),album:M(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void R()),navigator.mediaSession.setActionHandler(`pause`,()=>void R()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void z(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void z(-1)))}n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&I(n)}),n.prev.addEventListener(`click`,()=>void z(-1)),n.next.addEventListener(`click`,()=>void z(1)),n.stop.addEventListener(`click`,()=>void B()),n.playPause.addEventListener(`click`,()=>void R()),n.seek.addEventListener(`input`,()=>{f=!0}),n.seek.addEventListener(`change`,()=>{let e=N();e>0&&b.seek(Number(n.seek.value)/1e3*e),f=!1}),n.volume.addEventListener(`input`,()=>{let e=Number(n.volume.value)/100;b.volume=e;try{localStorage.setItem(O,String(e))}catch{}});let K=e=>{e.addEventListener(`change`,()=>{let t=c(Array.from(e.files??[]));if(t.length===0){u=`Nothing playable in that selection.`,H();return}d(i),i=t,a=0,r=`local`,S.close(),u=``,I(0)})};K(n.files),K(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=g(n.remoteUrl.value);if(t===``){u=`That is not an address.`,H();return}(async()=>{if(s=`connecting`,H(),await x(t)===null){s=`error`,l=`no nixamp answered there`,r=`local`,H();return}r=`remote`,u=``;try{localStorage.setItem(D,t)}catch{}S.connect(t),H()})()});let q=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));e=(await t.json()).streams??[]}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`,a.textContent=t.nowPlaying?`${t.nowPlaying} · ${t.tracks} tracks`:`${t.tracks} tracks`,r.append(i,a),r.addEventListener(`click`,()=>{n.remoteUrl.value=t.url,n.directory.hidden=!0,n.remoteForm.requestSubmit()}),e.append(r),n.directoryList.append(e)}};if(location.pathname.replace(/\/+$/,``)===`/directory`){document.body.classList.add(`route-directory`);let e=document.getElementById(`directory-back`);e&&(e.hidden=!1),q()}let J=null,oe=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)}},Y=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.`,oe(t.connections??[])}catch{n.adminNote.textContent=`lost touch with the server`}},X=async()=>{let e=!1,t=null;try{let n=await fetch(`/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,J&&clearInterval(J),J=null,e&&(n.adminNote.textContent=t===`owner`?`You own this server.`:`You hold this server's control link.`,Y(),J=setInterval(()=>void Y(),2e3))};n.adminRestream.addEventListener(`submit`,e=>{e.preventDefault();let t=n.adminSource.value.trim();t&&(async()=>{try{let e=await fetch(`/api/source`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({source:t})}),r=await e.json();n.adminNote.textContent=e.ok?`Now serving ${t}.`:r.error??`that did not work`,e.ok&&(n.adminSource.value=``)}catch{n.adminNote.textContent=`could not reach the server`}})()});let Z=!1,Q=e=>{let t=e!==null;n.accountForm.hidden=t,n.accountSignOut.hidden=!t,n.accountNote.textContent=t?`Signed in as ${e}.`:Z?`Create an account on nixamp.com.`:`Sign in to nixamp.com to publish and get paid.`,n.accountSubmit.textContent=Z?`Create account`:`Sign in`,n.accountToggle.textContent=Z?`I have one`:`Create one`,n.accountPassword.autocomplete=Z?`new-password`:`current-password`};n.accountToggle.addEventListener(`click`,()=>{Z=!Z,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/${Z?`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}n.accountPassword.value=``,Q(i.account?.email??t),X()}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{}Q(null),X()})()}),(async()=>{try{let e=await fetch(`/api/v1/auth/me`),t=await e.json();Q(e.ok?t.account?.email??`you`:null)}catch{Q(null)}})(),X(),n.browse.addEventListener(`click`,()=>{if(!n.directory.hidden){n.directory.hidden=!0;return}q(),n.directory.scrollIntoView({behavior:`smooth`,block:`nearest`})}),n.disconnect.addEventListener(`click`,()=>{S.close(),r=`local`,s=`idle`,l=``,H()}),n.listenHere.addEventListener(`change`,()=>{r===`remote`&&(async()=>{n.listenHere.checked?(await S.send({type:`stop`}),await L(o.index)):b.stop(),H()})()}),document.addEventListener(`keydown`,e=>{let t=e.target;if(!(t&&/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))switch(e.key){case` `:e.preventDefault(),R();return;case`s`:B();return;case`n`:case`ArrowRight`:z(1);return;case`p`:case`ArrowLeft`:z(-1);return;case`ArrowDown`:e.preventDefault(),I(Math.min(C()-1,A()+1));return;case`ArrowUp`:e.preventDefault(),I(Math.max(0,A()-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(O);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),b.volume=Number(e));let t=localStorage.getItem(D);t&&(n.remoteUrl.value=t)}catch{}(async()=>{if(n.remoteUrl.value!==``)return;let e=globalThis.location.origin;if(await x(e)===null)return;let t=await te(e);t&&t.tracks.length!==0&&(n.remoteUrl.value=e,r=`remote`,u=``,S.connect(e),H())})(),H(),requestAnimationFrame(W)}A(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});
|
package/web/dist/index.html
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
<meta property="og:title" content="nixamp" />
|
|
17
17
|
<meta property="og:description" content="It really whips the terminal's ass." />
|
|
18
18
|
<meta property="og:type" content="website" />
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-WYJ6R4uF.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-0wAv50Ay.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
|
23
23
|
<main id="app">
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
<button id="install" type="button" class="ghost" hidden>Install</button>
|
|
29
29
|
</header>
|
|
30
30
|
|
|
31
|
-
<section class="panel" data-title="Now Playing">
|
|
31
|
+
<section class="panel player-only" data-title="Now Playing">
|
|
32
32
|
<video id="video" playsinline hidden></video>
|
|
33
33
|
<audio id="audio"></audio>
|
|
34
34
|
<div id="title-line" class="track-title">Nothing loaded.</div>
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
</section>
|
|
42
42
|
|
|
43
43
|
<div class="split">
|
|
44
|
-
<section class="panel" data-title="Spectrum Analyser">
|
|
44
|
+
<section class="panel player-only" data-title="Spectrum Analyser">
|
|
45
45
|
<canvas id="spectrum" aria-hidden="true"></canvas>
|
|
46
46
|
<div class="meters">
|
|
47
47
|
<span id="glyphs" class="glyphs"></span>
|
|
@@ -66,7 +66,7 @@
|
|
|
66
66
|
</section>
|
|
67
67
|
|
|
68
68
|
<div class="split">
|
|
69
|
-
<section class="panel" data-title="Files">
|
|
69
|
+
<section class="panel player-only" data-title="Files">
|
|
70
70
|
<p class="hint">Nothing is uploaded. The browser decodes your files where they are.</p>
|
|
71
71
|
<div class="picker">
|
|
72
72
|
<label class="button">
|
|
@@ -80,13 +80,44 @@
|
|
|
80
80
|
</div>
|
|
81
81
|
</section>
|
|
82
82
|
|
|
83
|
-
<section class="panel" data-title="
|
|
83
|
+
<section class="panel" data-title="Live now" id="directory" hidden>
|
|
84
|
+
<p class="hint" id="directory-note">Looking for live streams…</p>
|
|
85
|
+
<ul id="directory-list" class="directory-list"></ul>
|
|
86
|
+
<p class="hint"><a href="/" id="directory-back" hidden>Back to the player</a></p>
|
|
87
|
+
</section>
|
|
88
|
+
|
|
89
|
+
<section class="panel player-only" data-title="Account">
|
|
90
|
+
<p class="hint" id="account-note">Sign in to nixamp.com to publish and get paid.</p>
|
|
91
|
+
<form id="account-form" class="picker">
|
|
92
|
+
<input id="account-email" type="email" autocomplete="email"
|
|
93
|
+
placeholder="you@example.com" aria-label="Email" />
|
|
94
|
+
<input id="account-password" type="password" autocomplete="current-password"
|
|
95
|
+
placeholder="password" aria-label="Password" />
|
|
96
|
+
<button type="submit" class="button" id="account-submit">Sign in</button>
|
|
97
|
+
<button id="account-toggle" type="button" class="ghost">Create one</button>
|
|
98
|
+
<button id="account-signout" type="button" class="ghost" hidden>Sign out</button>
|
|
99
|
+
</form>
|
|
100
|
+
</section>
|
|
101
|
+
|
|
102
|
+
<section class="panel player-only" data-title="Admin" id="admin-panel" hidden>
|
|
103
|
+
<p class="hint" id="admin-note">Checking…</p>
|
|
104
|
+
<table class="admin-table" id="admin-connections"></table>
|
|
105
|
+
<form id="admin-restream" class="picker">
|
|
106
|
+
<input id="admin-source" type="text" placeholder="a URL or a path to re-stream"
|
|
107
|
+
aria-label="Source to re-stream" />
|
|
108
|
+
<button type="submit" class="button">Re-stream</button>
|
|
109
|
+
</form>
|
|
110
|
+
</section>
|
|
111
|
+
|
|
112
|
+
<section class="panel player-only" data-title="Remote">
|
|
84
113
|
<p class="hint">Run <code>nixamp serve ~/Music --host 0.0.0.0</code> and drive it from here.</p>
|
|
85
114
|
<form id="remote-form" class="picker">
|
|
86
115
|
<input id="remote-url" type="text" inputmode="url" placeholder="192.168.1.7:4321" aria-label="nixamp server address" />
|
|
87
116
|
<button type="submit" class="button">Connect</button>
|
|
88
117
|
<button id="disconnect" type="button" class="ghost" hidden>Disconnect</button>
|
|
118
|
+
<button id="browse" type="button" class="ghost">Browse the directory</button>
|
|
89
119
|
</form>
|
|
120
|
+
|
|
90
121
|
<label class="check">
|
|
91
122
|
<input id="listen-here" type="checkbox" />
|
|
92
123
|
Listen on this device
|
package/web/dist/sw.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/* nixamp service worker — generated, do not edit */
|
|
2
|
-
const CACHE = "nixamp-
|
|
2
|
+
const CACHE = "nixamp-1788901977701";
|
|
3
3
|
const PRECACHE = [
|
|
4
4
|
"/",
|
|
5
5
|
"/apple-touch-icon.png",
|
|
6
|
-
"/assets/index-
|
|
7
|
-
"/assets/index-
|
|
6
|
+
"/assets/index-0wAv50Ay.css",
|
|
7
|
+
"/assets/index-WYJ6R4uF.js",
|
|
8
8
|
"/icons/icon-192-maskable.png",
|
|
9
9
|
"/icons/icon-192.png",
|
|
10
10
|
"/icons/icon-512-maskable.png",
|
|
@@ -1 +0,0 @@
|
|
|
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=new Set([`mp3`,`flac`,`ogg`,`oga`,`opus`,`m4a`,`aac`,`wav`,`wma`,`aiff`,`aif`,`alac`,`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`]);function o(e,t=``){if(t.startsWith(`audio/`)||t.startsWith(`video/`))return!0;let n=e.lastIndexOf(`.`);return n>0&&a.has(e.slice(n+1).toLowerCase())}function s(e,t){return e.localeCompare(t,void 0,{numeric:!0,sensitivity:`base`})}function c(e){return e.filter(e=>o(e.name,e.type)).sort((e,t)=>s(l(e),l(t))).map(e=>({title:n(e.name),artist:``,album:u(l(e)),duration:0,url:URL.createObjectURL(e),video:i(e.name,e.type),objectUrl:!0}))}function l(e){return e.webkitRelativePath||e.name}function u(e){let t=e.split(`/`);return t.length>1?t[t.length-2]:``}function d(e){for(let t of e)t.objectUrl&&URL.revokeObjectURL(t.url)}var f=2048,p=class{elements;handlers;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(m(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=f,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.video?this.elements.video:this.elements.audio;n!==this.active&&(this.active.pause(),this.active.removeAttribute(`src`),this.active.load(),this.active=n),this.active.src=e.url,this.active.load(),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}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 m(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 h(){return{revision:0,tracks:[],index:0,playing:!1,position:0,bars:[],levels:[0,0],silent:!0,note:``,root:``}}function g(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 _(e,t){return`${e===``?``:g(e)}${t.startsWith(`/`)?t:`/${t}`}`}function v(e,t){return _(e,`/api/media/${t}`)}function y(e){if(typeof e!=`object`||!e)return null;let t=e;if(!Array.isArray(t.tracks))return null;let n=h(),r=(e,t)=>typeof e==`number`&&Number.isFinite(e)?e:t,i=Array.isArray(t.levels)?t.levels:[];return{revision:r(t.revision,0),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)}}),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 b=class{handlers;source=null;base=``;lastRevision=-1;constructor(e){this.handlers=e}get address(){return this.base}get connected(){return this.source!==null}connect(e){let t=g(e);this.close(),this.base=t,this.lastRevision=-1,this.handlers.onStatus(`connecting`);let n=new EventSource(_(t,`/api/events`));this.source=n,n.onopen=()=>this.handlers.onStatus(`live`),n.onmessage=e=>{let t=y(x(e.data));t&&(t.revision<this.lastRevision||(this.lastRevision=t.revision,this.handlers.onStatus(`live`),this.handlers.onSnapshot(t)))},n.onerror=()=>{this.handlers.onStatus(`error`,`reconnecting…`)}}async send(e){if(this.base===``&&!this.connected)return;let t=await fetch(_(this.base,`/api/command`),{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=y(await t.json());n&&this.handlers.onSnapshot(n)}media(e){return v(this.base,e)}close(){this.source?.close(),this.source=null}};function x(e){try{return JSON.parse(e)}catch{return null}}async function S(e,t){try{let n=await fetch(_(e,`/api/state`),{signal:t});return n.ok?y(await n.json()):null}catch{return null}}async function C(e,t){try{let n=await fetch(_(e,`/api/health`),{signal:t});if(!n.ok)return null;let r=await n.json();return r.name===`nixamp`?r.version??`unknown`:null}catch{return null}}var w=.14,T=.02;function E(e,t){let n=[];for(let r=0;r<=e;r++){let i=r/e,a=Math.round(1*(t/1)**i),o=n[n.length-1];n.push(o===void 0?a:Math.max(a,o+1))}return n}function D(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 O(e,t,n=w){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function k(e,t,n=T){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function A(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 j=`nixamp.remote`,M=`nixamp.volume`;function N(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function P(){let n={status:N(`status`),source:N(`source`),install:N(`install`),video:N(`video`),audio:N(`audio`),title:N(`title-line`),album:N(`album-line`),elapsed:N(`elapsed`),total:N(`total`),seek:N(`seek`),canvas:N(`spectrum`),glyphs:N(`glyphs`),levels:N(`levels`),playlist:N(`playlist`),playlistTitle:N(`playlist-panel`),note:N(`note`),files:N(`files`),folder:N(`folder`),remoteUrl:N(`remote-url`),remoteForm:N(`remote-form`),remoteState:N(`remote-state`),disconnect:N(`disconnect`),listenHere:N(`listen-here`),volume:N(`volume`),prev:N(`prev`),playPause:N(`play-pause`),stop:N(`stop`),next:N(`next`)},r=`local`,i=[],a=0,o=h(),s=`idle`,l=``,u=`Pick files, or connect to a nixamp running somewhere else.`,f=!1,m=Array(24).fill(0),_=Array(24).fill(0),v=[],y=()=>r===`remote`&&!n.listenHere.checked,x=new p({audio:n.audio,video:n.video},{onTime:(e,t)=>{let n=i[a];r===`local`&&n&&t>0&&n.duration!==t&&(n.duration=t),K()},onEnded:()=>U(1),onState:()=>K(),onError:e=>{u=e,K()}}),w=new b({onSnapshot:e=>{o=e,y()&&(m=e.bars.length>0?e.bars:m,_=k(_,m)),K()},onStatus:(e,t)=>{s=e,l=t??``,K()}}),T=()=>r===`remote`?o.tracks.length:i.length,P=()=>r===`remote`?o.index:a,F=()=>{if(r===`remote`){let e=o.tracks[o.index];return e?t(e):`Nothing loaded.`}let e=i[a];return e?t(e):`Nothing loaded.`},I=()=>(r===`remote`?o.tracks[o.index]:i[a])?.album||`—`,L=()=>y()?o.tracks[o.index]?.duration??0:x.duration,R=()=>y()?o.position:x.position,z=()=>y()?o.playing:x.playing;async function B(e){if(r===`remote`){if(y()){await w.send({type:`play`,index:e});return}await w.send({type:`select`,index:e}),await V(e);return}let t=i[e];t&&(a=e,await x.load(t,!0),X(t.video),Z(),K())}async function V(e){let t=o.tracks[e];t&&(await x.load({title:t.title,artist:t.artist,album:t.album,duration:t.duration,url:w.media(e),video:!1,objectUrl:!1},!0),Z())}async function H(){if(y()){await w.send({type:`toggle`});return}T()!==0&&(x.playing?x.pause():x.position>0?await x.play():await B(P()),K())}async function U(e){let t=T();if(t!==0){if(y()){await w.send({type:e>0?`next`:`prev`});return}await B((P()+e+t)%t)}}async function W(){if(y()){await w.send({type:`stop`});return}x.stop(),m=Array(24).fill(0),_=[...m],K()}let G=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))];function K(){let t=T(),a=z();n.status.textContent=a?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=String(a),n.title.textContent=F(),n.album.textContent=I();let c=R(),d=L();n.elapsed.textContent=e(c),n.total.textContent=d>0?e(d):`--:--`,f||(n.seek.value=String(d>0?Math.round(c/d*1e3):0),n.seek.disabled=d<=0||y()),n.playPause.textContent=a?`❚❚`:`▶`,n.playPause.setAttribute(`aria-label`,a?`Pause`:`Play`),n.playlistTitle.dataset.title=`Playlist (${t})`,n.source.textContent=r===`remote`?`remote · ${w.address.replace(/^https?:\/\//,``)||`—`}`:i.length>0?`local · ${i.length} files`:`no source`,n.remoteState.textContent=r===`remote`?`${s}${l?` — ${l}`:``}`:`not connected`,n.remoteState.dataset.status=r===`remote`?s:`idle`,n.disconnect.hidden=r!==`remote`;let p=r===`remote`&&o.note!==``?o.note:u;n.note.textContent=p,n.note.hidden=p===``,J(),n.glyphs.textContent=m.map(G).join(``);let[h,g]=y()?o.levels:x.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(h*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(g*6)).padEnd(6,`·`)}`}let q=``;function J(){let a=r===`remote`?o.tracks.map(e=>[t(e),e.duration]):i.map(e=>[t(e),e.duration]),s=`${r}:${a.map(([e,t])=>`${e}@${t}`).join(`|`)}`;s!==q&&(q=s,n.playlist.replaceChildren(...a.map(([t,n],r)=>{let i=document.createElement(`li`);i.className=`row`,i.dataset.index=String(r);let a=document.createElement(`span`);a.className=`n`,a.textContent=String(r+1).padStart(2,` `);let o=document.createElement(`span`);o.className=`name`,o.textContent=t;let s=document.createElement(`span`);return s.className=`time`,s.textContent=n>0?e(n):`--:--`,i.append(a,o,s),i})));let c=P(),l=z();Array.from(n.playlist.children).forEach((e,t)=>{let n=e;n.classList.toggle(`selected`,t===c),n.classList.toggle(`playing`,t===c&&l)}),n.playlist.children[c]?.scrollIntoView({block:`nearest`})}function Y(){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(y())_=k(_,m);else{let e=x.read();e.length>0&&(v.length!==25&&(v=E(24,e.length)),m=O(m,D(e,v)),_=k(_,m))}if(s){let e=getComputedStyle(document.documentElement);A(s,{width:t.width,height:t.height},m,_,{bar:e.getPropertyValue(`--green`).trim()||`#4af689`,peak:e.getPropertyValue(`--green-dim`).trim()||`#227a4a`,background:`transparent`})}if(z()){n.glyphs.textContent=m.map(G).join(``);let[t,r]=y()?o.levels:x.levels();n.levels.textContent=`L${`▮`.repeat(Math.round(t*6)).padEnd(6,`·`)} R${`▮`.repeat(Math.round(r*6)).padEnd(6,`·`)}`,n.elapsed.textContent=e(R());let i=L();!f&&i>0&&(n.seek.value=String(Math.round(R()/i*1e3)))}requestAnimationFrame(Y)}function X(e){n.video.hidden=!e}function Z(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:F(),album:I(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void H()),navigator.mediaSession.setActionHandler(`pause`,()=>void H()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void U(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void U(-1)))}n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&B(n)}),n.prev.addEventListener(`click`,()=>void U(-1)),n.next.addEventListener(`click`,()=>void U(1)),n.stop.addEventListener(`click`,()=>void W()),n.playPause.addEventListener(`click`,()=>void H()),n.seek.addEventListener(`input`,()=>{f=!0}),n.seek.addEventListener(`change`,()=>{let e=L();e>0&&x.seek(Number(n.seek.value)/1e3*e),f=!1}),n.volume.addEventListener(`input`,()=>{let e=Number(n.volume.value)/100;x.volume=e;try{localStorage.setItem(M,String(e))}catch{}});let Q=e=>{e.addEventListener(`change`,()=>{let t=c(Array.from(e.files??[]));if(t.length===0){u=`Nothing playable in that selection.`,K();return}d(i),i=t,a=0,r=`local`,w.close(),u=``,B(0)})};Q(n.files),Q(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=g(n.remoteUrl.value);if(t===``){u=`That is not an address.`,K();return}(async()=>{if(s=`connecting`,K(),await C(t)===null){s=`error`,l=`no nixamp answered there`,r=`local`,K();return}r=`remote`,u=``;try{localStorage.setItem(j,t)}catch{}w.connect(t),K()})()}),n.disconnect.addEventListener(`click`,()=>{w.close(),r=`local`,s=`idle`,l=``,K()}),n.listenHere.addEventListener(`change`,()=>{r===`remote`&&(async()=>{n.listenHere.checked?(await w.send({type:`stop`}),await V(o.index)):x.stop(),K()})()}),document.addEventListener(`keydown`,e=>{let t=e.target;if(!(t&&/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))switch(e.key){case` `:e.preventDefault(),H();return;case`s`:W();return;case`n`:case`ArrowRight`:U(1);return;case`p`:case`ArrowLeft`:U(-1);return;case`ArrowDown`:e.preventDefault(),B(Math.min(T()-1,P()+1));return;case`ArrowUp`:e.preventDefault(),B(Math.max(0,P()-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(M);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),x.volume=Number(e));let t=localStorage.getItem(j);t&&(n.remoteUrl.value=t)}catch{}(async()=>{if(n.remoteUrl.value!==``)return;let e=globalThis.location.origin;if(await C(e)===null)return;let t=await S(e);t&&t.tracks.length!==0&&(n.remoteUrl.value=e,r=`remote`,u=``,w.connect(e),K())})(),K(),requestAnimationFrame(Y)}P(),`serviceWorker`in navigator&&globalThis.addEventListener(`load`,()=>{navigator.serviceWorker.register(`/sw.js`).catch(()=>{})});
|