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
+## 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