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