From cb30657b99e722fbadcb7fed9ac0d0bd8a05153e Mon Sep 17 00:00:00 2001 From: lavisor Date: Sun, 11 Oct 2026 12:36:39 +0530 Subject: [PATCH 1/2] feat: added gui --- README.md | 20 ++ package-lock.json | 474 +++++++++++++++++----------------- package.json | 14 +- src/cli/args.ts | 25 ++ src/core/createApp.ts | 224 ++++++++++++++++ src/gui/protocol.ts | 54 ++++ src/gui/server.ts | 345 +++++++++++++++++++++++++ src/index.tsx | 59 +++++ src/ui/App.tsx | 213 ++------------- test/cli-args.test.ts | 28 ++ test/gui-server.test.ts | 160 ++++++++++++ web/index.html | 12 + web/src/App.tsx | 114 ++++++++ web/src/NowPlaying.tsx | 65 +++++ web/src/connection.ts | 92 +++++++ web/src/format.ts | 21 ++ web/src/main.tsx | 5 + web/src/sections/Download.tsx | 79 ++++++ web/src/sections/Settings.tsx | 69 +++++ web/src/sections/Songs.tsx | 141 ++++++++++ web/src/styles.css | 66 +++++ web/tsconfig.json | 8 + web/vite.config.ts | 10 + 23 files changed, 1868 insertions(+), 430 deletions(-) create mode 100644 src/core/createApp.ts create mode 100644 src/gui/protocol.ts create mode 100644 src/gui/server.ts create mode 100644 test/gui-server.test.ts create mode 100644 web/index.html create mode 100644 web/src/App.tsx create mode 100644 web/src/NowPlaying.tsx create mode 100644 web/src/connection.ts create mode 100644 web/src/format.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/sections/Download.tsx create mode 100644 web/src/sections/Settings.tsx create mode 100644 web/src/sections/Songs.tsx create mode 100644 web/src/styles.css create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/README.md b/README.md index f9a80a8..e894932 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,26 @@ Everything runs from the keyboard, with controls that are quick to pick up. Pres The keyboard cheatsheet: navigate, player, and download keys

