diff --git a/README.md b/README.md index f9a80a8..243e7c1 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. 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: + +``` +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, 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. + ## 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..ce55930 --- /dev/null +++ b/src/core/createApp.ts @@ -0,0 +1,227 @@ +// 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; + /** The front end plays the audio itself (the GUI page): skip mpv entirely. */ + browserAudio?: boolean; +} + +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, browserAudio } = 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(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. + 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 && !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. + 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..b2eb1e8 --- /dev/null +++ b/src/gui/protocol.ts @@ -0,0 +1,56 @@ +// 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: "report"; trackId: string; position: number; duration: number } + | { cmd: "ended"; trackId: string } + | { 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..5b078f2 --- /dev/null +++ b/src/gui/server.ts @@ -0,0 +1,428 @@ +// 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", +}; + +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); + 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 "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": + 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); + + // Stream a library file to the page's