nixamp 0.7.8 → 0.7.10
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/dist/playlist.d.ts +23 -1
- package/dist/playlist.js +75 -3
- package/dist/server.d.ts +2 -0
- package/dist/server.js +22 -6
- package/dist/share.d.ts +25 -0
- package/dist/share.js +62 -0
- package/package.json +1 -1
- package/src/playlist.ts +71 -2
- package/src/server.ts +23 -5
- package/src/share.ts +75 -0
- package/web/dist/assets/{hls-3VKVEQE3-CtqtOX7p.js → hls-3VKVEQE3-wESMbXLt.js} +1 -1
- package/web/dist/assets/{index-B5ijv1SY.css → index-DCaP9OrI.css} +1 -1
- package/web/dist/assets/index-DS7hEQqB.js +1 -0
- package/web/dist/assets/{mpegts-DtUWYYid.js → mpegts-Cj-o1uUK.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-DIRN6lqd.js → mpegts-LO6RVLD6-D-ax_0pb.js} +1 -1
- package/web/dist/index.html +8 -5
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-BL-Q9Q2e.js +0 -1
package/dist/playlist.d.ts
CHANGED
|
@@ -53,5 +53,27 @@ export declare function loadPlaylist(tools: Tools, root: string, probeTags?: boo
|
|
|
53
53
|
*/
|
|
54
54
|
export declare function loadTagged(tools: Tools, source: string,
|
|
55
55
|
/** Injected by the test, which must not depend on ffprobe being installed. */
|
|
56
|
-
probeOne?: (tools: Tools, path: string) => Promise<Track
|
|
56
|
+
probeOne?: (tools: Tools, path: string) => Promise<Track>,
|
|
57
|
+
/**
|
|
58
|
+
* The files, when the caller has already found them.
|
|
59
|
+
*
|
|
60
|
+
* Startup walks the library to list it and then walked it again to tag it --
|
|
61
|
+
* twice through a large tree, and the second walk was the one that happened
|
|
62
|
+
* after the port was open, so it was the one people waited on.
|
|
63
|
+
*/
|
|
64
|
+
known?: string[]): Promise<Track[]>;
|
|
65
|
+
/**
|
|
66
|
+
* The same walk, without stopping everything for the length of it.
|
|
67
|
+
*
|
|
68
|
+
* `findAudio` is readdirSync and statSync all the way down, so on a large
|
|
69
|
+
* library it holds the event loop for its entire duration: the socket keeps
|
|
70
|
+
* accepting connections, the kernel completes their handshakes, and the
|
|
71
|
+
* process answers none of them. From outside that is indistinguishable from a
|
|
72
|
+
* server that has hung -- 417 gigabytes of downloads took long enough that
|
|
73
|
+
* requests timed out while the log said the server was up.
|
|
74
|
+
*
|
|
75
|
+
* Yielding every few hundred entries costs a few milliseconds over the whole
|
|
76
|
+
* walk and means a request waits for one directory rather than for the disk.
|
|
77
|
+
*/
|
|
78
|
+
export declare function findAudioAsync(root: string, every?: number): Promise<string[]>;
|
|
57
79
|
export declare function displayName(track: Track): string;
|
package/dist/playlist.js
CHANGED
|
@@ -180,7 +180,11 @@ export async function loadSource(tools, source, probeTags = true) {
|
|
|
180
180
|
// stream is ffmpeg's problem, and it is good at it.
|
|
181
181
|
return [bare({ source, title: nameOf(source), duration: 0 })];
|
|
182
182
|
}
|
|
183
|
-
|
|
183
|
+
// The walk yields, because by the time this runs at startup the port is
|
|
184
|
+
// already open and a synchronous walk of a large library answers nobody for
|
|
185
|
+
// as long as it takes.
|
|
186
|
+
const paths = await findAudioAsync(source);
|
|
187
|
+
return paths.map((path) => probeTags ? probe(tools, path) : { path, title: path.split("/").pop() ?? path, artist: "", album: "", duration: 0 });
|
|
184
188
|
}
|
|
185
189
|
/**
|
|
186
190
|
* Reading tags means an ffprobe per file, which is slow for a large library, so
|
|
@@ -209,11 +213,19 @@ export function loadPlaylist(tools, root, probeTags = true) {
|
|
|
209
213
|
*/
|
|
210
214
|
export async function loadTagged(tools, source,
|
|
211
215
|
/** Injected by the test, which must not depend on ffprobe being installed. */
|
|
212
|
-
probeOne = probeAsync
|
|
216
|
+
probeOne = probeAsync,
|
|
217
|
+
/**
|
|
218
|
+
* The files, when the caller has already found them.
|
|
219
|
+
*
|
|
220
|
+
* Startup walks the library to list it and then walked it again to tag it --
|
|
221
|
+
* twice through a large tree, and the second walk was the one that happened
|
|
222
|
+
* after the port was open, so it was the one people waited on.
|
|
223
|
+
*/
|
|
224
|
+
known) {
|
|
213
225
|
// A URL is one thing and is never probed; a playlist carries its own titles.
|
|
214
226
|
if (isRemote(source) || isPlaylistFile(source))
|
|
215
227
|
return loadSource(tools, source, true);
|
|
216
|
-
const paths =
|
|
228
|
+
const paths = known ?? (await findAudioAsync(source));
|
|
217
229
|
const tracks = [];
|
|
218
230
|
for (const path of paths) {
|
|
219
231
|
// Awaiting a child process, not blocking on one. Yielding between files
|
|
@@ -224,6 +236,66 @@ probeOne = probeAsync) {
|
|
|
224
236
|
}
|
|
225
237
|
return tracks;
|
|
226
238
|
}
|
|
239
|
+
/**
|
|
240
|
+
* The same walk, without stopping everything for the length of it.
|
|
241
|
+
*
|
|
242
|
+
* `findAudio` is readdirSync and statSync all the way down, so on a large
|
|
243
|
+
* library it holds the event loop for its entire duration: the socket keeps
|
|
244
|
+
* accepting connections, the kernel completes their handshakes, and the
|
|
245
|
+
* process answers none of them. From outside that is indistinguishable from a
|
|
246
|
+
* server that has hung -- 417 gigabytes of downloads took long enough that
|
|
247
|
+
* requests timed out while the log said the server was up.
|
|
248
|
+
*
|
|
249
|
+
* Yielding every few hundred entries costs a few milliseconds over the whole
|
|
250
|
+
* walk and means a request waits for one directory rather than for the disk.
|
|
251
|
+
*/
|
|
252
|
+
export async function findAudioAsync(root, every = 200) {
|
|
253
|
+
const out = [];
|
|
254
|
+
let stats;
|
|
255
|
+
try {
|
|
256
|
+
stats = statSync(root);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return out;
|
|
260
|
+
}
|
|
261
|
+
if (stats.isFile())
|
|
262
|
+
return isAudio(root) ? [root] : out;
|
|
263
|
+
let since = 0;
|
|
264
|
+
const breathe = async () => {
|
|
265
|
+
if (++since < every)
|
|
266
|
+
return;
|
|
267
|
+
since = 0;
|
|
268
|
+
await new Promise((done) => setImmediate(done));
|
|
269
|
+
};
|
|
270
|
+
const walk = async (dir) => {
|
|
271
|
+
let entries;
|
|
272
|
+
try {
|
|
273
|
+
entries = readdirSync(dir).sort();
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
for (const entry of entries) {
|
|
279
|
+
if (entry.startsWith("."))
|
|
280
|
+
continue;
|
|
281
|
+
const full = join(dir, entry);
|
|
282
|
+
await breathe();
|
|
283
|
+
let stat;
|
|
284
|
+
try {
|
|
285
|
+
stat = statSync(full);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (stat.isDirectory())
|
|
291
|
+
await walk(full);
|
|
292
|
+
else if (isAudio(full))
|
|
293
|
+
out.push(full);
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
await walk(root);
|
|
297
|
+
return out;
|
|
298
|
+
}
|
|
227
299
|
export function displayName(track) {
|
|
228
300
|
return track.artist ? `${track.artist} — ${track.title}` : track.title;
|
|
229
301
|
}
|
package/dist/server.d.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface ServeOptions {
|
|
|
29
29
|
* port, which is what the public deployment wants and no private one does.
|
|
30
30
|
*/
|
|
31
31
|
key: boolean;
|
|
32
|
+
/** Mint a new share key rather than reusing the one this port had. */
|
|
33
|
+
newKey: boolean;
|
|
32
34
|
/**
|
|
33
35
|
* Ask the local firewall to let the port through, and put it back on the way
|
|
34
36
|
* out. Off by default because it changes the machine, not just this process.
|
package/dist/server.js
CHANGED
|
@@ -29,6 +29,7 @@ import { DeviceGrants } from "./device.js";
|
|
|
29
29
|
import { BAD_KEY_LIMIT, callerOf, Guard, SIGN_IN_LIMIT } from "./guard.js";
|
|
30
30
|
import { deviceDonePage, devicePage, exchangeCode, providersFrom, signInFailedPage, SignIn, } from "./oauth.js";
|
|
31
31
|
import { needsAdmin, Owner } from "./owner.js";
|
|
32
|
+
import { stateDir } from "./daemon.js";
|
|
32
33
|
import { readSession } from "./session.js";
|
|
33
34
|
import { Directory, ENDED_TTL_MS, parseAnnouncement } from "./directory.js";
|
|
34
35
|
import { PartyLine, telnyxSms } from "./partyline.js";
|
|
@@ -40,8 +41,8 @@ import { notifyAll, resendEmail, webPush } from "./notify.js";
|
|
|
40
41
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
41
42
|
import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
|
|
42
43
|
import { isRemote, playsInBrowser, sourceLabel } from "./sources.js";
|
|
43
|
-
import { codecsOf, videoArgs } from "./audio.js";
|
|
44
|
-
import { allowedForListening, elevate, firewallInUse, certifiable, keyCookie, keyFrom, lookupPublicIp,
|
|
44
|
+
import { codecsOf, probeAsync, videoArgs } from "./audio.js";
|
|
45
|
+
import { allowedForListening, elevate, firewallInUse, certifiable, keyCookie, rememberedKeys, keyFrom, lookupPublicIp, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
|
|
45
46
|
import { extname, join, normalize, resolve, sep } from "node:path";
|
|
46
47
|
import { fileURLToPath } from "node:url";
|
|
47
48
|
import { detectTools, peaks, RATE, Stream, toMono, } from "./audio.js";
|
|
@@ -68,6 +69,7 @@ export function parseServeArgs(argv) {
|
|
|
68
69
|
web: null,
|
|
69
70
|
media: true,
|
|
70
71
|
key: true,
|
|
72
|
+
newKey: false,
|
|
71
73
|
openPort: false,
|
|
72
74
|
announce: false,
|
|
73
75
|
directory: false,
|
|
@@ -157,6 +159,9 @@ export function parseServeArgs(argv) {
|
|
|
157
159
|
else if (arg === "--owner") {
|
|
158
160
|
options.owner = value();
|
|
159
161
|
}
|
|
162
|
+
else if (arg === "--new-key") {
|
|
163
|
+
options.newKey = true;
|
|
164
|
+
}
|
|
160
165
|
else if (arg === "--ingest") {
|
|
161
166
|
options.ingest = true;
|
|
162
167
|
}
|
|
@@ -1852,6 +1857,9 @@ export function createHandler(engine, options) {
|
|
|
1852
1857
|
// Where to point OBS. Printed at startup since RTMP was added, which
|
|
1853
1858
|
// is no use at all to somebody looking at the admin panel a day later.
|
|
1854
1859
|
publish: options.publishUrls?.() ?? [],
|
|
1860
|
+
// And which of those slots somebody is already on, because the
|
|
1861
|
+
// question you have in front of three addresses is which one is free.
|
|
1862
|
+
channels: options.channels?.list().map(({ id, name, via }) => ({ id, name, via })) ?? [],
|
|
1855
1863
|
});
|
|
1856
1864
|
return;
|
|
1857
1865
|
}
|
|
@@ -2402,10 +2410,15 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2402
2410
|
// the slowest part of starting and nothing about it needs to happen first.
|
|
2403
2411
|
const engine = new PlayerEngine([], root, tools);
|
|
2404
2412
|
const web = options.web !== null ? resolve(options.web) : defaultWebDir();
|
|
2405
|
-
|
|
2406
|
-
//
|
|
2413
|
+
// The same keys this port used last time, so a link somebody was given
|
|
2414
|
+
// still works after a restart -- and a server is restarted to pick up a new
|
|
2415
|
+
// version, which is to say often. `--new-key` mints a fresh pair and forgets
|
|
2416
|
+
// the old one, which is the way to revoke a link that got out.
|
|
2417
|
+
const remembered = options.key ? rememberedKeys(stateDir(), options.port, options.newKey) : null;
|
|
2418
|
+
const key = remembered?.key ?? null;
|
|
2419
|
+
// Kept whether or not it is published, so `nixamp admin` and the operator
|
|
2407
2420
|
// both have a link they can hand out without handing over the controls.
|
|
2408
|
-
const listenKey =
|
|
2421
|
+
const listenKey = remembered?.listenKey ?? null;
|
|
2409
2422
|
// Configuration can arrive from the directory later, so it is a box the
|
|
2410
2423
|
// paywall reads rather than a value it was handed once.
|
|
2411
2424
|
let paywallConfig = { ...paywallFromEnv(), enabled: options.x402 || paywallFromEnv().enabled };
|
|
@@ -2768,7 +2781,10 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2768
2781
|
console.log(`nixamp serve — ${found.length} tracks under ${root}`);
|
|
2769
2782
|
if (isRemote(root))
|
|
2770
2783
|
return;
|
|
2771
|
-
|
|
2784
|
+
// Handed the files we already found. Tagging used to walk the whole
|
|
2785
|
+
// library a second time to discover the same paths, and that second walk
|
|
2786
|
+
// was the one that ran with the port already open.
|
|
2787
|
+
return loadTagged(tools, root, probeAsync, found.map((track) => track.path))
|
|
2772
2788
|
.then((tagged) => engine.retag(tagged, root))
|
|
2773
2789
|
.catch(() => {
|
|
2774
2790
|
// Filenames are a working player. A failure here is worth nothing
|
package/dist/share.d.ts
CHANGED
|
@@ -17,6 +17,31 @@ export declare const KEY_HEADER = "x-nixamp-key";
|
|
|
17
17
|
* enough to read down a phone screen when someone types it by hand.
|
|
18
18
|
*/
|
|
19
19
|
export declare function newKey(): string;
|
|
20
|
+
/** The pair of keys a server hands out: one that drives, one that only hears. */
|
|
21
|
+
export interface KeyPair {
|
|
22
|
+
key: string;
|
|
23
|
+
listenKey: string;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The keys this port used last time, or a new pair remembered for next time.
|
|
27
|
+
*
|
|
28
|
+
* Keys used to be minted on every start, so every link anybody had been given
|
|
29
|
+
* died the moment the server was restarted -- and a server gets restarted to
|
|
30
|
+
* pick up a new version, which is to say often. A link you cannot rely on is
|
|
31
|
+
* not a link you can share, which was most of why sharing did not feel like it
|
|
32
|
+
* worked.
|
|
33
|
+
*
|
|
34
|
+
* Kept per port, because two servers on one machine are two different
|
|
35
|
+
* audiences, and a single remembered key would hand each of them the other's.
|
|
36
|
+
*
|
|
37
|
+
* The trade is that a key which never changes is a key that stays valid if it
|
|
38
|
+
* leaks, so `fresh` mints a new pair and forgets the old one -- which is what
|
|
39
|
+
* `--new-key` is for.
|
|
40
|
+
*/
|
|
41
|
+
export declare function rememberedKeys(dir: string, port: number, fresh?: boolean, io?: {
|
|
42
|
+
read: (path: string) => string | null;
|
|
43
|
+
write: (path: string, body: string) => void;
|
|
44
|
+
}): KeyPair;
|
|
20
45
|
/** Compare without leaking where two keys first differ. */
|
|
21
46
|
export declare function keysMatch(a: string, b: string): boolean;
|
|
22
47
|
/** Every place a key is accepted from, in the order they are looked for. */
|
package/dist/share.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* every EventSource and every `<audio src>` on its own.
|
|
12
12
|
*/
|
|
13
13
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
14
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname } from "node:path";
|
|
14
16
|
import { networkInterfaces } from "node:os";
|
|
15
17
|
/** The cookie, and the query parameter that sets it. */
|
|
16
18
|
export const KEY_COOKIE = "nixamp_key";
|
|
@@ -23,6 +25,66 @@ export const KEY_HEADER = "x-nixamp-key";
|
|
|
23
25
|
export function newKey() {
|
|
24
26
|
return randomBytes(16).toString("base64url");
|
|
25
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* The keys this port used last time, or a new pair remembered for next time.
|
|
30
|
+
*
|
|
31
|
+
* Keys used to be minted on every start, so every link anybody had been given
|
|
32
|
+
* died the moment the server was restarted -- and a server gets restarted to
|
|
33
|
+
* pick up a new version, which is to say often. A link you cannot rely on is
|
|
34
|
+
* not a link you can share, which was most of why sharing did not feel like it
|
|
35
|
+
* worked.
|
|
36
|
+
*
|
|
37
|
+
* Kept per port, because two servers on one machine are two different
|
|
38
|
+
* audiences, and a single remembered key would hand each of them the other's.
|
|
39
|
+
*
|
|
40
|
+
* The trade is that a key which never changes is a key that stays valid if it
|
|
41
|
+
* leaks, so `fresh` mints a new pair and forgets the old one -- which is what
|
|
42
|
+
* `--new-key` is for.
|
|
43
|
+
*/
|
|
44
|
+
export function rememberedKeys(dir, port, fresh = false, io = defaultKeyStore) {
|
|
45
|
+
const path = `${dir}/keys.json`;
|
|
46
|
+
let all = {};
|
|
47
|
+
const existing = io.read(path);
|
|
48
|
+
if (existing !== null) {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(existing);
|
|
51
|
+
if (parsed && typeof parsed === "object")
|
|
52
|
+
all = parsed;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// A file we cannot read is a file we replace. Losing a key costs a link;
|
|
56
|
+
// refusing to start costs the whole server.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const held = all[String(port)];
|
|
60
|
+
if (!fresh && held && typeof held.key === "string" && typeof held.listenKey === "string")
|
|
61
|
+
return held;
|
|
62
|
+
const minted = { key: newKey(), listenKey: newKey() };
|
|
63
|
+
all[String(port)] = minted;
|
|
64
|
+
try {
|
|
65
|
+
io.write(path, JSON.stringify(all, null, 2));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
// Unwritable state is a key that will not survive a restart, which is how
|
|
69
|
+
// it behaved before this existed. Not a reason to refuse to serve.
|
|
70
|
+
}
|
|
71
|
+
return minted;
|
|
72
|
+
}
|
|
73
|
+
const defaultKeyStore = {
|
|
74
|
+
read: (path) => {
|
|
75
|
+
try {
|
|
76
|
+
return readFileSync(path, "utf8");
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
write: (path, body) => {
|
|
83
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
84
|
+
// Readable only by its owner: it is the password to this server.
|
|
85
|
+
writeFileSync(path, body, { mode: 0o600 });
|
|
86
|
+
},
|
|
87
|
+
};
|
|
26
88
|
/** Compare without leaking where two keys first differ. */
|
|
27
89
|
export function keysMatch(a, b) {
|
|
28
90
|
const left = Buffer.from(a);
|
package/package.json
CHANGED
package/src/playlist.ts
CHANGED
|
@@ -183,7 +183,13 @@ export async function loadSource(tools: Tools, source: string, probeTags = true)
|
|
|
183
183
|
return [bare({ source, title: nameOf(source), duration: 0 })];
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
|
|
186
|
+
// The walk yields, because by the time this runs at startup the port is
|
|
187
|
+
// already open and a synchronous walk of a large library answers nobody for
|
|
188
|
+
// as long as it takes.
|
|
189
|
+
const paths = await findAudioAsync(source);
|
|
190
|
+
return paths.map((path) =>
|
|
191
|
+
probeTags ? probe(tools, path) : { path, title: path.split("/").pop() ?? path, artist: "", album: "", duration: 0 },
|
|
192
|
+
);
|
|
187
193
|
}
|
|
188
194
|
|
|
189
195
|
/**
|
|
@@ -218,11 +224,19 @@ export async function loadTagged(
|
|
|
218
224
|
source: string,
|
|
219
225
|
/** Injected by the test, which must not depend on ffprobe being installed. */
|
|
220
226
|
probeOne: (tools: Tools, path: string) => Promise<Track> = probeAsync,
|
|
227
|
+
/**
|
|
228
|
+
* The files, when the caller has already found them.
|
|
229
|
+
*
|
|
230
|
+
* Startup walks the library to list it and then walked it again to tag it --
|
|
231
|
+
* twice through a large tree, and the second walk was the one that happened
|
|
232
|
+
* after the port was open, so it was the one people waited on.
|
|
233
|
+
*/
|
|
234
|
+
known?: string[],
|
|
221
235
|
): Promise<Track[]> {
|
|
222
236
|
// A URL is one thing and is never probed; a playlist carries its own titles.
|
|
223
237
|
if (isRemote(source) || isPlaylistFile(source)) return loadSource(tools, source, true);
|
|
224
238
|
|
|
225
|
-
const paths =
|
|
239
|
+
const paths = known ?? (await findAudioAsync(source));
|
|
226
240
|
const tracks: Track[] = [];
|
|
227
241
|
for (const path of paths) {
|
|
228
242
|
// Awaiting a child process, not blocking on one. Yielding between files
|
|
@@ -234,6 +248,61 @@ export async function loadTagged(
|
|
|
234
248
|
return tracks;
|
|
235
249
|
}
|
|
236
250
|
|
|
251
|
+
/**
|
|
252
|
+
* The same walk, without stopping everything for the length of it.
|
|
253
|
+
*
|
|
254
|
+
* `findAudio` is readdirSync and statSync all the way down, so on a large
|
|
255
|
+
* library it holds the event loop for its entire duration: the socket keeps
|
|
256
|
+
* accepting connections, the kernel completes their handshakes, and the
|
|
257
|
+
* process answers none of them. From outside that is indistinguishable from a
|
|
258
|
+
* server that has hung -- 417 gigabytes of downloads took long enough that
|
|
259
|
+
* requests timed out while the log said the server was up.
|
|
260
|
+
*
|
|
261
|
+
* Yielding every few hundred entries costs a few milliseconds over the whole
|
|
262
|
+
* walk and means a request waits for one directory rather than for the disk.
|
|
263
|
+
*/
|
|
264
|
+
export async function findAudioAsync(root: string, every = 200): Promise<string[]> {
|
|
265
|
+
const out: string[] = [];
|
|
266
|
+
let stats;
|
|
267
|
+
try {
|
|
268
|
+
stats = statSync(root);
|
|
269
|
+
} catch {
|
|
270
|
+
return out;
|
|
271
|
+
}
|
|
272
|
+
if (stats.isFile()) return isAudio(root) ? [root] : out;
|
|
273
|
+
|
|
274
|
+
let since = 0;
|
|
275
|
+
const breathe = async (): Promise<void> => {
|
|
276
|
+
if (++since < every) return;
|
|
277
|
+
since = 0;
|
|
278
|
+
await new Promise((done) => setImmediate(done));
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const walk = async (dir: string): Promise<void> => {
|
|
282
|
+
let entries: string[];
|
|
283
|
+
try {
|
|
284
|
+
entries = readdirSync(dir).sort();
|
|
285
|
+
} catch {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
for (const entry of entries) {
|
|
289
|
+
if (entry.startsWith(".")) continue;
|
|
290
|
+
const full = join(dir, entry);
|
|
291
|
+
await breathe();
|
|
292
|
+
let stat;
|
|
293
|
+
try {
|
|
294
|
+
stat = statSync(full);
|
|
295
|
+
} catch {
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (stat.isDirectory()) await walk(full);
|
|
299
|
+
else if (isAudio(full)) out.push(full);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
await walk(root);
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
237
306
|
export function displayName(track: Track): string {
|
|
238
307
|
return track.artist ? `${track.artist} — ${track.title}` : track.title;
|
|
239
308
|
}
|
package/src/server.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
SignIn,
|
|
44
44
|
} from "./oauth.ts";
|
|
45
45
|
import { needsAdmin, Owner } from "./owner.ts";
|
|
46
|
+
import { stateDir } from "./daemon.ts";
|
|
46
47
|
import { readSession } from "./session.ts";
|
|
47
48
|
import { Directory, ENDED_TTL_MS, parseAnnouncement, type Listing } from "./directory.ts";
|
|
48
49
|
import { PartyLine, telnyxSms } from "./partyline.ts";
|
|
@@ -60,13 +61,14 @@ import {
|
|
|
60
61
|
paywallFromEnv,
|
|
61
62
|
} from "./paywall.ts";
|
|
62
63
|
import { isRemote, playsInBrowser, sourceLabel } from "./sources.ts";
|
|
63
|
-
import { codecsOf, videoArgs } from "./audio.ts";
|
|
64
|
+
import { codecsOf, probeAsync, videoArgs } from "./audio.ts";
|
|
64
65
|
import {
|
|
65
66
|
allowedForListening,
|
|
66
67
|
elevate,
|
|
67
68
|
firewallInUse,
|
|
68
69
|
certifiable,
|
|
69
70
|
keyCookie,
|
|
71
|
+
rememberedKeys,
|
|
70
72
|
keyFrom,
|
|
71
73
|
keysMatch,
|
|
72
74
|
lookupPublicIp,
|
|
@@ -107,6 +109,8 @@ export interface ServeOptions {
|
|
|
107
109
|
* port, which is what the public deployment wants and no private one does.
|
|
108
110
|
*/
|
|
109
111
|
key: boolean;
|
|
112
|
+
/** Mint a new share key rather than reusing the one this port had. */
|
|
113
|
+
newKey: boolean;
|
|
110
114
|
/**
|
|
111
115
|
* Ask the local firewall to let the port through, and put it back on the way
|
|
112
116
|
* out. Off by default because it changes the machine, not just this process.
|
|
@@ -199,6 +203,7 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
199
203
|
web: null,
|
|
200
204
|
media: true,
|
|
201
205
|
key: true,
|
|
206
|
+
newKey: false,
|
|
202
207
|
openPort: false,
|
|
203
208
|
announce: false,
|
|
204
209
|
directory: false,
|
|
@@ -271,6 +276,8 @@ export function parseServeArgs(argv: string[]): ServeOptions {
|
|
|
271
276
|
options.name = value();
|
|
272
277
|
} else if (arg === "--owner") {
|
|
273
278
|
options.owner = value();
|
|
279
|
+
} else if (arg === "--new-key") {
|
|
280
|
+
options.newKey = true;
|
|
274
281
|
} else if (arg === "--ingest") {
|
|
275
282
|
options.ingest = true;
|
|
276
283
|
} else if (arg === "--rtmp-streams") {
|
|
@@ -2225,6 +2232,9 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2225
2232
|
// Where to point OBS. Printed at startup since RTMP was added, which
|
|
2226
2233
|
// is no use at all to somebody looking at the admin panel a day later.
|
|
2227
2234
|
publish: options.publishUrls?.() ?? [],
|
|
2235
|
+
// And which of those slots somebody is already on, because the
|
|
2236
|
+
// question you have in front of three addresses is which one is free.
|
|
2237
|
+
channels: options.channels?.list().map(({ id, name, via }) => ({ id, name, via })) ?? [],
|
|
2228
2238
|
});
|
|
2229
2239
|
return;
|
|
2230
2240
|
}
|
|
@@ -2815,10 +2825,15 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
2815
2825
|
const engine = new PlayerEngine([], root, tools);
|
|
2816
2826
|
|
|
2817
2827
|
const web = options.web !== null ? resolve(options.web) : defaultWebDir();
|
|
2818
|
-
|
|
2819
|
-
//
|
|
2828
|
+
// The same keys this port used last time, so a link somebody was given
|
|
2829
|
+
// still works after a restart -- and a server is restarted to pick up a new
|
|
2830
|
+
// version, which is to say often. `--new-key` mints a fresh pair and forgets
|
|
2831
|
+
// the old one, which is the way to revoke a link that got out.
|
|
2832
|
+
const remembered = options.key ? rememberedKeys(stateDir(), options.port, options.newKey) : null;
|
|
2833
|
+
const key = remembered?.key ?? null;
|
|
2834
|
+
// Kept whether or not it is published, so `nixamp admin` and the operator
|
|
2820
2835
|
// both have a link they can hand out without handing over the controls.
|
|
2821
|
-
const listenKey =
|
|
2836
|
+
const listenKey = remembered?.listenKey ?? null;
|
|
2822
2837
|
// Configuration can arrive from the directory later, so it is a box the
|
|
2823
2838
|
// paywall reads rather than a value it was handed once.
|
|
2824
2839
|
let paywallConfig: PaywallConfig = { ...paywallFromEnv(), enabled: options.x402 || paywallFromEnv().enabled };
|
|
@@ -3221,7 +3236,10 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3221
3236
|
}
|
|
3222
3237
|
console.log(`nixamp serve — ${found.length} tracks under ${root}`);
|
|
3223
3238
|
if (isRemote(root)) return;
|
|
3224
|
-
|
|
3239
|
+
// Handed the files we already found. Tagging used to walk the whole
|
|
3240
|
+
// library a second time to discover the same paths, and that second walk
|
|
3241
|
+
// was the one that ran with the port already open.
|
|
3242
|
+
return loadTagged(tools, root, probeAsync, found.map((track) => track.path))
|
|
3225
3243
|
.then((tagged) => engine.retag(tagged, root))
|
|
3226
3244
|
.catch(() => {
|
|
3227
3245
|
// Filenames are a working player. A failure here is worth nothing
|
package/src/share.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* every EventSource and every `<audio src>` on its own.
|
|
12
12
|
*/
|
|
13
13
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
14
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname } from "node:path";
|
|
14
16
|
import type { IncomingMessage } from "node:http";
|
|
15
17
|
import { networkInterfaces } from "node:os";
|
|
16
18
|
|
|
@@ -37,6 +39,79 @@ export function newKey(): string {
|
|
|
37
39
|
return randomBytes(16).toString("base64url");
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
/** The pair of keys a server hands out: one that drives, one that only hears. */
|
|
43
|
+
export interface KeyPair {
|
|
44
|
+
key: string;
|
|
45
|
+
listenKey: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The keys this port used last time, or a new pair remembered for next time.
|
|
50
|
+
*
|
|
51
|
+
* Keys used to be minted on every start, so every link anybody had been given
|
|
52
|
+
* died the moment the server was restarted -- and a server gets restarted to
|
|
53
|
+
* pick up a new version, which is to say often. A link you cannot rely on is
|
|
54
|
+
* not a link you can share, which was most of why sharing did not feel like it
|
|
55
|
+
* worked.
|
|
56
|
+
*
|
|
57
|
+
* Kept per port, because two servers on one machine are two different
|
|
58
|
+
* audiences, and a single remembered key would hand each of them the other's.
|
|
59
|
+
*
|
|
60
|
+
* The trade is that a key which never changes is a key that stays valid if it
|
|
61
|
+
* leaks, so `fresh` mints a new pair and forgets the old one -- which is what
|
|
62
|
+
* `--new-key` is for.
|
|
63
|
+
*/
|
|
64
|
+
export function rememberedKeys(
|
|
65
|
+
dir: string,
|
|
66
|
+
port: number,
|
|
67
|
+
fresh = false,
|
|
68
|
+
io: {
|
|
69
|
+
read: (path: string) => string | null;
|
|
70
|
+
write: (path: string, body: string) => void;
|
|
71
|
+
} = defaultKeyStore,
|
|
72
|
+
): KeyPair {
|
|
73
|
+
const path = `${dir}/keys.json`;
|
|
74
|
+
let all: Record<string, KeyPair> = {};
|
|
75
|
+
const existing = io.read(path);
|
|
76
|
+
if (existing !== null) {
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(existing) as Record<string, KeyPair>;
|
|
79
|
+
if (parsed && typeof parsed === "object") all = parsed;
|
|
80
|
+
} catch {
|
|
81
|
+
// A file we cannot read is a file we replace. Losing a key costs a link;
|
|
82
|
+
// refusing to start costs the whole server.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const held = all[String(port)];
|
|
87
|
+
if (!fresh && held && typeof held.key === "string" && typeof held.listenKey === "string") return held;
|
|
88
|
+
|
|
89
|
+
const minted: KeyPair = { key: newKey(), listenKey: newKey() };
|
|
90
|
+
all[String(port)] = minted;
|
|
91
|
+
try {
|
|
92
|
+
io.write(path, JSON.stringify(all, null, 2));
|
|
93
|
+
} catch {
|
|
94
|
+
// Unwritable state is a key that will not survive a restart, which is how
|
|
95
|
+
// it behaved before this existed. Not a reason to refuse to serve.
|
|
96
|
+
}
|
|
97
|
+
return minted;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const defaultKeyStore = {
|
|
101
|
+
read: (path: string): string | null => {
|
|
102
|
+
try {
|
|
103
|
+
return readFileSync(path, "utf8");
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
write: (path: string, body: string): void => {
|
|
109
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
110
|
+
// Readable only by its owner: it is the password to this server.
|
|
111
|
+
writeFileSync(path, body, { mode: 0o600 });
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
40
115
|
/** Compare without leaking where two keys first differ. */
|
|
41
116
|
export function keysMatch(a: string, b: string): boolean {
|
|
42
117
|
const left = Buffer.from(a);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-DS7hEQqB.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
:root{--bg:#080c09;--panel:#0c120e;--edge:#1d2c22;--green:#4af689;--green-dim:#227a4a;--fg:#cfe8d8;--muted:#6d8a79;--warn:#e8c35a;--accent:#7ef0c4;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}[hidden]{display:none!important}html,body{background:var(--bg);min-height:100%;color:var(--fg);margin:0;font:14px/1.45 ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace}body{background-image:repeating-linear-gradient(#4af68906 0 1px,#0000 1px 3px)}#app{max-width:1100px;padding:calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left));flex-direction:column;gap:10px;margin:0 auto;display:flex}.bar{flex-wrap:wrap;align-items:center;gap:10px;display:flex}.brand{color:var(--green);letter-spacing:.14em;font-weight:700}.status{color:var(--muted)}.status[data-playing=true]{color:var(--green)}.chip{color:var(--muted);border:1px solid var(--edge);text-overflow:ellipsis;white-space:nowrap;border-radius:999px;max-width:46vw;margin-left:auto;padding:1px 10px;font-size:12px;overflow:hidden}.panel{border:1px solid var(--edge);background:var(--panel);border-radius:6px;min-width:0;padding:14px 12px 12px;position:relative}.panel:before{content:attr(data-title);background:var(--panel);color:var(--green-dim);letter-spacing:.06em;padding:0 6px;font-size:12px;position:absolute;top:-.72em;left:10px}.split{grid-template-columns:1.3fr 1fr;gap:10px;display:grid}.split>.col-a{grid-column:1}.split>.col-b{grid-column:2}@media (max-width:720px){.split{grid-template-columns:1fr}.split>.col-a,.split>.col-b{grid-column:auto}}.track-title{color:var(--accent);text-overflow:ellipsis;white-space:nowrap;font-weight:700;overflow:hidden}.track-sub{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}#video{border:1px solid var(--edge);background:#000;border-radius:4px;width:100%;max-height:46vh;margin-bottom:8px}.scrub{align-items:center;gap:10px;margin-top:6px;display:flex}.time{color:var(--fg);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}input[type=range]{appearance:none;cursor:pointer;background:0 0;flex:1;min-width:0;height:14px}input[type=range]::-webkit-slider-runnable-track{background:linear-gradient(var(--edge), var(--edge));border:1px solid var(--edge);border-radius:3px;height:6px}input[type=range]::-moz-range-track{background:var(--edge);border-radius:3px;height:6px}input[type=range]::-webkit-slider-thumb{appearance:none;background:var(--green);border-radius:2px;width:10px;height:16px;margin-top:-6px}input[type=range]::-moz-range-thumb{background:var(--green);border:0;border-radius:2px;width:10px;height:16px}input[type=range]:disabled{opacity:.45;cursor:default}#spectrum{border:1px solid var(--edge);background:#060a07;border-radius:4px;width:100%;height:190px;display:block}.meters{align-items:baseline;gap:10px;margin-top:6px;display:flex;overflow:hidden}.glyphs{color:var(--green);letter-spacing:1px;white-space:nowrap;min-height:1.4em;overflow:hidden}.levelmeter{color:var(--accent);white-space:nowrap;margin-left:auto}.playlist{scrollbar-color:var(--green-dim) transparent;max-height:214px;margin:0;padding:0;list-style:none;overflow-y:auto}.row{cursor:pointer;white-space:nowrap;border-radius:3px;gap:8px;padding:2px 6px;display:flex}.row:hover{background:#142019}.row.selected{background:#16241c}.row.selected .name{color:var(--accent)}.row.playing .name{color:var(--green)}.group{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;border-top:1px solid var(--edge);align-items:center;gap:8px;margin-top:4px;padding:6px 6px 2px;font-size:11px;display:flex}.group:first-child{border-top:0;margin-top:0}.group-name{text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}.group-remove{border:1px solid var(--edge);color:var(--muted);cursor:pointer;background:0 0;border-radius:3px;flex:none;padding:0 6px;line-height:1.4}.group-remove:hover{color:var(--accent);border-color:var(--accent)}.row .n{color:var(--muted);text-align:right;flex:none;width:2.4em}.row .name{text-overflow:ellipsis;flex:1;overflow:hidden}.row .time{color:var(--muted);flex:none}.transport{border:1px solid var(--edge);background:var(--panel);border-radius:6px;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 10px;display:flex}button{font:inherit;color:var(--fg);border:1px solid var(--edge);cursor:pointer;background:#121a15;border-radius:4px;padding:6px 12px}button:hover{border-color:var(--green-dim);color:var(--green)}button:active{transform:translateY(1px)}button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid var(--green);outline-offset:2px}button.primary{color:var(--green);border-color:var(--green-dim);min-width:64px}button.ghost{color:var(--muted);background:0 0}.volume{flex:1;align-items:center;gap:8px;min-width:120px;max-width:220px;margin-left:auto;display:flex}.vol{color:var(--muted);letter-spacing:.08em;font-size:12px}.picker{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.button{border:1px solid var(--edge);cursor:pointer;color:var(--fg);background:#121a15;border-radius:4px;padding:6px 12px;display:inline-block}.button:hover{border-color:var(--green-dim);color:var(--green)}.button input[type=file]{display:none}#remote-url{font:inherit;min-width:12ch;color:var(--fg);border:1px solid var(--edge);background:#060a07;border-radius:4px;flex:1;padding:6px 10px}.check{color:var(--muted);align-items:center;gap:8px;margin-top:10px;display:flex}.check input{accent-color:var(--green)}.hint{color:var(--muted);margin:0 0 8px;font-size:13px}.hint code{color:var(--accent)}#remote-state[data-status=live]{color:var(--green)}#remote-state[data-status=connecting],#remote-state[data-status=error]{color:var(--warn)}.note{color:var(--warn);border-left:2px solid var(--warn);margin:0;padding-left:8px}.statusbar{color:var(--muted);border-top:1px solid var(--edge);flex-wrap:wrap;gap:14px;padding-top:8px;font-size:12px;display:flex}.statusbar b{color:var(--green);font-weight:700}.statusbar .spacer{flex:1}.statusbar a{color:var(--muted)}.directory{border-top:1px solid var(--line);margin-top:.6rem;padding-top:.6rem}.directory-list{max-height:12rem;margin:0;padding:0;list-style:none;overflow-y:auto}.directory-list li+li{margin-top:.3rem}.directory-list button{border:1px solid var(--line);color:inherit;font:inherit;cursor:pointer;background:0 0;border-radius:4px;padding:.4rem .5rem}.directory-list button:hover,.directory-list button:focus-visible{border-color:var(--accent);background:#ffffff0a}.directory-list .name{color:var(--accent);display:block}.directory-list .detail{opacity:.7;text-overflow:ellipsis;white-space:nowrap;font-size:.85em;display:block;overflow:hidden}.publish-list{margin:.3rem 0 0;padding:0;list-style:none}.publish-list li{align-items:center;gap:8px;margin-top:.3rem;display:flex}.publish-list .slot{color:var(--muted);flex:none;min-width:4.5em;font-size:12px}.publish-list input{flex:auto;min-width:0}.directory-list li.offline .recent-label{opacity:.55}.directory-list li.offline .detail{color:var(--warn)}.share-line{align-items:center;gap:8px;margin:.4rem 0;display:flex}.share-what{color:var(--muted);letter-spacing:.06em;text-transform:uppercase;flex:none;font-size:12px}.share-line input{flex:auto;min-width:0}#share-phone{color:var(--fg);font-size:1.05em}#share-phone b{color:var(--accent)}.admin-table{border-collapse:collapse;width:100%;max-height:14rem;margin:.4rem 0;font-size:.85em;display:block;overflow-y:auto}.admin-table th{text-align:left;opacity:.6;padding:.2rem .4rem .2rem 0;font-weight:400}.admin-table td{white-space:nowrap;text-overflow:ellipsis;max-width:12rem;padding:.2rem .4rem .2rem 0;overflow:hidden}.admin-table td.network-public{color:var(--warning,#e0b341)}.admin-table td.network-private{color:var(--success,#7fd18b)}.admin-table tr.ended{opacity:.45}body.route-directory .player-only{display:none}.directory-list li{align-items:stretch;gap:.4rem;display:flex}.directory-list li>:first-child{flex:auto;min-width:0}.directory-list li>button:first-child{text-align:left;width:100%}.directory-list li>.button,.directory-list li>.ghost{white-space:nowrap;flex:none;align-items:center;display:flex}.directory-list .follow{white-space:nowrap;width:auto;color:var(--muted);flex:none;padding-inline:.6rem}.directory-list .follow[data-following=yes]{border-color:var(--accent);color:var(--accent)}.toggle{color:var(--muted);cursor:pointer;align-items:center;gap:.35rem;display:inline-flex}.toggle input{accent-color:var(--accent)}.recent-label{border:1px solid var(--line);border-radius:4px;flex:auto;min-width:0;padding:.4rem .5rem}
|
|
1
|
+
:root{--bg:#080c09;--panel:#0c120e;--edge:#1d2c22;--green:#4af689;--green-dim:#227a4a;--fg:#cfe8d8;--muted:#6d8a79;--warn:#e8c35a;--accent:#7ef0c4;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}[hidden]{display:none!important}html,body{background:var(--bg);min-height:100%;color:var(--fg);margin:0;font:14px/1.45 ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace}body{background-image:repeating-linear-gradient(#4af68906 0 1px,#0000 1px 3px)}#app{max-width:1100px;padding:calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left));flex-direction:column;gap:10px;margin:0 auto;display:flex}.bar{flex-wrap:wrap;align-items:center;gap:10px;display:flex}.brand{color:var(--green);letter-spacing:.14em;font-weight:700}.status{color:var(--muted)}.status[data-playing=true]{color:var(--green)}.chip{color:var(--muted);border:1px solid var(--edge);text-overflow:ellipsis;white-space:nowrap;border-radius:999px;max-width:46vw;margin-left:auto;padding:1px 10px;font-size:12px;overflow:hidden}.panel{border:1px solid var(--edge);background:var(--panel);border-radius:6px;min-width:0;padding:14px 12px 12px;position:relative}.panel:before{content:attr(data-title);background:var(--panel);color:var(--green-dim);letter-spacing:.06em;padding:0 6px;font-size:12px;position:absolute;top:-.72em;left:10px}.split{grid-template-columns:1.3fr 1fr;gap:10px;display:grid}.split>.col-a{grid-column:1}.split>.col-b{grid-column:2}@media (max-width:720px){.split{grid-template-columns:1fr}.split>.col-a,.split>.col-b{grid-column:auto}}.track-title{color:var(--accent);text-overflow:ellipsis;white-space:nowrap;font-weight:700;overflow:hidden}.track-sub{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}#video{border:1px solid var(--edge);background:#000;border-radius:4px;width:100%;max-height:46vh;margin-bottom:8px}.scrub{align-items:center;gap:10px;margin-top:6px;display:flex}.time{color:var(--fg);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}input[type=range]{appearance:none;cursor:pointer;background:0 0;flex:1;min-width:0;height:14px}input[type=range]::-webkit-slider-runnable-track{background:linear-gradient(var(--edge), var(--edge));border:1px solid var(--edge);border-radius:3px;height:6px}input[type=range]::-moz-range-track{background:var(--edge);border-radius:3px;height:6px}input[type=range]::-webkit-slider-thumb{appearance:none;background:var(--green);border-radius:2px;width:10px;height:16px;margin-top:-6px}input[type=range]::-moz-range-thumb{background:var(--green);border:0;border-radius:2px;width:10px;height:16px}input[type=range]:disabled{opacity:.45;cursor:default}#spectrum{border:1px solid var(--edge);background:#060a07;border-radius:4px;width:100%;height:190px;display:block}.meters{align-items:baseline;gap:10px;margin-top:6px;display:flex;overflow:hidden}.glyphs{color:var(--green);letter-spacing:1px;white-space:nowrap;min-height:1.4em;overflow:hidden}.levelmeter{color:var(--accent);white-space:nowrap;margin-left:auto}.playlist{scrollbar-color:var(--green-dim) transparent;max-height:214px;margin:0;padding:0;list-style:none;overflow-y:auto}.row{cursor:pointer;white-space:nowrap;border-radius:3px;gap:8px;padding:2px 6px;display:flex}.row:hover{background:#142019}.row.selected{background:#16241c}.row.selected .name{color:var(--accent)}.row.playing .name{color:var(--green)}.group{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;border-top:1px solid var(--edge);align-items:center;gap:8px;margin-top:4px;padding:6px 6px 2px;font-size:11px;display:flex}.group:first-child{border-top:0;margin-top:0}.group-name{text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}.group-remove{border:1px solid var(--edge);color:var(--muted);cursor:pointer;background:0 0;border-radius:3px;flex:none;padding:0 6px;line-height:1.4}.group-remove:hover{color:var(--accent);border-color:var(--accent)}.row .n{color:var(--muted);text-align:right;flex:none;width:2.4em}.row .name{text-overflow:ellipsis;flex:1;overflow:hidden}.row .time{color:var(--muted);flex:none}.transport{border:1px solid var(--edge);background:var(--panel);border-radius:6px;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 10px;display:flex}button{font:inherit;color:var(--fg);border:1px solid var(--edge);cursor:pointer;background:#121a15;border-radius:4px;padding:6px 12px}button:hover{border-color:var(--green-dim);color:var(--green)}button:active{transform:translateY(1px)}button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid var(--green);outline-offset:2px}button.primary{color:var(--green);border-color:var(--green-dim);min-width:64px}button.ghost{color:var(--muted);background:0 0}.volume{flex:1;align-items:center;gap:8px;min-width:120px;max-width:220px;margin-left:auto;display:flex}.vol{color:var(--muted);letter-spacing:.08em;font-size:12px}.picker{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.button{border:1px solid var(--edge);cursor:pointer;color:var(--fg);background:#121a15;border-radius:4px;padding:6px 12px;display:inline-block}.button:hover{border-color:var(--green-dim);color:var(--green)}.button input[type=file]{display:none}#remote-url{font:inherit;min-width:12ch;color:var(--fg);border:1px solid var(--edge);background:#060a07;border-radius:4px;flex:1;padding:6px 10px}.check{color:var(--muted);align-items:center;gap:8px;margin-top:10px;display:flex}.check input{accent-color:var(--green)}.hint{color:var(--muted);margin:0 0 8px;font-size:13px}.hint code{color:var(--accent)}#remote-state[data-status=live]{color:var(--green)}#remote-state[data-status=connecting],#remote-state[data-status=error]{color:var(--warn)}.note{color:var(--warn);border-left:2px solid var(--warn);margin:0;padding-left:8px}.statusbar{color:var(--muted);border-top:1px solid var(--edge);flex-wrap:wrap;gap:14px;padding-top:8px;font-size:12px;display:flex}.statusbar b{color:var(--green);font-weight:700}.statusbar .spacer{flex:1}.statusbar a{color:var(--muted)}.directory{border-top:1px solid var(--line);margin-top:.6rem;padding-top:.6rem}.directory-list{max-height:12rem;margin:0;padding:0;list-style:none;overflow-y:auto}.directory-list li+li{margin-top:.3rem}.directory-list button{border:1px solid var(--line);color:inherit;font:inherit;cursor:pointer;background:0 0;border-radius:4px;padding:.4rem .5rem}.directory-list button:hover,.directory-list button:focus-visible{border-color:var(--accent);background:#ffffff0a}.directory-list .name{color:var(--accent);display:block}.directory-list .detail{opacity:.7;text-overflow:ellipsis;white-space:nowrap;font-size:.85em;display:block;overflow:hidden}.publish-list{margin:.3rem 0 0;padding:0;list-style:none}.publish-list li{align-items:center;gap:8px;margin-top:.3rem;display:flex}.publish-list li.in-use .slot{color:var(--green)}.publish-list .slot{color:var(--muted);flex:none;min-width:4.5em;font-size:12px}.publish-list input{flex:auto;min-width:0}.directory-list li.offline .recent-label{opacity:.55}.directory-list li.offline .detail{color:var(--warn)}.share-line{align-items:center;gap:8px;margin:.4rem 0;display:flex}.share-what{color:var(--muted);letter-spacing:.06em;text-transform:uppercase;flex:none;font-size:12px}.share-line input{flex:auto;min-width:0}#share-phone{color:var(--fg);font-size:1.05em}#share-phone b{color:var(--accent)}.admin-table{border-collapse:collapse;width:100%;max-height:14rem;margin:.4rem 0;font-size:.85em;display:block;overflow-y:auto}.admin-table th{text-align:left;opacity:.6;padding:.2rem .4rem .2rem 0;font-weight:400}.admin-table td{white-space:nowrap;text-overflow:ellipsis;max-width:12rem;padding:.2rem .4rem .2rem 0;overflow:hidden}.admin-table td.network-public{color:var(--warning,#e0b341)}.admin-table td.network-private{color:var(--success,#7fd18b)}.admin-table tr.ended{opacity:.45}body.route-directory .player-only{display:none}.directory-list li{align-items:stretch;gap:.4rem;display:flex}.directory-list li>:first-child{flex:auto;min-width:0}.directory-list li>button:first-child{text-align:left;width:100%}.directory-list li>.button,.directory-list li>.ghost{white-space:nowrap;flex:none;align-items:center;display:flex}.directory-list .follow{white-space:nowrap;width:auto;color:var(--muted);flex:none;padding-inline:.6rem}.directory-list .follow[data-following=yes]{border-color:var(--accent);color:var(--accent)}.toggle{color:var(--muted);cursor:pointer;align-items:center;gap:.35rem;display:inline-flex}.toggle input{accent-color:var(--accent)}.recent-label{border:1px solid var(--line);border-radius:4px;flex:auto;min-width:0;padding:.4rem .5rem}
|