+## Prefer a window? + +``` +sndcli --gui +``` + +Opens the same library, player and download queue in your browser. Options: `--port ` to pick a port, `--no-open` to print the link without opening it. Press `ctrl-c` in the terminal to quit. + +Running from a clone of this repo, one command builds the page and starts it: + +``` +npm run gui +npm run gui -- --port 4000 +npm run gui -- --no-open +``` + +The page is served only on your own computer (`127.0.0.1`) and the link carries a one-time token, so other sites and other machines can't reach it. Run either the terminal app or the browser one at a time; both write to the same library. + +Changing the library folder and converting formats are still terminal-only for now. + ## Contributing Issues and pull requests are welcome. soundcli is TypeScript with an Ink diff --git a/package-lock.json b/package-lock.json index 607de6d..71d0580 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "execa": "^9.6.1", "ink": "^7.0.5", "react": "^19.2.7", - "string-width": "^8.2.1" + "string-width": "^8.2.1", + "ws": "^8.22.0" }, "bin": { "sndcli": "dist/index.js" @@ -22,10 +23,15 @@ "@inkjs/ui": "^2.0.0", "@types/node": "^25.9.2", "@types/react": "^19.2.17", + "@types/react-dom": "^19.3.0", + "@types/ws": "^8.18.2", + "@vitejs/plugin-react": "^6.1.2", "ink-testing-library": "^4.0.0", + "react-dom": "^19.3.0", "tsup": "^8.5.1", "tsx": "^4.22.4", "typescript": "^6.0.3", + "vite": "^8.3.4", "vitest": "^4.1.8" }, "engines": { @@ -45,40 +51,6 @@ "node": ">=18" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -579,39 +551,37 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", "dev": true, "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "url": "https://github.com/sponsors/oxc-project" } }, - "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], "dev": true, "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", "cpu": [ "arm64" ], @@ -626,9 +596,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", "cpu": [ "arm64" ], @@ -643,9 +613,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", "cpu": [ "x64" ], @@ -660,9 +630,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", "cpu": [ "x64" ], @@ -677,9 +647,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", "cpu": [ "arm" ], @@ -694,9 +664,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", "cpu": [ "arm64" ], @@ -711,9 +681,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", "cpu": [ "arm64" ], @@ -728,9 +698,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", "cpu": [ "ppc64" ], @@ -745,9 +715,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", "cpu": [ "s390x" ], @@ -762,9 +732,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", "cpu": [ "x64" ], @@ -779,9 +749,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", "cpu": [ "x64" ], @@ -796,9 +766,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", "cpu": [ "arm64" ], @@ -812,29 +782,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", "cpu": [ "arm64" ], @@ -849,9 +800,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", "cpu": [ "x64" ], @@ -1247,17 +1198,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1294,15 +1234,66 @@ } }, "node_modules/@types/react": { - "version": "19.2.17", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", - "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", @@ -1782,6 +1773,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -2149,9 +2141,9 @@ } }, "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -2165,23 +2157,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", "cpu": [ "arm64" ], @@ -2200,9 +2192,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", "cpu": [ "arm64" ], @@ -2221,9 +2213,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", "cpu": [ "x64" ], @@ -2242,9 +2234,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", "cpu": [ "x64" ], @@ -2263,9 +2255,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", "cpu": [ "arm" ], @@ -2284,9 +2276,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", "cpu": [ "arm64" ], @@ -2305,9 +2297,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", "cpu": [ "arm64" ], @@ -2326,9 +2318,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", "cpu": [ "x64" ], @@ -2347,9 +2339,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", "cpu": [ "x64" ], @@ -2368,9 +2360,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", "cpu": [ "arm64" ], @@ -2389,9 +2381,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", "cpu": [ "x64" ], @@ -2491,9 +2483,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -2621,11 +2613,12 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2656,9 +2649,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", "dev": true, "funding": [ { @@ -2675,10 +2668,11 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.19", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -2743,14 +2737,35 @@ } }, "node_modules/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", - "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "dev": true, + "license": "MIT" + }, "node_modules/react-reconciler": { "version": "0.33.0", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", @@ -2813,13 +2828,13 @@ "license": "ISC" }, "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.133.0", + "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -2829,21 +2844,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" } }, "node_modules/rollup": { @@ -2964,9 +2979,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3170,14 +3185,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, "node_modules/tsup": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", @@ -3237,6 +3244,7 @@ "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -3755,6 +3763,7 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3790,16 +3799,17 @@ } }, "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { @@ -3816,7 +3826,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -4032,9 +4042,9 @@ } }, "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 90f009f..de34159 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,11 @@ }, "scripts": { "dev": "tsx src/index.tsx", - "build": "tsup", + "build": "tsup && npm run build:web", + "build:web": "vite build --config web/vite.config.ts", + "gui": "vite build --config web/vite.config.ts && tsx src/index.tsx --gui", "start": "node dist/index.js", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p web", "test": "vitest run", "previews": "tsx scripts/render-previews.ts", "prepublishOnly": "npm run build && tsx scripts/check-dist-imports.ts" @@ -53,16 +55,22 @@ "execa": "^9.6.1", "ink": "^7.0.5", "react": "^19.2.7", - "string-width": "^8.2.1" + "string-width": "^8.2.1", + "ws": "^8.22.0" }, "devDependencies": { "@inkjs/ui": "^2.0.0", "@types/node": "^25.9.2", "@types/react": "^19.2.17", + "@types/react-dom": "^19.3.0", + "@types/ws": "^8.18.2", + "@vitejs/plugin-react": "^6.1.2", "ink-testing-library": "^4.0.0", + "react-dom": "^19.3.0", "tsup": "^8.5.1", "tsx": "^4.22.4", "typescript": "^6.0.3", + "vite": "^8.3.4", "vitest": "^4.1.8" } } diff --git a/src/cli/args.ts b/src/cli/args.ts index 42752ed..26dfbfd 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -5,11 +5,13 @@ export type CliCommand = | { kind: "version" } | { kind: "help" } | { kind: "run"; initialAdd?: string } + | { kind: "gui"; port?: number; open: boolean } | { kind: "invalid"; arg: string }; export function parseCliArgs(argv: string[]): CliCommand { const args = argv.filter((a) => a.trim() !== ""); if (args.length === 0) return { kind: "run" }; + if (args[0] === "--gui") return parseGuiArgs(args.slice(1)); if (args.length > 1) return { kind: "invalid", arg: args[1]! }; const a = args[0]!; if (a === "--version" || a === "-v") return { kind: "version" }; @@ -19,11 +21,34 @@ export function parseCliArgs(argv: string[]): CliCommand { return { kind: "run", initialAdd: a }; } +function parseGuiArgs(rest: string[]): CliCommand { + let port: number | undefined; + let open = true; + for (let i = 0; i < rest.length; i++) { + const a = rest[i]!; + if (a === "--no-open") { + open = false; + } else if (a === "--port") { + const v = rest[++i]; + const n = v === undefined || !/^\d+$/.test(v) ? NaN : Number(v); + if (!Number.isInteger(n) || n < 1 || n > 65535) { + return { kind: "invalid", arg: v === undefined ? a : `${a} ${v}` }; + } + port = n; + } else { + return { kind: "invalid", arg: a }; + } + } + return { kind: "gui", port, open }; +} + export const HELP_TEXT = `soundcli, own your music usage soundcli open the dashboard soundcli download that song on launch + soundcli --gui open the dashboard in your browser + (--port , --no-open) soundcli --version print the version tip: quote links that contain & (e.g. "https://...?list=...") diff --git a/src/core/createApp.ts b/src/core/createApp.ts new file mode 100644 index 0000000..c1ef453 --- /dev/null +++ b/src/core/createApp.ts @@ -0,0 +1,224 @@ +// UI-agnostic bootstrap: builds the library, player, download queue and +// history and wires them together. Both front ends (the Ink dashboard and the +// local web GUI) start from here, so none of this may touch the terminal. + +import { promises as fs } from "node:fs"; +import { ensureBinaries, type Binaries } from "../bin/binaries"; +import { ensureFfmpeg } from "../bin/ffmpeg-fetch"; +import { ensureMpvInstalled } from "../bin/mpv-install"; +import { finalizeStagedYtDlp } from "../bin/ytdlp-fetch"; +import { maybeUpdateYtDlp } from "../bin/ytdlp-update"; +import { loadConfig, saveConfig, type Config } from "../config/config"; +import { legacyArchiveFile } from "../config/paths"; +import { DownloadQueue } from "../download/queue"; +import { loadQueue } from "../download/persist"; +import { Library } from "../library/library"; +import { migrateOwnerLayout } from "../library/migrate"; +import { reconcileLibrary } from "../library/reconcile"; +import { Playback, type PlaybackState } from "../player/playback"; +import { PlayHistory } from "../player/history"; + +export interface CreateAppOptions { + /** A link passed on the command line: counts as self-onboarded. */ + initialAdd?: string; + /** Progress text while binaries are fetched on first boot. */ + onStatus?: (text: string) => void; + /** + * Status line while mpv auto-installs in the background (null clears it). + * `transient` marks a failure message that the front end should clear itself + * after a short while. + */ + onMpvStatus?: (text: string | null, opts?: { transient?: boolean }) => void; +} + +export interface App { + config: Config; + library: Library; + binaries: Binaries; + queue: DownloadQueue; + playback: Playback; + history: PlayHistory; + /** + * Silent drift hygiene (drop dead entries + dupes). Runs behind the first + * paint so a full music-dir walk on a big library never holds up boot. + * Call once after the front end is up. + */ + startBackgroundReconcile(): void; + /** + * Re-sync the library with disk without a restart. Guarded so runs never + * stack, and coalesced so rapid section hops don't restat. Pass the live + * `libraryDir` since Settings can change it after boot. + */ + reconcile(libraryDir?: string): void; + /** Flush and stop everything; safe to call on exit. */ + dispose(): void; +} + +export async function createApp(opts: CreateAppOptions = {}): Promise { + const { initialAdd, onStatus, onMpvStatus } = opts; + + const config = await loadConfig(); + // A link passed on the command line means the user has self-onboarded: + // skip the welcome tour and drop straight into downloading it (the + // saveConfig below persists the flag). + if (initialAdd) config.firstRunComplete = true; + const library = await Library.load(); + // One-time layout migration (pre-owner files move into their handle + // folder) must finish before anything reads the library; the existence + // cache it fills feeds the deferred reconcile below so each file is + // still stat'd only once per boot. + const exists = new Map(); + await migrateOwnerLayout(library, config, exists); + // Library is now the source of truth, so drop the legacy yt-dlp archive. + void fs.rm(legacyArchiveFile, { force: true }).catch(() => {}); + const binaries = await ensureBinaries(onStatus); + const playback = new Playback(binaries.mpv); + + // Recently played: record every track the player actually starts, + // including auto-advance and next/prev, not just explicit picks. + const history = await PlayHistory.load(); + let lastPlayedId: string | undefined; + + const lastId = history.ids()[0]; + if (lastId) { + const lastTrack = library.all().find((t) => t.id === lastId); + if (lastTrack) { + // Reconcile now runs behind the first paint, so a file deleted + // while the app was closed hasn't been pruned yet; one stat keeps + // the resume from loading a dead row (and feeds the exists cache + // so the deferred reconcile doesn't stat it again). + let present = exists.get(lastTrack.filePath); + if (present === undefined) { + present = await fs + .access(lastTrack.filePath) + .then(() => true) + .catch(() => false); + exists.set(lastTrack.filePath, present); + } + if (present) { + lastPlayedId = lastTrack.id; + void playback.play(lastTrack, [lastTrack], 0, true).catch(() => {}); + } + } + } + + playback.on("state", (s: PlaybackState) => { + const id = s.track?.id; + if (id && id !== lastPlayedId) { + lastPlayedId = id; + history.record(id); + } + }); + + // Backfill durations the player discovers: adopted files never carry + // one (adoption can't know it without probing audio), so their rows + // render no time. mpv reports the real duration on load; teach the + // library once per track and the row shows a time forever. + let lastHealedId: string | undefined; + playback.on("state", (s: PlaybackState) => { + const id = s.track?.id; + if (!id || s.duration <= 0 || id === lastHealedId) return; + const entry = library.get(id); + if (!entry || entry.durationSec !== undefined) return; + lastHealedId = id; + void library.upsert({ ...entry, durationSec: s.duration }).catch(() => {}); + }); + + // Auto-install mpv in the background so the rich player "just works". + // Retried on every launch while it's missing: a one-time flag here once + // left playback silently broken forever after a single failed attempt. + if (!binaries.mpv) { + const failed = (): void => { + // One calm line, then quiet again: the NowPlayingBar hint stays the + // durable nudge while songs keep opening in the OS default app. + onMpvStatus?.("Couldn't set up the player, songs open in your default app", { + transient: true, + }); + }; + void ensureMpvInstalled((t) => onMpvStatus?.(t)) + .then((p) => { + if (p) { + onMpvStatus?.(null); + playback.enableMpv(p); + } else if (process.platform === "linux") { + // Linux never auto-installs (needs sudo); the install hint is + // the durable surface there, so saying "couldn't set up" about + // an attempt we never made would just be noise. + onMpvStatus?.(null); + } else { + failed(); + } + }) + .catch(failed); + } + + // Downloads hard-gate on the audio engine (fetched in the background by + // ensureBinaries); undefined keeps the default concurrency. + const queue = new DownloadQueue(config, library, undefined, () => + ensureFfmpeg(), + ); + // Bring back last session's queue; pending items resume from their .part. + queue.restore(await loadQueue()); + + // Check for a newer yt-dlp on every boot (a stale extractor turns every + // download from a source into "failed"). Async and silent: a newer + // binary is staged, then promoted right away unless a download is + // already running the current one (then it applies next launch). + // Offline stays completely normal. + if (config.ytdlpAutoUpdate !== false) { + void maybeUpdateYtDlp() + .then(async (staged) => { + if (staged && queue.stats().downloading === 0) { + await finalizeStagedYtDlp(); + } + }) + .catch(() => {}); + } + + // Reconcile state. A failure stays silent: the app is fully usable with a + // stale index and the next run retries anyway. + let reconciling = false; + let lastReconcile = 0; + let firstCache: Map | null = exists; + const runReconcile = (force: boolean, libraryDir?: string): void => { + if (reconciling) return; + if (!force && Date.now() - lastReconcile < 5_000) return; + reconciling = true; + // A fresh cache per run so the missing-check and prune passes share stats + // (one access per track, not two), while each run still sees current disk. + // The first run reuses the boot cache so each file is stat'd once per boot. + const cache = firstCache ?? new Map(); + firstCache = null; + void reconcileLibrary(library, cache, libraryDir ?? config.libraryDir) + .then((r) => { + // Only touch recently-played when a prune actually removed tracks, so + // a reconcile that changed nothing writes nothing. + if (r.prunedMissing > 0) history.retain((id) => library.has(id)); + }) + .catch(() => {}) + .finally(() => { + lastReconcile = Date.now(); + reconciling = false; + }); + }; + + await saveConfig(config).catch(() => {}); + + return { + config, + library, + binaries, + queue, + playback, + history, + startBackgroundReconcile: () => runReconcile(true), + reconcile: (libraryDir) => runReconcile(false, libraryDir), + dispose: () => { + queue.suspend(); + // Library writes are debounced; flush synchronously so a mutation made + // in the last half second still lands before the process goes away. + library.flushSync(); + playback.quit(); + }, + }; +} diff --git a/src/gui/protocol.ts b/src/gui/protocol.ts new file mode 100644 index 0000000..22582b7 --- /dev/null +++ b/src/gui/protocol.ts @@ -0,0 +1,54 @@ +// Messages between the local GUI server and the web page. Types only, so the +// web bundle can import this without pulling in any Node code. + +import type { Config } from "../config/config"; +import type { QueueItem, QueueStats } from "../download/queue"; +import type { Track } from "../library/types"; +import type { PlaybackState } from "../player/playback"; + +export interface Snapshot { + tracks: Track[]; + queue: QueueItem[]; + stats: QueueStats; + playback: PlaybackState; + /** Recently played track ids, newest first. */ + history: string[]; + config: Config; + mpvStatus: string | null; + version: string; +} + +/** Server -> page. */ +export type ServerEvent = + | { type: "snapshot"; data: Snapshot } + | { type: "playback"; data: PlaybackState } + | { type: "queue"; data: { queue: QueueItem[]; stats: QueueStats } } + | { type: "library"; data: { tracks: Track[]; history: string[] } } + | { type: "mpv"; data: string | null } + | { type: "result"; id: number; ok: boolean; message?: string }; + +/** Page -> server. `id` is echoed back in a `result` event. */ +export type ClientCommand = { id: number } & ( + | { cmd: "play"; trackId: string; listIds?: string[] } + | { cmd: "togglePause" } + | { cmd: "seek"; seconds: number } + | { cmd: "setVolume"; volume: number } + | { cmd: "next" } + | { cmd: "prev" } + | { cmd: "cycleRepeat" } + | { cmd: "toggleShuffle" } + | { cmd: "stop" } + | { cmd: "enqueueUrl"; url: string } + | { cmd: "queuePause"; itemId: string } + | { cmd: "queueResume"; itemId: string } + | { cmd: "queueCancel"; itemId: string } + | { cmd: "queuePauseAll" } + | { cmd: "queueResumeAll" } + | { cmd: "queueRetryFailed" } + | { cmd: "queueClearFinished" } + | { cmd: "deleteTracks"; trackIds: string[] } + | { cmd: "renameTrack"; trackId: string; title: string } + | { cmd: "setConfig"; patch: Partial } +); + +export type CommandName = ClientCommand["cmd"]; diff --git a/src/gui/server.ts b/src/gui/server.ts new file mode 100644 index 0000000..3b6d0df --- /dev/null +++ b/src/gui/server.ts @@ -0,0 +1,345 @@ +// Local web GUI server: serves the built web page and a WebSocket that mirrors +// the core (library, queue, playback) to the browser and applies its commands. +// Bound to loopback with a per-launch token, because it drives a process that +// spawns downloaders and a player. + +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { createReadStream, existsSync, statSync } from "node:fs"; +import { + createServer, + type IncomingMessage, + type Server, + type ServerResponse, +} from "node:http"; +import type { AddressInfo } from "node:net"; +import path from "node:path"; +import { WebSocketServer, type WebSocket } from "ws"; +import { saveConfig, type Config } from "../config/config"; +import type { App } from "../core/createApp"; +import { deleteTracks } from "../library/delete"; +import { renameTrack } from "../library/rename"; +import { detectPasteLink } from "../sources/detect"; +import { sourceLabel, tracksFromUrl } from "../sources/enqueue-url"; +import type { + ClientCommand, + ServerEvent, + Snapshot, +} from "./protocol"; + +export interface GuiServerOptions { + app: App; + /** Directory holding the built web page (index.html + assets). */ + webRoot: string; + version: string; + port?: number; + /** Min ms between queue broadcasts (download progress is chatty). */ + queueThrottleMs?: number; +} + +export interface GuiServer { + /** URL to open, including the access token. */ + url: string; + port: number; + token: string; + close(): Promise; + /** Push a status line (mpv install progress) to connected pages. */ + setMpvStatus(text: string | null): void; +} + +const MIME: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".svg": "image/svg+xml", + ".json": "application/json", + ".png": "image/png", + ".ico": "image/x-icon", + ".woff2": "font/woff2", +}; + +export function tokenMatches(given: string | null, token: string): boolean { + if (!given) return false; + const a = Buffer.from(given); + const b = Buffer.from(token); + return a.length === b.length && timingSafeEqual(a, b); +} + +/** Origin must be our own loopback origin (or absent, for non-browser clients). */ +export function originAllowed( + origin: string | undefined, + port: number, +): boolean { + if (!origin) return true; + return ( + origin === `http://127.0.0.1:${port}` || + origin === `http://localhost:${port}` + ); +} + +export function hostAllowed(host: string | undefined, port: number): boolean { + return host === `127.0.0.1:${port}` || host === `localhost:${port}`; +} + +export async function startGuiServer( + opts: GuiServerOptions, +): Promise { + const { app, webRoot, version } = opts; + const token = randomBytes(24).toString("hex"); + const throttle = opts.queueThrottleMs ?? 150; + let port = 0; + let mpvStatus: string | null = null; + + const snapshot = (): Snapshot => ({ + tracks: app.library.all(), + queue: app.queue.getItems(), + stats: app.queue.stats(), + playback: app.playback.getState(), + history: app.history.ids(), + config: app.config, + mpvStatus, + version, + }); + + const clients = new Set(); + const send = (ws: WebSocket, ev: ServerEvent): void => { + if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(ev)); + }; + const broadcast = (ev: ServerEvent): void => { + if (clients.size === 0) return; + const raw = JSON.stringify(ev); + for (const ws of clients) if (ws.readyState === ws.OPEN) ws.send(raw); + }; + + // Core -> pages. + const onPlayback = (): void => + broadcast({ type: "playback", data: app.playback.getState() }); + app.playback.on("state", onPlayback); + + let queueTimer: ReturnType | null = null; + const flushQueue = (): void => { + queueTimer = null; + broadcast({ + type: "queue", + data: { queue: app.queue.getItems(), stats: app.queue.stats() }, + }); + }; + const onQueue = (): void => { + if (!queueTimer) queueTimer = setTimeout(flushQueue, throttle); + }; + app.queue.on("update", onQueue); + + let libTimer: ReturnType | null = null; + const unsubLibrary = app.library.onChange(() => { + if (libTimer) return; + libTimer = setTimeout(() => { + libTimer = null; + broadcast({ + type: "library", + data: { tracks: app.library.all(), history: app.history.ids() }, + }); + }, throttle); + }); + + const trackList = (ids: string[] | undefined) => + (ids ?? []).flatMap((id) => { + const t = app.library.get(id); + return t ? [t] : []; + }); + + async function run(c: ClientCommand): Promise { + const pb = app.playback; + switch (c.cmd) { + case "play": { + const t = app.library.get(c.trackId); + if (!t) throw new Error("That song isn't in the library."); + const list = trackList(c.listIds); + await pb.play(t, list.length ? list : [t]); + return; + } + case "togglePause": + return pb.togglePause(); + case "seek": + return pb.seek(c.seconds); + case "setVolume": + return pb.setVolume(c.volume); + case "next": + return pb.next(); + case "prev": + return pb.prev(); + case "stop": + return pb.stop(); + case "cycleRepeat": + return pb.cycleRepeat(); + case "toggleShuffle": + return pb.toggleShuffle(); + case "enqueueUrl": { + const d = detectPasteLink(c.url); + if (!d.ok) throw new Error(d.reason); + const tracks = await tracksFromUrl(d.source, d.url); + if (tracks.length === 0) throw new Error("Nothing found at that link."); + if (tracks.length > 1) { + throw new Error("That's a whole playlist · paste one song's link."); + } + const r = app.queue.enqueue( + tracks.map((track) => ({ + source: d.source, + sourceLabel: sourceLabel(d.source), + track, + })), + ); + return r.added > 0 ? "Added to the queue." : "Already saved."; + } + case "queuePause": + return app.queue.pause(c.itemId); + case "queueResume": + return app.queue.resume(c.itemId); + case "queueCancel": + return app.queue.cancel(c.itemId); + case "queuePauseAll": + return app.queue.pauseAll(); + case "queueResumeAll": + return app.queue.resumeAll(); + case "queueRetryFailed": + return app.queue.retryFailed(); + case "queueClearFinished": + return app.queue.clearFinished(); + case "deleteTracks": { + const victims = trackList(c.trackIds); + const playing = pb.getState().track?.id; + // mpv holds the file open; Windows refuses to unlink it. + if (playing && victims.some((t) => t.id === playing)) await pb.stop(); + const r = await deleteTracks(app.library, victims, app.config.libraryDir); + return r.failed + ? `Removed ${r.removed}, ${r.failed} couldn't be deleted.` + : `Removed ${r.removed}.`; + } + case "renameTrack": { + const t = app.library.get(c.trackId); + if (!t) throw new Error("That song isn't in the library."); + const r = await renameTrack(app.library, t, c.title); + if (r === "collision") throw new Error("A file with that name exists."); + if (r === "failed") throw new Error("Couldn't rename the file."); + return; + } + case "setConfig": { + const next: Config = { ...app.config, ...c.patch }; + Object.assign(app.config, next); + app.queue.updateConfig(app.config); + await saveConfig(app.config); + broadcast({ type: "snapshot", data: snapshot() }); + return; + } + } + } + + async function onMessage(ws: WebSocket, raw: string): Promise { + let c: ClientCommand; + try { + c = JSON.parse(raw) as ClientCommand; + } catch { + return; + } + if (typeof c?.id !== "number" || typeof c.cmd !== "string") return; + try { + const message = await run(c); + send(ws, { type: "result", id: c.id, ok: true, message: message || undefined }); + } catch (e) { + send(ws, { + type: "result", + id: c.id, + ok: false, + message: e instanceof Error ? e.message : String(e), + }); + } + } + + const root = path.resolve(webRoot); + function serveStatic(req: IncomingMessage, res: ServerResponse): void { + const url = new URL(req.url ?? "/", "http://x"); + if (req.method !== "GET" && req.method !== "HEAD") { + res.writeHead(405).end(); + return; + } + // The page itself needs the token; assets are public but useless alone. + if (url.pathname === "/" || url.pathname === "/index.html") { + if (!tokenMatches(url.searchParams.get("token"), token)) { + res.writeHead(403, { "content-type": "text/plain" }); + res.end("Open the link printed by sndcli --gui."); + return; + } + } + const rel = url.pathname === "/" ? "index.html" : url.pathname.slice(1); + const file = path.resolve(root, rel); + if ( + (file !== root && !file.startsWith(root + path.sep)) || + !existsSync(file) || + !statSync(file).isFile() + ) { + res.writeHead(404, { "content-type": "text/plain" }).end("Not found"); + return; + } + res.writeHead(200, { + "content-type": MIME[path.extname(file)] ?? "application/octet-stream", + "cache-control": "no-store", + "x-content-type-options": "nosniff", + }); + if (req.method === "HEAD") res.end(); + else createReadStream(file).pipe(res); + } + + const server: Server = createServer((req, res) => { + if (!hostAllowed(req.headers.host, port)) { + res.writeHead(403).end(); + return; + } + serveStatic(req, res); + }); + + const wss = new WebSocketServer({ noServer: true }); + server.on("upgrade", (req, socket, head) => { + const url = new URL(req.url ?? "/", "http://x"); + const ok = + url.pathname === "/ws" && + hostAllowed(req.headers.host, port) && + originAllowed(req.headers.origin, port) && + tokenMatches(url.searchParams.get("token"), token); + if (!ok) { + socket.write("HTTP/1.1 403 Forbidden\r\n\r\n"); + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + clients.add(ws); + ws.on("close", () => clients.delete(ws)); + ws.on("error", () => clients.delete(ws)); + ws.on("message", (d) => void onMessage(ws, d.toString())); + send(ws, { type: "snapshot", data: snapshot() }); + }); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(opts.port ?? 0, "127.0.0.1", resolve); + }); + port = (server.address() as AddressInfo).port; + + return { + url: `http://127.0.0.1:${port}/?token=${token}`, + port, + token, + setMpvStatus(text) { + mpvStatus = text; + broadcast({ type: "mpv", data: text }); + }, + async close() { + app.playback.off("state", onPlayback); + app.queue.off("update", onQueue); + unsubLibrary(); + if (queueTimer) clearTimeout(queueTimer); + if (libTimer) clearTimeout(libTimer); + for (const ws of clients) ws.terminate(); + wss.close(); + await new Promise((r) => server.close(() => r())); + }, + }; +} diff --git a/src/index.tsx b/src/index.tsx index 56c39db..a039566 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -31,6 +31,60 @@ process.on("uncaughtException", (err) => { process.exit(1); }); +async function runGui(port: number | undefined, open: boolean): Promise { + const path = await import("node:path"); + const fs = await import("node:fs"); + const { fileURLToPath } = await import("node:url"); + const { createRequire } = await import("node:module"); + const { createApp } = await import("./core/createApp"); + const { startGuiServer } = await import("./gui/server"); + const { openPath } = await import("./util/open-path"); + + // Built page: next to the bundle when installed, ../dist/web when run from src. + const here = path.dirname(fileURLToPath(import.meta.url)); + const webRoot = [path.join(here, "web"), path.join(here, "..", "dist", "web")].find( + (d) => fs.existsSync(path.join(d, "index.html")), + ); + if (!webRoot) { + console.error("GUI files not found. Run `npm run build:web` first."); + process.exitCode = 1; + return; + } + const version = ( + createRequire(import.meta.url)("../package.json") as { version: string } + ).version; + + let gui: Awaited> | null = null; + let app: Awaited>; + try { + app = await createApp({ + onStatus: (t) => console.log(t), + onMpvStatus: (t) => gui?.setMpvStatus(t), + }); + } catch (e) { + console.error( + `Couldn't set up: ${e instanceof Error ? e.message : String(e)}`, + ); + process.exitCode = 1; + return; + } + app.startBackgroundReconcile(); + gui = await startGuiServer({ app, webRoot, version, port }); + + console.log(`soundcli GUI running at\n ${gui.url}\nPress ctrl-c to quit.`); + if (open) openPath(gui.url); + + const server = gui; + await new Promise((resolve) => { + const stop = (): void => resolve(); + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + }); + await server.close(); + app.dispose(); + process.exit(0); +} + async function main(): Promise { const command = parseCliArgs(process.argv.slice(2)); @@ -54,6 +108,11 @@ async function main(): Promise { return; } + if (command.kind === "gui") { + await runGui(command.port, command.open); + return; + } + // Run the dashboard in the alternate screen so it updates in place like a // real TUI (no scrollback, no snapping to the bottom on each keystroke). const useAlt = Boolean(process.stdout.isTTY); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 85347f9..6f4c3e8 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,22 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Text, useApp, useInput, useStdout, useStdin } from "ink"; import { Spinner } from "@inkjs/ui"; -import { ensureBinaries, type Binaries } from "../bin/binaries"; -import { ensureFfmpeg } from "../bin/ffmpeg-fetch"; -import { ensureMpvInstalled } from "../bin/mpv-install"; -import { finalizeStagedYtDlp } from "../bin/ytdlp-fetch"; -import { maybeUpdateYtDlp } from "../bin/ytdlp-update"; -import { loadConfig, saveConfig, type Config } from "../config/config"; -import { promises as fs } from "node:fs"; -import { DownloadQueue } from "../download/queue"; -import { loadQueue } from "../download/persist"; -import { Library as LibraryStore } from "../library/library"; -import { migrateOwnerLayout } from "../library/migrate"; -import { reconcileLibrary } from "../library/reconcile"; -import { legacyArchiveFile } from "../config/paths"; +import { saveConfig, type Config } from "../config/config"; +import { createApp, type App as Core } from "../core/createApp"; import type { Track } from "../library/types"; -import { Playback, type PlaybackState } from "../player/playback"; -import { PlayHistory } from "../player/history"; import { StoreContext, type CaptureMode, @@ -48,14 +35,6 @@ import { Settings } from "./sections/Settings"; import { Welcome } from "./views/Welcome"; import { useMouseWheel } from "./hooks/useMouseWheel"; -interface Boot { - library: LibraryStore; - binaries: Binaries; - queue: DownloadQueue; - playback: Playback; - history: PlayHistory; -} - function Content({ section }: { section: Section }) { switch (section) { case "library": @@ -107,7 +86,7 @@ export function App({ initialAdd }: { initialAdd?: string } = {}) { const [error, setError] = useState(null); const [status, setStatus] = useState("Getting your music ready…"); const [mpvStatus, setMpvStatus] = useState(null); - const [boot, setBoot] = useState(null); + const [boot, setBoot] = useState(null); const [config, setConfigState] = useState(null); // Bumped by `r` on the setup-failed screen to re-run the whole bootstrap. const [bootAttempt, setBootAttempt] = useState(0); @@ -169,158 +148,25 @@ export function App({ initialAdd }: { initialAdd?: string } = {}) { if (booting.current) return; booting.current = true; void (async () => { - const cfg = await loadConfig(); - // A link passed on the command line means the user has self-onboarded: - // skip the welcome tour and drop straight into downloading it (the - // saveConfig below persists the flag). - if (initialAdd) cfg.firstRunComplete = true; - const library = await LibraryStore.load(); - // One-time layout migration (pre-owner files move into their handle - // folder) must finish before anything reads the library; the existence - // cache it fills feeds the deferred reconcile below so each file is - // still stat'd only once per boot. - const exists = new Map(); - await migrateOwnerLayout(library, cfg, exists); - // Library is now the source of truth, so drop the legacy yt-dlp archive. - void fs.rm(legacyArchiveFile, { force: true }).catch(() => {}); - const binaries = await ensureBinaries(setStatus); - const playback = new Playback(binaries.mpv); - - // Recently played: record every track the player actually starts, - // including auto-advance and next/prev, not just explicit picks. - const history = await PlayHistory.load(); - let lastPlayedId: string | undefined; - - const lastId = history.ids()[0]; - if (lastId) { - const lastTrack = library.all().find((t) => t.id === lastId); - if (lastTrack) { - // Reconcile now runs behind the first paint, so a file deleted - // while the app was closed hasn't been pruned yet; one stat keeps - // the resume from loading a dead row (and feeds the exists cache - // so the deferred reconcile doesn't stat it again). - let present = exists.get(lastTrack.filePath); - if (present === undefined) { - present = await fs - .access(lastTrack.filePath) - .then(() => true) - .catch(() => false); - exists.set(lastTrack.filePath, present); - } - if (present) { - lastPlayedId = lastTrack.id; - void playback - .play(lastTrack, [lastTrack], 0, true) - .catch(() => {}); + const core = await createApp({ + initialAdd, + onStatus: setStatus, + onMpvStatus: (text, o) => { + setMpvStatus(text); + if (o?.transient) { + if (mpvStatusTimer.current) clearTimeout(mpvStatusTimer.current); + mpvStatusTimer.current = setTimeout(() => setMpvStatus(null), 8000); } - } - } - - playback.on("state", (s: PlaybackState) => { - const id = s.track?.id; - if (id && id !== lastPlayedId) { - lastPlayedId = id; - history.record(id); - } + }, }); - - // Backfill durations the player discovers: adopted files never carry - // one (adoption can't know it without probing audio), so their rows - // render no time. mpv reports the real duration on load; teach the - // library once per track and the row shows a time forever. - let lastHealedId: string | undefined; - playback.on("state", (s: PlaybackState) => { - const id = s.track?.id; - if (!id || s.duration <= 0 || id === lastHealedId) return; - const entry = library.get(id); - if (!entry || entry.durationSec !== undefined) return; - lastHealedId = id; - void library - .upsert({ ...entry, durationSec: s.duration }) - .catch(() => {}); - }); - - // Auto-install mpv in the background so the rich player "just works". - // Retried on every launch while it's missing: a one-time flag here once - // left playback silently broken forever after a single failed attempt. - if (!binaries.mpv) { - const failed = (): void => { - // One calm line, then quiet again: the NowPlayingBar hint stays the - // durable nudge while songs keep opening in the OS default app. - setMpvStatus( - "Couldn't set up the player, songs open in your default app", - ); - if (mpvStatusTimer.current) clearTimeout(mpvStatusTimer.current); - mpvStatusTimer.current = setTimeout(() => setMpvStatus(null), 8000); - }; - void ensureMpvInstalled(setMpvStatus) - .then((p) => { - if (p) { - setMpvStatus(null); - playback.enableMpv(p); - } else if (process.platform === "linux") { - // Linux never auto-installs (needs sudo); the install hint is - // the durable surface there, so saying "couldn't set up" about - // an attempt we never made would just be noise. - setMpvStatus(null); - } else { - failed(); - } - }) - .catch(failed); - } - - // Downloads hard-gate on the audio engine (fetched in the background by - // ensureBinaries); undefined keeps the default concurrency. - const queue = new DownloadQueue(cfg, library, undefined, () => - ensureFfmpeg(), - ); - // Bring back last session's queue; pending items resume from their .part. - queue.restore(await loadQueue()); - - // Check for a newer yt-dlp on every boot (a stale extractor turns every - // download from a source into "failed"). Async and silent: a newer - // binary is staged, then promoted right away unless a download is - // already running the current one (then it applies next launch). - // Offline stays completely normal. - if (cfg.ytdlpAutoUpdate !== false) { - void maybeUpdateYtDlp() - .then(async (staged) => { - if (staged && queue.stats().downloading === 0) { - await finalizeStagedYtDlp(); - } - }) - .catch(() => {}); - } - setConfigState(cfg); - setBoot({ library, binaries, queue, playback, history }); - // Silent drift hygiene (drop dead entries + dupes) runs behind the - // first paint: a full music-dir walk on a big library kept the user on - // the boot spinner, and every heal bumps the library version so the UI - // catches up on its own. It holds the same in-flight flag runReconcile - // checks, so a focus-triggered run can't overlap it. A failure stays - // silent: the app is fully usable with a stale index, the next - // section-focus reconcile retries anyway, and throwing the setup-error - // screen over a working session (with the r retry gated off once boot - // is set) would be worse than the drift. - if (!reconciling.current) { - reconciling.current = true; - void reconcileLibrary(library, exists, cfg.libraryDir) - .then((r) => { - if (r.prunedMissing > 0) history.retain((id) => library.has(id)); - }) - .catch(() => {}) - .finally(() => { - lastReconcile.current = Date.now(); - reconciling.current = false; - }); - } + setConfigState(core.config); + setBoot(core); + core.startBackgroundReconcile(); if (initialAdd) { setSection("download"); setRegion("content"); setPendingAdd(initialAdd); } - void saveConfig(cfg); })() .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e)), @@ -335,11 +181,7 @@ export function App({ initialAdd }: { initialAdd?: string } = {}) { useEffect( () => () => { - boot?.queue.suspend(); - // Library writes are debounced; flush synchronously so a mutation made - // in the last half second still lands before the process goes away. - boot?.library.flushSync(); - boot?.playback.quit(); + boot?.dispose(); }, [boot], ); @@ -350,25 +192,8 @@ export function App({ initialAdd }: { initialAdd?: string } = {}) { // library-backed section, guarded so runs never stack and coalesced so rapid // tab-hops don't restat. The folder rescan is readdir-only, so a run that // finds no drift stays cheap. - const reconciling = useRef(false); - const lastReconcile = useRef(0); const runReconcile = useCallback(() => { - const library = boot?.library; - if (!library || reconciling.current) return; - if (Date.now() - lastReconcile.current < 5_000) return; - reconciling.current = true; - // A fresh cache per run so the missing-check and prune passes share stats - // (one access per track, not two), while each run still sees current disk. - void reconcileLibrary(library, new Map(), config?.libraryDir) - .then((r) => { - // Only touch recently-played when a prune actually removed tracks, so a - // reconcile that changed nothing writes nothing. - if (r.prunedMissing > 0) boot.history.retain((id) => library.has(id)); - }) - .finally(() => { - lastReconcile.current = Date.now(); - reconciling.current = false; - }); + boot?.reconcile(config?.libraryDir); }, [boot, config]); useEffect(() => { @@ -382,9 +207,7 @@ export function App({ initialAdd }: { initialAdd?: string } = {}) { }, [section, runReconcile]); const quitAll = useCallback(() => { - boot?.queue.suspend(); - boot?.library.flushSync(); - boot?.playback.quit(); + boot?.dispose(); exit(); }, [boot, exit]); diff --git a/test/cli-args.test.ts b/test/cli-args.test.ts index 98b8838..f527a8b 100644 --- a/test/cli-args.test.ts +++ b/test/cli-args.test.ts @@ -32,6 +32,34 @@ describe("parseCliArgs", () => { expect(parseCliArgs(["a", "b"])).toEqual({ kind: "invalid", arg: "b" }); }); + it("parses --gui with optional port and no-open", () => { + expect(parseCliArgs(["--gui"])).toEqual({ + kind: "gui", + port: undefined, + open: true, + }); + expect(parseCliArgs(["--gui", "--port", "4321", "--no-open"])).toEqual({ + kind: "gui", + port: 4321, + open: false, + }); + }); + + it("rejects a bad --gui port or stray arg", () => { + expect(parseCliArgs(["--gui", "--port"])).toEqual({ + kind: "invalid", + arg: "--port", + }); + expect(parseCliArgs(["--gui", "--port", "99999"])).toEqual({ + kind: "invalid", + arg: "--port 99999", + }); + expect(parseCliArgs(["--gui", "link"])).toEqual({ + kind: "invalid", + arg: "link", + }); + }); + it("help text mentions the link pass-through", () => { expect(HELP_TEXT).toContain("soundcli "); }); diff --git a/test/gui-server.test.ts b/test/gui-server.test.ts new file mode 100644 index 0000000..19e6476 --- /dev/null +++ b/test/gui-server.test.ts @@ -0,0 +1,160 @@ +import { EventEmitter } from "node:events"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import WebSocket from "ws"; +import type { App } from "../src/core/createApp"; +import { + hostAllowed, + originAllowed, + startGuiServer, + tokenMatches, + type GuiServer, +} from "../src/gui/server"; +import type { ServerEvent } from "../src/gui/protocol"; + +const track = { id: "youtube:a", title: "Song", filePath: "/x/a.m4a" }; + +function fakeApp() { + const playback = Object.assign(new EventEmitter(), { + getState: () => ({ track: null, paused: true, position: 0, duration: 0, volume: 50, repeat: "off" }), + togglePause: vi.fn(async () => {}), + seek: vi.fn(async () => {}), + play: vi.fn(async () => {}), + }); + const queue = Object.assign(new EventEmitter(), { + getItems: () => [], + stats: () => ({ total: 0 }), + pause: vi.fn(), + }); + const library = { + all: () => [track], + get: (id: string) => (id === track.id ? track : undefined), + onChange: () => () => {}, + }; + const app = { + config: { libraryDir: "/x", firstRunComplete: true }, + library, + queue, + playback, + history: { ids: () => ["youtube:a"] }, + }; + return { app: app as unknown as App, playback, queue }; +} + +let server: GuiServer | null = null; +const sockets: WebSocket[] = []; + +async function open(url: string, origin?: string): Promise { + const ws = new WebSocket(url, origin ? { origin } : undefined); + sockets.push(ws); + await new Promise((res, rej) => { + ws.once("open", () => res()); + ws.once("error", rej); + ws.once("unexpected-response", (_r, resp) => rej(new Error(`HTTP ${resp.statusCode}`))); + }); + return ws; +} + +function nextMsg(ws: WebSocket, pred: (e: ServerEvent) => boolean): Promise { + return new Promise((res) => { + const on = (d: WebSocket.RawData) => { + const e = JSON.parse(d.toString()) as ServerEvent; + if (pred(e)) { + ws.off("message", on); + res(e); + } + }; + ws.on("message", on); + }); +} + +let webRoot: string; +beforeEach(() => { + webRoot = mkdtempSync(path.join(os.tmpdir(), "gui-web-")); + writeFileSync(path.join(webRoot, "index.html"), "hi"); +}); +afterEach(async () => { + for (const s of sockets.splice(0)) s.terminate(); + await server?.close(); + server = null; +}); + +describe("guards", () => { + it("compares tokens", () => { + expect(tokenMatches("abc", "abc")).toBe(true); + expect(tokenMatches("abd", "abc")).toBe(false); + expect(tokenMatches(null, "abc")).toBe(false); + expect(tokenMatches("ab", "abc")).toBe(false); + }); + it("only allows our own loopback origin and host", () => { + expect(originAllowed(undefined, 5)).toBe(true); + expect(originAllowed("http://127.0.0.1:5", 5)).toBe(true); + expect(originAllowed("http://evil.com", 5)).toBe(false); + expect(originAllowed("http://127.0.0.1:6", 5)).toBe(false); + expect(hostAllowed("127.0.0.1:5", 5)).toBe(true); + expect(hostAllowed("evil.com:5", 5)).toBe(false); + }); +}); + +describe("gui server", () => { + it("serves the page only with the token", async () => { + const { app } = fakeApp(); + server = await startGuiServer({ app, webRoot, version: "1" }); + const base = `http://127.0.0.1:${server.port}`; + expect((await fetch(`${base}/`)).status).toBe(403); + const ok = await fetch(`${base}/?token=${server.token}`); + expect(ok.status).toBe(200); + expect(await ok.text()).toContain("hi"); + expect((await fetch(`${base}/../../etc/passwd`)).status).not.toBe(200); + }); + + it("rejects websockets with a bad token or foreign origin", async () => { + const { app } = fakeApp(); + server = await startGuiServer({ app, webRoot, version: "1" }); + const base = `ws://127.0.0.1:${server.port}/ws`; + await expect(open(`${base}?token=nope`)).rejects.toThrow(); + await expect( + open(`${base}?token=${server.token}`, "http://evil.com"), + ).rejects.toThrow(); + }); + + it("sends a snapshot, then forwards playback and runs commands", async () => { + const { app, playback } = fakeApp(); + server = await startGuiServer({ app, webRoot, version: "9" }); + const ws = new WebSocket(`ws://127.0.0.1:${server.port}/ws?token=${server.token}`); + sockets.push(ws); + const snap = await nextMsg(ws, (e) => e.type === "snapshot"); + expect(snap).toMatchObject({ data: { version: "9", history: ["youtube:a"] } }); + + const pb = nextMsg(ws, (e) => e.type === "playback"); + playback.emit("state"); + expect(await pb).toMatchObject({ type: "playback" }); + + const res = nextMsg(ws, (e) => e.type === "result"); + ws.send(JSON.stringify({ id: 7, cmd: "togglePause" })); + expect(await res).toEqual({ type: "result", id: 7, ok: true }); + expect(playback.togglePause).toHaveBeenCalled(); + + const bad = nextMsg(ws, (e) => e.type === "result"); + ws.send(JSON.stringify({ id: 8, cmd: "play", trackId: "missing" })); + expect(await bad).toMatchObject({ id: 8, ok: false }); + expect(playback.play).not.toHaveBeenCalled(); + }); + + it("throttles queue updates into one broadcast", async () => { + const { app, queue } = fakeApp(); + server = await startGuiServer({ app, webRoot, version: "1", queueThrottleMs: 30 }); + const ws = new WebSocket(`ws://127.0.0.1:${server.port}/ws?token=${server.token}`); + sockets.push(ws); + await nextMsg(ws, (e) => e.type === "snapshot"); + let count = 0; + ws.on("message", (d) => { + if ((JSON.parse(d.toString()) as ServerEvent).type === "queue") count++; + }); + for (let i = 0; i < 20; i++) queue.emit("update"); + await new Promise((r) => setTimeout(r, 120)); + expect(count).toBe(1); + }); +}); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..fcfc34f --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + soundcli + + +
+ + + diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..e5838f2 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,114 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { Snapshot } from "../../src/gui/protocol"; +import { useConnection, type Connection } from "./connection"; +import { Library, Playlists, History } from "./sections/Songs"; +import { Download } from "./sections/Download"; +import { Settings } from "./sections/Settings"; +import { NowPlaying } from "./NowPlaying"; + +const SECTIONS = [ + ["library", "Library"], + ["playlists", "Playlists"], + ["history", "History"], + ["download", "Download"], + ["settings", "Settings"], +] as const; +type Section = (typeof SECTIONS)[number][0]; + +export interface Ctx { + s: Snapshot; + /** Run a command and show its outcome in the toast. */ + run: Connection["send"]; +} + +export function App() { + const { state, online, send } = useConnection(); + const [section, setSection] = useState
("library"); + const [toast, setToast] = useState<{ text: string; bad: boolean } | null>(null); + const toastTimer = useRef | undefined>(undefined); + + const run = useCallback( + async (c) => { + try { + const msg = await send(c); + if (msg) show(msg, false); + return msg; + } catch (e) { + show(e instanceof Error ? e.message : String(e), true); + throw e; + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [send], + ); + function show(text: string, bad: boolean): void { + setToast({ text, bad }); + clearTimeout(toastTimer.current); + toastTimer.current = setTimeout(() => setToast(null), 4000); + } + + // Space toggles play/pause unless the user is typing. + useEffect(() => { + const onKey = (e: KeyboardEvent): void => { + const t = e.target as HTMLElement; + if (t.closest("input, textarea, select, button")) return; + if (e.key === " ") { + e.preventDefault(); + void run({ cmd: "togglePause" }).catch(() => {}); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [run]); + + if (!state) { + return
{online ? "Loading…" : "Connecting…"}
; + } + const ctx: Ctx = { s: state, run }; + const active = state.stats.pending + state.stats.downloading; + + return ( +
+ +
+ {!online &&
Disconnected, retrying…
} + {state.mpvStatus &&
{state.mpvStatus}
} + {!state.config.firstRunComplete && section !== "settings" && ( +
+ Welcome! Paste a song link under Download or set your + YouTube / SoundCloud / Spotify handle in Settings.{" "} + +
+ )} + {section === "library" && } + {section === "playlists" && } + {section === "history" && } + {section === "download" && } + {section === "settings" && } +
+ + {toast &&
{toast.text}
} +
+ ); +} diff --git a/web/src/NowPlaying.tsx b/web/src/NowPlaying.tsx new file mode 100644 index 0000000..c9868cc --- /dev/null +++ b/web/src/NowPlaying.tsx @@ -0,0 +1,65 @@ +import type { Ctx } from "./App"; +import { formatDuration } from "./format"; + +export function NowPlaying({ s, run }: Ctx) { + const p = s.playback; + const go = (c: Parameters[0]) => void run(c).catch(() => {}); + const t = p.track; + const canControl = p.canControl ?? p.engine === "mpv"; + return ( +
+
+ {t ? ( + <> + {t.title} + {t.artist ?? ""} + + ) : ( + Nothing playing + )} +
+
+
+ + + + + +
+
+ {formatDuration(p.position)} + + go({ cmd: "seek", seconds: Number(e.target.value) - p.position }) + } + /> + {formatDuration(p.duration)} +
+
+
+ 🔊 + go({ cmd: "setVolume", volume: Number(e.target.value) })} + /> +
+
+ ); +} diff --git a/web/src/connection.ts b/web/src/connection.ts new file mode 100644 index 0000000..441665f --- /dev/null +++ b/web/src/connection.ts @@ -0,0 +1,92 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ClientCommand, ServerEvent, Snapshot } from "../../src/gui/protocol"; + +type Cmd = ClientCommand extends infer C + ? C extends { id: number } + ? Omit + : never + : never; + +export interface Connection { + state: Snapshot | null; + online: boolean; + /** Send a command; resolves with the server's message, rejects with its error. */ + send: (c: Cmd) => Promise; +} + +const token = new URLSearchParams(location.search).get("token") ?? ""; + +/** Live mirror of the core over the WebSocket, reconnecting if it drops. */ +export function useConnection(): Connection { + const [state, setState] = useState(null); + const [online, setOnline] = useState(false); + const ws = useRef(null); + const nextId = useRef(1); + const pending = useRef( + new Map void; fail: (e: Error) => void }>(), + ); + + useEffect(() => { + let closed = false; + let retry: ReturnType | undefined; + const connect = (): void => { + const proto = location.protocol === "https:" ? "wss" : "ws"; + const sock = new WebSocket( + `${proto}://${location.host}/ws?token=${encodeURIComponent(token)}`, + ); + ws.current = sock; + sock.onopen = () => setOnline(true); + sock.onclose = () => { + setOnline(false); + for (const p of pending.current.values()) p.fail(new Error("Disconnected")); + pending.current.clear(); + if (!closed) retry = setTimeout(connect, 1000); + }; + sock.onmessage = (m) => { + const ev = JSON.parse(m.data as string) as ServerEvent; + if (ev.type === "result") { + const p = pending.current.get(ev.id); + pending.current.delete(ev.id); + if (ev.ok) p?.ok(ev.message); + else p?.fail(new Error(ev.message ?? "Failed")); + return; + } + setState((s) => { + if (ev.type === "snapshot") return ev.data; + if (!s) return s; + switch (ev.type) { + case "playback": + return { ...s, playback: ev.data }; + case "queue": + return { ...s, queue: ev.data.queue, stats: ev.data.stats }; + case "library": + return { ...s, tracks: ev.data.tracks, history: ev.data.history }; + case "mpv": + return { ...s, mpvStatus: ev.data }; + } + }); + }; + }; + connect(); + return () => { + closed = true; + clearTimeout(retry); + ws.current?.close(); + }; + }, []); + + const send = useCallback((c: Cmd) => { + return new Promise((resolve, reject) => { + const sock = ws.current; + if (!sock || sock.readyState !== WebSocket.OPEN) { + reject(new Error("Not connected")); + return; + } + const id = nextId.current++; + pending.current.set(id, { ok: resolve, fail: reject }); + sock.send(JSON.stringify({ ...c, id })); + }); + }, []); + + return { state, online, send }; +} diff --git a/web/src/format.ts b/web/src/format.ts new file mode 100644 index 0000000..54b5751 --- /dev/null +++ b/web/src/format.ts @@ -0,0 +1,21 @@ +export function formatDuration(sec?: number): string { + if (sec === undefined || !Number.isFinite(sec) || sec < 0) return ""; + const s = Math.round(sec); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const r = String(s % 60).padStart(2, "0"); + return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${r}` : `${m}:${r}`; +} + +export function formatSpeed(bytes?: number): string { + if (!bytes) return ""; + const mb = bytes / 1024 / 1024; + return mb >= 1 ? `${mb.toFixed(1)} MB/s` : `${Math.round(bytes / 1024)} KB/s`; +} + +export function matches(query: string, ...fields: (string | undefined)[]): boolean { + const words = query.toLowerCase().split(/\s+/).filter(Boolean); + if (words.length === 0) return true; + const hay = fields.join(" ").toLowerCase(); + return words.every((w) => hay.includes(w)); +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..bbc4d24 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./styles.css"; + +createRoot(document.getElementById("root")!).render(); diff --git a/web/src/sections/Download.tsx b/web/src/sections/Download.tsx new file mode 100644 index 0000000..4376155 --- /dev/null +++ b/web/src/sections/Download.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import type { Ctx } from "../App"; +import { formatSpeed } from "../format"; + +export function Download({ s, run }: Ctx) { + const [url, setUrl] = useState(""); + const [busy, setBusy] = useState(false); + const go = (c: Parameters[0]) => void run(c).catch(() => {}); + const st = s.stats; + + async function add(e: React.FormEvent): Promise { + e.preventDefault(); + if (!url.trim() || busy) return; + setBusy(true); + try { + await run({ cmd: "enqueueUrl", url: url.trim() }); + setUrl(""); + } catch { + // the toast already shows why + } finally { + setBusy(false); + } + } + + return ( +
+

Download

+
+ setUrl(e.target.value)} + /> + +
+
+ + {st.downloading} downloading · {st.pending} waiting · {st.done} done + {st.failed ? ` · ${st.failed} failed` : ""} + + + + + +
+ {s.queue.length === 0 ? ( +

Queue is empty.

+ ) : ( +
    + {s.queue.map((it) => ( +
  • + {it.track.title} + {it.sourceLabel} + + {it.status === "downloading" + ? `${Math.round(it.percent)}% ${formatSpeed(it.speed)}` + : (it.error ?? it.status)} + + + + {it.status === "downloading" && ( + + )} + {it.status === "paused" && ( + + )} + {(it.status === "downloading" || + it.status === "pending" || + it.status === "paused") && ( + + )} + +
  • + ))} +
+ )} +
+ ); +} diff --git a/web/src/sections/Settings.tsx b/web/src/sections/Settings.tsx new file mode 100644 index 0000000..1f82fff --- /dev/null +++ b/web/src/sections/Settings.tsx @@ -0,0 +1,69 @@ +import { useState } from "react"; +import type { Config } from "../../../src/config/config"; +import type { Ctx } from "../App"; + +type TextKey = "youtubeHandle" | "soundcloudHandle" | "spotifyHandle"; +const HANDLES: [TextKey, string][] = [ + ["youtubeHandle", "YouTube handle"], + ["soundcloudHandle", "SoundCloud handle"], + ["spotifyHandle", "Spotify username"], +]; + +export function Settings({ s, run }: Ctx) { + const c = s.config; + const [draft, setDraft] = useState>({}); + const val = (k: K): Config[K] => + (k in draft ? draft[k] : c[k]) as Config[K]; + const dirty = Object.keys(draft).length > 0; + + return ( +
+

Settings

+
{ + e.preventDefault(); + void run({ cmd: "setConfig", patch: draft }) + .then(() => setDraft({})) + .catch(() => {}); + }} + > + {HANDLES.map(([k, label]) => ( + + ))} + + +

+ Library folder: {c.libraryDir} (change it from the terminal + app, which can move your files safely). +

+ +
+
+ ); +} diff --git a/web/src/sections/Songs.tsx b/web/src/sections/Songs.tsx new file mode 100644 index 0000000..283bd28 --- /dev/null +++ b/web/src/sections/Songs.tsx @@ -0,0 +1,141 @@ +import { useMemo, useState } from "react"; +import type { Track } from "../../../src/library/types"; +import type { Ctx } from "../App"; +import { formatDuration, matches } from "../format"; + +function SongTable({ + s, + run, + tracks, + empty, +}: Ctx & { tracks: Track[]; empty: string }) { + const playingId = s.playback.track?.id; + const ids = tracks.map((t) => t.id); + const go = (c: Parameters[0]) => void run(c).catch(() => {}); + if (tracks.length === 0) return

{empty}

; + return ( +
    + {tracks.map((t) => ( +
  • + + + + + +
  • + ))} +
+ ); +} + +export function Library(ctx: Ctx) { + const [q, setQ] = useState(""); + const tracks = useMemo( + () => + ctx.s.tracks.filter((t) => matches(q, t.title, t.artist, t.album, t.playlist)), + [ctx.s.tracks, q], + ); + return ( +
+
+

Library {ctx.s.tracks.length} songs

+ setQ(e.target.value)} + /> +
+ +
+ ); +} + +export function Playlists(ctx: Ctx) { + const [open, setOpen] = useState(null); + const groups = useMemo(() => { + const m = new Map(); + for (const t of ctx.s.tracks) { + const key = t.playlist ?? "Singles"; + const k = `${t.source}:${t.owner ?? ""}:${key}`; + (m.get(k) ?? m.set(k, []).get(k)!).push(t); + } + for (const list of m.values()) + list.sort((a, b) => (a.playlistPos ?? 1e9) - (b.playlistPos ?? 1e9)); + return [...m.entries()]; + }, [ctx.s.tracks]); + + const sel = groups.find(([k]) => k === open); + if (sel) { + return ( +
+
+

+ {" "} + {sel[1][0]?.playlist ?? "Singles"} +

+
+ +
+ ); + } + return ( +
+

Playlists

+ {groups.length === 0 ? ( +

No playlists yet.

+ ) : ( +
    + {groups.map(([k, list]) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} + +export function History(ctx: Ctx) { + const byId = new Map(ctx.s.tracks.map((t) => [t.id, t])); + const tracks = ctx.s.history.flatMap((id) => (byId.get(id) ? [byId.get(id)!] : [])); + return ( +
+

Recently played

+ +
+ ); +} diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..48f48ef --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,66 @@ +:root { + --bg: #17130f; --panel: #1f1a15; --line: #2e2721; --text: #ece4da; --dim: #9b9086; + --accent: #ff6a3d; --good: #86d6a2; --bad: #ee7d92; --warn: #f0c560; + color-scheme: dark; +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.4 system-ui, sans-serif; } +button, input, select { font: inherit; color: inherit; } +button { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 4px 10px; cursor: pointer; } +button:hover:not(:disabled) { border-color: var(--accent); } +button:disabled { opacity: .4; cursor: default; } +button.on { color: var(--accent); } +input, select { background: var(--panel); border: 1px solid var(--line); border-radius: 6px; padding: 6px 10px; } +.boot { display: grid; place-items: center; height: 100vh; color: var(--dim); } +.app { display: grid; grid-template: "side main" 1fr "now now" auto / 190px 1fr; height: 100vh; } +.sidebar { grid-area: side; background: var(--panel); border-right: 1px solid var(--line); padding: 16px 10px; display: flex; flex-direction: column; gap: 12px; } +.sidebar h1 { margin: 0 6px; font-size: 18px; color: var(--accent); } +.sidebar nav { display: flex; flex-direction: column; gap: 2px; } +.sidebar nav button { text-align: left; border: 0; background: none; padding: 8px 10px; } +.sidebar nav button.on { background: var(--bg); color: var(--accent); } +.sidebar em { float: right; font-style: normal; background: var(--accent); color: #000; border-radius: 9px; padding: 0 7px; font-size: 12px; } +.sidebar footer { margin-top: auto; color: var(--dim); font-size: 12px; padding: 0 6px; } +main { grid-area: main; overflow: auto; padding: 16px 24px; } +section > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; } +h2 { margin: 0; font-size: 18px; } h2 small { color: var(--dim); font-weight: normal; font-size: 13px; margin-left: 6px; } +.link { border: 0; background: none; color: var(--accent); padding: 0; } +.banner { background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--warn); border-radius: 6px; padding: 8px 12px; margin-bottom: 12px; } +.banner.bad { border-left-color: var(--bad); } +.empty { color: var(--dim); } +ul { list-style: none; margin: 0; padding: 0; } +.songs li, .sets li { display: flex; align-items: center; border-radius: 6px; } +.songs li:hover, .sets li:hover { background: var(--panel); } +.songs li.playing .title { color: var(--good); } +.row { flex: 1; display: grid; grid-template-columns: 20px 3fr 2fr 56px; gap: 10px; text-align: left; border: 0; background: none; padding: 7px 8px; min-width: 0; } +.sets .row { grid-template-columns: 3fr 2fr 56px; } +.row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.artist, .dur { color: var(--dim); } .dur { text-align: right; } .mark { color: var(--good); } +.actions { display: flex; gap: 4px; padding-right: 6px; visibility: hidden; } +li:hover > .actions { visibility: visible; } +.actions button { border: 0; background: none; padding: 2px 6px; } +.add { display: flex; gap: 8px; margin-bottom: 12px; } .add input { flex: 1; } +.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; } .toolbar span { flex: 1; color: var(--dim); } +.queue li { display: grid; grid-template-columns: 3fr 1fr 2fr 120px auto; gap: 10px; align-items: center; padding: 7px 8px; border-bottom: 1px solid var(--line); } +.queue .actions { visibility: visible; } +.queue li.error .state { color: var(--bad); } .queue li.done .state { color: var(--good); } +.queue .title, .queue .state { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +progress { width: 100%; accent-color: var(--accent); } +.settings { display: flex; flex-direction: column; gap: 12px; max-width: 420px; } +.settings label { display: flex; flex-direction: column; gap: 4px; color: var(--dim); } +.settings label.check { flex-direction: row; align-items: center; gap: 8px; } +.hint { color: var(--dim); font-size: 13px; } code { color: var(--warn); word-break: break-all; } +.now { grid-area: now; display: grid; grid-template-columns: 1fr 2fr 1fr; gap: 16px; align-items: center; padding: 10px 16px; background: var(--panel); border-top: 1px solid var(--line); } +.now .meta { display: flex; flex-direction: column; min-width: 0; } .now .meta span { color: var(--dim); } +.now .meta > * { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.transport { display: flex; flex-direction: column; gap: 6px; align-items: center; } +.buttons { display: flex; gap: 6px; } +.seek { display: flex; gap: 8px; width: 100%; align-items: center; color: var(--dim); } .seek input { flex: 1; padding: 0; accent-color: var(--accent); } +.volume { display: flex; gap: 8px; justify-content: flex-end; align-items: center; } .volume input { width: 110px; padding: 0; accent-color: var(--accent); } +.toast { position: fixed; right: 20px; bottom: 90px; background: var(--panel); border: 1px solid var(--good); border-radius: 8px; padding: 8px 14px; } +.toast.bad { border-color: var(--bad); } +@media (max-width: 700px) { + .app { grid-template: "side" auto "main" 1fr "now" auto / 1fr; } + .sidebar { flex-direction: row; align-items: center; padding: 8px; } .sidebar nav { flex-direction: row; overflow-x: auto; } .sidebar footer { display: none; } + .now { grid-template-columns: 1fr; } .volume { display: none; } + .queue li { grid-template-columns: 1fr auto; } +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..7ce7291 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts", "../src/gui/protocol.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..50eaac1 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// The page is served by `sndcli --gui` from dist/web, always at the server root. +export default defineConfig({ + root: import.meta.dirname, + base: "./", + plugins: [react()], + build: { outDir: "../dist/web", emptyOutDir: true }, +}); From 22b3997784caa299f36f920f88dc45c873594131 Mon Sep 17 00:00:00 2001 From: lavisor Date: Sun, 11 Oct 2026 13:20:03 +0530 Subject: [PATCH 2/2] fix: audio player fixed --- README.md | 4 +- src/core/createApp.ts | 9 ++- src/gui/protocol.ts | 2 + src/gui/server.ts | 83 +++++++++++++++++++++++++ src/index.tsx | 1 + src/player/playback.ts | 55 ++++++++++++++++- test/gui-server.test.ts | 47 +++++++++++++- test/player.test.ts | 41 +++++++++++++ web/src/NowPlaying.tsx | 132 ++++++++++++++++++++++++++++++++++++++-- web/src/connection.ts | 2 +- 10 files changed, 361 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e894932..243e7c1 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Everything runs from the keyboard, with controls that are quick to pick up. Pres sndcli --gui ``` -Opens the same library, player and download queue in your browser. Options: `--port ` to pick a port, `--no-open` to print the link without opening it. Press `ctrl-c` in the terminal to quit. +Opens the same library, player and download queue in your browser. Songs play right in the page, so mpv isn't needed and no other player opens. Options: `--port ` to pick a port, `--no-open` to print the link without opening it. Press `ctrl-c` in the terminal to quit. Running from a clone of this repo, one command builds the page and starts it: @@ -58,7 +58,7 @@ npm run gui -- --port 4000 npm run gui -- --no-open ``` -The page is served only on your own computer (`127.0.0.1`) and the link carries a one-time token, so other sites and other machines can't reach it. Run either the terminal app or the browser one at a time; both write to the same library. +The page is served only on your own computer (`127.0.0.1`) and the link carries a one-time token, so other sites and other machines can't reach it. Run either the terminal app or the browser one at a time, with a single browser tab open; both write to the same library. Changing the library folder and converting formats are still terminal-only for now. diff --git a/src/core/createApp.ts b/src/core/createApp.ts index c1ef453..ce55930 100644 --- a/src/core/createApp.ts +++ b/src/core/createApp.ts @@ -29,6 +29,8 @@ export interface CreateAppOptions { * after a short while. */ onMpvStatus?: (text: string | null, opts?: { transient?: boolean }) => void; + /** The front end plays the audio itself (the GUI page): skip mpv entirely. */ + browserAudio?: boolean; } export interface App { @@ -55,7 +57,7 @@ export interface App { } export async function createApp(opts: CreateAppOptions = {}): Promise { - const { initialAdd, onStatus, onMpvStatus } = opts; + const { initialAdd, onStatus, onMpvStatus, browserAudio } = opts; const config = await loadConfig(); // A link passed on the command line means the user has self-onboarded: @@ -72,7 +74,8 @@ export async function createApp(opts: CreateAppOptions = {}): Promise { // Library is now the source of truth, so drop the legacy yt-dlp archive. void fs.rm(legacyArchiveFile, { force: true }).catch(() => {}); const binaries = await ensureBinaries(onStatus); - const playback = new Playback(binaries.mpv); + const playback = new Playback(browserAudio ? null : binaries.mpv); + if (browserAudio) playback.enableBrowser(); // Recently played: record every track the player actually starts, // including auto-advance and next/prev, not just explicit picks. @@ -127,7 +130,7 @@ export async function createApp(opts: CreateAppOptions = {}): Promise { // Auto-install mpv in the background so the rich player "just works". // Retried on every launch while it's missing: a one-time flag here once // left playback silently broken forever after a single failed attempt. - if (!binaries.mpv) { + if (!binaries.mpv && !browserAudio) { const failed = (): void => { // One calm line, then quiet again: the NowPlayingBar hint stays the // durable nudge while songs keep opening in the OS default app. diff --git a/src/gui/protocol.ts b/src/gui/protocol.ts index 22582b7..b2eb1e8 100644 --- a/src/gui/protocol.ts +++ b/src/gui/protocol.ts @@ -31,6 +31,8 @@ export type ServerEvent = export type ClientCommand = { id: number } & ( | { cmd: "play"; trackId: string; listIds?: string[] } | { cmd: "togglePause" } + | { cmd: "report"; trackId: string; position: number; duration: number } + | { cmd: "ended"; trackId: string } | { cmd: "seek"; seconds: number } | { cmd: "setVolume"; volume: number } | { cmd: "next" } diff --git a/src/gui/server.ts b/src/gui/server.ts index 3b6d0df..5b078f2 100644 --- a/src/gui/server.ts +++ b/src/gui/server.ts @@ -57,6 +57,18 @@ const MIME: Record = { ".woff2": "font/woff2", }; +const AUDIO_MIME: Record = { + ".mp3": "audio/mpeg", + ".m4a": "audio/mp4", + ".mp4": "audio/mp4", + ".aac": "audio/aac", + ".opus": "audio/ogg; codecs=opus", + ".ogg": "audio/ogg", + ".webm": "audio/webm", + ".flac": "audio/flac", + ".wav": "audio/wav", +}; + export function tokenMatches(given: string | null, token: string): boolean { if (!given) return false; const a = Buffer.from(given); @@ -158,6 +170,10 @@ export async function startGuiServer( } case "togglePause": return pb.togglePause(); + case "report": + return pb.reportBrowser(c.trackId, Number(c.position), Number(c.duration)); + case "ended": + return pb.browserEnded(c.trackId); case "seek": return pb.seek(c.seconds); case "setVolume": @@ -254,12 +270,79 @@ export async function startGuiServer( } const root = path.resolve(webRoot); + + // Stream a library file to the page's