nixamp 0.2.0 → 0.4.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 +186 -0
- package/dist/directory.js +275 -0
- package/dist/durable.d.ts +70 -0
- package/dist/durable.js +156 -0
- package/dist/follows.d.ts +92 -0
- package/dist/follows.js +248 -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/notify.d.ts +83 -0
- package/dist/notify.js +126 -0
- package/dist/optin.d.ts +37 -0
- package/dist/optin.js +122 -0
- package/dist/owner.d.ts +53 -0
- package/dist/owner.js +96 -0
- package/dist/partyline.d.ts +259 -0
- package/dist/partyline.js +616 -0
- package/dist/paywall.d.ts +60 -0
- package/dist/paywall.js +162 -0
- package/dist/playlist.js +5 -0
- package/dist/publish.d.ts +57 -0
- package/dist/publish.js +106 -0
- package/dist/rtmp-in.d.ts +22 -0
- package/dist/rtmp-in.js +79 -0
- package/dist/server.d.ts +94 -0
- package/dist/server.js +1158 -12
- package/dist/session.d.ts +29 -0
- package/dist/session.js +184 -0
- package/dist/share.d.ts +26 -0
- package/dist/share.js +31 -0
- package/package.json +8 -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 +362 -0
- package/src/durable.ts +215 -0
- package/src/follows.ts +307 -0
- package/src/ingest.ts +297 -0
- package/src/main.ts +21 -0
- package/src/manage.ts +2 -1
- package/src/notify.ts +217 -0
- package/src/optin.ts +128 -0
- package/src/owner.ts +113 -0
- package/src/partyline.ts +742 -0
- package/src/paywall.ts +198 -0
- package/src/playlist.ts +5 -0
- package/src/publish.ts +137 -0
- package/src/rtmp-in.ts +90 -0
- package/src/server.ts +1304 -12
- package/src/session.ts +209 -0
- package/src/share.ts +40 -0
- package/src/types/auth-system.d.ts +77 -0
- package/web/dist/assets/{index-BGKWWaIx.css → index-DSIDSSPF.css} +1 -1
- package/web/dist/assets/index-qRguFskX.js +1 -0
- package/web/dist/index.html +62 -6
- package/web/dist/install.sh +82 -0
- package/web/dist/sw.js +45 -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,36 @@ export function shareLink(base: string, key: string | null): string {
|
|
|
116
126
|
return key === null ? base : `${base}/s/${key}`;
|
|
117
127
|
}
|
|
118
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The same stream, as bytes rather than as a page.
|
|
131
|
+
*
|
|
132
|
+
* A share link is for a browser: it answers 302, leaves a cookie behind and
|
|
133
|
+
* redirects to the player. Anything that cannot hold a cookie -- the phone
|
|
134
|
+
* line, curl, ffplay -- gets a 401 from it and no audio. This carries the key
|
|
135
|
+
* in the query instead, which keyFrom() accepts, so a single anonymous GET is
|
|
136
|
+
* enough to start hearing sound.
|
|
137
|
+
*/
|
|
138
|
+
export function audioLink(base: string, key: string | null): string {
|
|
139
|
+
return key === null ? `${base}/api/live` : `${base}/api/live?${KEY_QUERY}=${encodeURIComponent(key)}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* What a key is allowed to do. An unknown key is allowed nothing, which is the
|
|
144
|
+
* same answer as no key at all.
|
|
145
|
+
*/
|
|
146
|
+
export function scopeOf(offered: string | null, control: string | null, listen: string | null): Scope | null {
|
|
147
|
+
if (offered === null) return null;
|
|
148
|
+
if (control !== null && keysMatch(offered, control)) return "control";
|
|
149
|
+
if (listen !== null && keysMatch(offered, listen)) return "listen";
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Paths a listen key may have. Everything else needs the control key. */
|
|
154
|
+
export function allowedForListening(path: string): boolean {
|
|
155
|
+
if (path === "/api/command" || path === "/api/source") return false;
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
119
159
|
/** How to run a command, so the tests never touch a real firewall. */
|
|
120
160
|
export interface Runner {
|
|
121
161
|
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}.directory-list li{align-items:stretch;gap:.4rem;display:flex}.directory-list li>button:first-child{flex:auto;min-width:0}.directory-list .follow{white-space:nowrap;width:auto;color:var(--muted);flex:none;padding-inline:.6rem}.directory-list .follow[data-following=yes]{border-color:var(--accent);color:var(--accent)}.toggle{color:var(--muted);cursor:pointer;align-items:center;gap:.35rem;display:inline-flex}.toggle input{accent-color:var(--accent)}.recent-label{border:1px solid var(--line);border-radius:4px;flex:auto;min-width:0;padding:.4rem .5rem}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();function e(e){if(!Number.isFinite(e)||e<0)return`--:--`;let t=Math.floor(e),n=Math.floor(t/60),r=t%60;return`${String(n).padStart(2,`0`)}:${String(r).padStart(2,`0`)}`}function t(e){return e.artist?`${e.artist} — ${e.title}`:e.title}function n(e){let t=e.split(`/`).pop()??e,n=t.lastIndexOf(`.`);return n>0?t.slice(0,n):t}var r=new Set([`mp4`,`webm`,`mkv`,`mov`,`m4v`,`ogv`,`avi`]);function i(e,t=``){if(t.startsWith(`video/`))return!0;if(t.startsWith(`audio/`))return!1;let n=e.lastIndexOf(`.`);return n>0&&r.has(e.slice(n+1).toLowerCase())}var a=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 ie(e,t,n=S){return t.map((t,r)=>{let i=e[r]??0;return t>=i?t:Math.max(t,i-n)})}function w(e,t,n=C){return t.map((t,r)=>Math.max(t,(e[r]??0)-n))}function ae(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 oe=`nixamp.remote`,se=`nixamp.volume`;function T(e){let t=document.getElementById(e);if(!t)throw Error(`nixamp: #${e} is missing from the shell`);return t}function E(){let n={status:T(`status`),source:T(`source`),install:T(`install`),video:T(`video`),audio:T(`audio`),title:T(`title-line`),album:T(`album-line`),elapsed:T(`elapsed`),total:T(`total`),seek:T(`seek`),canvas:T(`spectrum`),glyphs:T(`glyphs`),levels:T(`levels`),playlist:T(`playlist`),playlistTitle:T(`playlist-panel`),note:T(`note`),files:T(`files`),folder:T(`folder`),remoteUrl:T(`remote-url`),remoteForm:T(`remote-form`),remoteState:T(`remote-state`),disconnect:T(`disconnect`),browse:T(`browse`),accountForm:T(`account-form`),accountEmail:T(`account-email`),accountPassword:T(`account-password`),accountSubmit:T(`account-submit`),accountToggle:T(`account-toggle`),accountSignOut:T(`account-signout`),accountNote:T(`account-note`),adminPanel:T(`admin-panel`),adminNote:T(`admin-note`),adminConnections:T(`admin-connections`),adminRestream:T(`admin-restream`),adminSource:T(`admin-source`),directory:T(`directory`),recentNote:T(`recent-note`),recentList:T(`recent-list`),followingNote:T(`following-note`),followingList:T(`following-list`),notifyPanel:T(`notify-panel`),notifyNote:T(`notify-note`),notifyWeb:T(`notify-web`),notifyEmail:T(`notify-email`),notifySms:T(`notify-sms`),notifyPhone:T(`notify-phone`),notifyPhoneForm:T(`notify-phone-form`),notifyPhoneNote:T(`notify-phone-note`),directoryNote:T(`directory-note`),directoryList:T(`directory-list`),listenHere:T(`listen-here`),volume:T(`volume`),prev:T(`prev`),playPause:T(`play-pause`),stop:T(`stop`),next:T(`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),I()},onEnded:()=>F(1),onState:()=>I(),onError:e=>{u=e,I()}}),S=new ee({onSnapshot:e=>{o=e,y()&&(m=e.bars.length>0?e.bars:m,_=w(_,m)),I()},onStatus:(e,t)=>{s=e,l=t??``,I()}}),C=()=>r===`remote`?o.tracks.length:i.length,E=()=>r===`remote`?o.index:a,D=()=>{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.`},O=()=>(r===`remote`?o.tracks[o.index]:i[a])?.album||`—`,k=()=>y()?o.tracks[o.index]?.duration??0:b.duration,A=()=>y()?o.position:b.position,j=()=>y()?o.playing:b.playing;async function M(e){if(r===`remote`){if(y()){await S.send({type:`play`,index:e});return}await S.send({type:`select`,index:e}),await N(e);return}let t=i[e];t&&(a=e,await b.load(t,!0),de(t.video),z(),I())}async function N(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),z())}async function P(){if(y()){await S.send({type:`toggle`});return}C()!==0&&(b.playing?b.pause():b.position>0?await b.play():await M(E()),I())}async function F(e){let t=C();if(t!==0){if(y()){await S.send({type:e>0?`next`:`prev`});return}await M((E()+e+t)%t)}}async function ce(){if(y()){await S.send({type:`stop`});return}b.stop(),m=Array(24).fill(0),_=[...m],I()}let le=e=>`▁▂▃▄▅▆▇█`[Math.max(0,Math.min(7,Math.round(e*7)))];function I(){let t=C(),a=j();n.status.textContent=a?`▶ PLAYING`:`■ STOPPED`,n.status.dataset.playing=String(a),n.title.textContent=D(),n.album.textContent=O();let c=A(),d=k();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===``,ue(),n.glyphs.textContent=m.map(le).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 L=``;function ue(){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!==L&&(L=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=E(),l=j();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 R(){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())_=w(_,m);else{let e=b.read();e.length>0&&(v.length!==25&&(v=ne(24,e.length)),m=ie(m,re(e,v)),_=w(_,m))}if(s){let e=getComputedStyle(document.documentElement);ae(s,{width:t.width,height:t.height},m,_,{bar:e.getPropertyValue(`--green`).trim()||`#4af689`,peak:e.getPropertyValue(`--green-dim`).trim()||`#227a4a`,background:`transparent`})}if(j()){n.glyphs.textContent=m.map(le).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(A());let i=k();!f&&i>0&&(n.seek.value=String(Math.round(A()/i*1e3)))}requestAnimationFrame(R)}function de(e){n.video.hidden=!e}function z(){`mediaSession`in navigator&&(navigator.mediaSession.metadata=new MediaMetadata({title:D(),album:O(),artist:`nixamp`,artwork:[{src:`/icons/icon-512.png`,sizes:`512x512`,type:`image/png`}]}),navigator.mediaSession.setActionHandler(`play`,()=>void P()),navigator.mediaSession.setActionHandler(`pause`,()=>void P()),navigator.mediaSession.setActionHandler(`nexttrack`,()=>void F(1)),navigator.mediaSession.setActionHandler(`previoustrack`,()=>void F(-1)))}n.playlist.addEventListener(`click`,e=>{let t=e.target.closest(`li`),n=Number(t?.dataset.index);Number.isInteger(n)&&M(n)}),n.prev.addEventListener(`click`,()=>void F(-1)),n.next.addEventListener(`click`,()=>void F(1)),n.stop.addEventListener(`click`,()=>void ce()),n.playPause.addEventListener(`click`,()=>void P()),n.seek.addEventListener(`input`,()=>{f=!0}),n.seek.addEventListener(`change`,()=>{let e=k();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(se,String(e))}catch{}});let B=e=>{e.addEventListener(`change`,()=>{let t=c(Array.from(e.files??[]));if(t.length===0){u=`Nothing playable in that selection.`,I();return}d(i),i=t,a=0,r=`local`,S.close(),u=``,M(0)})};B(n.files),B(n.folder),n.remoteForm.addEventListener(`submit`,e=>{e.preventDefault();let t=g(n.remoteUrl.value);if(t===``){u=`That is not an address.`,I();return}(async()=>{if(s=`connecting`,I(),await x(t)===null){s=`error`,l=`no nixamp answered there`,r=`local`,I();return}r=`remote`,u=``;try{localStorage.setItem(oe,t)}catch{}S.connect(t),I()})()});let V=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??[],pe(n.recent??[])}catch{n.directoryNote.textContent=`The directory is not answering. Type an address instead.`;return}if(e.length===0){n.directoryNote.textContent=`Nobody is streaming right now.`;return}n.directoryNote.textContent=`${e.length} live ${e.length===1?`stream`:`streams`}:`;for(let t of e){let e=document.createElement(`li`),r=document.createElement(`button`);r.type=`button`;let i=document.createElement(`span`);i.className=`name`,i.textContent=t.name;let a=document.createElement(`span`);a.className=`detail`;let o=[t.nowPlaying,`${t.tracks} tracks`].filter(Boolean);t.code&&o.push(t.callers?`☎ ${t.code} · ${t.callers} on the phone`:`☎ ${t.code}`),a.textContent=o.join(` · `),r.append(i,a),r.addEventListener(`click`,()=>{n.remoteUrl.value=t.url,n.directory.hidden=!0,n.remoteForm.requestSubmit()}),e.append(r),t.ownerId&&Z&&t.ownerId!==Z&&e.append(q(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),V()}let H=null,fe=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)}},U=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.`,fe(t.connections??[])}catch{n.adminNote.textContent=`lost touch with the server`}},W=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,H&&clearInterval(H),H=null,e&&(n.adminNote.textContent=t===`owner`?`You own this server.`:`You hold this server's control link.`,U(),H=setInterval(()=>void U(),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 G=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`},pe=e=>{n.recentList.replaceChildren();let t=Z?e.filter(e=>e.ownerId&&e.ownerId!==Z):[];if(n.recentNote.hidden=t.length===0,t.length!==0)for(let e of t){let t=document.createElement(`li`),r=document.createElement(`span`);r.className=`recent-label`;let i=document.createElement(`span`);i.className=`name`,i.textContent=e.name;let a=document.createElement(`span`);a.className=`detail`,a.textContent=e.nowPlaying?`${e.nowPlaying} · ended ${G(e.endedAt)}`:`ended ${G(e.endedAt)}`,r.append(i,a),t.append(r,q(e.ownerId,e.name)),n.recentList.append(t)}},K=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}},q=(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),K())}catch{}finally{n.disabled=!1}})()}),n},me=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},J=()=>`serviceWorker`in navigator&&`PushManager`in window&&`Notification`in window,he=async()=>{if(!J())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:me(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}},ge=async()=>{try{let e=await(await navigator.serviceWorker.ready).pushManager.getSubscription();if(!e)return;await fetch(`/api/v1/notify/subscribe?endpoint=${encodeURIComponent(e.endpoint)}`,{method:`DELETE`}),await e.unsubscribe()}catch{}},Y=async e=>{try{let t=await fetch(`/api/v1/notify/prefs`,{method:`PUT`,headers:{"content-type":`application/json`},body:JSON.stringify(e)}),r=await t.json();n.notifyPhoneNote.textContent=t.ok?``:r.error??`that did not save`,t.ok&&typeof r.phone==`string`&&(n.notifyPhone.value=r.phone)}catch{n.notifyPhoneNote.textContent=`could not reach nixamp.com`}},_e=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=J()&&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 he();n.notifyWeb.checked=e,await Y({wantsWeb:e});return}await ge(),await Y({wantsWeb:!1}),n.notifyNote.textContent=`Turn on “On this device” to be told here.`})()}),n.notifyEmail.addEventListener(`change`,()=>{Y({wantsEmail:n.notifyEmail.checked})}),n.notifySms.addEventListener(`change`,()=>{(async()=>{if(n.notifySms.checked&&!n.notifyPhone.value.trim()){n.notifyPhoneNote.textContent=`Add a phone number first.`,n.notifySms.checked=!1,n.notifyPhone.focus();return}await Y({wantsSms:n.notifySms.checked})})()}),n.notifyPhoneForm.addEventListener(`submit`,e=>{e.preventDefault(),Y({phone:n.notifyPhone.value.trim()})});let X=!1,Z=``,Q=e=>{let t=e!==null;n.notifyPanel.hidden=!t,t?(_e(),K()):(n.followingNote.hidden=!0,n.followingList.replaceChildren(),n.recentNote.hidden=!0,n.recentList.replaceChildren()),n.accountForm.hidden=t,n.accountSignOut.hidden=!t,n.accountNote.textContent=t?`Signed in as ${e}.`:X?`Create an account on nixamp.com.`:`Sign in to nixamp.com to publish and get paid.`,n.accountSubmit.textContent=X?`Create account`:`Sign in`,n.accountToggle.textContent=X?`I have one`:`Create one`,n.accountPassword.autocomplete=X?`new-password`:`current-password`};n.accountToggle.addEventListener(`click`,()=>{X=!X,Q(null)}),n.accountForm.addEventListener(`submit`,e=>{e.preventDefault();let t=n.accountEmail.value.trim(),r=n.accountPassword.value;(async()=>{n.accountSubmit.disabled=!0;try{let e=await fetch(`/api/v1/auth/${X?`signup`:`login`}`,{method:`POST`,headers:{"content-type":`application/json`},body:JSON.stringify({email:t,password:r})}),i=await e.json();if(!e.ok){n.accountNote.textContent=i.error??`that did not work`;return}Z=i.account?.id??``,n.accountPassword.value=``,Q(i.account?.email??t),W()}catch{n.accountNote.textContent=`could not reach nixamp.com`}finally{n.accountSubmit.disabled=!1}})()}),n.accountSignOut.addEventListener(`click`,()=>{(async()=>{try{await fetch(`/api/v1/auth/logout`,{method:`POST`})}catch{}Z=``,Q(null),W()})()}),(async()=>{try{let e=await fetch(`/api/v1/auth/me`),t=await e.json();Z=e.ok?t.account?.id??``:``,Q(e.ok?t.account?.email??`you`:null)}catch{Z=``,Q(null)}})(),W(),n.browse.addEventListener(`click`,()=>{if(!n.directory.hidden){n.directory.hidden=!0;return}V(),n.directory.scrollIntoView({behavior:`smooth`,block:`nearest`})}),n.disconnect.addEventListener(`click`,()=>{S.close(),r=`local`,s=`idle`,l=``,I()}),n.listenHere.addEventListener(`change`,()=>{r===`remote`&&(async()=>{n.listenHere.checked?(await S.send({type:`stop`}),await N(o.index)):b.stop(),I()})()}),document.addEventListener(`keydown`,e=>{let t=e.target;if(!(t&&/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))switch(e.key){case` `:e.preventDefault(),P();return;case`s`:ce();return;case`n`:case`ArrowRight`:F(1);return;case`p`:case`ArrowLeft`:F(-1);return;case`ArrowDown`:e.preventDefault(),M(Math.min(C()-1,E()+1));return;case`ArrowUp`:e.preventDefault(),M(Math.max(0,E()-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(se);e!==null&&(n.volume.value=String(Math.round(Number(e)*100)),b.volume=Number(e));let t=localStorage.getItem(oe);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),I())})(),I(),requestAnimationFrame(R)}E(),`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-qRguFskX.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DSIDSSPF.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,69 @@
|
|
|
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" id="recent-note" hidden>Recently live — follow them to hear next time:</p>
|
|
87
|
+
<ul id="recent-list" class="directory-list"></ul>
|
|
88
|
+
<p class="hint"><a href="/" id="directory-back" hidden>Back to the player</a></p>
|
|
89
|
+
</section>
|
|
90
|
+
|
|
91
|
+
<section class="panel player-only" data-title="Account">
|
|
92
|
+
<p class="hint" id="account-note">Sign in to nixamp.com to publish and get paid.</p>
|
|
93
|
+
<form id="account-form" class="picker">
|
|
94
|
+
<input id="account-email" type="email" autocomplete="email"
|
|
95
|
+
placeholder="you@example.com" aria-label="Email" />
|
|
96
|
+
<input id="account-password" type="password" autocomplete="current-password"
|
|
97
|
+
placeholder="password" aria-label="Password" />
|
|
98
|
+
<button type="submit" class="button" id="account-submit">Sign in</button>
|
|
99
|
+
<button id="account-toggle" type="button" class="ghost">Create one</button>
|
|
100
|
+
<button id="account-signout" type="button" class="ghost" hidden>Sign out</button>
|
|
101
|
+
</form>
|
|
102
|
+
</section>
|
|
103
|
+
|
|
104
|
+
<section class="panel player-only" data-title="Notifications" id="notify-panel" hidden>
|
|
105
|
+
<p class="hint" id="notify-note">Get told when someone you follow goes live.</p>
|
|
106
|
+
<div class="picker">
|
|
107
|
+
<label class="toggle">
|
|
108
|
+
<input id="notify-web" type="checkbox" /> On this device
|
|
109
|
+
</label>
|
|
110
|
+
<label class="toggle">
|
|
111
|
+
<input id="notify-email" type="checkbox" /> By email
|
|
112
|
+
</label>
|
|
113
|
+
<label class="toggle">
|
|
114
|
+
<input id="notify-sms" type="checkbox" /> By text
|
|
115
|
+
</label>
|
|
116
|
+
</div>
|
|
117
|
+
<form id="notify-phone-form" class="picker">
|
|
118
|
+
<input id="notify-phone" type="tel" autocomplete="tel"
|
|
119
|
+
placeholder="+1 555 555 0123" aria-label="Phone number for texts" />
|
|
120
|
+
<button type="submit" class="button">Save number</button>
|
|
121
|
+
</form>
|
|
122
|
+
<p class="hint" id="notify-phone-note"></p>
|
|
123
|
+
<p class="hint" id="following-note" hidden>You follow:</p>
|
|
124
|
+
<ul id="following-list" class="directory-list"></ul>
|
|
125
|
+
</section>
|
|
126
|
+
|
|
127
|
+
<section class="panel player-only" data-title="Admin" id="admin-panel" hidden>
|
|
128
|
+
<p class="hint" id="admin-note">Checking…</p>
|
|
129
|
+
<table class="admin-table" id="admin-connections"></table>
|
|
130
|
+
<form id="admin-restream" class="picker">
|
|
131
|
+
<input id="admin-source" type="text" placeholder="a URL or a path to re-stream"
|
|
132
|
+
aria-label="Source to re-stream" />
|
|
133
|
+
<button type="submit" class="button">Re-stream</button>
|
|
134
|
+
</form>
|
|
135
|
+
</section>
|
|
136
|
+
|
|
137
|
+
<section class="panel player-only" data-title="Remote">
|
|
84
138
|
<p class="hint">Run <code>nixamp serve ~/Music --host 0.0.0.0</code> and drive it from here.</p>
|
|
85
139
|
<form id="remote-form" class="picker">
|
|
86
140
|
<input id="remote-url" type="text" inputmode="url" placeholder="192.168.1.7:4321" aria-label="nixamp server address" />
|
|
87
141
|
<button type="submit" class="button">Connect</button>
|
|
88
142
|
<button id="disconnect" type="button" class="ghost" hidden>Disconnect</button>
|
|
143
|
+
<button id="browse" type="button" class="ghost">Browse the directory</button>
|
|
89
144
|
</form>
|
|
145
|
+
|
|
90
146
|
<label class="check">
|
|
91
147
|
<input id="listen-here" type="checkbox" />
|
|
92
148
|
Listen on this device
|
package/web/dist/install.sh
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
# sh -s -- --desktop install it even with no desktop session detected
|
|
13
13
|
# sh -s -- --version X install a specific release
|
|
14
14
|
# sh -s -- --prefix DIR install root (default: ~/.local)
|
|
15
|
+
# sh -s -- --port N the port to open in the firewall (default: 4321)
|
|
16
|
+
# sh -s -- --no-firewall leave the firewall alone
|
|
15
17
|
set -eu
|
|
16
18
|
|
|
17
19
|
REPO="profullstack/nixamp"
|
|
@@ -19,11 +21,17 @@ SITE="${NIXAMP_SITE:-https://nixamp.com}"
|
|
|
19
21
|
PREFIX="${NIXAMP_PREFIX:-$HOME/.local}"
|
|
20
22
|
VERSION="${NIXAMP_VERSION:-}"
|
|
21
23
|
WANT_DESKTOP=auto
|
|
24
|
+
# The port `nixamp serve` listens on unless told otherwise, which is the one
|
|
25
|
+
# worth opening ahead of time.
|
|
26
|
+
PORT="${NIXAMP_PORT:-4321}"
|
|
27
|
+
WANT_FIREWALL=auto
|
|
22
28
|
|
|
23
29
|
while [ $# -gt 0 ]; do
|
|
24
30
|
case "$1" in
|
|
25
31
|
--cli-only) WANT_DESKTOP=no ;;
|
|
26
32
|
--desktop) WANT_DESKTOP=yes ;;
|
|
33
|
+
--no-firewall) WANT_FIREWALL=no ;;
|
|
34
|
+
--port) PORT="${2:?--port needs a value}"; shift ;;
|
|
27
35
|
--version) VERSION="${2:?--version needs a value}"; shift ;;
|
|
28
36
|
--prefix) PREFIX="${2:?--prefix needs a value}"; shift ;;
|
|
29
37
|
-h|--help) sed -n '2,15p' "$0" 2>/dev/null || echo "See $SITE"; exit 0 ;;
|
|
@@ -265,6 +273,69 @@ DESKTOP_FLAG=false
|
|
|
265
273
|
} > "$SHARE/uninstall.sh"
|
|
266
274
|
chmod 0755 "$SHARE/uninstall.sh"
|
|
267
275
|
|
|
276
|
+
# --- firewall -----------------------------------------------------------------
|
|
277
|
+
#
|
|
278
|
+
# A nixamp that lists itself hands out an address on this machine, and the
|
|
279
|
+
# phone line fetches the audio from that address to play into a call. A
|
|
280
|
+
# firewall dropping the port turns every one of those into a listing nobody
|
|
281
|
+
# can open and a caller who hears nothing, and the failure says so nowhere:
|
|
282
|
+
# the stream is up, the listing is up, and the port is shut.
|
|
283
|
+
#
|
|
284
|
+
# `nixamp serve --open-port` opens it for one run and closes it after. This is
|
|
285
|
+
# the other half: a machine that is going to publish wants the port open for
|
|
286
|
+
# longer than a single process, and being told to run a command by hand after
|
|
287
|
+
# an installer has finished is a setup step the installer should have done.
|
|
288
|
+
#
|
|
289
|
+
# Root is needed and this installer otherwise needs none, so it is asked for
|
|
290
|
+
# non-interactively and never waited on: a `curl | sh` has no terminal to type
|
|
291
|
+
# a password into. When that will not work the exact command is printed rather
|
|
292
|
+
# than the port being left quietly closed.
|
|
293
|
+
FIREWALL=""
|
|
294
|
+
if [ "$WANT_FIREWALL" != no ] && [ "$OS" = linux ]; then
|
|
295
|
+
if [ -r /etc/ufw/ufw.conf ] && grep -qi '^ENABLED=yes' /etc/ufw/ufw.conf; then
|
|
296
|
+
FIREWALL=ufw
|
|
297
|
+
elif command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet firewalld; then
|
|
298
|
+
FIREWALL=firewalld
|
|
299
|
+
fi
|
|
300
|
+
fi
|
|
301
|
+
|
|
302
|
+
# Run one privileged command, however this machine gets to root.
|
|
303
|
+
as_root() {
|
|
304
|
+
if [ "$(id -u)" = 0 ]; then
|
|
305
|
+
"$@"
|
|
306
|
+
else
|
|
307
|
+
sudo -n "$@"
|
|
308
|
+
fi
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
# What a person would type, for when we cannot.
|
|
312
|
+
firewall_command() {
|
|
313
|
+
if [ "$FIREWALL" = ufw ]; then
|
|
314
|
+
echo "sudo ufw allow $PORT/tcp"
|
|
315
|
+
else
|
|
316
|
+
echo "sudo firewall-cmd --permanent --add-port=$PORT/tcp && sudo firewall-cmd --reload"
|
|
317
|
+
fi
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
open_firewall() {
|
|
321
|
+
if [ "$FIREWALL" = ufw ]; then
|
|
322
|
+
as_root ufw allow "$PORT/tcp"
|
|
323
|
+
else
|
|
324
|
+
as_root firewall-cmd --permanent "--add-port=$PORT/tcp" && as_root firewall-cmd --reload
|
|
325
|
+
fi
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
FW_RESULT=""
|
|
329
|
+
if [ -n "$FIREWALL" ]; then
|
|
330
|
+
if [ "$(id -u)" != 0 ] && ! { command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; }; then
|
|
331
|
+
FW_RESULT=manual
|
|
332
|
+
elif open_firewall >/dev/null 2>&1; then
|
|
333
|
+
FW_RESULT=opened
|
|
334
|
+
else
|
|
335
|
+
FW_RESULT=manual
|
|
336
|
+
fi
|
|
337
|
+
fi
|
|
338
|
+
|
|
268
339
|
# --- report -------------------------------------------------------------------
|
|
269
340
|
|
|
270
341
|
say ""
|
|
@@ -280,6 +351,17 @@ if ! command -v ffmpeg >/dev/null 2>&1; then
|
|
|
280
351
|
say " macOS: brew install ffmpeg"
|
|
281
352
|
fi
|
|
282
353
|
|
|
354
|
+
if [ "$FW_RESULT" = opened ]; then
|
|
355
|
+
say ""
|
|
356
|
+
say " Opened $PORT/tcp in $FIREWALL, so a stream you publish is reachable."
|
|
357
|
+
say " Undo with: $(firewall_command | sed 's/allow/delete allow/; s/--add-port/--remove-port/')"
|
|
358
|
+
elif [ "$FW_RESULT" = manual ]; then
|
|
359
|
+
say ""
|
|
360
|
+
say " $FIREWALL is running and $PORT/tcp is closed, so a published stream"
|
|
361
|
+
say " would be listed at an address nobody outside this machine can open."
|
|
362
|
+
say " Open it with: $(firewall_command)"
|
|
363
|
+
fi
|
|
364
|
+
|
|
283
365
|
case ":$PATH:" in
|
|
284
366
|
*":$BIN:"*)
|
|
285
367
|
say ""
|