From 76aa70353908b20bbdced8f311084b6eca2f77f6 Mon Sep 17 00:00:00 2001 From: James Ding Date: Mon, 1 Jun 2026 14:04:23 -0700 Subject: [PATCH 01/21] feat(ui): scaffold shadcn-svelte foundation for player refresh (Phase 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initialize shadcn-svelte in ui/ as the foundation for adopting sv11-ui components in the JCEF player. No player behavior changes: App.svelte is untouched and the new primitives are unused — this only lays groundwork. - Add $lib path alias to tsconfig.json, tsconfig.app.json and vite.config.ts (the Vite project had no $lib; the shadcn-svelte CLI requires it) - shadcn-svelte init: components.json (Nova preset, Lucide + Geist to match sv11-ui's stack), cn util, theme tokens - Reconcile theme with jetplay's IDE-driven theming: shadcn's dark palette is the :root default (the player opens dark like the IDE) and the light palette lives under @media(prefers-color-scheme: light); jetplay uses no .dark class. Define --radius in the dark default so rounding isn't undefined in dark mode. - Pull button + dropdown-menu primitives (default shadcn registry) - Ignore the whole generated src/main/resources/player/ dir (vite outDir with emptyOutDir); previously only index.html was ignored, leaking ~4.7MB of rebuilt demo assets into git status Deferred to Phase 1: the sv11 audio-player composite — its registry items land at a doubled path (target-path bug in sv11 registry.json) and it still needs waveform-sampler plus local-media wiring. Verified: ./gradlew test green (48/48); npm run build green (single-file, 203kB / 108kB gzip). npm run check shows only 2 pre-existing App.svelte $state errors that also fail on clean main (not introduced here). --- .gitignore | 3 +- ui/components.json | 20 ++ ui/package-lock.json | 269 +++++++++++++++++- ui/package.json | 10 +- ui/src/app.css | 122 ++++++++ ui/src/lib/components/ui/button/button.svelte | 82 ++++++ ui/src/lib/components/ui/button/index.ts | 17 ++ .../dropdown-menu-checkbox-group.svelte | 16 ++ .../dropdown-menu-checkbox-item.svelte | 44 +++ .../dropdown-menu-content.svelte | 31 ++ .../dropdown-menu-group-heading.svelte | 22 ++ .../dropdown-menu/dropdown-menu-group.svelte | 7 + .../dropdown-menu/dropdown-menu-item.svelte | 27 ++ .../dropdown-menu/dropdown-menu-label.svelte | 24 ++ .../dropdown-menu/dropdown-menu-portal.svelte | 7 + .../dropdown-menu-radio-group.svelte | 16 ++ .../dropdown-menu-radio-item.svelte | 34 +++ .../dropdown-menu-separator.svelte | 17 ++ .../dropdown-menu-shortcut.svelte | 20 ++ .../dropdown-menu-sub-content.svelte | 17 ++ .../dropdown-menu-sub-trigger.svelte | 29 ++ .../ui/dropdown-menu/dropdown-menu-sub.svelte | 7 + .../dropdown-menu-trigger.svelte | 7 + .../ui/dropdown-menu/dropdown-menu.svelte | 7 + .../lib/components/ui/dropdown-menu/index.ts | 54 ++++ ui/src/lib/utils.ts | 13 + ui/tsconfig.app.json | 7 +- ui/tsconfig.json | 9 +- ui/vite.config.ts | 6 + 29 files changed, 934 insertions(+), 10 deletions(-) create mode 100644 ui/components.json create mode 100644 ui/src/lib/components/ui/button/button.svelte create mode 100644 ui/src/lib/components/ui/button/index.ts create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte create mode 100644 ui/src/lib/components/ui/dropdown-menu/index.ts create mode 100644 ui/src/lib/utils.ts diff --git a/.gitignore b/.gitignore index dc094190..38cb9b1b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ .kotlin .qodana build -src/main/resources/player/index.html +# Generated by `npm run build` in ui/ (vite outDir, emptyOutDir wipes it each build) +src/main/resources/player/ diff --git a/ui/components.json b/ui/components.json new file mode 100644 index 00000000..b6112051 --- /dev/null +++ b/ui/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://shadcn-svelte.com/schema.json", + "tailwind": { + "css": "src/app.css", + "baseColor": "neutral" + }, + "aliases": { + "components": "$lib/components", + "utils": "$lib/utils", + "ui": "$lib/components/ui", + "hooks": "$lib/hooks", + "lib": "$lib" + }, + "typescript": true, + "registry": "https://shadcn-svelte.com/registry", + "style": "nova", + "iconLibrary": "lucide", + "menuColor": "default", + "menuAccent": "subtle" +} diff --git a/ui/package-lock.json b/ui/package-lock.json index bfee8831..1a6014c2 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -8,15 +8,23 @@ "name": "ui", "version": "0.0.0", "devDependencies": { - "@lucide/svelte": "^1.3.0", + "@fontsource-variable/geist": "^5.2.9", + "@internationalized/date": "^3.12.2", + "@lucide/svelte": "^1.17.0", "@playwright/test": "^1.59.1", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/vite": "^4.2.2", "@tsconfig/svelte": "^5.0.8", "@types/node": "^24.12.0", + "bits-ui": "^2.18.1", + "clsx": "^2.1.1", + "shadcn-svelte": "^1.3.0", "svelte": "^5.53.12", "svelte-check": "^4.4.5", + "tailwind-merge": "^3.6.0", + "tailwind-variants": "^3.2.2", "tailwindcss": "^4.2.2", + "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", "vite": "^8.0.5", "vite-plugin-singlefile": "^2.3.2" @@ -56,6 +64,54 @@ "tslib": "^2.4.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@fontsource-variable/geist": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.2.9.tgz", + "integrity": "sha512-TP+QSBG3wxKGPE33CbMy/L0Nu3qvJ6Fy81Yc4LnQ95xH+i+cfEp8fyU8/kfV14YwszxIFPhnoMTbjL71waVpyQ==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@internationalized/date": { + "version": "3.12.2", + "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz", + "integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/helpers": "^0.5.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -107,9 +163,9 @@ } }, "node_modules/@lucide/svelte": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.3.0.tgz", - "integrity": "sha512-ZRjfWKklbD9Sjhnb6d7v8A3zSGwVHNTM8kOiObSUIiF4B2rU4zskWV7ysp5tY0wOPl7ttj2bXGQvAd6qOCVhIA==", + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.17.0.tgz", + "integrity": "sha512-q06YCFBN5CO8cd1ADmLCxWRVMVb7xxvHzqC0lvNoxGa+FLW6Cd1Y1AOxgbQk4Iwe68vkAMCRveNHint4WoaVKg==", "dev": true, "license": "ISC", "peerDependencies": { @@ -828,6 +884,16 @@ "vite": "^8.0.0-beta.7 || ^8.0.0" } }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@tailwindcss/node": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", @@ -1189,6 +1255,31 @@ "node": ">= 0.4" } }, + "node_modules/bits-ui": { + "version": "2.18.1", + "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz", + "integrity": "sha512-KkemzKFH4T3gt3H+P86JcnAWExjByv/6vlwjm/BoCwTPHu03yiCdxbghdJLvFReQTe0acCAiRcKfmixxD6XvlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.1", + "@floating-ui/dom": "^1.7.1", + "esm-env": "^1.1.2", + "runed": "^0.35.1", + "svelte-toolbelt": "^0.10.6", + "tabbable": "^6.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/huntabyte" + }, + "peerDependencies": { + "@internationalized/date": "^3.8.1", + "svelte": "^5.33.0" + } + }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -1228,6 +1319,16 @@ "node": ">=6" } }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -1238,6 +1339,16 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1340,6 +1451,13 @@ "dev": true, "license": "ISC" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -1638,6 +1756,16 @@ "dev": true, "license": "MIT" }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1704,6 +1832,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "dev": true, + "license": "MIT" + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -1905,6 +2040,31 @@ "fsevents": "~2.3.2" } }, + "node_modules/runed": { + "version": "0.35.1", + "resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz", + "integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte", + "https://github.com/sponsors/tglide" + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "esm-env": "^1.0.0", + "lz-string": "^1.5.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.21.0", + "svelte": "^5.7.0" + }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -1918,6 +2078,25 @@ "node": ">=6" } }, + "node_modules/shadcn-svelte": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/shadcn-svelte/-/shadcn-svelte-1.3.0.tgz", + "integrity": "sha512-Pd4ICWTkTks/b2YU4c9vF2XsX1x5HFPRl5bKszS1LcnWS83x+7T4WiIvbWz8Qh9knkcGZ+SCz1+Dmhdq+AYooA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.0", + "node-fetch-native": "^1.6.4", + "postcss": "^8.5.5", + "tailwind-merge": "^3.0.0" + }, + "bin": { + "shadcn-svelte": "dist/index.mjs" + }, + "peerDependencies": { + "svelte": "^5.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1928,6 +2107,16 @@ "node": ">=0.10.0" } }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/svelte": { "version": "5.55.1", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz", @@ -1980,6 +2169,65 @@ "typescript": ">=5.0.0" } }, + "node_modules/svelte-toolbelt": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz", + "integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/huntabyte" + ], + "dependencies": { + "clsx": "^2.1.1", + "runed": "^0.35.1", + "style-to-object": "^1.0.8" + }, + "engines": { + "node": ">=18", + "pnpm": ">=8.7.0" + }, + "peerDependencies": { + "svelte": "^5.30.2" + } + }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwind-variants": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/tailwind-variants/-/tailwind-variants-3.2.2.tgz", + "integrity": "sha512-Mi4kHeMTLvKlM98XPnK+7HoBPmf4gygdFmqQPaDivc3DpYS6aIY6KiG/PgThrGvii5YZJqRsPz0aPyhoFzmZgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.x", + "pnpm": ">=7.x" + }, + "peerDependencies": { + "tailwind-merge": ">=3.0.0", + "tailwindcss": "*" + }, + "peerDependenciesMeta": { + "tailwind-merge": { + "optional": true + } + } + }, "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -2036,8 +2284,17 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } }, "node_modules/typescript": { "version": "5.9.3", diff --git a/ui/package.json b/ui/package.json index 91b02ae6..af457b22 100644 --- a/ui/package.json +++ b/ui/package.json @@ -12,15 +12,23 @@ "test:ui": "playwright test --ui" }, "devDependencies": { - "@lucide/svelte": "^1.3.0", + "@fontsource-variable/geist": "^5.2.9", + "@internationalized/date": "^3.12.2", + "@lucide/svelte": "^1.17.0", "@playwright/test": "^1.59.1", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/vite": "^4.2.2", "@tsconfig/svelte": "^5.0.8", "@types/node": "^24.12.0", + "bits-ui": "^2.18.1", + "clsx": "^2.1.1", + "shadcn-svelte": "^1.3.0", "svelte": "^5.53.12", "svelte-check": "^4.4.5", + "tailwind-merge": "^3.6.0", + "tailwind-variants": "^3.2.2", "tailwindcss": "^4.2.2", + "tw-animate-css": "^1.4.0", "typescript": "~5.9.3", "vite": "^8.0.5", "vite-plugin-singlefile": "^2.3.2" diff --git a/ui/src/app.css b/ui/src/app.css index 006c37f0..067e578a 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -1,4 +1,9 @@ @import "tailwindcss"; +@import "tw-animate-css"; +@import "shadcn-svelte/tailwind.css"; +@import "@fontsource-variable/geist"; + +@custom-variant dark (&:is(.dark *)); @theme { --color-surface: #2b2b2b; @@ -8,6 +13,41 @@ --color-accent: #4a88c7; --color-error: #e05555; --color-border: #404040; + --font-sans: 'Geist Variable', sans-serif; + --color-sidebar-ring: var(--sidebar-ring); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar: var(--sidebar); + --color-chart-5: var(--chart-5); + --color-chart-4: var(--chart-4); + --color-chart-3: var(--chart-3); + --color-chart-2: var(--chart-2); + --color-chart-1: var(--chart-1); + --color-ring: var(--ring); + --color-input: var(--input); + --color-destructive: var(--destructive); + --color-accent-foreground: var(--accent-foreground); + --color-muted-foreground: var(--muted-foreground); + --color-secondary-foreground: var(--secondary-foreground); + --color-secondary: var(--secondary); + --color-primary-foreground: var(--primary-foreground); + --color-popover-foreground: var(--popover-foreground); + --color-popover: var(--popover); + --color-card-foreground: var(--card-foreground); + --color-card: var(--card); + --color-foreground: var(--foreground); + --color-background: var(--background); + --radius-sm: calc(var(--radius) * 0.6); + --radius-md: calc(var(--radius) * 0.8); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) * 1.4); + --radius-2xl: calc(var(--radius) * 1.8); + --radius-3xl: calc(var(--radius) * 2.2); + --radius-4xl: calc(var(--radius) * 2.6); } @layer base { @@ -15,6 +55,7 @@ margin: 0; padding: 0; box-sizing: border-box; + @apply border-border outline-ring/50; } html, @@ -44,6 +85,87 @@ --color-accent: #2675bf; --color-error: #c44040; --color-border: #d0d0d0; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); } } + body { + @apply bg-background text-foreground; + } + html { + @apply font-sans; + } +} + +/* + * jetplay tracks the IDE/OS theme via `prefers-color-scheme`, not a `.dark` + * class. So shadcn's dark palette is the `:root` default (the player opens + * dark by default, like the IDE), and the light palette overrides it in the + * `prefers-color-scheme: light` block above. This mirrors how the jetplay + * `--color-*` tokens are themed. + */ +:root { + /* radius is theme-invariant — define once here so the dark default has it + (it must not live only in the light @media block, or dark rounding breaks) */ + --radius: 0.625rem; + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --chart-1: oklch(0.87 0 0); + --chart-2: oklch(0.556 0 0); + --chart-3: oklch(0.439 0 0); + --chart-4: oklch(0.371 0 0); + --chart-5: oklch(0.269 0 0); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(1 0 0 / 10%); + --sidebar-ring: oklch(0.556 0 0); } diff --git a/ui/src/lib/components/ui/button/button.svelte b/ui/src/lib/components/ui/button/button.svelte new file mode 100644 index 00000000..89cedcb7 --- /dev/null +++ b/ui/src/lib/components/ui/button/button.svelte @@ -0,0 +1,82 @@ + + + + +{#if href} + + {@render children?.()} + +{:else} + +{/if} diff --git a/ui/src/lib/components/ui/button/index.ts b/ui/src/lib/components/ui/button/index.ts new file mode 100644 index 00000000..fb585d76 --- /dev/null +++ b/ui/src/lib/components/ui/button/index.ts @@ -0,0 +1,17 @@ +import Root, { + type ButtonProps, + type ButtonSize, + type ButtonVariant, + buttonVariants, +} from "./button.svelte"; + +export { + Root, + type ButtonProps as Props, + // + Root as Button, + buttonVariants, + type ButtonProps, + type ButtonSize, + type ButtonVariant, +}; diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte new file mode 100644 index 00000000..e0e19718 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte new file mode 100644 index 00000000..a81b48d0 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte @@ -0,0 +1,44 @@ + + + + {#snippet children({ checked, indeterminate })} + + {#if indeterminate} + + {:else if checked} + + {/if} + + {@render childrenProp?.()} + {/snippet} + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte new file mode 100644 index 00000000..261a5b08 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-content.svelte @@ -0,0 +1,31 @@ + + + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte new file mode 100644 index 00000000..433540fd --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte @@ -0,0 +1,22 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte new file mode 100644 index 00000000..aca1f7bd --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte new file mode 100644 index 00000000..c425190d --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-item.svelte @@ -0,0 +1,27 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte new file mode 100644 index 00000000..e0c534fe --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-label.svelte @@ -0,0 +1,24 @@ + + +
+ {@render children?.()} +
diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte new file mode 100644 index 00000000..274cfef7 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte new file mode 100644 index 00000000..189aef40 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte @@ -0,0 +1,16 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte new file mode 100644 index 00000000..c8aa07b1 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte @@ -0,0 +1,34 @@ + + + + {#snippet children({ checked })} + + {#if checked} + + {/if} + + {@render childrenProp?.({ checked })} + {/snippet} + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte new file mode 100644 index 00000000..90f1b6f1 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-separator.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte new file mode 100644 index 00000000..ed7cc85a --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte @@ -0,0 +1,20 @@ + + + + {@render children?.()} + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte new file mode 100644 index 00000000..b5750d4d --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte new file mode 100644 index 00000000..fab0275d --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte new file mode 100644 index 00000000..f0445813 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-sub.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte new file mode 100644 index 00000000..cb053444 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu-trigger.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte new file mode 100644 index 00000000..cb4bc621 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/dropdown-menu.svelte @@ -0,0 +1,7 @@ + + + diff --git a/ui/src/lib/components/ui/dropdown-menu/index.ts b/ui/src/lib/components/ui/dropdown-menu/index.ts new file mode 100644 index 00000000..7850c6a3 --- /dev/null +++ b/ui/src/lib/components/ui/dropdown-menu/index.ts @@ -0,0 +1,54 @@ +import Root from "./dropdown-menu.svelte"; +import Sub from "./dropdown-menu-sub.svelte"; +import CheckboxGroup from "./dropdown-menu-checkbox-group.svelte"; +import CheckboxItem from "./dropdown-menu-checkbox-item.svelte"; +import Content from "./dropdown-menu-content.svelte"; +import Group from "./dropdown-menu-group.svelte"; +import Item from "./dropdown-menu-item.svelte"; +import Label from "./dropdown-menu-label.svelte"; +import RadioGroup from "./dropdown-menu-radio-group.svelte"; +import RadioItem from "./dropdown-menu-radio-item.svelte"; +import Separator from "./dropdown-menu-separator.svelte"; +import Shortcut from "./dropdown-menu-shortcut.svelte"; +import Trigger from "./dropdown-menu-trigger.svelte"; +import SubContent from "./dropdown-menu-sub-content.svelte"; +import SubTrigger from "./dropdown-menu-sub-trigger.svelte"; +import GroupHeading from "./dropdown-menu-group-heading.svelte"; +import Portal from "./dropdown-menu-portal.svelte"; + +export { + CheckboxGroup, + CheckboxItem, + Content, + Portal, + Root as DropdownMenu, + CheckboxGroup as DropdownMenuCheckboxGroup, + CheckboxItem as DropdownMenuCheckboxItem, + Content as DropdownMenuContent, + Portal as DropdownMenuPortal, + Group as DropdownMenuGroup, + Item as DropdownMenuItem, + Label as DropdownMenuLabel, + RadioGroup as DropdownMenuRadioGroup, + RadioItem as DropdownMenuRadioItem, + Separator as DropdownMenuSeparator, + Shortcut as DropdownMenuShortcut, + Sub as DropdownMenuSub, + SubContent as DropdownMenuSubContent, + SubTrigger as DropdownMenuSubTrigger, + Trigger as DropdownMenuTrigger, + GroupHeading as DropdownMenuGroupHeading, + Group, + GroupHeading, + Item, + Label, + RadioGroup, + RadioItem, + Root, + Separator, + Shortcut, + Sub, + SubContent, + SubTrigger, + Trigger, +}; diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts new file mode 100644 index 00000000..55b3a918 --- /dev/null +++ b/ui/src/lib/utils.ts @@ -0,0 +1,13 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChild = T extends { child?: any } ? Omit : T; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type WithoutChildren = T extends { children?: any } ? Omit : T; +export type WithoutChildrenOrChild = WithoutChildren>; +export type WithElementRef = T & { ref?: U | null }; diff --git a/ui/tsconfig.app.json b/ui/tsconfig.app.json index acfd2e5b..1e291ea6 100644 --- a/ui/tsconfig.app.json +++ b/ui/tsconfig.app.json @@ -15,7 +15,12 @@ */ "allowJs": true, "checkJs": true, - "moduleDetection": "force" + "moduleDetection": "force", + "baseUrl": ".", + "paths": { + "$lib": ["./src/lib"], + "$lib/*": ["./src/lib/*"] + } }, "include": ["src/**/*.ts", "src/**/*.js", "src/**/*.svelte"] } diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 1ffef600..c2baf424 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -3,5 +3,12 @@ "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" } - ] + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "$lib": ["./src/lib"], + "$lib/*": ["./src/lib/*"] + } + } } diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 51f7c7a3..027c2f3b 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,10 +1,16 @@ import { defineConfig } from 'vite' +import { fileURLToPath } from 'node:url' import tailwindcss from '@tailwindcss/vite' import { svelte } from '@sveltejs/vite-plugin-svelte' import { viteSingleFile } from 'vite-plugin-singlefile' export default defineConfig({ plugins: [tailwindcss(), svelte(), viteSingleFile()], + resolve: { + alias: { + $lib: fileURLToPath(new URL('./src/lib', import.meta.url)), + }, + }, build: { outDir: '../src/main/resources/player', emptyOutDir: true, From 0304accdfdd2bd545e2e711a614446910f72d729 Mon Sep 17 00:00:00 2001 From: James Ding Date: Sat, 6 Jun 2026 02:33:16 -0700 Subject: [PATCH 02/21] feat(ui): adopt sv11 waveform audio player (Phase 1) Replace the hand-rolled audio player with sv11 components: pull the audio-player + waveform registry items and adapt speaker-01's scratchable waveform into a single-file player driven by the IDE bridge. - AudioPlayerBody: sv11 AudioPlayer.Root/Button/Time/Progress/Duration plus a scrolling waveform via speaker-01's use-scratchable-waveform hook (drag to scrub, momentum, keyboard seek). The one opened file is fed as the track. - Hook adapted: playhead pinned at the LEFT edge so the waveform fills from the start; short clips stretch to fit, longer ones scroll. - Waveform decodes at runtime (precomputeWaveform), length-capped. It only mounts once bars exist and slides in, so the resting/loading layout stays compact (no empty reserved box, title not floating above dead space). - Volume kept via jetplay's VolumeControl (sv11 ships no standalone one); skip +/-10s and keyboard retained. - Dropped speaker-01's orbs (three.js weight) and playlist (single-file). - Fix the inert `dark:` variant in app.css so sv11 components theme under prefers-color-scheme (jetplay has no .dark class). App.svelte unchanged. ./gradlew test green (48); npm run build single-file 295 kB / 137 kB gzip; 27 Playwright tests green. --- ui/src/app.css | 6 +- ui/src/lib/AudioPlayer.svelte | 134 +---- ui/src/lib/AudioPlayerBody.svelte | 249 ++++++++ .../ui/audio-player/audio-graph.svelte.ts | 58 ++ .../audio-player/audio-player-button.svelte | 72 +++ .../audio-player/audio-player-duration.svelte | 26 + .../audio-player/audio-player-progress.svelte | 101 ++++ .../audio-player-speed-button-group.svelte | 33 ++ .../ui/audio-player/audio-player-speed.svelte | 57 ++ .../ui/audio-player/audio-player-time.svelte | 18 + .../ui/audio-player/audio-player.svelte | 68 +++ .../ui/audio-player/context.svelte.ts | 123 ++++ .../ui/audio-player/example-tracks.ts | 29 + .../lib/components/ui/audio-player/index.ts | 32 + .../lib/components/ui/audio-player/utils.ts | 10 + .../ui/audio-player/waveform-sampler.ts | 64 ++ ui/src/lib/components/ui/waveform/index.ts | 33 ++ ui/src/lib/components/ui/waveform/utils.ts | 15 + .../waveform/waveform-live-microphone.svelte | 549 ++++++++++++++++++ .../ui/waveform/waveform-microphone.svelte | 205 +++++++ .../ui/waveform/waveform-recording.svelte | 310 ++++++++++ .../ui/waveform/waveform-scrolling.svelte | 207 +++++++ .../ui/waveform/waveform-scrubber.svelte | 121 ++++ .../ui/waveform/waveform-static.svelte | 15 + .../components/ui/waveform/waveform.svelte | 197 +++++++ ui/src/lib/use-scratchable-waveform.svelte.ts | 234 ++++++++ ui/tests/audio-player.spec.ts | 6 +- 27 files changed, 2845 insertions(+), 127 deletions(-) create mode 100644 ui/src/lib/AudioPlayerBody.svelte create mode 100644 ui/src/lib/components/ui/audio-player/audio-graph.svelte.ts create mode 100644 ui/src/lib/components/ui/audio-player/audio-player-button.svelte create mode 100644 ui/src/lib/components/ui/audio-player/audio-player-duration.svelte create mode 100644 ui/src/lib/components/ui/audio-player/audio-player-progress.svelte create mode 100644 ui/src/lib/components/ui/audio-player/audio-player-speed-button-group.svelte create mode 100644 ui/src/lib/components/ui/audio-player/audio-player-speed.svelte create mode 100644 ui/src/lib/components/ui/audio-player/audio-player-time.svelte create mode 100644 ui/src/lib/components/ui/audio-player/audio-player.svelte create mode 100644 ui/src/lib/components/ui/audio-player/context.svelte.ts create mode 100644 ui/src/lib/components/ui/audio-player/example-tracks.ts create mode 100644 ui/src/lib/components/ui/audio-player/index.ts create mode 100644 ui/src/lib/components/ui/audio-player/utils.ts create mode 100644 ui/src/lib/components/ui/audio-player/waveform-sampler.ts create mode 100644 ui/src/lib/components/ui/waveform/index.ts create mode 100644 ui/src/lib/components/ui/waveform/utils.ts create mode 100644 ui/src/lib/components/ui/waveform/waveform-live-microphone.svelte create mode 100644 ui/src/lib/components/ui/waveform/waveform-microphone.svelte create mode 100644 ui/src/lib/components/ui/waveform/waveform-recording.svelte create mode 100644 ui/src/lib/components/ui/waveform/waveform-scrolling.svelte create mode 100644 ui/src/lib/components/ui/waveform/waveform-scrubber.svelte create mode 100644 ui/src/lib/components/ui/waveform/waveform-static.svelte create mode 100644 ui/src/lib/components/ui/waveform/waveform.svelte create mode 100644 ui/src/lib/use-scratchable-waveform.svelte.ts diff --git a/ui/src/app.css b/ui/src/app.css index 067e578a..f4f99c31 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -3,7 +3,11 @@ @import "shadcn-svelte/tailwind.css"; @import "@fontsource-variable/geist"; -@custom-variant dark (&:is(.dark *)); +/* jetplay has no `.dark` class — it tracks the IDE/OS theme via + prefers-color-scheme, with the dark palette as the :root default (see below). + So `dark:` utilities in sv11 components must be active whenever we're NOT in + light mode (i.e. dark preference OR no preference), matching the token setup. */ +@custom-variant dark (@media not (prefers-color-scheme: light)); @theme { --color-surface: #2b2b2b; diff --git a/ui/src/lib/AudioPlayer.svelte b/ui/src/lib/AudioPlayer.svelte index 257b42eb..2ef5bcfd 100644 --- a/ui/src/lib/AudioPlayer.svelte +++ b/ui/src/lib/AudioPlayer.svelte @@ -1,128 +1,16 @@ - -
- - - -
- -
- - -
- - {fileName} - - {#if extension} - - {extension} - - {/if} -
- - -
- (audioEl.currentTime = t)} - /> -
- - -
- - - -
- - - - - -
\ No newline at end of file + + + + diff --git a/ui/src/lib/AudioPlayerBody.svelte b/ui/src/lib/AudioPlayerBody.svelte new file mode 100644 index 00000000..1500a68f --- /dev/null +++ b/ui/src/lib/AudioPlayerBody.svelte @@ -0,0 +1,249 @@ + + + +
+ +
+ + {fileName} + + {#if extension} + + {extension} + + {/if} +
+ + + {#if hasWaveform} +
+ +
+
+
+ +
+ +
+
+
+
+ {/if} + + +
+ + + +
+ + +
+ + + +
+ + +
+ +
+ + +
diff --git a/ui/src/lib/components/ui/audio-player/audio-graph.svelte.ts b/ui/src/lib/components/ui/audio-player/audio-graph.svelte.ts new file mode 100644 index 00000000..dbc1277c --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/audio-graph.svelte.ts @@ -0,0 +1,58 @@ +/** + * Lazy shared Web Audio graph for an audio element. Splits context creation + * from analyser wiring so non-analyser callers (e.g. a scratch synth) can warm + * the context from a user gesture without forcing a `createMediaElementSource` + * call — only one is legal per element lifetime. + */ +export class AudioGraph { + audioContext = $state(null); + analyser = $state(null); + #source: MediaElementAudioSourceNode | null = null; + + ensureContext(): AudioContext | null { + if (this.audioContext) return this.audioContext; + try { + const AC = + window.AudioContext || + (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext; + this.audioContext = new AC(); + } catch (err) { + console.error("AudioContext creation failed", err); + return null; + } + return this.audioContext; + } + + ensureAnalyser(audioEl: HTMLAudioElement): AnalyserNode | null { + if (this.analyser) return this.analyser; + const ctx = this.ensureContext(); + if (!ctx) return null; + if (ctx.state === "suspended") void ctx.resume().catch(() => {}); + try { + this.#source = ctx.createMediaElementSource(audioEl); + const a = ctx.createAnalyser(); + a.fftSize = 512; + a.smoothingTimeConstant = 0.7; + this.#source.connect(a); + a.connect(ctx.destination); + this.analyser = a; + return a; + } catch (err) { + console.error("analyser wire failed", err); + return null; + } + } + + destroy(): void { + try { + this.#source?.disconnect(); + this.analyser?.disconnect(); + } catch { + // nodes may already be gone + } + void this.audioContext?.close().catch(() => {}); + this.audioContext = null; + this.analyser = null; + this.#source = null; + } +} diff --git a/ui/src/lib/components/ui/audio-player/audio-player-button.svelte b/ui/src/lib/components/ui/audio-player/audio-player-button.svelte new file mode 100644 index 00000000..510afc2d --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/audio-player-button.svelte @@ -0,0 +1,72 @@ + + + diff --git a/ui/src/lib/components/ui/audio-player/audio-player-duration.svelte b/ui/src/lib/components/ui/audio-player/audio-player-duration.svelte new file mode 100644 index 00000000..21deda18 --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/audio-player-duration.svelte @@ -0,0 +1,26 @@ + + + + {display} + diff --git a/ui/src/lib/components/ui/audio-player/audio-player-progress.svelte b/ui/src/lib/components/ui/audio-player/audio-player-progress.svelte new file mode 100644 index 00000000..c8adf0d2 --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/audio-player-progress.svelte @@ -0,0 +1,101 @@ + + + { + if (!userInteracting) return; + player.seek(v); + externalOnValueChange?.(v); + }} + onpointerdown={(e) => { + userInteracting = true; + wasPlaying = player.isPlaying; + void player.pause(); + externalPointerDown?.(e); + }} + onpointerup={(e) => { + userInteracting = false; + if (wasPlaying) void player.play(); + externalPointerUp?.(e); + }} + onkeydown={(e) => { + if (e.key === " ") { + e.preventDefault(); + if (player.isPlaying) void player.pause(); + else void player.play(); + } else { + userInteracting = true; + queueMicrotask(() => { + userInteracting = false; + }); + } + externalKeyDown?.(e); + }} + class={cn( + "group/player relative flex h-4 touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:min-h-44 data-vertical:w-auto data-vertical:flex-col", + className + )} + {...restProps} +> + {#snippet children({ thumbItems })} + + + + {#each thumbItems as thumb (thumb.index)} + + {/each} + {/snippet} + diff --git a/ui/src/lib/components/ui/audio-player/audio-player-speed-button-group.svelte b/ui/src/lib/components/ui/audio-player/audio-player-speed-button-group.svelte new file mode 100644 index 00000000..18893895 --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/audio-player-speed-button-group.svelte @@ -0,0 +1,33 @@ + + +
+ {#each speeds as speed (speed)} + + {/each} +
diff --git a/ui/src/lib/components/ui/audio-player/audio-player-speed.svelte b/ui/src/lib/components/ui/audio-player/audio-player-speed.svelte new file mode 100644 index 00000000..fc6a9bba --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/audio-player-speed.svelte @@ -0,0 +1,57 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + {#each speeds as speed (speed)} + player.setPlaybackRate(speed)} + class="flex items-center justify-between" + > + + {speed === 1 ? "Normal" : `${speed}x`} + + {#if player.playbackRate === speed} + + {/if} + + {/each} + + diff --git a/ui/src/lib/components/ui/audio-player/audio-player-time.svelte b/ui/src/lib/components/ui/audio-player/audio-player-time.svelte new file mode 100644 index 00000000..0db7c397 --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/audio-player-time.svelte @@ -0,0 +1,18 @@ + + + + {formatTime(player.time)} + diff --git a/ui/src/lib/components/ui/audio-player/audio-player.svelte b/ui/src/lib/components/ui/audio-player/audio-player.svelte new file mode 100644 index 00000000..4fbe0fae --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/audio-player.svelte @@ -0,0 +1,68 @@ + + + + + +{@render children?.()} diff --git a/ui/src/lib/components/ui/audio-player/context.svelte.ts b/ui/src/lib/components/ui/audio-player/context.svelte.ts new file mode 100644 index 00000000..f7ca0ba2 --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/context.svelte.ts @@ -0,0 +1,123 @@ +import { getContext, setContext } from "svelte"; + +const AUDIO_PLAYER_CONTEXT_KEY = Symbol("sv11-audio-player"); + +export interface AudioPlayerItem { + id: string | number; + src: string; + data?: TData; +} + +export class AudioPlayerState { + audio: HTMLAudioElement | null = $state(null); + activeItem: AudioPlayerItem | null = $state(null); + time = $state(0); + duration = $state(undefined); + paused = $state(true); + playbackRate = $state(1); + readyState = $state(0); + networkState = $state(0); + error: MediaError | null = $state(null); + + isBuffering = $derived(this.readyState < 3 && this.networkState === 2); + isPlaying = $derived(!this.paused); + + #playPromise: Promise | null = null; + + isItemActive = (id: string | number | null): boolean => { + if (id === null) return this.activeItem === null; + return this.activeItem?.id === id; + }; + + #swapTrack = (item: AudioPlayerItem | null): void => { + const audio = this.audio; + if (!audio) return; + this.activeItem = item; + const currentRate = audio.playbackRate; + if (!audio.paused) audio.pause(); + audio.currentTime = 0; + if (item === null) { + audio.removeAttribute("src"); + } else { + audio.src = item.src; + } + audio.load(); + audio.playbackRate = currentRate; + }; + + setActiveItem = async (item: AudioPlayerItem | null): Promise => { + if (!this.audio) return; + if ((item?.id ?? null) === (this.activeItem?.id ?? null)) return; + this.#swapTrack(item); + }; + + play = async (item?: AudioPlayerItem | null): Promise => { + const audio = this.audio; + if (!audio) return; + + if (this.#playPromise) { + try { + await this.#playPromise; + } catch (error) { + console.error("Play promise error:", error); + } + } + + if (item === undefined) { + const playPromise = audio.play(); + this.#playPromise = playPromise; + return playPromise; + } + if ((item?.id ?? null) === (this.activeItem?.id ?? null)) { + const playPromise = audio.play(); + this.#playPromise = playPromise; + return playPromise; + } + + this.#swapTrack(item); + const playPromise = audio.play(); + this.#playPromise = playPromise; + return playPromise; + }; + + pause = async (): Promise => { + const audio = this.audio; + if (!audio) return; + + if (this.#playPromise) { + try { + await this.#playPromise; + } catch (e) { + console.error(e); + } + } + + audio.pause(); + this.#playPromise = null; + }; + + seek = (time: number): void => { + if (!this.audio) return; + this.audio.currentTime = time; + }; + + setPlaybackRate = (rate: number): void => { + if (!this.audio) return; + this.playbackRate = rate; + this.audio.playbackRate = rate; + }; +} + +export function setAudioPlayer(): AudioPlayerState { + const state = new AudioPlayerState(); + setContext(AUDIO_PLAYER_CONTEXT_KEY, state); + return state; +} + +export function useAudioPlayer(): AudioPlayerState { + const ctx = getContext | undefined>(AUDIO_PLAYER_CONTEXT_KEY); + if (!ctx) { + throw new Error("useAudioPlayer cannot be called outside of an "); + } + return ctx; +} diff --git a/ui/src/lib/components/ui/audio-player/example-tracks.ts b/ui/src/lib/components/ui/audio-player/example-tracks.ts new file mode 100644 index 00000000..c91b101d --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/example-tracks.ts @@ -0,0 +1,29 @@ +export type ExampleTrack = { + id: string; + name: string; + url: string; + /** Precomputed waveform bars inlined. Highest priority. */ + waveform?: number[]; + /** URL to a JSON file containing precomputed bars. Second priority. */ + waveformUrl?: string; +}; + +const TRACK_NAMES = [ + "alpha", + "bravo", + "charlie", + "delta", + "echo", + "foxtrot", + "golf", + "hotel", + "india", + "juliett", +] as const; + +export const exampleTracks: ExampleTrack[] = TRACK_NAMES.map((name, i) => ({ + id: String(i), + name: name.charAt(0).toUpperCase() + name.slice(1), + url: `https://sv11.ui.twango.dev/audio/${name}.mp3`, + waveformUrl: `https://sv11.ui.twango.dev/audio/waveforms/${name}.json`, +})); diff --git a/ui/src/lib/components/ui/audio-player/index.ts b/ui/src/lib/components/ui/audio-player/index.ts new file mode 100644 index 00000000..7c52d602 --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/index.ts @@ -0,0 +1,32 @@ +import Root from "./audio-player.svelte"; +import Button from "./audio-player-button.svelte"; +import Progress from "./audio-player-progress.svelte"; +import Time from "./audio-player-time.svelte"; +import Duration from "./audio-player-duration.svelte"; +import Speed from "./audio-player-speed.svelte"; +import SpeedButtonGroup from "./audio-player-speed-button-group.svelte"; + +export { + Root, + Button, + Progress, + Time, + Duration, + Speed, + SpeedButtonGroup, + // + Root as AudioPlayer, + Button as AudioPlayerButton, + Progress as AudioPlayerProgress, + Time as AudioPlayerTime, + Duration as AudioPlayerDuration, + Speed as AudioPlayerSpeed, + SpeedButtonGroup as AudioPlayerSpeedButtonGroup, +}; + +export { setAudioPlayer, useAudioPlayer, AudioPlayerState } from "./context.svelte.js"; +export type { AudioPlayerItem } from "./context.svelte.js"; +export { formatTime } from "./utils.js"; +export { exampleTracks } from "./example-tracks.js"; +export { precomputeWaveform, sampleWaveform } from "./waveform-sampler.js"; +export { AudioGraph } from "./audio-graph.svelte.js"; diff --git a/ui/src/lib/components/ui/audio-player/utils.ts b/ui/src/lib/components/ui/audio-player/utils.ts new file mode 100644 index 00000000..49d3b436 --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/utils.ts @@ -0,0 +1,10 @@ +export function formatTime(seconds: number): string { + const hrs = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = Math.floor(seconds % 60); + + const formattedMins = mins < 10 ? `0${mins}` : mins; + const formattedSecs = secs < 10 ? `0${secs}` : secs; + + return hrs > 0 ? `${hrs}:${formattedMins}:${formattedSecs}` : `${mins}:${formattedSecs}`; +} diff --git a/ui/src/lib/components/ui/audio-player/waveform-sampler.ts b/ui/src/lib/components/ui/audio-player/waveform-sampler.ts new file mode 100644 index 00000000..4d180a34 --- /dev/null +++ b/ui/src/lib/components/ui/audio-player/waveform-sampler.ts @@ -0,0 +1,64 @@ +/** + * Default rate used when callers don't pass one. Constant-rate sampling (as + * opposed to a fixed total bar count) keeps scroll speed and detail identical + * across songs of different lengths. + */ +export const DEFAULT_BARS_PER_SECOND = 8; + +/** + * Pure sampler: given a mono Float32 PCM channel, returns `bars` normalized + * amplitude values in `[0, 1]`. The algorithm walks every 100th sample in each + * bucket to stay cheap and multiplies by 3 so quiet tracks still read visually. + * + * Values are quantized to 2 decimal places — bars render at ≤51 physical px at + * 2× DPR, so finer precision isn't visible and 2dp cuts shipped JSON size ~3×. + * + * The same function runs in both the browser (inside `precomputeWaveform` + * below) and the Vite plugin (`vite/waveforms-plugin.ts`) so shipped JSONs + * and the runtime fallback agree by construction. + */ +export function sampleWaveform(channelData: Float32Array, bars: number): number[] { + const samplesPerBar = Math.floor(channelData.length / bars); + const out: number[] = []; + for (let i = 0; i < bars; i++) { + const start = i * samplesPerBar; + const end = start + samplesPerBar; + let sum = 0; + let count = 0; + for (let j = start; j < end && j < channelData.length; j += 100) { + sum += Math.abs(channelData[j]); + count++; + } + const avg = count > 0 ? sum / count : 0; + out.push(Math.round(Math.min(1, avg * 3) * 100) / 100); + } + return out; +} + +/** + * Browser fallback: fetch an audio URL, decode it via `OfflineAudioContext`, + * and sample the first channel at `barsPerSecond` amplitude values per second + * of audio. + * + * Prefer shipping precomputed waveform JSONs (via the Vite plugin) and + * pointing tracks at them with `waveformUrl`. This function is the lazy + * fallback for tracks without one. + */ +export async function precomputeWaveform( + url: string, + barsPerSecond = DEFAULT_BARS_PER_SECOND +): Promise { + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + const OfflineCtx = + window.OfflineAudioContext || + (window as unknown as { webkitOfflineAudioContext: typeof OfflineAudioContext }) + .webkitOfflineAudioContext; + // Length is irrelevant — we only use the context to invoke decodeAudioData, + // which returns an AudioBuffer sized to the source. Use the minimum valid + // value to make that intent obvious. + const offlineContext = new OfflineCtx(1, 1, 44100); + const audioBuffer = await offlineContext.decodeAudioData(arrayBuffer.slice(0)); + const bars = Math.max(1, Math.round(audioBuffer.duration * barsPerSecond)); + return sampleWaveform(audioBuffer.getChannelData(0), bars); +} diff --git a/ui/src/lib/components/ui/waveform/index.ts b/ui/src/lib/components/ui/waveform/index.ts new file mode 100644 index 00000000..ea707ca7 --- /dev/null +++ b/ui/src/lib/components/ui/waveform/index.ts @@ -0,0 +1,33 @@ +import Root from "./waveform.svelte"; +import Scrolling from "./waveform-scrolling.svelte"; +import Scrubber from "./waveform-scrubber.svelte"; +import Microphone from "./waveform-microphone.svelte"; +import Static from "./waveform-static.svelte"; +import LiveMicrophone from "./waveform-live-microphone.svelte"; +import Recording from "./waveform-recording.svelte"; + +export { + Root, + Scrolling, + Scrubber, + Microphone, + Static, + LiveMicrophone, + Recording, + // + Root as Waveform, + Scrolling as ScrollingWaveform, + Scrubber as AudioScrubber, + Microphone as MicrophoneWaveform, + Static as StaticWaveform, + LiveMicrophone as LiveMicrophoneWaveform, + Recording as RecordingWaveform, +}; +export type { WaveformProps } from "./waveform.svelte"; +export type { ScrollingWaveformProps } from "./waveform-scrolling.svelte"; +export type { AudioScrubberProps } from "./waveform-scrubber.svelte"; +export type { MicrophoneWaveformProps } from "./waveform-microphone.svelte"; +export type { StaticWaveformProps } from "./waveform-static.svelte"; +export type { LiveMicrophoneWaveformProps } from "./waveform-live-microphone.svelte"; +export type { RecordingWaveformProps } from "./waveform-recording.svelte"; +export { seededRandom, heightToCssSize, getComputedBarColor } from "./utils.js"; diff --git a/ui/src/lib/components/ui/waveform/utils.ts b/ui/src/lib/components/ui/waveform/utils.ts new file mode 100644 index 00000000..945cdc8b --- /dev/null +++ b/ui/src/lib/components/ui/waveform/utils.ts @@ -0,0 +1,15 @@ +export function seededRandom(seed: number): number { + const x = Math.sin(seed) * 10000; + return x - Math.floor(x); +} + +export function heightToCssSize(height: string | number): string { + return typeof height === "number" ? `${height}px` : height; +} + +export function getComputedBarColor( + canvas: HTMLCanvasElement, + override: string | undefined +): string { + return override || getComputedStyle(canvas).getPropertyValue("--foreground") || "#000"; +} diff --git a/ui/src/lib/components/ui/waveform/waveform-live-microphone.svelte b/ui/src/lib/components/ui/waveform/waveform-live-microphone.svelte new file mode 100644 index 00000000..944d1a78 --- /dev/null +++ b/ui/src/lib/components/ui/waveform/waveform-live-microphone.svelte @@ -0,0 +1,549 @@ + + + +
0 && "cursor-pointer", + className + )} + role={!active && historyRef.current.length > 0 ? "slider" : undefined} + aria-label={!active && historyRef.current.length > 0 + ? "Drag to scrub through recording" + : undefined} + aria-valuenow={!active && historyRef.current.length > 0 ? Math.abs(dragOffset) : undefined} + aria-valuemin={!active && historyRef.current.length > 0 ? 0 : undefined} + aria-valuemax={!active && historyRef.current.length > 0 ? historyRef.current.length : undefined} + tabindex={!active && historyRef.current.length > 0 ? 0 : undefined} + style:height={heightStyle} + onpointerdown={handlePointerDown} + {...restProps} +> + +
diff --git a/ui/src/lib/components/ui/waveform/waveform-microphone.svelte b/ui/src/lib/components/ui/waveform/waveform-microphone.svelte new file mode 100644 index 00000000..d937c316 --- /dev/null +++ b/ui/src/lib/components/ui/waveform/waveform-microphone.svelte @@ -0,0 +1,205 @@ + + + diff --git a/ui/src/lib/components/ui/waveform/waveform-recording.svelte b/ui/src/lib/components/ui/waveform/waveform-recording.svelte new file mode 100644 index 00000000..429f0253 --- /dev/null +++ b/ui/src/lib/components/ui/waveform/waveform-recording.svelte @@ -0,0 +1,310 @@ + + + +
+ +
diff --git a/ui/src/lib/components/ui/waveform/waveform-scrolling.svelte b/ui/src/lib/components/ui/waveform/waveform-scrolling.svelte new file mode 100644 index 00000000..c7d1fb4d --- /dev/null +++ b/ui/src/lib/components/ui/waveform/waveform-scrolling.svelte @@ -0,0 +1,207 @@ + + +
+ +
diff --git a/ui/src/lib/components/ui/waveform/waveform-scrubber.svelte b/ui/src/lib/components/ui/waveform/waveform-scrubber.svelte new file mode 100644 index 00000000..0cd0367b --- /dev/null +++ b/ui/src/lib/components/ui/waveform/waveform-scrubber.svelte @@ -0,0 +1,121 @@ + + +
+ + +
+ +
+ + {#if showHandle} +
+ {/if} +
diff --git a/ui/src/lib/components/ui/waveform/waveform-static.svelte b/ui/src/lib/components/ui/waveform/waveform-static.svelte new file mode 100644 index 00000000..1f5025f0 --- /dev/null +++ b/ui/src/lib/components/ui/waveform/waveform-static.svelte @@ -0,0 +1,15 @@ + + + diff --git a/ui/src/lib/components/ui/waveform/waveform.svelte b/ui/src/lib/components/ui/waveform/waveform.svelte new file mode 100644 index 00000000..5efe5b4c --- /dev/null +++ b/ui/src/lib/components/ui/waveform/waveform.svelte @@ -0,0 +1,197 @@ + + +
+ +
diff --git a/ui/src/lib/use-scratchable-waveform.svelte.ts b/ui/src/lib/use-scratchable-waveform.svelte.ts new file mode 100644 index 00000000..22ed9f9c --- /dev/null +++ b/ui/src/lib/use-scratchable-waveform.svelte.ts @@ -0,0 +1,234 @@ +import type { AudioGraph, AudioPlayerState } from "$lib/components/ui/audio-player/index.js"; + +// Module-level cache, intentionally non-reactive: keyed by track URL so +// navigating away and back reuses the decode, and concurrent pointerdowns +// dedupe onto one in-flight promise. +// eslint-disable-next-line svelte/prefer-svelte-reactivity +const scratchBufferCache = new Map>(); + +async function fetchScratchBuffer(url: string, ctx: AudioContext): Promise { + try { + const response = await fetch(url); + const arrayBuffer = await response.arrayBuffer(); + return await ctx.decodeAudioData(arrayBuffer); + } catch (error) { + console.warn("Scratch buffer decode failed:", error); + return null; + } +} + +function getScratchBuffer(url: string, ctx: AudioContext): Promise { + let cached = scratchBufferCache.get(url); + if (!cached) { + cached = fetchScratchBuffer(url, ctx); + scratchBufferCache.set(url, cached); + } + return cached; +} + +export interface ScratchableWaveformOptions { + player: AudioPlayerState; + graph: AudioGraph; + trackUrl: () => string | null; + totalWidth: () => number; + containerWidth: () => number; +} + +export function useScratchableWaveform(opts: ScratchableWaveformOptions) { + let isScrubbing = $state(false); + let isMomentumActive = $state(false); + let offset = $state(0); + + let scratchBuffer: AudioBuffer | null = null; + let scratchSource: AudioBufferSourceNode | null = null; + + function warmScratchBuffer(): void { + if (scratchBuffer) return; + const url = opts.trackUrl(); + const ctx = opts.graph.ensureContext(); + if (!url || !ctx) return; + void getScratchBuffer(url, ctx).then((buf) => { + if (buf && opts.trackUrl() === url) scratchBuffer = buf; + }); + } + + function setOffsetAndSeek(next: number): void { + offset = next; + const totalW = opts.totalWidth(); + if (totalW <= 0) return; + // jetplay: playhead pinned at the LEFT edge (offset 0 = start), so the + // waveform fills the box from the left and scrolls left as it plays. + const position = Math.max(0, Math.min(1, -next / totalW)); + const audio = opts.player.audio; + if (audio && isFinite(audio.duration)) audio.currentTime = position * audio.duration; + } + + function playScratch(position: number, speed: number): void { + const ctx = opts.graph.ensureContext(); + if (!ctx || !scratchBuffer) return; + if (ctx.state === "suspended") void ctx.resume().catch(() => {}); + stopScratch(); + try { + const source = ctx.createBufferSource(); + source.buffer = scratchBuffer; + const startTime = Math.max( + 0, + Math.min(scratchBuffer.duration - 0.1, position * scratchBuffer.duration) + ); + const filter = ctx.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.value = Math.max(800, 2500 - speed * 1500); + filter.Q.value = 3; + source.playbackRate.value = Math.max(0.4, Math.min(2.5, 1 + speed * 0.5)); + source.connect(filter); + filter.connect(ctx.destination); + source.start(0, startTime, 0.06); + scratchSource = source; + } catch (error) { + console.error("scratch playback failed:", error); + } + } + + function stopScratch(): void { + if (!scratchSource) return; + try { + scratchSource.stop(); + } catch { + // already stopped + } + scratchSource = null; + } + + function handlePointerDown(e: PointerEvent): void { + if (e.pointerType === "mouse" && e.button !== 0) return; + e.preventDefault(); + warmScratchBuffer(); + + isScrubbing = true; + const wasPlaying = opts.player.isPlaying; + if (wasPlaying) void opts.player.pause(); + + const startX = e.clientX; + const totalW = opts.totalWidth(); + const startOffset = offset; + // Left-pinned playhead: offset ranges [-totalW, 0] (0 = start, -totalW = end). + const clamp = (v: number) => Math.max(-totalW, Math.min(0, v)); + const posAt = (o: number) => Math.max(0, Math.min(1, -o / totalW)); + + let lastPointerX = startX; + let lastScratchTime = 0; + let velocity = 0; + let lastTime = Date.now(); + let lastClientX = e.clientX; + + const onMove = (ev: PointerEvent) => { + if (ev.pointerId !== e.pointerId) return; + const clamped = clamp(startOffset + (ev.clientX - startX)); + setOffsetAndSeek(clamped); + + const now = Date.now(); + const pointerDelta = ev.clientX - lastPointerX; + const timeDelta = now - lastTime; + if (timeDelta > 0) { + const instant = (ev.clientX - lastClientX) / timeDelta; + velocity = velocity * 0.6 + instant * 0.4; + } + lastTime = now; + lastClientX = ev.clientX; + + if (pointerDelta !== 0 && now - lastScratchTime >= 10) { + playScratch(posAt(clamped), Math.min(3, Math.abs(pointerDelta) / 3)); + lastScratchTime = now; + } + lastPointerX = ev.clientX; + }; + + const onEnd = (ev: PointerEvent) => { + if (ev.pointerId !== e.pointerId) return; + document.removeEventListener("pointermove", onMove); + document.removeEventListener("pointerup", onEnd); + document.removeEventListener("pointercancel", onEnd); + + isScrubbing = false; + stopScratch(); + + if (Math.abs(velocity) <= 0.1) { + if (wasPlaying) void opts.player.play(); + return; + } + + isMomentumActive = true; + let current = offset; + let v = velocity * 15; + let lastFrame = 0; + + const step = () => { + if (Math.abs(v) <= 0.5) { + stopScratch(); + isMomentumActive = false; + // Delay the resume so the final scratch slice releases + // before the media element starts — prevents a crackle. + if (wasPlaying) setTimeout(() => void opts.player.play(), 10); + return; + } + current += v; + v *= 0.92; + const clamped = clamp(current); + if (clamped !== current) v = 0; + current = clamped; + setOffsetAndSeek(clamped); + + const now = Date.now(); + if (now - lastFrame >= 50) { + const speed = Math.min(2.5, Math.abs(v) / 10); + if (speed > 0.1) playScratch(posAt(clamped), speed); + lastFrame = now; + } + requestAnimationFrame(step); + }; + requestAnimationFrame(step); + }; + + document.addEventListener("pointermove", onMove); + document.addEventListener("pointerup", onEnd); + document.addEventListener("pointercancel", onEnd); + } + + function handleKeyDown(e: KeyboardEvent): void { + const audio = opts.player.audio; + if (!audio || !isFinite(audio.duration) || audio.duration <= 0) return; + const step = e.shiftKey ? 5 : 1; + const currentPct = (audio.currentTime / audio.duration) * 100; + let nextPct: number | null = null; + if (e.key === "ArrowLeft") nextPct = Math.max(0, currentPct - step); + else if (e.key === "ArrowRight") nextPct = Math.min(100, currentPct + step); + else if (e.key === "Home") nextPct = 0; + else if (e.key === "End") nextPct = 100; + if (nextPct === null) return; + e.preventDefault(); + audio.currentTime = (nextPct / 100) * audio.duration; + } + + function reset(): void { + scratchBuffer = null; + stopScratch(); + } + + return { + get isScrubbing() { + return isScrubbing; + }, + get isMomentumActive() { + return isMomentumActive; + }, + get offset() { + return offset; + }, + set offset(value: number) { + offset = value; + }, + handlePointerDown, + handleKeyDown, + reset, + }; +} diff --git a/ui/tests/audio-player.spec.ts b/ui/tests/audio-player.spec.ts index 5ff0e498..af63eb89 100644 --- a/ui/tests/audio-player.spec.ts +++ b/ui/tests/audio-player.spec.ts @@ -113,11 +113,11 @@ test('seek bar click seeks to position', async ({ loadApp }) => { return el && el.duration > 0 }) - const seekBar = page.locator('.group.h-5') + const seekBar = page.locator('[data-slot="audio-player-progress"]') const box = await seekBar.boundingBox() - if (!box) throw new Error('SeekBar not visible') + if (!box) throw new Error('Progress bar not visible') - // Click at ~50% of seek bar + // Click at ~50% of the progress bar (the waveform above is drag-to-scrub) await seekBar.click({ position: { x: box.width * 0.5, y: box.height / 2 } }) const currentTime = await audio.evaluate((el: HTMLAudioElement) => el.currentTime) From 46ceaa1e70c0835782883c3af83da5e33eb65db2 Mon Sep 17 00:00:00 2001 From: James Ding Date: Sat, 6 Jun 2026 02:45:27 -0700 Subject: [PATCH 03/21] fix(ui): drop crossorigin so file:// media plays in JCEF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sv11