nixamp 0.1.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/src/server.ts ADDED
@@ -0,0 +1,649 @@
1
+ /**
2
+ * Server mode: nixamp keeps playing on this machine and hands out a remote.
3
+ *
4
+ * nixamp serve ~/Music --port 4321
5
+ *
6
+ * The same decode that feeds the speakers feeds the analyser, exactly as in the
7
+ * terminal app; the HTTP layer only reads the state it produces and writes the
8
+ * commands a remote sends. State is pushed over Server-Sent Events rather than
9
+ * a WebSocket because SSE is plain HTTP: no dependency, and it reconnects on
10
+ * its own when the laptop running the remote goes to sleep.
11
+ */
12
+ import { createReadStream, statSync } from "node:fs";
13
+ import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
14
+ import { networkInterfaces } from "node:os";
15
+ import { extname, join, normalize, resolve, sep } from "node:path";
16
+ import {
17
+ detectTools, peaks, RATE, Stream, toMono,
18
+ type Tools, type Track,
19
+ } from "./audio.ts";
20
+ import { Analyser, bandEdges, bands, decay } from "./fft.ts";
21
+ import { loadPlaylist } from "./playlist.ts";
22
+ import {
23
+ emptySnapshot, parseCommand,
24
+ type Command, type RemoteTrack, type Snapshot,
25
+ } from "./protocol.ts";
26
+
27
+ const FFT_SIZE = 2048;
28
+ export const SERVE_BAND_COUNT = 24;
29
+ export const DEFAULT_PORT = 4321;
30
+
31
+ export interface ServeOptions {
32
+ root: string;
33
+ port: number;
34
+ host: string;
35
+ /** Directory of built PWA files to serve at `/`, when there is one. */
36
+ web: string | null;
37
+ /** Stream the library's bytes to remotes. Off keeps the audio on this box. */
38
+ media: boolean;
39
+ }
40
+
41
+ /**
42
+ * Flags are parsed by hand: three of them do not justify a dependency, and the
43
+ * failure mode of a wrong `--port` should be a message rather than NaN.
44
+ */
45
+ export function parseServeArgs(argv: string[]): ServeOptions {
46
+ // A platform that hands out the port does it through PORT; a flag still wins.
47
+ const fromEnv = Number(process.env.PORT);
48
+ const options: ServeOptions = {
49
+ root: ".",
50
+ port: Number.isInteger(fromEnv) && fromEnv > 0 && fromEnv <= 65535 ? fromEnv : DEFAULT_PORT,
51
+ host: "127.0.0.1",
52
+ web: null,
53
+ media: true,
54
+ };
55
+ let sawRoot = false;
56
+ for (let i = 0; i < argv.length; i++) {
57
+ const arg = argv[i] as string;
58
+ const value = (): string => {
59
+ const next = argv[i + 1];
60
+ if (next === undefined) throw new Error(`nixamp serve: ${arg} needs a value`);
61
+ i++;
62
+ return next;
63
+ };
64
+ if (arg === "--port" || arg === "-p") {
65
+ const port = Number(value());
66
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
67
+ throw new Error("nixamp serve: --port must be a port number");
68
+ }
69
+ options.port = port;
70
+ } else if (arg === "--host" || arg === "-h") {
71
+ options.host = value();
72
+ } else if (arg === "--web") {
73
+ options.web = value();
74
+ } else if (arg === "--no-media") {
75
+ options.media = false;
76
+ } else if (arg.startsWith("-")) {
77
+ throw new Error(`nixamp serve: unknown option ${arg}`);
78
+ } else if (!sawRoot) {
79
+ options.root = arg;
80
+ sawRoot = true;
81
+ }
82
+ }
83
+ return options;
84
+ }
85
+
86
+ const TYPES: Record<string, string> = {
87
+ ".html": "text/html; charset=utf-8",
88
+ ".js": "text/javascript; charset=utf-8",
89
+ ".mjs": "text/javascript; charset=utf-8",
90
+ ".css": "text/css; charset=utf-8",
91
+ ".json": "application/json; charset=utf-8",
92
+ ".webmanifest": "application/manifest+json; charset=utf-8",
93
+ // The installer, so `curl https://nixamp.com/install.sh` is readable rather
94
+ // than a download prompt.
95
+ ".sh": "text/x-shellscript; charset=utf-8",
96
+ ".svg": "image/svg+xml",
97
+ ".png": "image/png",
98
+ ".ico": "image/x-icon",
99
+ ".woff2": "font/woff2",
100
+ ".mp3": "audio/mpeg",
101
+ ".flac": "audio/flac",
102
+ ".ogg": "audio/ogg",
103
+ ".oga": "audio/ogg",
104
+ ".opus": "audio/ogg",
105
+ ".m4a": "audio/mp4",
106
+ ".aac": "audio/aac",
107
+ ".wav": "audio/wav",
108
+ ".wma": "audio/x-ms-wma",
109
+ ".aiff": "audio/aiff",
110
+ ".aif": "audio/aiff",
111
+ ".alac": "audio/mp4",
112
+ ".mp4": "video/mp4",
113
+ ".webm": "video/webm",
114
+ };
115
+
116
+ export function contentType(path: string): string {
117
+ return TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
118
+ }
119
+
120
+ export interface ByteRange {
121
+ start: number;
122
+ end: number;
123
+ }
124
+
125
+ /**
126
+ * `Range: bytes=0-` and friends. Anything malformed, unsatisfiable or
127
+ * multi-range is a null, which the caller answers with the whole file —
128
+ * the behaviour a browser expects when it cannot have the range it asked for.
129
+ */
130
+ export function parseRange(header: string | undefined, size: number): ByteRange | null {
131
+ if (!header || size <= 0) return null;
132
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
133
+ if (!match) return null;
134
+ const [, rawStart = "", rawEnd = ""] = match;
135
+ if (rawStart === "" && rawEnd === "") return null;
136
+ let start: number;
137
+ let end: number;
138
+ if (rawStart === "") {
139
+ // A suffix range: the last N bytes.
140
+ const length = Number(rawEnd);
141
+ if (!Number.isFinite(length) || length <= 0) return null;
142
+ start = Math.max(0, size - length);
143
+ end = size - 1;
144
+ } else {
145
+ start = Number(rawStart);
146
+ end = rawEnd === "" ? size - 1 : Number(rawEnd);
147
+ }
148
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
149
+ if (start > end || start >= size) return null;
150
+ return { start, end: Math.min(end, size - 1) };
151
+ }
152
+
153
+ /**
154
+ * Resolve a URL path inside a directory, or null when it escapes.
155
+ * `..` in a request path is the oldest bug in static file serving.
156
+ */
157
+ export function safeJoin(rootDir: string, urlPath: string): string | null {
158
+ let decoded: string;
159
+ try {
160
+ decoded = decodeURIComponent(urlPath);
161
+ } catch {
162
+ return null;
163
+ }
164
+ if (decoded.includes("\0")) return null;
165
+ const base = resolve(rootDir);
166
+ const full = resolve(base, "." + normalize(decoded.startsWith("/") ? decoded : `/${decoded}`));
167
+ if (full !== base && !full.startsWith(base + sep)) return null;
168
+ return full;
169
+ }
170
+
171
+ /** What the HTTP layer needs from a player. Tests hand it a fake. */
172
+ export interface Engine {
173
+ snapshot(): Snapshot;
174
+ command(command: Command): void;
175
+ subscribe(listener: (snapshot: Snapshot) => void): () => void;
176
+ /** Absolute path of a track, or undefined when the index is not one. */
177
+ trackPath(index: number): string | undefined;
178
+ stop(): void;
179
+ }
180
+
181
+ export function toRemoteTracks(tracks: Track[]): RemoteTrack[] {
182
+ return tracks.map((t) => ({
183
+ title: t.title,
184
+ artist: t.artist,
185
+ album: t.album,
186
+ duration: t.duration,
187
+ }));
188
+ }
189
+
190
+ /**
191
+ * The headless player: the terminal app's engine without the terminal.
192
+ * One ffmpeg decodes, ffplay makes the sound, and every sample is measured on
193
+ * its way past so a remote can draw the same spectrum the TUI would.
194
+ */
195
+ export class PlayerEngine implements Engine {
196
+ private readonly listeners = new Set<(snapshot: Snapshot) => void>();
197
+ private readonly analyser = new Analyser(FFT_SIZE, RATE);
198
+ private readonly edges = bandEdges(SERVE_BAND_COUNT, RATE, FFT_SIZE);
199
+ private readonly stream: Stream;
200
+ private pending = new Float32Array(0);
201
+ private revision = 0;
202
+ /** Pushes are coalesced: the analyser fires far faster than a remote can draw. */
203
+ private timer: ReturnType<typeof setInterval> | null = null;
204
+ private dirty = false;
205
+
206
+ private state = {
207
+ index: 0,
208
+ playing: false,
209
+ position: 0,
210
+ bars: new Array<number>(SERVE_BAND_COUNT).fill(0),
211
+ levels: [0, 0] as [number, number],
212
+ note: "",
213
+ };
214
+
215
+ constructor(
216
+ private readonly tracks: Track[],
217
+ private readonly root: string,
218
+ tools: Tools,
219
+ /** Frames a second pushed to remotes. */
220
+ private readonly fps = 12,
221
+ ) {
222
+ if (tools.play === null) {
223
+ this.state.note = "No audio output found (install ffplay) — analyser only.";
224
+ }
225
+ this.silent = tools.play === null;
226
+ this.stream = new Stream(tools, {
227
+ onSamples: (pcm) => this.consume(pcm),
228
+ onEnd: (error) => {
229
+ if (error) {
230
+ this.state.note = error;
231
+ this.state.playing = false;
232
+ this.push();
233
+ return;
234
+ }
235
+ this.command({ type: "next" });
236
+ },
237
+ });
238
+ }
239
+
240
+ private readonly silent: boolean;
241
+
242
+ private consume(pcm: Float32Array): void {
243
+ this.state.levels = peaks(pcm);
244
+ this.state.position = this.stream.position;
245
+ const mono = toMono(pcm);
246
+ const joined = new Float32Array(this.pending.length + mono.length);
247
+ joined.set(this.pending);
248
+ joined.set(mono, this.pending.length);
249
+ let at = 0;
250
+ while (joined.length - at >= FFT_SIZE) {
251
+ this.analyser.run(joined.subarray(at, at + FFT_SIZE));
252
+ this.state.bars = decay(this.state.bars, bands(this.analyser.magnitudes, this.edges));
253
+ at += FFT_SIZE;
254
+ }
255
+ this.pending = joined.subarray(at);
256
+ this.dirty = true;
257
+ }
258
+
259
+ snapshot(): Snapshot {
260
+ return {
261
+ revision: this.revision,
262
+ tracks: toRemoteTracks(this.tracks),
263
+ index: this.state.index,
264
+ playing: this.state.playing,
265
+ position: this.state.position,
266
+ bars: [...this.state.bars],
267
+ levels: [this.state.levels[0], this.state.levels[1]],
268
+ silent: this.silent,
269
+ note: this.state.note,
270
+ root: this.root,
271
+ };
272
+ }
273
+
274
+ trackPath(index: number): string | undefined {
275
+ return this.tracks[index]?.path;
276
+ }
277
+
278
+ command(command: Command): void {
279
+ switch (command.type) {
280
+ case "play":
281
+ if (command.index !== undefined) this.state.index = this.clamp(command.index);
282
+ this.start();
283
+ break;
284
+ case "toggle":
285
+ if (this.state.playing) this.halt(); else this.start();
286
+ break;
287
+ case "stop":
288
+ this.halt();
289
+ break;
290
+ case "next":
291
+ this.step(1);
292
+ break;
293
+ case "prev":
294
+ this.step(-1);
295
+ break;
296
+ case "select":
297
+ this.state.index = this.clamp(command.index);
298
+ if (this.state.playing) this.start();
299
+ break;
300
+ }
301
+ this.push();
302
+ }
303
+
304
+ private clamp(index: number): number {
305
+ if (this.tracks.length === 0) return 0;
306
+ return Math.max(0, Math.min(this.tracks.length - 1, index));
307
+ }
308
+
309
+ private step(delta: number): void {
310
+ if (this.tracks.length === 0) return;
311
+ this.state.index = (this.state.index + delta + this.tracks.length) % this.tracks.length;
312
+ if (this.state.playing) this.start(); else this.state.position = 0;
313
+ }
314
+
315
+ private start(): void {
316
+ const track = this.tracks[this.state.index];
317
+ if (!track) return;
318
+ this.pending = new Float32Array(0);
319
+ this.state.position = 0;
320
+ this.state.playing = true;
321
+ this.stream.start(track);
322
+ }
323
+
324
+ private halt(): void {
325
+ this.stream.stop();
326
+ this.state.playing = false;
327
+ this.state.position = 0;
328
+ this.state.bars = new Array<number>(SERVE_BAND_COUNT).fill(0);
329
+ this.state.levels = [0, 0];
330
+ }
331
+
332
+ subscribe(listener: (snapshot: Snapshot) => void): () => void {
333
+ this.listeners.add(listener);
334
+ listener(this.snapshot());
335
+ if (this.timer === null && this.listeners.size > 0) {
336
+ this.timer = setInterval(() => {
337
+ if (!this.dirty) return;
338
+ this.dirty = false;
339
+ this.push();
340
+ }, Math.max(1, Math.round(1000 / this.fps)));
341
+ this.timer.unref?.();
342
+ }
343
+ return () => {
344
+ this.listeners.delete(listener);
345
+ if (this.listeners.size === 0 && this.timer !== null) {
346
+ clearInterval(this.timer);
347
+ this.timer = null;
348
+ }
349
+ };
350
+ }
351
+
352
+ private push(): void {
353
+ this.revision++;
354
+ if (this.listeners.size === 0) return;
355
+ const snapshot = this.snapshot();
356
+ for (const listener of this.listeners) listener(snapshot);
357
+ }
358
+
359
+ stop(): void {
360
+ this.halt();
361
+ if (this.timer !== null) {
362
+ clearInterval(this.timer);
363
+ this.timer = null;
364
+ }
365
+ this.listeners.clear();
366
+ }
367
+ }
368
+
369
+ /** An engine with no library behind it, for the hosted PWA. */
370
+ export class EmptyEngine implements Engine {
371
+ constructor(private readonly note = "No library on this server — open files, or point this remote at your own nixamp.") {}
372
+ snapshot(): Snapshot {
373
+ return { ...emptySnapshot(), note: this.note };
374
+ }
375
+ command(): void {}
376
+ subscribe(listener: (snapshot: Snapshot) => void): () => void {
377
+ listener(this.snapshot());
378
+ return () => {};
379
+ }
380
+ trackPath(): undefined {
381
+ return undefined;
382
+ }
383
+ stop(): void {}
384
+ }
385
+
386
+ const CORS: Record<string, string> = {
387
+ // A remote is a browser on another device on the same network, so the
388
+ // control API has to be reachable cross-origin. It exposes no filesystem
389
+ // paths and takes six commands; binding to 127.0.0.1 is what keeps it shut.
390
+ "access-control-allow-origin": "*",
391
+ "access-control-allow-methods": "GET, POST, OPTIONS",
392
+ "access-control-allow-headers": "content-type",
393
+ "access-control-max-age": "86400",
394
+ };
395
+
396
+ function json(response: ServerResponse, code: number, body: unknown): void {
397
+ const text = JSON.stringify(body);
398
+ response.writeHead(code, {
399
+ ...CORS,
400
+ "content-type": "application/json; charset=utf-8",
401
+ "content-length": Buffer.byteLength(text),
402
+ "cache-control": "no-store",
403
+ });
404
+ response.end(text);
405
+ }
406
+
407
+ async function readBody(request: IncomingMessage, limit = 64 * 1024): Promise<string> {
408
+ const chunks: Buffer[] = [];
409
+ let size = 0;
410
+ for await (const chunk of request) {
411
+ const buffer = chunk as Buffer;
412
+ size += buffer.length;
413
+ if (size > limit) throw new Error("body too large");
414
+ chunks.push(buffer);
415
+ }
416
+ return Buffer.concat(chunks).toString("utf8");
417
+ }
418
+
419
+ export interface HandlerOptions {
420
+ web: string | null;
421
+ media: boolean;
422
+ version: string;
423
+ }
424
+
425
+ /**
426
+ * The whole HTTP surface, as a plain function of a request — so a test can
427
+ * drive it with a real socket and no ffmpeg in sight.
428
+ */
429
+ export function createHandler(engine: Engine, options: HandlerOptions) {
430
+ return async function handle(request: IncomingMessage, response: ServerResponse): Promise<void> {
431
+ const url = new URL(request.url ?? "/", "http://localhost");
432
+ const path = url.pathname;
433
+
434
+ if (request.method === "OPTIONS") {
435
+ response.writeHead(204, CORS);
436
+ response.end();
437
+ return;
438
+ }
439
+
440
+ if (path === "/api/health") {
441
+ json(response, 200, { name: "nixamp", version: options.version, media: options.media });
442
+ return;
443
+ }
444
+
445
+ if (path === "/api/state") {
446
+ json(response, 200, engine.snapshot());
447
+ return;
448
+ }
449
+
450
+ if (path === "/api/events") {
451
+ response.writeHead(200, {
452
+ ...CORS,
453
+ "content-type": "text/event-stream; charset=utf-8",
454
+ "cache-control": "no-store",
455
+ connection: "keep-alive",
456
+ // nginx and friends buffer text/event-stream into uselessness.
457
+ "x-accel-buffering": "no",
458
+ });
459
+ const send = (snapshot: Snapshot): void => {
460
+ response.write(`data: ${JSON.stringify(snapshot)}\n\n`);
461
+ };
462
+ const unsubscribe = engine.subscribe(send);
463
+ // A comment line keeps proxies from closing an idle stream.
464
+ const beat = setInterval(() => response.write(": beat\n\n"), 20_000);
465
+ beat.unref?.();
466
+ const done = (): void => {
467
+ clearInterval(beat);
468
+ unsubscribe();
469
+ };
470
+ request.on("close", done);
471
+ response.on("close", done);
472
+ return;
473
+ }
474
+
475
+ if (path === "/api/command") {
476
+ if (request.method !== "POST") {
477
+ json(response, 405, { error: "POST only" });
478
+ return;
479
+ }
480
+ let parsed: unknown;
481
+ try {
482
+ parsed = JSON.parse(await readBody(request)) as unknown;
483
+ } catch {
484
+ json(response, 400, { error: "bad JSON" });
485
+ return;
486
+ }
487
+ const command = parseCommand(parsed);
488
+ if (!command) {
489
+ json(response, 400, { error: "unknown command" });
490
+ return;
491
+ }
492
+ engine.command(command);
493
+ json(response, 200, engine.snapshot());
494
+ return;
495
+ }
496
+
497
+ if (path.startsWith("/api/media/")) {
498
+ if (!options.media) {
499
+ json(response, 403, { error: "media streaming is off" });
500
+ return;
501
+ }
502
+ const index = Number(path.slice("/api/media/".length));
503
+ const file = Number.isInteger(index) ? engine.trackPath(index) : undefined;
504
+ if (file === undefined) {
505
+ json(response, 404, { error: "no such track" });
506
+ return;
507
+ }
508
+ sendFile(request, response, file);
509
+ return;
510
+ }
511
+
512
+ if (path.startsWith("/api/")) {
513
+ json(response, 404, { error: "no such endpoint" });
514
+ return;
515
+ }
516
+
517
+ if (options.web !== null) {
518
+ const direct = safeJoin(options.web, path);
519
+ if (direct === null) {
520
+ json(response, 400, { error: "bad path" });
521
+ return;
522
+ }
523
+ let file: string | null = null;
524
+ if (isFile(direct)) file = direct;
525
+ else if (isFile(join(direct, "index.html"))) file = join(direct, "index.html");
526
+ // A single-page app: any unknown path is the shell, and the client routes.
527
+ else if (isFile(join(options.web, "index.html"))) file = join(options.web, "index.html");
528
+ if (file !== null) {
529
+ sendFile(request, response, file);
530
+ return;
531
+ }
532
+ }
533
+
534
+ json(response, 404, { error: "not found" });
535
+ };
536
+ }
537
+
538
+ function isFile(path: string): boolean {
539
+ try {
540
+ return statSync(path).isFile();
541
+ } catch {
542
+ return false;
543
+ }
544
+ }
545
+
546
+ function sendFile(request: IncomingMessage, response: ServerResponse, file: string): void {
547
+ let size: number;
548
+ try {
549
+ size = statSync(file).size;
550
+ } catch {
551
+ json(response, 404, { error: "not found" });
552
+ return;
553
+ }
554
+ const type = contentType(file);
555
+ const range = parseRange(request.headers.range, size);
556
+ const headers: Record<string, string> = {
557
+ ...CORS,
558
+ "content-type": type,
559
+ "accept-ranges": "bytes",
560
+ };
561
+ // The shell must never be cached by a service worker's fetch fallback, but
562
+ // hashed assets and audio can be.
563
+ headers["cache-control"] = type.startsWith("text/html") ? "no-cache" : "public, max-age=3600";
564
+
565
+ if (range) {
566
+ headers["content-range"] = `bytes ${range.start}-${range.end}/${size}`;
567
+ headers["content-length"] = String(range.end - range.start + 1);
568
+ response.writeHead(206, headers);
569
+ } else {
570
+ headers["content-length"] = String(size);
571
+ response.writeHead(200, headers);
572
+ }
573
+ if (request.method === "HEAD") {
574
+ response.end();
575
+ return;
576
+ }
577
+ const stream = range
578
+ ? createReadStream(file, { start: range.start, end: range.end })
579
+ : createReadStream(file);
580
+ stream.on("error", () => response.destroy());
581
+ response.on("close", () => stream.destroy());
582
+ stream.pipe(response);
583
+ }
584
+
585
+ export function createServer(engine: Engine, options: HandlerOptions): Server {
586
+ const handle = createHandler(engine, options);
587
+ return createHttpServer((request, response) => {
588
+ handle(request, response).catch(() => {
589
+ if (!response.headersSent) json(response, 500, { error: "server error" });
590
+ else response.end();
591
+ });
592
+ });
593
+ }
594
+
595
+ /** Where a remote on another device should point its browser. */
596
+ export function addressesFor(host: string, port: number): string[] {
597
+ if (host !== "0.0.0.0" && host !== "::") return [`http://${host}:${port}`];
598
+ const out = [`http://localhost:${port}`];
599
+ for (const entries of Object.values(networkInterfaces())) {
600
+ for (const entry of entries ?? []) {
601
+ if (entry.family === "IPv4" && !entry.internal) out.push(`http://${entry.address}:${port}`);
602
+ }
603
+ }
604
+ return out;
605
+ }
606
+
607
+ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
608
+ const options = parseServeArgs(argv);
609
+ const root = resolve(options.root);
610
+ const tools = detectTools();
611
+ const tracks = loadPlaylist(tools, root);
612
+ const engine: Engine = tracks.length > 0
613
+ ? new PlayerEngine(tracks, root, tools)
614
+ : new EmptyEngine(`No audio files under ${root}.`);
615
+
616
+ const web = options.web !== null ? resolve(options.web) : defaultWebDir();
617
+ const server = createServer(engine, { web, media: options.media, version });
618
+
619
+ await new Promise<void>((done) => server.listen(options.port, options.host, done));
620
+ const bound = server.address();
621
+ const port = typeof bound === "object" && bound !== null ? bound.port : options.port;
622
+ console.log(`nixamp serve — ${tracks.length} tracks under ${root}`);
623
+ for (const address of addressesFor(options.host, port)) console.log(` ${address}`);
624
+ if (web === null) console.log(" (no built PWA found — run `bun run web:build` to serve one)");
625
+
626
+ const shutdown = (): void => {
627
+ engine.stop();
628
+ server.close(() => process.exit(0));
629
+ // A hung keep-alive should not outlive a ctrl-c.
630
+ setTimeout(() => process.exit(0), 1000).unref();
631
+ };
632
+ process.on("SIGINT", shutdown);
633
+ process.on("SIGTERM", shutdown);
634
+ }
635
+
636
+ /** The built PWA, when it is sitting next to us in the same install. */
637
+ function defaultWebDir(): string | null {
638
+ const fromEnv = process.env.NIXAMP_WEB_DIR;
639
+ if (fromEnv && isFile(join(fromEnv, "index.html"))) return fromEnv;
640
+ const here = new URL(".", import.meta.url).pathname;
641
+ for (const guess of [
642
+ join(here, "..", "web", "dist"),
643
+ join(here, "..", "..", "web", "dist"),
644
+ join(here, "..", "web"),
645
+ ]) {
646
+ if (isFile(join(guess, "index.html"))) return resolve(guess);
647
+ }
648
+ return null;
649
+ }
Binary file
@@ -0,0 +1 @@
1
+ :root{--bg:#080c09;--panel:#0c120e;--edge:#1d2c22;--green:#4af689;--green-dim:#227a4a;--fg:#cfe8d8;--muted:#6d8a79;--warn:#e8c35a;--accent:#7ef0c4;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}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)}