From deb95ba60350ee80fc8e62c28fe9e51318bc4d80 Mon Sep 17 00:00:00 2001 From: Danielku15 Date: Sat, 25 Apr 2026 16:35:18 +0200 Subject: [PATCH 1/3] refactor(playground): rewrite as class-based components --- .vscode/extensions.json | 6 + package-lock.json | 121 +--- packages/playground/README.md | 314 ++++++++ packages/playground/alphatex-editor.css | 34 - packages/playground/alphatex-editor.html | 41 -- packages/playground/alphatex-editor.ts | 158 ---- .../playground/alphatexLanguageServerWrap.ts | 5 +- packages/playground/control-template.html | 193 ----- packages/playground/control.css | 525 -------------- packages/playground/control.html | 25 - packages/playground/control.ts | 679 ------------------ packages/playground/crosshair.css | 27 - packages/playground/crosshair.ts | 50 -- .../demos/alphatex-editor/index.html | 17 + .../playground/demos/alphatex-editor/index.ts | 8 + packages/playground/demos/control/index.html | 12 + packages/playground/demos/control/index.ts | 8 + packages/playground/demos/recorder/index.html | 12 + packages/playground/demos/recorder/index.ts | 8 + .../playground/demos/test-results/index.html | 13 + .../playground/demos/test-results/index.ts | 8 + .../playground/demos/youtube-sync/index.html | 16 + .../playground/demos/youtube-sync/index.ts | 8 + packages/playground/index.html | 13 + packages/playground/index.ts | 8 + packages/playground/package.json | 5 +- packages/playground/recorder.html | 18 - packages/playground/recorder.ts | 584 --------------- packages/playground/select-handles.css | 35 - packages/playground/select-handles.ts | 176 ----- .../playground/src/apps/AlphaTexEditorApp.ts | 226 ++++++ packages/playground/src/apps/ControlApp.ts | 173 +++++ packages/playground/src/apps/LabsIndexApp.ts | 92 +++ packages/playground/src/apps/RecorderApp.ts | 354 +++++++++ .../playground/src/apps/TestResultsApp.ts | 352 +++++++++ .../playground/src/apps/YoutubeSyncApp.ts | 136 ++++ .../src/components/AudioExporter.ts | 103 +++ .../playground/src/components/Crosshair.ts | 96 +++ .../playground/src/components/DragDrop.ts | 64 ++ packages/playground/src/components/Footer.ts | 53 ++ .../src/components/LoadingOverlay.ts | 86 +++ packages/playground/src/components/NavMenu.ts | 171 +++++ .../src/components/SelectionHandles.ts | 155 ++++ packages/playground/src/components/Sidebar.ts | 56 ++ .../playground/src/components/TimeSlider.ts | 48 ++ .../playground/src/components/TrackItem.ts | 180 +++++ .../playground/src/components/TrackList.ts | 81 +++ .../playground/src/components/TransportBar.ts | 394 ++++++++++ .../playground/src/components/Waveform.ts | 184 +++++ .../alphatex-editor/MonacoEditor.ts | 98 +++ .../src/components/primitives/Dropdown.ts | 270 +++++++ .../src/components/primitives/IconButton.ts | 94 +++ .../components/primitives/LoadingProgress.ts | 97 +++ .../src/components/primitives/ProgressBar.ts | 51 ++ .../src/components/primitives/Slider.ts | 74 ++ .../src/components/primitives/Spinner.ts | 36 + .../src/components/primitives/ToggleButton.ts | 45 ++ .../src/components/primitives/Tooltip.ts | 89 +++ .../src/components/recorder/DrumPadPanel.ts | 191 +++++ .../src/components/recorder/RhythmConfig.ts | 18 + .../components/recorder/RhythmGridOverlay.ts | 92 +++ .../components/youtube-sync/YoutubeSync.ts | 191 +++++ packages/playground/src/styles/common.css | 83 +++ packages/playground/src/util/Demos.ts | 38 + packages/playground/src/util/Dom.ts | 64 ++ packages/playground/src/util/Icons.ts | 63 ++ packages/playground/src/util/Paths.ts | 6 + packages/playground/src/util/window.d.ts | 8 + packages/playground/test-results.html | 143 ---- packages/playground/test-results.ts | 294 -------- packages/playground/vite.config.ts | 2 +- packages/playground/vite.plugin.server.ts | 258 +++---- packages/playground/youtube.css | 10 - packages/playground/youtube.html | 144 ---- tsconfig.base.json | 13 +- 75 files changed, 5225 insertions(+), 3378 deletions(-) create mode 100644 .vscode/extensions.json create mode 100644 packages/playground/README.md delete mode 100644 packages/playground/alphatex-editor.css delete mode 100644 packages/playground/alphatex-editor.html delete mode 100644 packages/playground/alphatex-editor.ts delete mode 100644 packages/playground/control-template.html delete mode 100644 packages/playground/control.css delete mode 100644 packages/playground/control.html delete mode 100644 packages/playground/control.ts delete mode 100644 packages/playground/crosshair.css delete mode 100644 packages/playground/crosshair.ts create mode 100644 packages/playground/demos/alphatex-editor/index.html create mode 100644 packages/playground/demos/alphatex-editor/index.ts create mode 100644 packages/playground/demos/control/index.html create mode 100644 packages/playground/demos/control/index.ts create mode 100644 packages/playground/demos/recorder/index.html create mode 100644 packages/playground/demos/recorder/index.ts create mode 100644 packages/playground/demos/test-results/index.html create mode 100644 packages/playground/demos/test-results/index.ts create mode 100644 packages/playground/demos/youtube-sync/index.html create mode 100644 packages/playground/demos/youtube-sync/index.ts create mode 100644 packages/playground/index.html create mode 100644 packages/playground/index.ts delete mode 100644 packages/playground/recorder.html delete mode 100644 packages/playground/recorder.ts delete mode 100644 packages/playground/select-handles.css delete mode 100644 packages/playground/select-handles.ts create mode 100644 packages/playground/src/apps/AlphaTexEditorApp.ts create mode 100644 packages/playground/src/apps/ControlApp.ts create mode 100644 packages/playground/src/apps/LabsIndexApp.ts create mode 100644 packages/playground/src/apps/RecorderApp.ts create mode 100644 packages/playground/src/apps/TestResultsApp.ts create mode 100644 packages/playground/src/apps/YoutubeSyncApp.ts create mode 100644 packages/playground/src/components/AudioExporter.ts create mode 100644 packages/playground/src/components/Crosshair.ts create mode 100644 packages/playground/src/components/DragDrop.ts create mode 100644 packages/playground/src/components/Footer.ts create mode 100644 packages/playground/src/components/LoadingOverlay.ts create mode 100644 packages/playground/src/components/NavMenu.ts create mode 100644 packages/playground/src/components/SelectionHandles.ts create mode 100644 packages/playground/src/components/Sidebar.ts create mode 100644 packages/playground/src/components/TimeSlider.ts create mode 100644 packages/playground/src/components/TrackItem.ts create mode 100644 packages/playground/src/components/TrackList.ts create mode 100644 packages/playground/src/components/TransportBar.ts create mode 100644 packages/playground/src/components/Waveform.ts create mode 100644 packages/playground/src/components/alphatex-editor/MonacoEditor.ts create mode 100644 packages/playground/src/components/primitives/Dropdown.ts create mode 100644 packages/playground/src/components/primitives/IconButton.ts create mode 100644 packages/playground/src/components/primitives/LoadingProgress.ts create mode 100644 packages/playground/src/components/primitives/ProgressBar.ts create mode 100644 packages/playground/src/components/primitives/Slider.ts create mode 100644 packages/playground/src/components/primitives/Spinner.ts create mode 100644 packages/playground/src/components/primitives/ToggleButton.ts create mode 100644 packages/playground/src/components/primitives/Tooltip.ts create mode 100644 packages/playground/src/components/recorder/DrumPadPanel.ts create mode 100644 packages/playground/src/components/recorder/RhythmConfig.ts create mode 100644 packages/playground/src/components/recorder/RhythmGridOverlay.ts create mode 100644 packages/playground/src/components/youtube-sync/YoutubeSync.ts create mode 100644 packages/playground/src/styles/common.css create mode 100644 packages/playground/src/util/Demos.ts create mode 100644 packages/playground/src/util/Dom.ts create mode 100644 packages/playground/src/util/Icons.ts create mode 100644 packages/playground/src/util/Paths.ts create mode 100644 packages/playground/src/util/window.d.ts delete mode 100644 packages/playground/test-results.html delete mode 100644 packages/playground/test-results.ts delete mode 100644 packages/playground/youtube.css delete mode 100644 packages/playground/youtube.html diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..71b36c461 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "biomejs.biome", + "bierner.lit-html" + ] +} diff --git a/package-lock.json b/package-lock.json index 9a3fb8d89..ee2540ed4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1242,15 +1242,6 @@ "url": "https://github.com/sponsors/ayuhito" } }, - "node_modules/@fortawesome/fontawesome-free": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.2.0.tgz", - "integrity": "sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg==", - "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", - "engines": { - "node": ">=6" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -2265,16 +2256,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/bootstrap": { - "version": "5.2.10", - "resolved": "https://registry.npmjs.org/@types/bootstrap/-/bootstrap-5.2.10.tgz", - "integrity": "sha512-F2X+cd6551tep0MvVZ6nM8v7XgGN/twpdNDjqS1TUM7YFNEtQYWk+dKAnH+T1gr6QgCoGMPl487xw/9hXooa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@popperjs/core": "^2.9.2" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3057,25 +3038,6 @@ "dev": true, "license": "ISC" }, - "node_modules/bootstrap": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", - "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/twbs" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/bootstrap" - } - ], - "license": "MIT", - "peerDependencies": { - "@popperjs/core": "^2.11.8" - } - }, "node_modules/brace-expansion": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", @@ -4450,27 +4412,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -5449,6 +5390,12 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/lucide/-/lucide-1.11.0.tgz", + "integrity": "sha512-2uvbGlcztUY+z0Ef++YCaxD6mtzrPsUJ1qWbIfqZrZGRxZBCL3icE6g2nzRTtJ6YywOoXC5blL/NmkLI66en5g==", + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -5566,15 +5513,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -7520,19 +7458,6 @@ "node": ">=14.17" } }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/undici-types": { "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", @@ -8024,12 +7949,6 @@ "dev": true, "license": "MIT" }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "license": "MIT" - }, "node_modules/workerpool": { "version": "9.3.4", "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-9.3.4.tgz", @@ -8183,6 +8102,29 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/alphasynth": { + "name": "@coderline/alphasynth", + "version": "1.9.0", + "extraneous": true, + "license": "MPL-2.0", + "devDependencies": { + "@biomejs/biome": "^2.3.14", + "@types/chai": "^5.2.3", + "@types/mocha": "^10.0.10", + "@types/node": "^25.2.2", + "assert": "^2.1.0", + "chai": "^6.2.2", + "mocha": "^11.7.5", + "rimraf": "^6.1.2", + "tslib": "^2.8.1", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vite": "^7.3.1" + }, + "engines": { + "node": ">=6.0.0" + } + }, "packages/alphatab": { "name": "@coderline/alphatab", "version": "1.9.0", @@ -8308,16 +8250,13 @@ "@coderline/alphatab": "*", "@fontsource/noto-sans": "^5.2.10", "@fontsource/noto-serif": "^5.2.9", - "@fortawesome/fontawesome-free": "^7.2.0", "@popperjs/core": "^2.11.8", "@types/serve-static": "^2.2.0", - "bootstrap": "^5.3.8", - "handlebars": "^4.7.9", + "lucide": "^1.11.0", "monaco-editor": "^0.55.1", "serve-static": "^2.2.1" }, "devDependencies": { - "@types/bootstrap": "^5.2.10", "split.js": "^1.6.5", "tslib": "^2.8.1", "typescript": "^5.9.3", diff --git a/packages/playground/README.md b/packages/playground/README.md new file mode 100644 index 000000000..1efcad704 --- /dev/null +++ b/packages/playground/README.md @@ -0,0 +1,314 @@ +# alphaTab Playground + +A development harness for alphaTab. Each demo under `demos/` exercises a specific capability of the +library against the live source under `packages/alphatab/src/`. Editing alphaTab source hot-reloads +the playground via Vite's workspace alias. + +The playground is **not** a framework reference for end users. Framework-specific components are +planned as separate packages. The patterns shown here are deliberately framework-agnostic: a +React/Angular/Svelte/Vue user can read these classes, understand which alphaTab events to subscribe +to and when to clean up, then apply the same logic in their framework's idiomatic shape. + +## Running it + +From the monorepo root: + +```bash +npm run dev +``` + +This is a shortcut for `npm run dev --workspace=packages/playground` and opens the labs index at +[`http://localhost:5173/`](http://localhost:5173/). + +Other scripts (run from `packages/playground/`): + +- `npm run typecheck` — `tsc --noEmit` against the playground's tsconfig. +- `npm run lint` — Biome lint. + +## Architecture at a glance + +``` +demos//index.html minimal "
" + module script +demos//index.ts constructs an App and appends `app.root` + +src/apps/App.ts top-level composer per demo, owns the AlphaTabApi +src/components/.ts alphaTab-aware composer (TransportBar, Footer, Sidebar, …) +src/components/primitives/ alphaTab-unaware UI bricks (IconButton, Dropdown, Slider, …) +src/util/Dom.ts html/css tagged templates, parseHtml, injectStyles, mount +src/util/Icons.ts lucide pass-through +src/util/Paths.ts default file paths +src/styles/common.css palette, fonts, resets — global CSS +``` + +The component tree for a typical demo is `App → composers → primitives`. The App constructs the +AlphaTabApi after mounting its viewport, then mounts composers into placeholders in its template. +Composers do the same for their primitives. + +## The component contract + +Every UI piece is a class with this consistent shape: + +```ts +class Transport implements Mountable { + readonly root: HTMLElement; // detached at construction; mounted by caller + + constructor(api: alphaTab.AlphaTabApi); // engine deps as args; never `parent` + + setReady(ready: boolean): void; // parent → child push API + onSomething: (() => void) | null = null; // child → parent event API + + dispose(): void; +} +``` + +Rules: + +- **Constructor takes engine dependencies and props, never `parent`.** Build DOM detached as `.root`. + Mounting is the caller's job. +- **Public methods are the parent → child push API.** Parents call `transport.setReady(true)` + directly. No prop diffing, no virtual DOM. +- **Assignable callback fields are the child → parent event API.** A child exposes + `onPlayClick: (() => void) | null = null`; the parent assigns a function. For multi-listener + fan-out, a typed `EventEmitter` is fine. +- **`dispose()` releases everything**: api event subscriptions (the `() => void` returned by + `api.event.on(...)`), DOM listeners, intervals, ResizeObservers, child components. Removing + `this.root` takes the entire DOM subtree with it. +- **Composition over inheritance.** `Footer` composes `TransportBar`; `App` composes one of each + top-level piece. +- **Engine events drive UI.** Each component subscribes directly to the alphaTab events it needs + (`TransportBar` → `playerStateChanged`; `TrackList` → `scoreLoaded`; `TimeSlider` → + `playerPositionChanged`; `Waveform` → `scoreLoaded` + DOM events on the audio element). The api + is the source of truth — no intermediate state container. + +### No IDs, kebab-prefixed classes, component-local nesting + +- **No `id` attributes inside component markup.** IDs collide across multiple instances. Use class + names or `data-*`. +- **Prefix every class with the component's kebab name** — `at-icon-btn`, `at-transport-…`, + `at-drum-pad-…`. Names are global; prefixes prevent leaks. +- **Nested selectors stay component-local** — `.at-track .at-track-controls`, never bare + `.at-track-controls`. + +### Styles co-located via `injectStyles` + +```ts +import { html, css, parseHtml, injectStyles } from '../../util/Dom'; + +injectStyles('IconButton', css` + .at-icon-btn { display: inline-flex; align-items: center; … } + .at-icon-btn[disabled] { opacity: 0.4; cursor: default; } +`); +``` + +`injectStyles(key, sheet)` is keyed by component name and injects the ` + + +
+ + + diff --git a/packages/playground/demos/alphatex-editor/index.ts b/packages/playground/demos/alphatex-editor/index.ts new file mode 100644 index 000000000..9a39a4d7b --- /dev/null +++ b/packages/playground/demos/alphatex-editor/index.ts @@ -0,0 +1,8 @@ +import { AlphaTexEditorApp } from '../../src/apps/AlphaTexEditorApp'; + +const root = document.getElementById('app'); +if (!root) { + throw new Error('#app element not found'); +} +const app = new AlphaTexEditorApp(); +root.appendChild(app.root); diff --git a/packages/playground/demos/control/index.html b/packages/playground/demos/control/index.html new file mode 100644 index 000000000..b6e992ac6 --- /dev/null +++ b/packages/playground/demos/control/index.html @@ -0,0 +1,12 @@ + + + + + alphaTab — Control + + + +
+ + + diff --git a/packages/playground/demos/control/index.ts b/packages/playground/demos/control/index.ts new file mode 100644 index 000000000..475b3d5b1 --- /dev/null +++ b/packages/playground/demos/control/index.ts @@ -0,0 +1,8 @@ +import { ControlApp } from '../../src/apps/ControlApp'; + +const root = document.getElementById('app'); +if (!root) { + throw new Error('#app element not found'); +} +const app = new ControlApp(); +root.appendChild(app.root); diff --git a/packages/playground/demos/recorder/index.html b/packages/playground/demos/recorder/index.html new file mode 100644 index 000000000..c9d3f9c94 --- /dev/null +++ b/packages/playground/demos/recorder/index.html @@ -0,0 +1,12 @@ + + + + + alphaTab — Drum Recorder + + + +
+ + + diff --git a/packages/playground/demos/recorder/index.ts b/packages/playground/demos/recorder/index.ts new file mode 100644 index 000000000..cefba3304 --- /dev/null +++ b/packages/playground/demos/recorder/index.ts @@ -0,0 +1,8 @@ +import { RecorderApp } from '../../src/apps/RecorderApp'; + +const root = document.getElementById('app'); +if (!root) { + throw new Error('#app element not found'); +} +const app = new RecorderApp(); +root.appendChild(app.root); diff --git a/packages/playground/demos/test-results/index.html b/packages/playground/demos/test-results/index.html new file mode 100644 index 000000000..01a8d066f --- /dev/null +++ b/packages/playground/demos/test-results/index.html @@ -0,0 +1,13 @@ + + + + + alphaTab — Visual Test Results + + + + +
+ + + diff --git a/packages/playground/demos/test-results/index.ts b/packages/playground/demos/test-results/index.ts new file mode 100644 index 000000000..69752e3b4 --- /dev/null +++ b/packages/playground/demos/test-results/index.ts @@ -0,0 +1,8 @@ +import { TestResultsApp } from '../../src/apps/TestResultsApp'; + +const root = document.getElementById('app'); +if (!root) { + throw new Error('#app element not found'); +} +const app = new TestResultsApp(); +root.appendChild(app.root); diff --git a/packages/playground/demos/youtube-sync/index.html b/packages/playground/demos/youtube-sync/index.html new file mode 100644 index 000000000..d8c81f9b9 --- /dev/null +++ b/packages/playground/demos/youtube-sync/index.html @@ -0,0 +1,16 @@ + + + + + alphaTab — YouTube Sync + + + + +
+ + + diff --git a/packages/playground/demos/youtube-sync/index.ts b/packages/playground/demos/youtube-sync/index.ts new file mode 100644 index 000000000..5ce2be37e --- /dev/null +++ b/packages/playground/demos/youtube-sync/index.ts @@ -0,0 +1,8 @@ +import { YoutubeSyncApp } from '../../src/apps/YoutubeSyncApp'; + +const root = document.getElementById('app'); +if (!root) { + throw new Error('#app element not found'); +} +const app = new YoutubeSyncApp(); +root.appendChild(app.root); diff --git a/packages/playground/index.html b/packages/playground/index.html new file mode 100644 index 000000000..622361af5 --- /dev/null +++ b/packages/playground/index.html @@ -0,0 +1,13 @@ + + + + + alphaTab Playground + + + + +
+ + + diff --git a/packages/playground/index.ts b/packages/playground/index.ts new file mode 100644 index 000000000..edbc0d114 --- /dev/null +++ b/packages/playground/index.ts @@ -0,0 +1,8 @@ +import { LabsIndexApp } from './src/apps/LabsIndexApp'; + +const root = document.getElementById('app'); +if (!root) { + throw new Error('#app element not found'); +} +const app = new LabsIndexApp(); +root.appendChild(app.root); diff --git a/packages/playground/package.json b/packages/playground/package.json index 431308171..3c58c7c54 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -8,11 +8,9 @@ "@coderline/alphatab": "*", "@fontsource/noto-sans": "^5.2.10", "@fontsource/noto-serif": "^5.2.9", - "@fortawesome/fontawesome-free": "^7.2.0", "@popperjs/core": "^2.11.8", "@types/serve-static": "^2.2.0", - "bootstrap": "^5.3.8", - "handlebars": "^4.7.9", + "lucide": "^1.11.0", "monaco-editor": "^0.55.1", "serve-static": "^2.2.1" }, @@ -22,7 +20,6 @@ "dev": "vite" }, "devDependencies": { - "@types/bootstrap": "^5.2.10", "split.js": "^1.6.5", "tslib": "^2.8.1", "typescript": "^5.9.3", diff --git a/packages/playground/recorder.html b/packages/playground/recorder.html deleted file mode 100644 index 82b5ae436..000000000 --- a/packages/playground/recorder.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - AlphaTab alphaTex Recorder Demo - - - - - -
- - - - \ No newline at end of file diff --git a/packages/playground/recorder.ts b/packages/playground/recorder.ts deleted file mode 100644 index fb677070a..000000000 --- a/packages/playground/recorder.ts +++ /dev/null @@ -1,584 +0,0 @@ -import { setupControl } from './control'; -import * as alphaTab from '@coderline/alphatab'; - -const req = new XMLHttpRequest(); -req.onload = () => { - document.getElementById('placeholder')!.outerHTML = req.responseText; - - // this is a drum-recording demo built with alphaTab. - // we do not actually play a song - we use the rendering + player + cursor capabilities to - // let the user "record" drum hits onto a percussion staff. for the sake of simplicity we do - // not have a real MIDI input but simulate it: - // 1. the score is seeded with bars of empty beats laid out on a rhythm-configuration grid. - // 2. pressing a keyboard key (or an on-screen pad) adds a percussion note to the beat under - // the cursor. the overlay grid shows the subdivisions so the user can see where hits land. - - // the overall recorder goes with various assumptions: - // 1. we only have one track/staff being recorded - // -> would need more complex update of the data model. - // 2. we have a single voice with multi-note beats (no stems-up/stems-down split for - // drums vs cymbals) -> out of scope for this POC. - // 3. we do not have any tempo, time signature or similar changes as we simply record lineary - // -> would need more complex handling of updating the lookups. - // 4. we do not have any re-recording flows (stop, seek and restart recording) - // -> would need further extensions. - // 5. every bar uses the same rhythm configuration (pre-filled grid of empty beats); no - // dynamic beat creation or splitting at runtime - too fragile. - - // we want bars dynamically being added as we record, to achieve this we use following tricks for rendering: - - // 1. we start with one full system of pre-filled empty bars - // -> this ensures the player/cursor doesn't think we have an end, but we still continue. - // 2. we add a new system when we reach 80% of the second-last bar. - // -> this gives us always one empty future bar ensuring correct rendering and cursor behavior. - // 3. the seed beats are marked isEmpty = true so they render as nothing (not as rests). - // with 32 subdivisions per bar a dense row of rest glyphs would be visual noise - the - // overlay grid is the visual rhythm indicator instead. - - // to get the cursor behaving as we want we do following: - - // 1. we generate an empty midi at start. this gives us a base Midi and MidiTickLookup to start with. - // 2. we extend this midi to the expected maximum recording length (multiple minutes of playback). - // this way the player will internally play quasi endlessly and allows us to extend the song. - // 3. when we extend the score we need to update the MidiTickLookup with the new bars to have - // correct cursor alignment. - - // input handling: - - // 1. the pad is active only while the player is actually playing (playerState === Playing). - // a state indicator on the panel surfaces this; pads are visually dimmed and click-disabled - // otherwise. - // 2. we rely on api.playedBeatChanged to know which beat to attach a note to. alphaTab already - // tracks the "beat under the cursor", which gives us quantization for free - no parallel - // tick polling loop. - // 3. on hit we flip the target beat's isEmpty to false and partially re-render only the - // affected bar via renderScore's firstChangedMasterBar hint. - - // --- rhythm configuration ----------------------------------------------- - // beatMask values are the "display weight" of each slot: 4 = quarter boundary, 8 = eighth, - // 16 = sixteenth, 32 = thirty-second. only slots with weight <= displayWeightThreshold - // get a visible grid line. every slot in the bar gets a concrete empty Beat. - interface RhythmConfig { - timeSignatureNumerator: number; - timeSignatureDenominator: number; - subdivisionsPerBeat: number; // grid slots inside one beat (quarter) - beatMask: number[]; // length = numerator * subdivisionsPerBeat, values are display weights - displayWeightThreshold: number; // max weight to draw in the overlay - } - - const RHYTHM_4_4_STRAIGHT: RhythmConfig = { - timeSignatureNumerator: 4, - timeSignatureDenominator: 4, - subdivisionsPerBeat: 4, - // per quarter: [quarter, 16th, 8th, 16th] - finest grid is 16th. - beatMask: Array.from({ length: 4 }, () => [4, 16, 8, 16]).flat(), - displayWeightThreshold: 16 - }; - - const activeRhythm = RHYTHM_4_4_STRAIGHT; - const MIDI_QUARTER_TIME = 960; - // every beat in the bar gets the same Duration (the finest subdivision) so playbackDuration - // stays uniform across all grid slots. this keeps playedBeatChanged firing at every slot. - const slotDuration = (() => { - switch (activeRhythm.subdivisionsPerBeat) { - case 2: - return alphaTab.model.Duration.Eighth; - case 4: - return alphaTab.model.Duration.Sixteenth; - case 8: - return alphaTab.model.Duration.ThirtySecond; - case 16: - return alphaTab.model.Duration.SixtyFourth; - default: - throw new Error(`unsupported subdivisionsPerBeat: ${activeRhythm.subdivisionsPerBeat}`); - } - })(); - const slotTicks = MIDI_QUARTER_TIME / activeRhythm.subdivisionsPerBeat; - - // --- drum pad mapping --------------------------------------------------- - // keyboard key -> { midiNote, label }. a single keypress becomes a single percussion note - // placed on the currently-played beat. the default GP7 articulation list in alphaTab's - // PercussionMapper uses MIDI note numbers as articulation indices, so no custom - // track.percussionArticulations[] is needed. - interface DrumPad { - key: string; - midiNote: number; - label: string; - } - - const DRUM_PADS: DrumPad[] = [ - { key: 'a', midiNote: 36, label: 'Kick' }, - { key: 's', midiNote: 38, label: 'Snare' }, - { key: 'd', midiNote: 42, label: 'Hi-Hat' }, - { key: 'f', midiNote: 46, label: 'Open HH' }, - { key: 'g', midiNote: 44, label: 'HH Pedal' }, - { key: 'h', midiNote: 45, label: 'Low Tom' }, - { key: 'j', midiNote: 47, label: 'Mid Tom' }, - { key: 'k', midiNote: 50, label: 'Hi Tom' }, - { key: 'l', midiNote: 49, label: 'Crash' }, - { key: ';', midiNote: 51, label: 'Ride' } - ]; - const padByKey = new Map(DRUM_PADS.map(p => [p.key, p])); - - // --- alphaTab setup ----------------------------------------------------- - const api = setupControl('#alphaTab', { - core: { - file: undefined - }, - display: { - // parchment gives the best deterministic system creation without flickering as bars are added - layoutMode: alphaTab.LayoutMode.Parchment, - justifyLastSystem: true - } - }); - - // build an empty percussion score programmatically. default tempo is 120 bpm (Score.tempo getter). - const score = new alphaTab.model.Score(); - const track = new alphaTab.model.Track(); - track.name = 'Drums'; - track.shortName = 'Drums'; - track.ensureStaveCount(1); - track.playbackInfo.primaryChannel = 9; - track.playbackInfo.secondaryChannel = 9; - const staff = track.staves[0]; - staff.isPercussion = true; - staff.showTablature = false; - staff.showStandardNotation = true; - score.addTrack(track); - score.defaultSystemsLayout = 5; - track.defaultSystemsLayout = 5; - - // threshold indicating we need to insert a new bar, -1 as marker to not do anything - let insertTickThreshold = -1; - - function createEmptyBeatsForBar(): alphaTab.model.Beat[] { - const beats: alphaTab.model.Beat[] = []; - for (let i = 0; i < activeRhythm.beatMask.length; i++) { - const beat = new alphaTab.model.Beat(); - beat.duration = slotDuration; - beat.isEmpty = true; - beats.push(beat); - } - return beats; - } - - function insertNewMasterBar() { - const newMasterBar = new alphaTab.model.MasterBar(); - newMasterBar.timeSignatureNumerator = activeRhythm.timeSignatureNumerator; - newMasterBar.timeSignatureDenominator = activeRhythm.timeSignatureDenominator; - score.addMasterBar(newMasterBar); - - // insert new bar to tick cache for cursor placement - const masterBarTickLookup = new alphaTab.midi.MasterBarTickLookup(); - masterBarTickLookup.tempoChanges.push( - new alphaTab.midi.MasterBarTickLookupTempoChange(newMasterBar.start, score.tempo) - ); - masterBarTickLookup.start = newMasterBar.start; - masterBarTickLookup.end = newMasterBar.start + newMasterBar.calculateDuration(); - masterBarTickLookup.masterBar = newMasterBar; - api.tickCache?.addMasterBar(masterBarTickLookup); - - return newMasterBar; - } - - function insertNewBar() { - const previousBar = staff.bars.length > 0 ? staff.bars[staff.bars.length - 1] : null; - const newBar = new alphaTab.model.Bar(); - if (previousBar) { - newBar.clef = previousBar.clef; - newBar.clefOttava = previousBar.clefOttava; - newBar.keySignature = previousBar.keySignature; - newBar.keySignatureType = previousBar.keySignatureType; - } - - staff.addBar(newBar); - - const initialVoice = new alphaTab.model.Voice(); - newBar.addVoice(initialVoice); - - const emptyBeats = createEmptyBeatsForBar(); - for (let i = 0; i < emptyBeats.length; i++) { - initialVoice.addBeat(emptyBeats[i]); - api.tickCache?.addBeat(emptyBeats[i], i * slotTicks, slotTicks); - } - - return newBar; - } - - function insertNewSystem() { - // clear threshold after we create bar, will be set again after render - insertTickThreshold = -1; - - const currentSystemCount = Math.floor(score.masterBars.length / score.defaultSystemsLayout); - const neededSystemCount = currentSystemCount + 1; - const neededBars = neededSystemCount * score.defaultSystemsLayout; - const lastMasterBarIndex = score.masterBars.length - 1; - - let missingBars = neededBars - score.masterBars.length; - - while (missingBars > 0) { - const newMasterBar = insertNewMasterBar(); - const newBar = insertNewBar(); - - const sharedDataBag = new Map(); - newMasterBar.finish(sharedDataBag); - newBar.finish(api.settings, sharedDataBag); - - missingBars--; - } - - // - // update remaining bits and render - - updateInsertTickThreshold(); - - api.renderScore(score, undefined, { - reuseViewport: currentSystemCount > 0, - firstChangedMasterBar: currentSystemCount > 0 ? lastMasterBarIndex : undefined - }); - } - - function updateInsertTickThreshold() { - // assumption: due to recording we do not have any repeats but a linear score - const lastBar = score!.masterBars![score.masterBars.length - 2]; - const thresholdPercent = 0.8; - const lastBarDuration = lastBar.calculateDuration(); - insertTickThreshold = lastBar.start + lastBarDuration * thresholdPercent; - } - - api.scoreLoaded.on(() => { - updateInsertTickThreshold(); - }); - - // seed with 2 bars (so we always have 1 future bar buffered) and do the initial render - insertNewSystem(); - - // extend the midi to be very long so the player keeps running while the user records. - api.midiLoad.on(midi => { - // find last rest event as starting point to extend - let rest: alphaTab.midi.AlphaTabRestEvent | undefined = undefined; - for (let i = midi.tracks[0].events.length; i >= 0; i--) { - const e = midi.tracks[0].events[i]; - if (e instanceof alphaTab.midi.AlphaTabRestEvent) { - rest = e; - break; - } - } - - // should never happen assuming we start with an empty song like in this sample - if (!rest) { - return; - } - - // 30mins should be enough for everyone ;) - const desiredLengthInMilliseconds = 60 * 30 * 1000; - const tempo = api.tickCache!.masterBars[0].tempoChanges[0].tempo; - const desiredLengthInTicks = (desiredLengthInMilliseconds / (60000.0 / (tempo * MIDI_QUARTER_TIME))) | 0; - - const endOfTrack = midi.tracks[0].events.pop()! as alphaTab.midi.EndOfTrackEvent; - - // add rest events every quarter note - let tick = rest.tick + MIDI_QUARTER_TIME; - while (tick < desiredLengthInTicks) { - midi.tracks[0].events.push(new alphaTab.midi.AlphaTabRestEvent(rest.track, tick, rest.channel)); - tick += MIDI_QUARTER_TIME; - } - - // shift end message - endOfTrack.tick = tick; - }); - - api.playerPositionChanged.on(e => { - if (insertTickThreshold !== -1 && e.currentTick >= insertTickThreshold) { - insertNewSystem(); - } - }); - - // --- input -> notes ----------------------------------------------------- - // alphaTab already tracks the "beat under the cursor" for us; subscribing to playedBeatChanged - // is cheaper and more consistent than running a parallel tick polling loop. - let currentBeat: alphaTab.model.Beat | undefined = undefined; - api.playedBeatChanged.on(beat => { - currentBeat = beat; - }); - - function isRecording() { - return api.playerState === alphaTab.synth.PlayerState.Playing; - } - - function addDrumHit(midiNote: number) { - if (!isRecording() || !currentBeat) { - return; - } - - const note = new alphaTab.model.Note(); - note.percussionArticulation = midiNote; - currentBeat.addNote(note); - // a beat with a note is no longer empty - keep the data model correct. - currentBeat.isEmpty = false; - - const bar = currentBeat.voice.bar; - bar.finish(api.settings, null); - - api.renderScore(score, undefined, { - reuseViewport: true, - firstChangedMasterBar: bar.index - }); - - flashPad(midiNote); - } - - // button flash on hit - reused by both keyboard and on-screen clicks - const padButtonByMidi = new Map(); - function flashPad(midiNote: number) { - const btn = padButtonByMidi.get(midiNote); - if (!btn) { - return; - } - btn.style.background = '#ff7043'; - btn.style.borderColor = '#ffab91'; - window.setTimeout(() => { - btn.style.background = ''; - btn.style.borderColor = ''; - }, 120); - } - - document.addEventListener( - 'keydown', - e => { - if (e.repeat) { - return; - } - const pad = padByKey.get(e.key.toLowerCase()); - if (!pad) { - return; - } - e.preventDefault(); - addDrumHit(pad.midiNote); - }, - true - ); - - // --- drum pad UI + overlay ---------------------------------------------- - // the grid overlay is attached to the outer #alphaTab container (same approach as the website's - // BoundsLookupViewer). the container is set to position:relative and the overlay stretches - // over its full rect. BoundsLookup coordinates are relative to the container top-left since - // the inner .at-surface sits at (0,0) there. attaching to .at-surface would clip lines via - // its overflow:hidden style. - const containerEl = document.getElementById('alphaTab')!; - containerEl.style.position = containerEl.style.position || 'relative'; - - const overlayEl = document.createElement('div'); - overlayEl.className = 'recorder-grid-overlay'; - Object.assign(overlayEl.style, { - position: 'absolute', - left: '0', - top: '0', - right: '0', - bottom: '0', - pointerEvents: 'none', - zIndex: '6' - } as Partial); - // insert before the first child so alphaTab's own DOM mutations (child replacement of - // placeholders) don't touch our overlay. - containerEl.insertBefore(overlayEl, containerEl.firstChild); - - function redrawOverlay() { - overlayEl.replaceChildren(); - - const lookup = api.renderer?.boundsLookup; - if (!lookup) { - return; - } - - // the grid renders as short tick marks above and below each bar's bounding box rather - // than full-height lines through the staff. this keeps the notation area itself visually - // unobstructed and reads as a gridline "guide" rather than an overlay. - const tickLength = 12; - const mainColor = 'rgba(0, 120, 220, 0.55)'; - const subColor = 'rgba(0, 120, 220, 0.25)'; - - for (const system of lookup.staffSystems) { - for (const masterBarBounds of system.bars) { - for (const barBounds of masterBarBounds.bars) { - const beats = barBounds.beats; - const barTop = barBounds.realBounds.y; - const barBottom = barBounds.realBounds.y + barBounds.realBounds.h; - - for (let i = 0; i < beats.length && i < activeRhythm.beatMask.length; i++) { - const weight = activeRhythm.beatMask[i]; - if (weight > activeRhythm.displayWeightThreshold) { - continue; - } - const beatBounds = beats[i]; - const isMainBeat = weight === 4; - const width = isMainBeat ? 2 : 1; - const color = isMainBeat ? mainColor : subColor; - const x = beatBounds.realBounds.x; - - const topTick = document.createElement('div'); - Object.assign(topTick.style, { - position: 'absolute', - left: `${x}px`, - top: `${barTop - tickLength}px`, - width: `${width}px`, - height: `${tickLength}px`, - background: color - } as Partial); - overlayEl.appendChild(topTick); - - const bottomTick = document.createElement('div'); - Object.assign(bottomTick.style, { - position: 'absolute', - left: `${x}px`, - top: `${barBottom}px`, - width: `${width}px`, - height: `${tickLength}px`, - background: color - } as Partial); - overlayEl.appendChild(bottomTick); - } - } - } - } - } - - api.postRenderFinished.on(() => { - redrawOverlay(); - }); - - // build the drum pad panel - const padPanel = document.createElement('div'); - padPanel.className = 'recorder-pad-panel'; - Object.assign(padPanel.style, { - position: 'fixed', - bottom: '72px', - right: '16px', - padding: '0 12px 12px 12px', - background: 'rgba(33, 37, 41, 0.92)', - borderRadius: '8px', - boxShadow: '0 4px 12px rgba(0, 0, 0, 0.25)', - zIndex: '1000', - color: '#fff', - fontFamily: 'system-ui, sans-serif' - } as Partial); - - // drag handle across the top of the panel, doubles as a recording-state indicator - const dragHandle = document.createElement('div'); - Object.assign(dragHandle.style, { - padding: '8px 10px', - marginBottom: '8px', - borderBottom: '1px solid rgba(255, 255, 255, 0.15)', - cursor: 'move', - userSelect: 'none', - fontSize: '11px', - textAlign: 'center', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - gap: '8px' - } as Partial); - const stateDot = document.createElement('span'); - Object.assign(stateDot.style, { - display: 'inline-block', - width: '10px', - height: '10px', - borderRadius: '50%', - background: '#6c757d' - } as Partial); - const stateLabel = document.createElement('span'); - stateLabel.textContent = 'Paused - press Play to record'; - dragHandle.appendChild(stateDot); - dragHandle.appendChild(stateLabel); - padPanel.appendChild(dragHandle); - - const padGrid = document.createElement('div'); - Object.assign(padGrid.style, { - display: 'grid', - gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', - gap: '8px' - } as Partial); - padPanel.appendChild(padGrid); - - function refreshPadState() { - if (isRecording()) { - stateDot.style.background = '#e53935'; - stateDot.style.boxShadow = '0 0 6px #e53935'; - stateLabel.textContent = 'Recording - hit pads or press keys'; - padGrid.style.opacity = '1'; - padGrid.style.pointerEvents = 'auto'; - } else { - stateDot.style.background = '#6c757d'; - stateDot.style.boxShadow = 'none'; - stateLabel.textContent = 'Paused - press Play to record'; - padGrid.style.opacity = '0.4'; - padGrid.style.pointerEvents = 'none'; - } - } - // whenever playback stops/pauses we regenerate the midi from the current score so - // previously-recorded drums become audible on replay. the existing midiLoad hook re-extends - // the fresh midi with rest events so further recording still has playback runway beyond the - // last bar. on pause (stopped=false) we restore api.tickPosition so the cursor stays where - // the user paused; on full stop (stopped=true) we skip the restore so the player's natural - // reset to the start takes effect. - let wasPlaying = false; - api.playerStateChanged.on(e => { - const isPlaying = e.state === alphaTab.synth.PlayerState.Playing; - if (wasPlaying && !isPlaying) { - api.loadMidiForScore(); - } - wasPlaying = isPlaying; - refreshPadState(); - }); - - let dragOffsetX = 0; - let dragOffsetY = 0; - dragHandle.addEventListener('pointerdown', e => { - const rect = padPanel.getBoundingClientRect(); - dragOffsetX = e.clientX - rect.left; - dragOffsetY = e.clientY - rect.top; - // switch from bottom/right anchoring to left/top so dragging works in both axes - padPanel.style.left = `${rect.left}px`; - padPanel.style.top = `${rect.top}px`; - padPanel.style.right = 'auto'; - padPanel.style.bottom = 'auto'; - dragHandle.setPointerCapture(e.pointerId); - }); - dragHandle.addEventListener('pointermove', e => { - if (!dragHandle.hasPointerCapture(e.pointerId)) { - return; - } - padPanel.style.left = `${e.clientX - dragOffsetX}px`; - padPanel.style.top = `${e.clientY - dragOffsetY}px`; - }); - dragHandle.addEventListener('pointerup', e => { - dragHandle.releasePointerCapture(e.pointerId); - }); - - for (const pad of DRUM_PADS) { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.textContent = `${pad.label}\n[${pad.key.toUpperCase()}]`; - Object.assign(btn.style, { - padding: '8px 10px', - minWidth: '68px', - background: '#4a5056', - color: '#fff', - border: '1px solid #6c757d', - borderRadius: '4px', - cursor: 'pointer', - fontSize: '12px', - lineHeight: '1.2', - whiteSpace: 'pre' - } as Partial); - btn.addEventListener('click', () => addDrumHit(pad.midiNote)); - padGrid.appendChild(btn); - padButtonByMidi.set(pad.midiNote, btn); - } - document.body.appendChild(padPanel); - refreshPadState(); - - (window as any).at = api; -}; -req.open('GET', 'control-template.html'); -req.send(); diff --git a/packages/playground/select-handles.css b/packages/playground/select-handles.css deleted file mode 100644 index e49aa7ef6..000000000 --- a/packages/playground/select-handles.css +++ /dev/null @@ -1,35 +0,0 @@ -.at-selection-handles { - position : absolute; - pointer-events: none; - z-index: 1001; - display: inline; - top: 0; - left: 0; - right: 0; - bottom: 0; -} - -.at-selection-handle { - position : absolute; - pointer-events: auto; - cursor: ew-resize; - background: #7cb9ff; - width: 4px; - opacity: 0; - transition: opacity 150ms ease-in-out; - display: none; -} - -.at-selection-handle:hover, -.at-selection-handle.at-selection-handle-drag { - opacity: 1; -} - -.at-selection-handle.active { - display: block; -} - -.at-selection-handle-drag * { - cursor: ew-resize !important; -} - diff --git a/packages/playground/select-handles.ts b/packages/playground/select-handles.ts deleted file mode 100644 index 80d3aeb29..000000000 --- a/packages/playground/select-handles.ts +++ /dev/null @@ -1,176 +0,0 @@ -import type * as alphaTab from '@coderline/alphatab'; - -interface HandleDragState { - isDragging: 'start' | 'end' | undefined; -} - -function createSelectionHandles(element: HTMLElement): { startHandle: HTMLElement; endHandle: HTMLElement } { - const handleWrapper = document.createElement('div'); - handleWrapper.classList.add('at-selection-handles'); - element.insertBefore(handleWrapper, element.querySelector('at-surface')); - - const startHandle = document.createElement('div'); - startHandle.classList.add('at-selection-handle', 'at-selection-handle-start'); - handleWrapper.appendChild(startHandle); - - const endHandle = document.createElement('div'); - endHandle.classList.add('at-selection-handle', 'at-selection-handle-end'); - handleWrapper.appendChild(endHandle); - - return { startHandle, endHandle }; -} - -function setupHandleDrag( - element: HTMLElement, - handle: HTMLElement, - dragState: HandleDragState, - type: HandleDragState['isDragging'], - onMove: (e: MouseEvent) => void, - onDragEnd: (e: MouseEvent) => void -) { - handle.addEventListener( - 'mousedown', - e => { - e.preventDefault(); - element.classList.add('at-selection-handle-drag'); - handle.classList.add('at-selection-handle-drag'); - dragState.isDragging = type; - }, - false - ); - document.addEventListener( - 'mousemove', - e => { - if (dragState.isDragging !== type) { - return; - } - e.preventDefault(); - onMove(e); - }, - true - ); - document.addEventListener( - 'mouseup', - e => { - if (dragState.isDragging !== type) { - return; - } - e.preventDefault(); - dragState.isDragging = undefined; - element.classList.remove('at-selection-handle-drag'); - handle.classList.remove('at-selection-handle-drag'); - onDragEnd(e); - }, - true - ); -} - -function getRelativePosition(parent: HTMLElement, e: MouseEvent): { relX: number; relY: number } { - const parentPos = parent.getBoundingClientRect(); - const parentLeft: number = parentPos.left + parent.ownerDocument!.defaultView!.pageXOffset; - const parentTop: number = parentPos.top + parent.ownerDocument!.defaultView!.pageYOffset; - - const relX = e.pageX - parentLeft; - const relY = e.pageY - parentTop; - - return { relX, relY }; -} - -function getBeatFromEvent( - element: HTMLElement, - api: alphaTab.AlphaTabApi, - e: MouseEvent -): alphaTab.model.Beat | undefined { - const { relX, relY } = getRelativePosition(element, e); - const beat = api.boundsLookup?.getBeatAtPos(relX, relY); - if (!beat) { - return undefined; - } - - const bounds = api.boundsLookup!.findBeat(beat); - if (!bounds) { - return undefined; - } - - // only snap to beat beat if we are over the whitespace after the beat - const visualBoundsEnd = bounds.visualBounds.x + bounds.visualBounds.w; - const realBoundsEnd = bounds.realBounds.x + bounds.realBounds.w; - if (relX < visualBoundsEnd || relX > realBoundsEnd) { - return undefined; - } - - return beat; -} - -export function setupSelectionHandles(element: HTMLElement, api: alphaTab.AlphaTabApi) { - const { startHandle, endHandle } = createSelectionHandles(element); - - // listen to selection range changes to place handles - let currentHighlight: alphaTab.PlaybackHighlightChangeEventArgs | undefined; - api.playbackRangeHighlightChanged.on(e => { - currentHighlight = e; - // no selection - if (!e.startBeat || !e.endBeat) { - startHandle.classList.remove('active'); - endHandle.classList.remove('active'); - return; - } - - startHandle.classList.add('active'); - startHandle.style.left = `${e.startBeatBounds!.realBounds.x}px`; - startHandle.style.top = `${e.startBeatBounds!.barBounds.masterBarBounds.visualBounds.y}px`; - startHandle.style.height = `${e.startBeatBounds!.barBounds.masterBarBounds.visualBounds.h}px`; - - endHandle.classList.add('active'); - endHandle.style.left = `${e.endBeatBounds!.realBounds.x + e.endBeatBounds!.realBounds.w}px`; - endHandle.style.top = `${e.endBeatBounds!.barBounds.masterBarBounds.visualBounds.y}px`; - endHandle.style.height = `${e.endBeatBounds!.barBounds.masterBarBounds.visualBounds.h}px`; - }); - - // setup dragging of handles - const dragState: HandleDragState = { isDragging: undefined }; - - setupHandleDrag( - element, - startHandle, - dragState, - 'start', - e => { - if (!currentHighlight?.startBeat) { - return; - } - - const beat = getBeatFromEvent(element, api, e); - if (!beat) { - return; - } - - api.highlightPlaybackRange(beat, currentHighlight.endBeat!); - }, - () => { - api.applyPlaybackRangeFromHighlight(); - } - ); - - setupHandleDrag( - element, - endHandle, - dragState, - 'end', - e => { - if (!currentHighlight?.startBeat) { - return; - } - - const beat = getBeatFromEvent(element, api, e); - if (!beat) { - return; - } - - api.highlightPlaybackRange(currentHighlight!.startBeat!, beat); - }, - () => { - api.applyPlaybackRangeFromHighlight(); - } - ); -} diff --git a/packages/playground/src/apps/AlphaTexEditorApp.ts b/packages/playground/src/apps/AlphaTexEditorApp.ts new file mode 100644 index 000000000..ffbc74530 --- /dev/null +++ b/packages/playground/src/apps/AlphaTexEditorApp.ts @@ -0,0 +1,226 @@ +import * as alphaTab from '@coderline/alphatab'; +import Split from 'split.js'; +import { Footer } from '../components/Footer'; +import { LoadingOverlay } from '../components/LoadingOverlay'; +import { MonacoEditor } from '../components/alphatex-editor/MonacoEditor'; +import { NavMenu } from '../components/NavMenu'; +import { Sidebar } from '../components/Sidebar'; +import { type Mountable, css, html, injectStyles, mount, parseHtml } from '../util/Dom'; +import { Paths } from '../util/Paths'; + +injectStyles( + 'AlphaTexEditorApp', + css` + .at-tex-app { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: row; + } + .at-tex-app > .at-tex-editor-pane { + height: 100%; + overflow: visible; + z-index: 10000; + } + .at-tex-app > .at-tex-player-pane { + height: 100%; + overflow: hidden; + } + .at-tex-app .at-wrap-tex { + position: relative; + width: 100%; + height: 100%; + margin: 0; + background: #fff; + display: flex; + flex-direction: column; + overflow: hidden; + } + .at-tex-app .at-wrap-tex > .at-content { + flex: 1 1 auto; + overflow: hidden; + position: relative; + } + .at-tex-app .at-wrap-tex .at-viewport { + overflow-y: auto; + position: absolute; + top: 0; + left: 70px; + right: 0; + bottom: 0; + padding-right: 20px; + } + .at-tex-app .gutter { + background-color: #eee; + background-repeat: no-repeat; + background-position: 50%; + } + .at-tex-app .gutter.gutter-horizontal { + cursor: col-resize; + } +` +); + +const DEFAULT_TEX = `\\title "Canon Rock" +\\subtitle "JerryC" +\\tempo 90 + +:2 19.2{v f} 17.2{v f} | +15.2{v f} 14.2{v f}| +12.2{v f} 10.2{v f}| +12.2{v f} 14.2{v f}.4 :8 15.2 17.2 | +14.1.2 :8 17.2 15.1 14.1{h} 17.2 | +15.2{v d}.4 :16 17.2{h} 15.2 :8 14.2 14.1 17.1{b(0 4 4 0)}.4 | +15.1.8 :16 14.1{tu 3} 15.1{tu 3} 14.1{tu 3} :8 17.2 15.1 14.1 :16 12.1{tu 3} 14.1{tu 3} 12.1{tu 3} :8 15.2 14.2 | +12.2 14.3 12.3 15.2 :32 14.2{h} 15.2{h} 14.2{h} 15.2{h}14.2{h} 15.2{h}14.2{h} 15.2{h}14.2{h} 15.2{h}14.2{h} 15.2{h}14.2{h} 15.2{h}14.2{h} 15.2{h}`; + +const STORAGE_KEY = 'alphatex-editor.code'; + +export interface AlphaTexEditorAppOptions { + initialTex?: string; +} + +export class AlphaTexEditorApp implements Mountable { + readonly root: HTMLElement; + readonly api: alphaTab.AlphaTabApi; + private nav: NavMenu; + private overlay: LoadingOverlay; + private sidebar: Sidebar; + private footer: Footer; + private editor: MonacoEditor; + private split: ReturnType | null = null; + private fromTex = true; + private subscriptions: (() => void)[] = []; + + constructor(options: AlphaTexEditorAppOptions = {}) { + this.root = parseHtml(html` +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ `); + + const viewport = this.root.querySelector('.at-viewport')!; + const canvas = this.root.querySelector('.at-canvas')!; + + const settings = new alphaTab.Settings(); + settings.fillFromJson({ + core: { + fontDirectory: Paths.fontDirectory + }, + player: { + playerMode: alphaTab.PlayerMode.EnabledAutomatic, + soundFont: Paths.soundFont, + scrollOffsetX: -10, + scrollOffsetY: -20, + scrollElement: viewport + } + } satisfies alphaTab.json.SettingsJson); + + this.api = new alphaTab.AlphaTabApi(canvas, settings); + this.subscriptions.push(this.api.error.on(e => console.error('alphaTab error', e))); + + this.api.settings.exporter.comments = true; + this.api.settings.exporter.indent = 2; + + this.subscriptions.push( + this.api.scoreLoaded.on(score => { + if (!this.fromTex) { + const exporter = new alphaTab.exporter.AlphaTexExporter(); + const tex = exporter.exportToString(score, this.api.settings); + this.editor.setValue(tex); + } + }) + ); + + this.overlay = mount(this.root, '.cmp-overlay', new LoadingOverlay(this.api)); + this.sidebar = mount(this.root, '.cmp-sidebar', new Sidebar(this.api)); + this.footer = mount( + this.root, + '.cmp-footer', + new Footer(this.api, { trackList: this.sidebar.trackList }) + ); + + const initialCode = + (typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(STORAGE_KEY) : null) ?? + options.initialTex ?? + DEFAULT_TEX; + + this.editor = mount(this.root, '.cmp-editor', new MonacoEditor({ initialCode })); + this.editor.onChange = tex => this.loadTex(tex); + + this.nav = new NavMenu(); + document.body.appendChild(this.nav.root); + + // Initial load and split-pane setup happen after the root is in the DOM. + // Defer to next tick so the caller has a chance to appendChild before Split() measures. + queueMicrotask(() => this.afterMount(initialCode)); + + if (typeof window !== 'undefined') { + window.api = this.api; + window.alphaTab = alphaTab; + } + } + + private afterMount(initialCode: string): void { + if (!this.root.isConnected) { + queueMicrotask(() => this.afterMount(initialCode)); + return; + } + const editorPane = this.root.querySelector('.at-tex-editor-pane')!; + const playerPane = this.root.querySelector('.at-tex-player-pane')!; + this.split = Split([editorPane, playerPane]); + this.loadTex(initialCode); + } + + private loadTex(tex: string): void { + const importer = new alphaTab.importer.AlphaTexImporter(); + importer.initFromString(tex, this.api.settings); + importer.logErrors = true; + let score: alphaTab.model.Score; + try { + score = importer.readScore(); + this.api.updateSettings(); + } catch { + return; + } + + if (typeof sessionStorage !== 'undefined') { + sessionStorage.setItem(STORAGE_KEY, tex); + } + this.fromTex = true; + this.api.renderTracks(score.tracks, { reuseViewport: true }); + this.fromTex = false; + } + + dispose(): void { + for (const u of this.subscriptions) { + u(); + } + this.subscriptions = []; + this.split?.destroy(); + this.nav.dispose(); + this.editor.dispose(); + this.footer.dispose(); + this.sidebar.dispose(); + this.overlay.dispose(); + this.api.destroy(); + this.root.remove(); + } +} diff --git a/packages/playground/src/apps/ControlApp.ts b/packages/playground/src/apps/ControlApp.ts new file mode 100644 index 000000000..d3af97c6a --- /dev/null +++ b/packages/playground/src/apps/ControlApp.ts @@ -0,0 +1,173 @@ +import * as alphaTab from '@coderline/alphatab'; +import { Crosshair } from '../components/Crosshair'; +import { DragDrop } from '../components/DragDrop'; +import { Footer } from '../components/Footer'; +import { LoadingOverlay } from '../components/LoadingOverlay'; +import { NavMenu } from '../components/NavMenu'; +import { SelectionHandles } from '../components/SelectionHandles'; +import { Sidebar } from '../components/Sidebar'; +import { type Mountable, css, html, injectStyles, mount, parseHtml } from '../util/Dom'; +import { Paths } from '../util/Paths'; + +injectStyles( + 'ControlApp', + css` + .at-wrap { + position: relative; + width: 90vw; + height: 90vh; + margin: 0 auto; + border: 1px solid rgba(0, 0, 0, 0.12); + background: #fff; + display: flex; + flex-direction: column; + overflow: hidden; + } + .at-wrap > .at-content { + flex: 1 1 auto; + overflow: hidden; + position: relative; + } + .at-wrap .at-viewport { + overflow-y: auto; + position: absolute; + top: 0; + left: 70px; + right: 0; + bottom: 0; + padding-right: 20px; + } + @media screen and (max-width: 1100px) { + .at-wrap .at-viewport { left: 0; } + } + @media screen and (max-width: 920px) { + .at-wrap { height: 60vh; } + } +` +); + +export interface ControlAppOptions { + file?: string; + soundFont?: string; + fontDirectory?: string; + settings?: alphaTab.json.SettingsJson; +} + +function applyFonts(settings: alphaTab.Settings): void { + settings.display.resources.copyrightFont.families = ['Noto Sans']; + settings.display.resources.titleFont.families = ['Noto Serif']; + settings.display.resources.subTitleFont.families = ['Noto Serif']; + settings.display.resources.wordsFont.families = ['Noto Serif']; + settings.display.resources.effectFont.families = ['Noto Serif']; + settings.display.resources.timerFont.families = ['Noto Serif']; + settings.display.resources.fretboardNumberFont.families = ['Noto Sans']; + settings.display.resources.tablatureFont.families = ['Noto Sans']; + settings.display.resources.graceFont.families = ['Noto Sans']; + settings.display.resources.barNumberFont.families = ['Noto Sans']; + settings.display.resources.markerFont.families = ['Noto Serif']; + settings.display.resources.directionsFont.families = ['Noto Serif']; + settings.display.resources.numberedNotationFont.families = ['Noto Sans']; + settings.display.resources.numberedNotationGraceFont.families = ['Noto Sans']; +} + +export function buildSettings(options: ControlAppOptions, viewport: HTMLElement): alphaTab.Settings { + const params = new URL(window.location.href).searchParams; + const settings = new alphaTab.Settings(); + applyFonts(settings); + settings.fillFromJson({ + core: { + logLevel: (params.get('loglevel') ?? 'info') as alphaTab.json.CoreSettingsJson['logLevel'], + engine: params.get('engine') ?? 'default', + file: options.file ?? Paths.defaultScore, + fontDirectory: options.fontDirectory ?? Paths.fontDirectory + }, + player: { + playerMode: alphaTab.PlayerMode.EnabledAutomatic, + scrollOffsetX: -10, + scrollOffsetY: -20, + soundFont: options.soundFont ?? Paths.soundFont, + scrollElement: viewport + } + } satisfies alphaTab.json.SettingsJson); + if (options.settings) { + settings.fillFromJson(options.settings); + } + return settings; +} + +export class ControlApp implements Mountable { + readonly root: HTMLElement; + readonly api: alphaTab.AlphaTabApi; + readonly sidebar: Sidebar; + readonly footer: Footer; + private overlay: LoadingOverlay; + private selectionHandles: SelectionHandles; + private crosshair: Crosshair; + private dragDrop: DragDrop; + private nav: NavMenu; + private unsubError: () => void; + + constructor(options: ControlAppOptions = {}) { + this.root = parseHtml(html` +
+
+
+
+
+
+
+
+ +
+
+ `); + + const viewport = this.root.querySelector('.at-viewport')!; + const canvas = this.root.querySelector('.at-canvas')!; + const settings = buildSettings(options, viewport); + + this.api = new alphaTab.AlphaTabApi(canvas, settings); + this.unsubError = this.api.error.on(e => { + console.error('alphaTab error', e); + }); + + this.overlay = mount(this.root, '.cmp-overlay', new LoadingOverlay(this.api)); + this.sidebar = mount(this.root, '.cmp-sidebar', new Sidebar(this.api)); + this.footer = mount( + this.root, + '.cmp-footer', + new Footer(this.api, { trackList: this.sidebar.trackList }) + ); + this.selectionHandles = mount( + this.root, + '.cmp-selection-handles', + new SelectionHandles(this.api, viewport) + ); + this.crosshair = new Crosshair(); + this.dragDrop = new DragDrop(this.api, { + onEnter: () => this.overlay.enterDrag(), + onLeave: () => this.overlay.leaveDrag() + }); + this.nav = new NavMenu(); + document.body.appendChild(this.nav.root); + + // expose for fiddling in dev tools + if (typeof window !== 'undefined') { + window.api = this.api; + window.alphaTab = alphaTab; + } + } + + dispose(): void { + this.unsubError(); + this.nav.dispose(); + this.dragDrop.dispose(); + this.crosshair.dispose(); + this.selectionHandles.dispose(); + this.footer.dispose(); + this.sidebar.dispose(); + this.overlay.dispose(); + this.api.destroy(); + this.root.remove(); + } +} diff --git a/packages/playground/src/apps/LabsIndexApp.ts b/packages/playground/src/apps/LabsIndexApp.ts new file mode 100644 index 000000000..674768ed3 --- /dev/null +++ b/packages/playground/src/apps/LabsIndexApp.ts @@ -0,0 +1,92 @@ +import { NavMenu } from '../components/NavMenu'; +import { DEMOS } from '../util/Demos'; +import { type Mountable, css, html, injectStyles, parseHtml } from '../util/Dom'; + +injectStyles( + 'LabsIndexApp', + css` + .at-labs { + max-width: 980px; + margin: 0 auto; + padding: 32px 24px; + } + .at-labs h1 { + margin-top: 0; + font-family: 'Noto Serif', serif; + } + .at-labs > p { color: #444; } + .at-labs-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; + margin-top: 24px; + } + .at-labs-card { + display: flex; + flex-direction: column; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 6px; + padding: 18px 20px; + background: #fff; + text-decoration: none; + color: inherit; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + } + .at-labs-card:hover { + border-color: var(--at-accent); + box-shadow: 0 4px 14px rgba(73, 114, 161, 0.18); + } + .at-labs-card-title { + font-weight: 500; + margin: 0 0 8px 0; + font-size: 1.05rem; + } + .at-labs-card-description { + margin: 0; + color: #555; + font-size: 0.9rem; + line-height: 1.4; + } + .at-labs-footnote { + margin-top: 32px; + font-size: 0.85rem; + color: #777; + } +` +); + +export class LabsIndexApp implements Mountable { + readonly root: HTMLElement; + private nav: NavMenu; + + constructor() { + this.root = parseHtml(html` +
+

alphaTab Playground

+

Development demos exercising specific capabilities of alphaTab. Pick one to start.

+
+

+ Editing alphaTab source under packages/alphatab/src/ hot-reloads via Vite's workspace alias. +

+
+ `); + const grid = this.root.querySelector('.at-labs-grid')!; + for (const demo of DEMOS) { + const card = parseHtml(html` + +

${demo.title}

+

${demo.description}

+
+ `); + grid.appendChild(card); + } + + this.nav = new NavMenu(); + document.body.appendChild(this.nav.root); + } + + dispose(): void { + this.nav.dispose(); + this.root.remove(); + } +} diff --git a/packages/playground/src/apps/RecorderApp.ts b/packages/playground/src/apps/RecorderApp.ts new file mode 100644 index 000000000..7a10eee64 --- /dev/null +++ b/packages/playground/src/apps/RecorderApp.ts @@ -0,0 +1,354 @@ +import * as alphaTab from '@coderline/alphatab'; +import { Footer } from '../components/Footer'; +import { LoadingOverlay } from '../components/LoadingOverlay'; +import { NavMenu } from '../components/NavMenu'; +import { Sidebar } from '../components/Sidebar'; +import { DrumPadPanel } from '../components/recorder/DrumPadPanel'; +import { type RhythmConfig, RHYTHM_4_4_STRAIGHT } from '../components/recorder/RhythmConfig'; +import { RhythmGridOverlay } from '../components/recorder/RhythmGridOverlay'; +import { type Mountable, css, html, injectStyles, mount, parseHtml } from '../util/Dom'; +import { Paths } from '../util/Paths'; + +injectStyles( + 'RecorderApp', + css` + .at-wrap.at-wrap-recorder { + position: relative; + width: 90vw; + height: 90vh; + margin: 0 auto; + border: 1px solid rgba(0, 0, 0, 0.12); + background: #fff; + display: flex; + flex-direction: column; + overflow: hidden; + } + .at-wrap-recorder > .at-content { + flex: 1 1 auto; + overflow: hidden; + position: relative; + } + .at-wrap-recorder .at-viewport { + overflow-y: auto; + position: absolute; + top: 0; + left: 70px; + right: 0; + bottom: 0; + padding-right: 20px; + } + .at-wrap-recorder .at-canvas { + position: relative; + } + @media screen and (max-width: 1100px) { + .at-wrap-recorder .at-viewport { left: 0; } + } +` +); + +const MIDI_QUARTER_TIME = 960; + +export interface RecorderAppOptions { + rhythm?: RhythmConfig; + fontDirectory?: string; + soundFont?: string; +} + +export class RecorderApp implements Mountable { + readonly api: alphaTab.AlphaTabApi; + readonly root: HTMLElement; + + private rhythm: RhythmConfig; + private slotDuration: alphaTab.model.Duration; + private slotTicks: number; + private score: alphaTab.model.Score; + private track: alphaTab.model.Track; + private staff: alphaTab.model.Staff; + private currentBeat: alphaTab.model.Beat | undefined; + private insertTickThreshold = -1; + private wasPlaying = false; + + private overlay: LoadingOverlay; + private sidebar: Sidebar; + private footer: Footer; + private gridOverlay: RhythmGridOverlay; + private padPanel: DrumPadPanel; + private nav: NavMenu; + + private subscriptions: (() => void)[] = []; + + constructor(options: RecorderAppOptions = {}) { + this.rhythm = options.rhythm ?? RHYTHM_4_4_STRAIGHT; + this.slotDuration = pickSlotDuration(this.rhythm.subdivisionsPerBeat); + this.slotTicks = MIDI_QUARTER_TIME / this.rhythm.subdivisionsPerBeat; + + this.root = parseHtml(html` +
+
+
+
+
+
+
+
+ +
+ `); + + const viewport = this.root.querySelector('.at-viewport')!; + const canvas = this.root.querySelector('.at-canvas')!; + + const settings = new alphaTab.Settings(); + settings.fillFromJson({ + core: { + fontDirectory: options.fontDirectory ?? Paths.fontDirectory + }, + player: { + playerMode: alphaTab.PlayerMode.EnabledAutomatic, + soundFont: options.soundFont ?? Paths.soundFont, + scrollOffsetX: -10, + scrollOffsetY: -20, + scrollElement: viewport + }, + display: { + layoutMode: alphaTab.LayoutMode.Parchment, + justifyLastSystem: true + } + } satisfies alphaTab.json.SettingsJson); + + this.api = new alphaTab.AlphaTabApi(canvas, settings); + this.subscriptions.push(this.api.error.on(e => console.error('alphaTab error', e))); + + // build the empty percussion score + this.score = new alphaTab.model.Score(); + this.track = new alphaTab.model.Track(); + this.track.name = 'Drums'; + this.track.shortName = 'Drums'; + this.track.ensureStaveCount(1); + this.track.playbackInfo.primaryChannel = 9; + this.track.playbackInfo.secondaryChannel = 9; + this.staff = this.track.staves[0]; + this.staff.isPercussion = true; + this.staff.showTablature = false; + this.staff.showStandardNotation = true; + this.score.addTrack(this.track); + this.score.defaultSystemsLayout = 5; + this.track.defaultSystemsLayout = 5; + + // mount overlay/sidebar/footer + this.overlay = mount(this.root, '.cmp-overlay', new LoadingOverlay(this.api)); + this.sidebar = mount(this.root, '.cmp-sidebar', new Sidebar(this.api)); + this.footer = mount( + this.root, + '.cmp-footer', + new Footer(this.api, { trackList: this.sidebar.trackList }) + ); + + // grid overlay sits inside .at-canvas — alphaTab adds its rendering as siblings/children. + this.gridOverlay = new RhythmGridOverlay(this.api, this.rhythm); + canvas.appendChild(this.gridOverlay.root); + + // drum pad panel: floating on body + this.padPanel = new DrumPadPanel(); + this.padPanel.onHit = note => this.addDrumHit(note); + document.body.appendChild(this.padPanel.root); + + this.nav = new NavMenu(); + document.body.appendChild(this.nav.root); + + // wire api events + this.subscriptions.push(this.api.scoreLoaded.on(() => this.updateInsertTickThreshold())); + this.subscriptions.push( + this.api.midiLoad.on(midi => { + this.extendMidi(midi); + }) + ); + this.subscriptions.push( + this.api.playerPositionChanged.on(e => { + if (this.insertTickThreshold !== -1 && e.currentTick >= this.insertTickThreshold) { + this.insertNewSystem(); + } + }) + ); + this.subscriptions.push( + this.api.playedBeatChanged.on(beat => { + this.currentBeat = beat; + }) + ); + this.subscriptions.push( + this.api.playerStateChanged.on(e => { + const isPlaying = e.state === alphaTab.synth.PlayerState.Playing; + if (this.wasPlaying && !isPlaying) { + // Regenerate midi so just-recorded drums become audible on replay. + this.api.loadMidiForScore(); + } + this.wasPlaying = isPlaying; + this.padPanel.setRecording(isPlaying); + }) + ); + + // seed initial system + this.insertNewSystem(); + + if (typeof window !== 'undefined') { + window.api = this.api; + window.alphaTab = alphaTab; + } + } + + private addDrumHit(midiNote: number): void { + if (this.api.playerState !== alphaTab.synth.PlayerState.Playing || !this.currentBeat) { + return; + } + const note = new alphaTab.model.Note(); + note.percussionArticulation = midiNote; + this.currentBeat.addNote(note); + this.currentBeat.isEmpty = false; + + const bar = this.currentBeat.voice.bar; + bar.finish(this.api.settings, null); + + this.api.renderScore(this.score, undefined, { + reuseViewport: true, + firstChangedMasterBar: bar.index + }); + + this.padPanel.flash(midiNote); + } + + private createEmptyBeatsForBar(): alphaTab.model.Beat[] { + const beats: alphaTab.model.Beat[] = []; + for (let i = 0; i < this.rhythm.beatMask.length; i++) { + const beat = new alphaTab.model.Beat(); + beat.duration = this.slotDuration; + beat.isEmpty = true; + beats.push(beat); + } + return beats; + } + + private insertNewMasterBar(): alphaTab.model.MasterBar { + const masterBar = new alphaTab.model.MasterBar(); + masterBar.timeSignatureNumerator = this.rhythm.timeSignatureNumerator; + masterBar.timeSignatureDenominator = this.rhythm.timeSignatureDenominator; + this.score.addMasterBar(masterBar); + + const lookup = new alphaTab.midi.MasterBarTickLookup(); + lookup.tempoChanges.push(new alphaTab.midi.MasterBarTickLookupTempoChange(masterBar.start, this.score.tempo)); + lookup.start = masterBar.start; + lookup.end = masterBar.start + masterBar.calculateDuration(); + lookup.masterBar = masterBar; + this.api.tickCache?.addMasterBar(lookup); + return masterBar; + } + + private insertNewBar(): alphaTab.model.Bar { + const previousBar = this.staff.bars.length > 0 ? this.staff.bars[this.staff.bars.length - 1] : null; + const bar = new alphaTab.model.Bar(); + if (previousBar) { + bar.clef = previousBar.clef; + bar.clefOttava = previousBar.clefOttava; + bar.keySignature = previousBar.keySignature; + bar.keySignatureType = previousBar.keySignatureType; + } + this.staff.addBar(bar); + const voice = new alphaTab.model.Voice(); + bar.addVoice(voice); + + const beats = this.createEmptyBeatsForBar(); + for (let i = 0; i < beats.length; i++) { + voice.addBeat(beats[i]); + this.api.tickCache?.addBeat(beats[i], i * this.slotTicks, this.slotTicks); + } + return bar; + } + + private insertNewSystem(): void { + this.insertTickThreshold = -1; + const currentSystemCount = Math.floor(this.score.masterBars.length / this.score.defaultSystemsLayout); + const neededSystemCount = currentSystemCount + 1; + const neededBars = neededSystemCount * this.score.defaultSystemsLayout; + const lastMasterBarIndex = this.score.masterBars.length - 1; + + let missing = neededBars - this.score.masterBars.length; + while (missing > 0) { + const masterBar = this.insertNewMasterBar(); + const bar = this.insertNewBar(); + const dataBag = new Map(); + masterBar.finish(dataBag); + bar.finish(this.api.settings, dataBag); + missing--; + } + + this.updateInsertTickThreshold(); + this.api.renderScore(this.score, undefined, { + reuseViewport: currentSystemCount > 0, + firstChangedMasterBar: currentSystemCount > 0 ? lastMasterBarIndex : undefined + }); + } + + private updateInsertTickThreshold(): void { + if (this.score.masterBars.length < 2) { + this.insertTickThreshold = -1; + return; + } + const lastBar = this.score.masterBars[this.score.masterBars.length - 2]; + const thresholdPercent = 0.8; + this.insertTickThreshold = lastBar.start + lastBar.calculateDuration() * thresholdPercent; + } + + private extendMidi(midi: alphaTab.midi.MidiFile): void { + let rest: alphaTab.midi.AlphaTabRestEvent | undefined; + const events = midi.tracks[0].events; + for (let i = events.length - 1; i >= 0; i--) { + const e = events[i]; + if (e instanceof alphaTab.midi.AlphaTabRestEvent) { + rest = e; + break; + } + } + if (!rest) { + return; + } + const desiredMs = 60 * 30 * 1000; // 30 min + const tempo = this.api.tickCache!.masterBars[0].tempoChanges[0].tempo; + const desiredTicks = (desiredMs / (60000.0 / (tempo * MIDI_QUARTER_TIME))) | 0; + const endOfTrack = events.pop()! as alphaTab.midi.EndOfTrackEvent; + let tick = rest.tick + MIDI_QUARTER_TIME; + while (tick < desiredTicks) { + events.push(new alphaTab.midi.AlphaTabRestEvent(rest.track, tick, rest.channel)); + tick += MIDI_QUARTER_TIME; + } + endOfTrack.tick = tick; + } + + dispose(): void { + for (const u of this.subscriptions) { + u(); + } + this.subscriptions = []; + this.nav.dispose(); + this.padPanel.dispose(); + this.gridOverlay.dispose(); + this.footer.dispose(); + this.sidebar.dispose(); + this.overlay.dispose(); + this.api.destroy(); + this.root.remove(); + } +} + +function pickSlotDuration(subdivisionsPerBeat: number): alphaTab.model.Duration { + switch (subdivisionsPerBeat) { + case 2: + return alphaTab.model.Duration.Eighth; + case 4: + return alphaTab.model.Duration.Sixteenth; + case 8: + return alphaTab.model.Duration.ThirtySecond; + case 16: + return alphaTab.model.Duration.SixtyFourth; + default: + throw new Error(`unsupported subdivisionsPerBeat: ${subdivisionsPerBeat}`); + } +} diff --git a/packages/playground/src/apps/TestResultsApp.ts b/packages/playground/src/apps/TestResultsApp.ts new file mode 100644 index 000000000..545a74c3b --- /dev/null +++ b/packages/playground/src/apps/TestResultsApp.ts @@ -0,0 +1,352 @@ +import { ByteBuffer } from '@coderline/alphatab/io/ByteBuffer'; +import { ZipReader } from '@coderline/alphatab/zip/ZipReader'; +import { NavMenu } from '../components/NavMenu'; +import { type Mountable, css, html, injectStyles, parseHtml } from '../util/Dom'; + +injectStyles( + 'TestResultsApp', + css` + .at-test-results { + padding: 1rem; + font-family: 'Noto Sans', sans-serif; + min-height: 100vh; + } + .at-test-results > h1 { margin-top: 0; } + .at-test-results-toolbar { margin: 1rem 0; } + .at-test-results-list .at-test-card { + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 4px; + margin: 1rem 0; + padding: 12px; + } + .at-test-results-list .at-test-card.accepted { border-color: green; } + .at-test-results-list .at-test-card-title { + font-weight: 500; + font-size: 1rem; + margin: 0 0 8px 0; + } + .at-test-comparer { position: relative; } + .at-test-comparer .slider { + position: absolute; + top: 30px; + right: 0; + left: 0; + width: 100%; + } + .at-test-comparer .expected, + .at-test-comparer .actual, + .at-test-comparer .diff { + background: #fff; + border: 1px solid red; + position: absolute; + } + .at-test-comparer .expected { left: 0; } + .at-test-comparer .actual { + right: -2px; + box-shadow: -7px 0 10px -5px rgba(0, 0, 0, 0.5); + overflow: hidden; + border-left: 0; + } + .at-test-comparer .actual img { + position: absolute; + right: 0; + top: 0; + border-left: 1px solid red; + } + .at-test-comparer .diff { + display: none; + left: 0; + } + .at-test-card.accepted .diff, + .at-test-card.accepted .expected, + .at-test-card.accepted .actual { border-color: green; } + body.hide-accepted .at-test-card.accepted { display: none; } + + .at-test-controls { + position: absolute; + top: 0; + left: 0; + display: flex; + gap: 12px; + align-items: center; + } + .at-test-controls .btn { + padding: 4px 10px; + background: #6c757d; + border: 0; + color: #fff; + font: inherit; + cursor: pointer; + border-radius: 4px; + } + .at-test-controls .btn[disabled] { opacity: 0.5; cursor: default; } + + .drop-area { + position: fixed; + inset: 0; + background: rgba(255, 255, 255, 0.5); + display: flex; + justify-content: center; + align-items: center; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease-in-out; + } + body.drop .drop-area { opacity: 1; } + .drop-message { + width: 200px; + height: 200px; + font-weight: bold; + border: 5px dashed #426d9d; + color: #426d9d; + display: flex; + text-align: center; + border-radius: 10px; + justify-content: center; + align-items: center; + padding: 1rem; + } + .at-test-no-failures { + padding: 12px; + background: #d1e7dd; + color: #0f5132; + border-radius: 4px; + } +` +); + +interface TestResult { + originalFile: string; + newFile: string | Uint8Array; + diffFile: string | Uint8Array; + accepted?: true; +} + +export class TestResultsApp implements Mountable { + readonly root: HTMLElement; + private listEl: HTMLElement; + private remainingEl: HTMLElement; + private currentResults: TestResult[] = []; + private nav: NavMenu; + + private onDragOver = (e: DragEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'link'; + } + }; + private onDragEnter = () => document.body.classList.add('drop'); + private onDragLeave = () => document.body.classList.remove('drop'); + private onDrop = (e: DragEvent) => this.handleDrop(e); + + constructor() { + this.root = parseHtml(html` +
+

alphaTab — Visual Test Results

+

+ This page shows failing visual tests for review and acceptance. + Run npm run test to populate results, or drop a results zip onto the page. +

+
+ +
+
+
Drop test-results zip here
+
+ `); + this.listEl = this.root.querySelector('.at-test-results-list')!; + this.remainingEl = this.root.querySelector('.at-test-remaining')!; + + this.root.querySelector('.at-test-hide-accepted')!.onchange = e => { + document.body.classList.toggle('hide-accepted', (e.target as HTMLInputElement).checked); + }; + + document.body.addEventListener('dragover', this.onDragOver, false); + document.body.addEventListener('dragenter', this.onDragEnter, true); + document.body.addEventListener('dragleave', this.onDragLeave); + document.body.addEventListener('drop', this.onDrop, true); + + this.nav = new NavMenu(); + document.body.appendChild(this.nav.root); + + this.loadFromServer(); + } + + private async loadFromServer(): Promise { + try { + const res = await fetch('/test-results/list'); + const list = (await res.json()) as TestResult[]; + this.displayResults(list); + } catch { + alert('error loading test results'); + } + } + + private updateRemaining(): void { + if (this.currentResults.length === 0) { + this.remainingEl.textContent = ''; + return; + } + const remaining = this.currentResults.filter(r => !r.accepted).length; + this.remainingEl.textContent = `(${remaining}/${this.currentResults.length})`; + } + + private async displayResults(results: TestResult[]): Promise { + this.listEl.replaceChildren(); + this.currentResults = results; + if (results.length === 0) { + const banner = parseHtml( + html`
No reported errors on visual tests.
` + ); + this.listEl.appendChild(banner); + this.updateRemaining(); + return; + } + for (const result of results) { + this.listEl.appendChild(await this.createResultCard(result)); + } + this.updateRemaining(); + } + + private async createResultCard(result: TestResult): Promise { + const card = parseHtml(html` +
+
${result.originalFile}
+
+
expected
+
actual
+
diff
+ +
+ + +
+
+
+ `); + const comparer = card.querySelector('.at-test-comparer')!; + const ex = comparer.querySelector('.expected')!; + const ac = comparer.querySelector('.actual')!; + const df = comparer.querySelector('.diff')!; + const slider = comparer.querySelector('.slider')!; + const exImg = ex.querySelector('img')!; + const acImg = ac.querySelector('img')!; + const dfImg = df.querySelector('img')!; + + await Promise.allSettled([ + loadImage(exImg, result.originalFile), + loadImage(acImg, result.newFile), + loadImage(dfImg, result.diffFile) + ]); + + const width = Math.max(exImg.width, acImg.width); + const height = Math.max(exImg.height, acImg.height); + const controlsHeight = 60; + comparer.style.width = `${width}px`; + comparer.style.height = `${height + controlsHeight}px`; + ex.style.width = `${width}px`; + ex.style.height = `${height}px`; + ex.style.top = `${controlsHeight}px`; + ac.style.width = `${width / 2}px`; + ac.style.height = `${height}px`; + ac.style.top = `${controlsHeight}px`; + df.style.width = `${width}px`; + df.style.height = `${height}px`; + df.style.top = `${controlsHeight}px`; + + slider.oninput = () => { + ac.style.width = `${width * (1 - slider.valueAsNumber)}px`; + }; + comparer.querySelector('.diff-toggle')!.onchange = e => { + df.style.display = (e.target as HTMLInputElement).checked ? 'block' : 'none'; + }; + const acceptBtn = comparer.querySelector('.accept')!; + acceptBtn.onclick = async () => { + acceptBtn.disabled = true; + acceptBtn.textContent = 'Accepting...'; + try { + const res = await fetch('/test-results/accept', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(result) + }); + const body = await res.json(); + acceptBtn.textContent = body.message ?? 'Accepted'; + card.classList.add('accepted'); + result.accepted = true; + this.updateRemaining(); + } catch { + alert('error accepting test result'); + acceptBtn.disabled = false; + acceptBtn.textContent = 'Accept'; + } + }; + return card; + } + + private handleDrop(e: DragEvent): void { + e.stopPropagation(); + e.preventDefault(); + document.body.classList.remove('drop'); + const files = e.dataTransfer?.files; + if (!files || files.length !== 1) { + return; + } + const reader = new FileReader(); + reader.onload = data => { + const buffer = data.target?.result; + if (!(buffer instanceof ArrayBuffer)) { + return; + } + const zip = new ZipReader(ByteBuffer.fromBuffer(new Uint8Array(buffer))); + const entries = zip.read(); + const grouped = new Map(); + for (const entry of entries) { + if (entry.data.length === 0) { + continue; + } + const path = entry.fullName.startsWith('test-data/') ? entry.fullName : `test-data/${entry.fullName}`; + const key = `${path.replace('.diff.png', '').replace('.new.png', '')}.png`; + let result = grouped.get(key); + if (!result) { + result = { originalFile: key, newFile: '', diffFile: '' }; + grouped.set(key, result); + } + if (entry.fullName.endsWith('.diff.png')) { + result.diffFile = entry.data; + } else if (entry.fullName.endsWith('.new.png')) { + result.newFile = entry.data; + } + } + this.displayResults(Array.from(grouped.values())); + }; + reader.readAsArrayBuffer(files[0]); + } + + dispose(): void { + document.body.removeEventListener('dragover', this.onDragOver, false); + document.body.removeEventListener('dragenter', this.onDragEnter, true); + document.body.removeEventListener('dragleave', this.onDragLeave); + document.body.removeEventListener('drop', this.onDrop, true); + this.nav.dispose(); + this.root.remove(); + } +} + +function loadImage(img: HTMLImageElement, source: string | Uint8Array): Promise { + return new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = () => reject(); + if (source instanceof Uint8Array) { + img.src = URL.createObjectURL(new Blob([source.buffer as ArrayBuffer], { type: 'image/png' })); + } else if (typeof source === 'string' && source.length > 0) { + img.src = `/${source}`; + } else { + reject(); + } + }); +} diff --git a/packages/playground/src/apps/YoutubeSyncApp.ts b/packages/playground/src/apps/YoutubeSyncApp.ts new file mode 100644 index 000000000..9f04c2f9e --- /dev/null +++ b/packages/playground/src/apps/YoutubeSyncApp.ts @@ -0,0 +1,136 @@ +import * as alphaTab from '@coderline/alphatab'; +import { Footer } from '../components/Footer'; +import { LoadingOverlay } from '../components/LoadingOverlay'; +import { NavMenu } from '../components/NavMenu'; +import { Sidebar } from '../components/Sidebar'; +import { YoutubeSync } from '../components/youtube-sync/YoutubeSync'; +import { type Mountable, css, html, injectStyles, mount, parseHtml } from '../util/Dom'; +import { Paths } from '../util/Paths'; + +injectStyles( + 'YoutubeSyncApp', + css` + .at-yt-app { + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; + } + .at-yt-app > .cmp-yt { + flex: 0 0 auto; + } + .at-yt-app > .at-wrap-yt { + position: relative; + flex: 1 1 auto; + margin: 0; + background: #fff; + display: flex; + flex-direction: column; + overflow: hidden; + } + .at-yt-app > .at-wrap-yt > .at-content { + flex: 1 1 auto; + overflow: hidden; + position: relative; + } + .at-yt-app .at-viewport { + overflow-y: auto; + position: absolute; + top: 0; + left: 70px; + right: 0; + bottom: 0; + padding-right: 20px; + } +` +); + +export interface YoutubeSyncAppOptions { + videoId?: string; + file?: string; +} + +export class YoutubeSyncApp implements Mountable { + readonly root: HTMLElement; + readonly api: alphaTab.AlphaTabApi; + private nav: NavMenu; + private overlay: LoadingOverlay; + private sidebar: Sidebar; + private footer: Footer; + private youtube: YoutubeSync; + private subscriptions: (() => void)[] = []; + + constructor(options: YoutubeSyncAppOptions = {}) { + this.root = parseHtml(html` +
+
+
+
+
+
+
+
+
+
+ +
+
+ `); + + const viewport = this.root.querySelector('.at-viewport')!; + const canvas = this.root.querySelector('.at-canvas')!; + + const settings = new alphaTab.Settings(); + settings.fillFromJson({ + core: { + file: options.file ?? '/test-data/guitarpro8/canon-audio-track.gp', + fontDirectory: Paths.fontDirectory + }, + player: { + playerMode: alphaTab.PlayerMode.EnabledExternalMedia, + soundFont: Paths.soundFont, + scrollOffsetX: -10, + scrollOffsetY: -20, + scrollElement: viewport + } + } satisfies alphaTab.json.SettingsJson); + + this.api = new alphaTab.AlphaTabApi(canvas, settings); + this.subscriptions.push(this.api.error.on(e => console.error('alphaTab error', e))); + + this.overlay = mount(this.root, '.cmp-overlay', new LoadingOverlay(this.api)); + this.sidebar = mount(this.root, '.cmp-sidebar', new Sidebar(this.api)); + this.footer = mount( + this.root, + '.cmp-footer', + new Footer(this.api, { trackList: this.sidebar.trackList }) + ); + this.youtube = mount( + this.root, + '.cmp-yt', + new YoutubeSync(this.api, { videoId: options.videoId ?? 'by8oyJztzwo' }) + ); + + this.nav = new NavMenu(); + document.body.appendChild(this.nav.root); + + if (typeof window !== 'undefined') { + window.api = this.api; + window.alphaTab = alphaTab; + } + } + + dispose(): void { + for (const u of this.subscriptions) { + u(); + } + this.subscriptions = []; + this.nav.dispose(); + this.youtube.dispose(); + this.footer.dispose(); + this.sidebar.dispose(); + this.overlay.dispose(); + this.api.destroy(); + this.root.remove(); + } +} diff --git a/packages/playground/src/components/AudioExporter.ts b/packages/playground/src/components/AudioExporter.ts new file mode 100644 index 000000000..ca6f5eb11 --- /dev/null +++ b/packages/playground/src/components/AudioExporter.ts @@ -0,0 +1,103 @@ +import * as alphaTab from '@coderline/alphatab'; +import { Paths } from '../util/Paths'; +import type { TrackItem } from './TrackItem'; + +function downloadBlob(blob: Blob, filename: string): void { + const a = document.createElement('a'); + a.download = filename; + a.href = URL.createObjectURL(blob); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(a.href); +} + +export function exportGp7(api: alphaTab.AlphaTabApi): void { + if (!api.score) { + return; + } + const exporter = new alphaTab.exporter.Gp7Exporter(); + const data = exporter.export(api.score, api.settings); + const filename = api.score.title.length > 0 ? `${api.score.title}.gp` : 'song.gp'; + downloadBlob(new Blob([data as Uint8Array]), filename); +} + +async function fetchSoundFont(url: string): Promise { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to load soundfont '${url}': ${res.status} ${res.statusText}`); + } + return new Uint8Array(await res.arrayBuffer()); +} + +export async function exportAudio(api: alphaTab.AlphaTabApi, trackItems: readonly TrackItem[] = []): Promise { + if (!api.score) { + return; + } + const options = new alphaTab.synth.AudioExportOptions(); + options.sampleRate = 44100; + options.masterVolume = api.masterVolume; + options.metronomeVolume = api.metronomeVolume; + if (api.playbackRange) { + options.playbackRange = api.playbackRange; + } + + const soloed = new Set(); + for (const item of trackItems) { + const idx = item.track.index; + const ratio = item.getVolume() / Math.max(1, item.track.playbackInfo.volume); + options.trackVolume.set(idx, item.isMuted() ? 0 : ratio); + if (item.isSoloed()) { + soloed.add(idx); + } + } + if (soloed.size > 0) { + for (const t of api.score.tracks) { + if (!soloed.has(t.index)) { + options.trackVolume.set(t.index, 0); + } + } + } + + options.soundFonts = [await fetchSoundFont(Paths.soundFont)]; + + const exporter = await api.exportAudio(options); + let generated: Float32Array | undefined; + let totalSamples = 0; + try { + while (true) { + const chunk = await exporter.render(500); + if (!chunk) { + break; + } + if (!generated) { + generated = new Float32Array(options.sampleRate * (chunk.endTime / 1000) * 2); + } + const needed = totalSamples + chunk.samples.length; + if (generated.length < needed) { + const grown = new Float32Array(needed); + grown.set(generated, 0); + generated = grown; + } + generated.set(chunk.samples, totalSamples); + totalSamples += chunk.samples.length; + } + } finally { + exporter.destroy(); + } + + if (!generated) { + return; + } + if (totalSamples < generated.length) { + generated = generated.subarray(0, totalSamples); + } + const filename = + api.score.title.length > 0 + ? `${api.score.title}_${options.sampleRate}_float32.pcm` + : `song_${options.sampleRate}_float32.pcm`; + const blob = new Blob([ + new Uint8Array(generated.buffer as ArrayBuffer, generated.byteOffset, generated.byteLength) + ]); + downloadBlob(blob, filename); +} diff --git a/packages/playground/src/components/Crosshair.ts b/packages/playground/src/components/Crosshair.ts new file mode 100644 index 000000000..c1c0bd207 --- /dev/null +++ b/packages/playground/src/components/Crosshair.ts @@ -0,0 +1,96 @@ +import { css, injectStyles } from '../util/Dom'; + +injectStyles( + 'Crosshair', + css` + .at-crosshair { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + pointer-events: none; + z-index: 20000; + } + .at-crosshair-x, + .at-crosshair-y { + position: absolute; + pointer-events: none; + } + .at-crosshair-y { + width: 100vw; + border-bottom: 1px dotted #000; + height: 1px; + left: 0; + } + .at-crosshair-x { + height: 100vh; + border-left: 1px dotted #000; + width: 1px; + top: 0; + } +` +); + +/** + * Behaviour-only debug helper: while CapsLock is on, draws a full-viewport crosshair + * that follows the mouse. No DOM until activated; cleaned up when CapsLock turns off. + */ +export class Crosshair { + private root: HTMLElement | null = null; + private xLine: HTMLElement | null = null; + private yLine: HTMLElement | null = null; + private active = false; + + private onKeyDown = (e: KeyboardEvent) => { + const should = e.getModifierState('CapsLock'); + if (should !== this.active) { + if (should) { + this.show(); + } else { + this.hide(); + } + } + }; + + private onMouseMove = (e: MouseEvent) => { + if (this.xLine) { + this.xLine.style.left = `${e.pageX}px`; + } + if (this.yLine) { + this.yLine.style.top = `${e.pageY}px`; + } + }; + + constructor() { + document.addEventListener('keydown', this.onKeyDown); + } + + private show(): void { + this.active = true; + this.root = document.createElement('div'); + this.root.className = 'at-crosshair'; + this.xLine = document.createElement('div'); + this.xLine.className = 'at-crosshair-x'; + this.yLine = document.createElement('div'); + this.yLine.className = 'at-crosshair-y'; + this.root.appendChild(this.xLine); + this.root.appendChild(this.yLine); + document.body.appendChild(this.root); + document.addEventListener('mousemove', this.onMouseMove, true); + } + + private hide(): void { + this.active = false; + document.removeEventListener('mousemove', this.onMouseMove, true); + this.root?.remove(); + this.root = null; + this.xLine = null; + this.yLine = null; + } + + dispose(): void { + document.removeEventListener('keydown', this.onKeyDown); + this.hide(); + } +} diff --git a/packages/playground/src/components/DragDrop.ts b/packages/playground/src/components/DragDrop.ts new file mode 100644 index 000000000..65cc281e5 --- /dev/null +++ b/packages/playground/src/components/DragDrop.ts @@ -0,0 +1,64 @@ +import type * as alphaTab from '@coderline/alphatab'; + +export interface DragDropOptions { + onEnter?: () => void; + onLeave?: () => void; +} + +/** + * Behaviour-only component: attaches document-level drag/drop handlers that + * load a dropped file into the alphaTab API. Does not render any DOM of its own. + */ +export class DragDrop { + private over = (e: DragEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = 'copy'; + } + if (!this.dragging) { + this.dragging = true; + this.options.onEnter?.(); + } + }; + private leave = (e: DragEvent) => { + // The dragleave fires on every child element transition. Reset only when leaving the document. + if (e.relatedTarget === null) { + this.dragging = false; + this.options.onLeave?.(); + } + }; + private drop = (e: DragEvent) => { + e.stopPropagation(); + e.preventDefault(); + this.dragging = false; + this.options.onLeave?.(); + const files = e.dataTransfer?.files; + if (files && files.length === 1) { + const reader = new FileReader(); + reader.onload = data => { + if (data.target?.result) { + this.api.load(data.target.result, [0]); + } + }; + reader.readAsArrayBuffer(files[0]); + } + }; + + private dragging = false; + + constructor( + private api: alphaTab.AlphaTabApi, + private options: DragDropOptions = {} + ) { + document.addEventListener('dragover', this.over); + document.addEventListener('dragleave', this.leave); + document.addEventListener('drop', this.drop); + } + + dispose(): void { + document.removeEventListener('dragover', this.over); + document.removeEventListener('dragleave', this.leave); + document.removeEventListener('drop', this.drop); + } +} diff --git a/packages/playground/src/components/Footer.ts b/packages/playground/src/components/Footer.ts new file mode 100644 index 000000000..cfe52e242 --- /dev/null +++ b/packages/playground/src/components/Footer.ts @@ -0,0 +1,53 @@ +import type * as alphaTab from '@coderline/alphatab'; +import { type Mountable, css, html, injectStyles, mount, parseHtml } from '../util/Dom'; +import { TimeSlider } from './TimeSlider'; +import { TransportBar } from './TransportBar'; +import type { TrackList } from './TrackList'; +import { Waveform } from './Waveform'; + +injectStyles( + 'Footer', + css` + .at-footer { + flex: 0 0 auto; + background: var(--at-footer-bg); + color: var(--at-footer-fg); + } + .at-footer a { color: inherit; text-decoration: none; } +` +); + +export interface FooterOptions { + trackList?: TrackList; +} + +export class Footer implements Mountable { + readonly root: HTMLElement; + readonly waveform: Waveform; + readonly timeSlider: TimeSlider; + readonly transport: TransportBar; + + constructor(api: alphaTab.AlphaTabApi, options: FooterOptions = {}) { + this.root = parseHtml(html` + + `); + this.waveform = mount(this.root, '.cmp-waveform', new Waveform(api)); + this.timeSlider = mount(this.root, '.cmp-time-slider', new TimeSlider(api)); + this.transport = mount( + this.root, + '.cmp-transport', + new TransportBar(api, { trackList: options.trackList }) + ); + } + + dispose(): void { + this.waveform.dispose(); + this.timeSlider.dispose(); + this.transport.dispose(); + this.root.remove(); + } +} diff --git a/packages/playground/src/components/LoadingOverlay.ts b/packages/playground/src/components/LoadingOverlay.ts new file mode 100644 index 000000000..d5e743639 --- /dev/null +++ b/packages/playground/src/components/LoadingOverlay.ts @@ -0,0 +1,86 @@ +import type * as alphaTab from '@coderline/alphatab'; +import { type Mountable, css, html, injectStyles, mount, parseHtml } from '../util/Dom'; +import { Spinner } from './primitives/Spinner'; + +injectStyles( + 'LoadingOverlay', + css` + .at-overlay { + display: flex; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1002; + backdrop-filter: blur(3px); + background: var(--at-overlay-bg); + justify-content: center; + align-items: flex-start; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease-in-out, visibility 0.2s ease-in-out; + transition-delay: 0; + } + .at-overlay.visible { + visibility: visible; + opacity: 1; + transition-delay: 0.2s; + } + .at-overlay > .at-overlay-content { + margin-top: 20px; + background: #fff; + box-shadow: 0px 5px 10px 0px rgba(0, 0, 0, 0.3); + padding: 10px; + } +` +); + +export class LoadingOverlay implements Mountable { + readonly root: HTMLElement; + private unsubRenderStarted: () => void; + private unsubRenderFinished: () => void; + private dragCount = 0; + + constructor(api: alphaTab.AlphaTabApi) { + this.root = parseHtml(html` +
+
+
+
+
+ `); + mount(this.root, '.cmp-spinner', new Spinner()); + + this.unsubRenderStarted = api.renderStarted.on(isResize => { + if (!isResize) { + this.setVisible(true); + } + }); + this.unsubRenderFinished = api.renderFinished.on(() => { + this.setVisible(false); + }); + } + + setVisible(visible: boolean): void { + this.root.classList.toggle('visible', visible); + } + + enterDrag(): void { + this.dragCount++; + this.setVisible(true); + } + + leaveDrag(): void { + this.dragCount = Math.max(0, this.dragCount - 1); + if (this.dragCount === 0) { + this.setVisible(false); + } + } + + dispose(): void { + this.unsubRenderStarted(); + this.unsubRenderFinished(); + this.root.remove(); + } +} diff --git a/packages/playground/src/components/NavMenu.ts b/packages/playground/src/components/NavMenu.ts new file mode 100644 index 000000000..375109ac7 --- /dev/null +++ b/packages/playground/src/components/NavMenu.ts @@ -0,0 +1,171 @@ +import { createPopper, type Instance as PopperInstance } from '@popperjs/core'; +import { type DemoEntry, DEMOS } from '../util/Demos'; +import { type Mountable, css, html, injectStyles, parseHtml } from '../util/Dom'; +import { Icons, icon as renderIcon } from '../util/Icons'; + +injectStyles( + 'NavMenu', + css` + .at-nav-menu { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 10000; + } + .at-nav-menu-trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: rgba(33, 37, 41, 0.92); + color: #fff; + border: 0; + border-radius: 6px; + cursor: pointer; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18); + transition: background-color 0.15s ease-in-out; + } + .at-nav-menu-trigger:hover { background: rgba(33, 37, 41, 1); } + .at-nav-menu-trigger > svg { width: 18px; height: 18px; } + + .at-nav-menu-popover { + position: fixed; + top: 0; + left: 0; + z-index: 20000; + min-width: 220px; + background: #fff; + color: #212529; + border-radius: 6px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18); + padding: 6px 0; + opacity: 0; + visibility: hidden; + transition: opacity 0.12s ease-in-out; + } + .at-nav-menu-popover.open { opacity: 1; visibility: visible; } + .at-nav-menu-item { + display: block; + padding: 8px 14px; + color: inherit; + text-decoration: none; + font-size: 0.9rem; + } + .at-nav-menu-item:hover { background: rgba(0, 0, 0, 0.06); } + .at-nav-menu-item.active { background: rgba(73, 114, 161, 0.12); font-weight: 500; } + .at-nav-menu-divider { + height: 1px; + background: rgba(0, 0, 0, 0.08); + margin: 4px 0; + } +` +); + +export interface NavMenuOptions { + /** Demos to list. Defaults to the shared `DEMOS` array. */ + items?: DemoEntry[]; + /** Whether to include a "Labs index" link to /. Default: true. */ + includeIndex?: boolean; +} + +export class NavMenu implements Mountable { + readonly root: HTMLElement; + private trigger: HTMLButtonElement; + private popover: HTMLElement; + private popper: PopperInstance | null = null; + private isOpen = false; + + private outsideClick = (e: MouseEvent) => { + if (!this.root.contains(e.target as Node) && !this.popover.contains(e.target as Node)) { + this.close(); + } + }; + private onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && this.isOpen) { + e.preventDefault(); + this.close(); + this.trigger.focus(); + } + }; + + constructor(options: NavMenuOptions = {}) { + this.root = parseHtml(html` +
+ +
+ `); + this.trigger = this.root.querySelector('.at-nav-menu-trigger')!; + this.trigger.appendChild(renderIcon(Icons.Menu)); + + this.popover = parseHtml(html``); + + const items = options.items ?? DEMOS; + const includeIndex = options.includeIndex ?? true; + const here = window.location.pathname; + + if (includeIndex) { + const indexLink = parseHtml( + html`Labs index` + ); + if (here === '/' || here === '/index.html') { + indexLink.classList.add('active'); + } + this.popover.appendChild(indexLink); + this.popover.appendChild(parseHtml(html`
`)); + } + + for (const item of items) { + const link = parseHtml( + html`${item.title}` + ); + if (here === item.href || here === `${item.href}index.html`) { + link.classList.add('active'); + } + this.popover.appendChild(link); + } + + this.trigger.addEventListener('click', e => { + e.stopPropagation(); + if (this.isOpen) { + this.close(); + } else { + this.open(); + } + }); + } + + private open(): void { + if (!this.popover.parentElement) { + document.body.appendChild(this.popover); + } + if (!this.popper) { + this.popper = createPopper(this.trigger, this.popover, { + placement: 'bottom-end', + modifiers: [{ name: 'offset', options: { offset: [0, 6] } }] + }); + } else { + this.popper.update(); + } + this.isOpen = true; + this.popover.classList.add('open'); + this.trigger.setAttribute('aria-expanded', 'true'); + document.addEventListener('mousedown', this.outsideClick, true); + document.addEventListener('keydown', this.onKey, true); + } + + private close(): void { + this.isOpen = false; + this.popover.classList.remove('open'); + this.trigger.setAttribute('aria-expanded', 'false'); + document.removeEventListener('mousedown', this.outsideClick, true); + document.removeEventListener('keydown', this.onKey, true); + } + + dispose(): void { + this.close(); + this.popper?.destroy(); + this.popover.remove(); + this.root.remove(); + } +} diff --git a/packages/playground/src/components/SelectionHandles.ts b/packages/playground/src/components/SelectionHandles.ts new file mode 100644 index 000000000..e471f779d --- /dev/null +++ b/packages/playground/src/components/SelectionHandles.ts @@ -0,0 +1,155 @@ +import type * as alphaTab from '@coderline/alphatab'; +import { type Mountable, css, html, injectStyles, parseHtml } from '../util/Dom'; + +injectStyles( + 'SelectionHandles', + css` + .at-selection-handles { + position: absolute; + pointer-events: none; + z-index: 1001; + display: inline; + top: 0; + left: 0; + right: 0; + bottom: 0; + } + .at-selection-handle { + position: absolute; + pointer-events: auto; + cursor: ew-resize; + background: #7cb9ff; + width: 4px; + opacity: 0; + transition: opacity 150ms ease-in-out; + display: none; + } + .at-selection-handle:hover, + .at-selection-handle.dragging { + opacity: 1; + } + .at-selection-handle.active { display: block; } + .at-selection-handle-drag * { cursor: ew-resize !important; } +` +); + +type DragSide = 'start' | 'end'; + +export class SelectionHandles implements Mountable { + readonly root: HTMLElement; + private startHandle: HTMLElement; + private endHandle: HTMLElement; + private currentHighlight: alphaTab.PlaybackHighlightChangeEventArgs | undefined; + private dragging: DragSide | undefined; + private unsubHighlight: () => void; + + private onMouseMove = (e: MouseEvent) => { + if (!this.dragging) { + return; + } + e.preventDefault(); + const beat = this.beatFromEvent(e); + if (!beat || !this.currentHighlight) { + return; + } + if (this.dragging === 'start') { + this.api.highlightPlaybackRange(beat, this.currentHighlight.endBeat!); + } else { + this.api.highlightPlaybackRange(this.currentHighlight.startBeat!, beat); + } + }; + + private onMouseUp = (e: MouseEvent) => { + if (!this.dragging) { + return; + } + e.preventDefault(); + this.endDrag(); + this.api.applyPlaybackRangeFromHighlight(); + }; + + constructor( + private api: alphaTab.AlphaTabApi, + private viewportEl: HTMLElement + ) { + this.root = parseHtml(html` +
+
+
+
+ `); + this.startHandle = this.root.querySelector('.at-selection-handle-start')!; + this.endHandle = this.root.querySelector('.at-selection-handle-end')!; + + this.startHandle.addEventListener('mousedown', e => this.beginDrag(e, 'start')); + this.endHandle.addEventListener('mousedown', e => this.beginDrag(e, 'end')); + document.addEventListener('mousemove', this.onMouseMove, true); + document.addEventListener('mouseup', this.onMouseUp, true); + + this.unsubHighlight = api.playbackRangeHighlightChanged.on(e => this.update(e)); + } + + private beginDrag(e: MouseEvent, side: DragSide): void { + e.preventDefault(); + this.dragging = side; + this.viewportEl.classList.add('at-selection-handle-drag'); + const handle = side === 'start' ? this.startHandle : this.endHandle; + handle.classList.add('dragging'); + } + + private endDrag(): void { + if (!this.dragging) { + return; + } + const handle = this.dragging === 'start' ? this.startHandle : this.endHandle; + handle.classList.remove('dragging'); + this.viewportEl.classList.remove('at-selection-handle-drag'); + this.dragging = undefined; + } + + private update(e: alphaTab.PlaybackHighlightChangeEventArgs): void { + this.currentHighlight = e; + if (!e.startBeat || !e.endBeat) { + this.startHandle.classList.remove('active'); + this.endHandle.classList.remove('active'); + return; + } + const startBounds = e.startBeatBounds!; + const endBounds = e.endBeatBounds!; + this.startHandle.classList.add('active'); + this.startHandle.style.left = `${startBounds.realBounds.x}px`; + this.startHandle.style.top = `${startBounds.barBounds.masterBarBounds.visualBounds.y}px`; + this.startHandle.style.height = `${startBounds.barBounds.masterBarBounds.visualBounds.h}px`; + this.endHandle.classList.add('active'); + this.endHandle.style.left = `${endBounds.realBounds.x + endBounds.realBounds.w}px`; + this.endHandle.style.top = `${endBounds.barBounds.masterBarBounds.visualBounds.y}px`; + this.endHandle.style.height = `${endBounds.barBounds.masterBarBounds.visualBounds.h}px`; + } + + private beatFromEvent(e: MouseEvent): alphaTab.model.Beat | undefined { + const rect = this.viewportEl.getBoundingClientRect(); + const relX = e.clientX - rect.left; + const relY = e.clientY - rect.top; + const beat = this.api.boundsLookup?.getBeatAtPos(relX, relY); + if (!beat) { + return undefined; + } + const bounds = this.api.boundsLookup!.findBeat(beat); + if (!bounds) { + return undefined; + } + const visualEnd = bounds.visualBounds.x + bounds.visualBounds.w; + const realEnd = bounds.realBounds.x + bounds.realBounds.w; + if (relX < visualEnd || relX > realEnd) { + return undefined; + } + return beat; + } + + dispose(): void { + document.removeEventListener('mousemove', this.onMouseMove, true); + document.removeEventListener('mouseup', this.onMouseUp, true); + this.unsubHighlight(); + this.root.remove(); + } +} diff --git a/packages/playground/src/components/Sidebar.ts b/packages/playground/src/components/Sidebar.ts new file mode 100644 index 000000000..84bc420b0 --- /dev/null +++ b/packages/playground/src/components/Sidebar.ts @@ -0,0 +1,56 @@ +import type * as alphaTab from '@coderline/alphatab'; +import { type Mountable, css, html, injectStyles, mount, parseHtml } from '../util/Dom'; +import { TrackList } from './TrackList'; + +injectStyles( + 'Sidebar', + css` + .at-sidebar { + max-width: 70px; + width: auto; + display: flex; + align-content: stretch; + z-index: 1001; + position: absolute; + top: 0; + left: 0; + bottom: 0; + overflow: hidden; + border-right: 1px solid rgba(0, 0, 0, 0.12); + background: var(--at-sidebar-bg); + } + .at-sidebar:hover { + max-width: 400px; + transition: max-width 0.2s; + overflow-y: auto; + } + .at-sidebar > .at-sidebar-content { + flex: 1 1 auto; + min-width: 0; + } + @media screen and (max-width: 1100px) { + .at-sidebar { display: none; } + } +` +); + +export class Sidebar implements Mountable { + readonly root: HTMLElement; + readonly trackList: TrackList; + + constructor(api: alphaTab.AlphaTabApi) { + this.root = parseHtml(html` +
+
+
+
+
+ `); + this.trackList = mount(this.root, '.cmp-track-list', new TrackList(api)); + } + + dispose(): void { + this.trackList.dispose(); + this.root.remove(); + } +} diff --git a/packages/playground/src/components/TimeSlider.ts b/packages/playground/src/components/TimeSlider.ts new file mode 100644 index 000000000..5784ed2b9 --- /dev/null +++ b/packages/playground/src/components/TimeSlider.ts @@ -0,0 +1,48 @@ +import type * as alphaTab from '@coderline/alphatab'; +import { type Mountable, css, html, injectStyles, mount, parseHtml } from '../util/Dom'; +import { ProgressBar } from './primitives/ProgressBar'; + +injectStyles( + 'TimeSlider', + css` + .at-time-slider-wrap { width: 100%; } +` +); + +export class TimeSlider implements Mountable { + readonly root: HTMLElement; + private bar: ProgressBar; + private endTime = 0; + private unsubMidiLoaded: () => void; + private unsubPosition: () => void; + + constructor(api: alphaTab.AlphaTabApi) { + this.root = parseHtml(html` +
+
+
+ `); + this.bar = mount(this.root, '.cmp-progress', new ProgressBar()); + this.bar.onClickPercent = percent => { + if (this.endTime > 0) { + api.timePosition = Math.floor(this.endTime * percent); + } + }; + + this.unsubMidiLoaded = api.midiLoaded.on(e => { + this.endTime = e.endTime; + }); + this.unsubPosition = api.playerPositionChanged.on(args => { + if (args.endTime > 0) { + this.bar.setValue(args.currentTime / args.endTime); + } + }); + } + + dispose(): void { + this.unsubMidiLoaded(); + this.unsubPosition(); + this.bar.dispose(); + this.root.remove(); + } +} diff --git a/packages/playground/src/components/TrackItem.ts b/packages/playground/src/components/TrackItem.ts new file mode 100644 index 000000000..b7b50e772 --- /dev/null +++ b/packages/playground/src/components/TrackItem.ts @@ -0,0 +1,180 @@ +import type * as alphaTab from '@coderline/alphatab'; +import { type Mountable, css, html, injectStyles, mount, parseHtml } from '../util/Dom'; +import { type IconNode, Icons, icon as renderIcon } from '../util/Icons'; +import { Slider } from './primitives/Slider'; +import { ToggleButton } from './primitives/ToggleButton'; + +function pickTrackIcon(track: alphaTab.model.Track): IconNode { + if (track.playbackInfo.primaryChannel === 9 || track.staves[0]?.isPercussion) { + return Icons.TrackDrum; + } + const program = track.playbackInfo.program; + if (program >= 0 && program <= 7) { + // Acoustic Grand .. Clavi + return Icons.TrackPiano; + } + if ((program >= 52 && program <= 54) || program === 85) { + // Choir Aahs / Voice Oohs / Synth Voice / Lead 6 (voice) + return Icons.TrackVoice; + } + if (program >= 112 && program <= 119) { + // Percussive program range (Tinkle Bell .. Reverse Cymbal) + return Icons.TrackDrum; + } + return Icons.Track; +} + +injectStyles( + 'TrackItem', + css` + .at-track { + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto; + grid-template-areas: 'icon title' 'icon controls'; + padding: 5px; + transition: background 0.2s; + grid-gap: 5px; + cursor: pointer; + } + .at-track:hover { background: rgba(0, 0, 0, 0.1); } + .at-track.active { background: var(--at-track-active-bg); } + .at-track > .at-track-icon { + grid-area: icon; + font-size: 32px; + display: flex; + justify-content: center; + align-items: center; + opacity: 0.5; + transition: opacity 0.2s; + width: 64px; + height: 64px; + color: inherit; + } + .at-track > .at-track-icon > svg { width: 32px; height: 32px; } + .at-track:hover > .at-track-icon { opacity: 0.8; } + .at-track.active > .at-track-icon { color: var(--at-accent); opacity: 1; } + .at-track > .at-track-name { + grid-area: title; + font-weight: 500; + } + .at-track > .at-track-controls { + grid-area: controls; + display: flex; + align-items: center; + } + .at-track > .at-track-controls > * { margin: 0 2px; } + .at-track > .at-track-controls > .at-track-mute, + .at-track > .at-track-controls > .at-track-solo { + display: inline-flex; + align-items: center; + height: 26px; + padding: 2px 8px; + font-size: 11px; + border: 1px solid; + background: transparent; + cursor: pointer; + border-radius: 4px; + } + .at-track-mute { color: #dc3545; border-color: #dc3545; } + .at-track-mute.at-toggle-active { background: #dc3545; color: #fff; } + .at-track-solo { color: #198754; border-color: #198754; } + .at-track-solo.at-toggle-active { background: #198754; color: #fff; } + .at-track > .at-track-controls > .at-track-volume-icon { display: inline-flex; } + .at-track > .at-track-controls > .at-track-volume-icon > svg { width: 14px; height: 14px; } +` +); + +export class TrackItem implements Mountable { + readonly root: HTMLElement; + readonly track: alphaTab.model.Track; + + private muteBtn: ToggleButton; + private soloBtn: ToggleButton; + private volume: Slider; + + onSelect: ((e: MouseEvent) => void) | null = null; + + constructor(api: alphaTab.AlphaTabApi, track: alphaTab.model.Track) { + this.track = track; + this.root = parseHtml(html` +
+
+ ${track.name} +
+
+
+ +
+
+
+ `); + this.root.querySelector('.at-track-icon')!.appendChild(renderIcon(pickTrackIcon(track))); + this.root.querySelector('.at-track-volume-icon')!.appendChild(renderIcon(Icons.Volume)); + + this.muteBtn = mount( + this.root, + '.cmp-mute', + new ToggleButton({ icon: Icons.Track, label: 'Mute', tooltip: 'Mute track' }) + ); + // Replace the IconButton svg with simple text button by clearing the icon slot + const muteIconSlot = this.muteBtn.root.querySelector('.at-icon-btn-icon'); + if (muteIconSlot) { + muteIconSlot.remove(); + } + this.muteBtn.root.classList.remove('at-icon-btn'); + this.muteBtn.root.classList.add('at-track-mute'); + this.muteBtn.onChange = active => { + api.changeTrackMute([this.track], active); + }; + + this.soloBtn = mount( + this.root, + '.cmp-solo', + new ToggleButton({ icon: Icons.Track, label: 'Solo', tooltip: 'Solo track' }) + ); + const soloIconSlot = this.soloBtn.root.querySelector('.at-icon-btn-icon'); + if (soloIconSlot) { + soloIconSlot.remove(); + } + this.soloBtn.root.classList.remove('at-icon-btn'); + this.soloBtn.root.classList.add('at-track-solo'); + this.soloBtn.onChange = active => { + api.changeTrackSolo([this.track], active); + }; + + this.volume = mount( + this.root, + '.cmp-volume', + new Slider({ min: 0, max: 16, step: 1, initialValue: track.playbackInfo.volume }) + ); + this.volume.onInput = value => { + api.changeTrackVolume([this.track], value / Math.max(1, this.track.playbackInfo.volume)); + }; + + this.root.addEventListener('click', e => this.onSelect?.(e)); + } + + setActive(active: boolean): void { + this.root.classList.toggle('active', active); + } + + isMuted(): boolean { + return this.muteBtn.isActive(); + } + + isSoloed(): boolean { + return this.soloBtn.isActive(); + } + + getVolume(): number { + return this.volume.getValue(); + } + + dispose(): void { + this.muteBtn.dispose(); + this.soloBtn.dispose(); + this.volume.dispose(); + this.root.remove(); + } +} diff --git a/packages/playground/src/components/TrackList.ts b/packages/playground/src/components/TrackList.ts new file mode 100644 index 000000000..3b6ba4be0 --- /dev/null +++ b/packages/playground/src/components/TrackList.ts @@ -0,0 +1,81 @@ +import type * as alphaTab from '@coderline/alphatab'; +import { type Mountable, css, html, injectStyles, parseHtml } from '../util/Dom'; +import { TrackItem } from './TrackItem'; + +injectStyles( + 'TrackList', + css` + .at-track-list { + display: flex; + flex-direction: column; + } +` +); + +export class TrackList implements Mountable { + readonly root: HTMLElement; + private items: TrackItem[] = []; + private selection = new Map(); + private unsubScoreLoaded: () => void; + private unsubRenderStarted: () => void; + + constructor(private api: alphaTab.AlphaTabApi) { + this.root = parseHtml(html`
`); + + this.unsubScoreLoaded = api.scoreLoaded.on(score => this.rebuild(score)); + this.unsubRenderStarted = api.renderStarted.on(() => this.refreshActive()); + } + + private rebuild(score: alphaTab.model.Score): void { + for (const item of this.items) { + item.dispose(); + } + this.items = []; + this.root.replaceChildren(); + this.selection.clear(); + + for (const track of score.tracks) { + const item = new TrackItem(this.api, track); + item.onSelect = e => { + e.stopPropagation(); + if (!e.ctrlKey) { + this.selection.clear(); + this.selection.set(track.index, track); + } else if (this.selection.has(track.index)) { + this.selection.delete(track.index); + } else { + this.selection.set(track.index, track); + } + this.api.renderTracks(Array.from(this.selection.values()).sort((a, b) => a.index - b.index)); + }; + this.items.push(item); + this.root.appendChild(item.root); + } + this.refreshActive(); + } + + private refreshActive(): void { + const active = new Set(); + for (const t of this.api.tracks) { + active.add(t.index); + } + for (const item of this.items) { + item.setActive(active.has(item.track.index)); + } + } + + /** All TrackItem instances, in score order. */ + getItems(): readonly TrackItem[] { + return this.items; + } + + dispose(): void { + this.unsubScoreLoaded(); + this.unsubRenderStarted(); + for (const item of this.items) { + item.dispose(); + } + this.items = []; + this.root.remove(); + } +} diff --git a/packages/playground/src/components/TransportBar.ts b/packages/playground/src/components/TransportBar.ts new file mode 100644 index 000000000..c9d365119 --- /dev/null +++ b/packages/playground/src/components/TransportBar.ts @@ -0,0 +1,394 @@ +import * as alphaTab from '@coderline/alphatab'; +import { type Mountable, css, html, injectStyles, mount, parseHtml } from '../util/Dom'; +import { Icons } from '../util/Icons'; +import { exportAudio, exportGp7 } from './AudioExporter'; +import { Dropdown, type DropdownItem } from './primitives/Dropdown'; +import { IconButton } from './primitives/IconButton'; +import { LoadingProgress } from './primitives/LoadingProgress'; +import { ToggleButton } from './primitives/ToggleButton'; +import type { TrackList } from './TrackList'; + +injectStyles( + 'TransportBar', + css` + .at-transport { + display: flex; + justify-content: space-between; + align-items: center; + background: var(--at-footer-bg); + color: var(--at-footer-fg); + } + .at-transport-left, + .at-transport-right { + display: flex; + align-items: center; + padding: 3px; + } + .at-transport-left > *, + .at-transport-right > * { + margin-right: 4px; + } + .at-transport-separator { + align-self: stretch; + width: 1px; + margin: 6px 4px; + background: var(--at-divider); + } + .at-song-details { + font-size: 12px; + padding: 0 8px; + } + .at-song-details > .at-song-title { font-weight: 500; } + .at-time-position { + font-weight: bold; + padding: 0 8px; + } + .at-loading-slot { display: inline-flex; align-items: center; } + .at-loading-slot.hidden { display: none; } + + @media screen and (max-width: 920px) { + .at-transport-right > *:not(.at-transport-essential) { display: none !important; } + } + @media screen and (max-width: 1100px) { + .at-transport * { font-size: 12px !important; } + } +` +); + +const SPEED_ITEMS: DropdownItem[] = [ + { value: 0.25, label: '0.25x' }, + { value: 0.5, label: '0.5x' }, + { value: 0.75, label: '0.75x' }, + { value: 0.9, label: '0.9x' }, + { value: 1, label: '1x' }, + { value: 1.1, label: '1.1x' }, + { value: 1.25, label: '1.25x' }, + { value: 1.5, label: '1.5x' }, + { value: 2, label: '2x' } +]; + +const ZOOM_ITEMS: DropdownItem[] = [ + { value: 0.25, label: '25%' }, + { value: 0.5, label: '50%' }, + { value: 0.75, label: '75%' }, + { value: 0.9, label: '90%' }, + { value: 1, label: '100%' }, + { value: 1.1, label: '110%' }, + { value: 1.25, label: '125%' }, + { value: 1.5, label: '150%' }, + { value: 2, label: '200%' } +]; + +const LAYOUT_ITEMS: DropdownItem[] = [ + { value: alphaTab.LayoutMode.Horizontal, label: 'Horizontal', icon: Icons.LayoutHorizontal }, + { value: alphaTab.LayoutMode.Page, label: 'Vertical', icon: Icons.LayoutPage }, + { value: alphaTab.LayoutMode.Parchment, label: 'Parchment', icon: Icons.LayoutParchment } +]; + +const SCROLL_ITEMS: DropdownItem[] = [ + { value: alphaTab.ScrollMode.Off, label: 'No automatic Scrolling', icon: Icons.ScrollOff }, + { value: alphaTab.ScrollMode.Continuous, label: 'On bar change (Continuous)', icon: Icons.ScrollContinuous }, + { value: alphaTab.ScrollMode.OffScreen, label: 'On bar change (Out of Screen)', icon: Icons.ScrollOffScreen }, + { value: alphaTab.ScrollMode.Smooth, label: 'Smooth', icon: Icons.ScrollSmooth } +]; + +export interface TransportBarOptions { + trackList?: TrackList; +} + +export class TransportBar implements Mountable { + readonly root: HTMLElement; + private playPause: IconButton; + private stop: IconButton; + private metronome: ToggleButton; + private countIn: ToggleButton; + private loop: ToggleButton; + private outputDevice: Dropdown; + private loadingProgress: LoadingProgress; + private loadingSlot: HTMLElement; + private titleEl: HTMLElement; + private artistEl: HTMLElement; + private timePositionEl: HTMLElement; + private subscriptions: (() => void)[] = []; + private outputDevices: alphaTab.synth.ISynthOutputDevice[] = []; + private previousTime = -1; + + constructor( + api: alphaTab.AlphaTabApi, + private options: TransportBarOptions = {} + ) { + this.root = parseHtml(html` +
+
+
+
+
+ +
+ - + +
+
00:00 / 00:00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `); + this.titleEl = this.root.querySelector('.at-song-title')!; + this.artistEl = this.root.querySelector('.at-song-artist')!; + this.timePositionEl = this.root.querySelector('.at-time-position')!; + this.loadingSlot = this.root.querySelector('.at-loading-slot')!; + + // --- left group --- + this.stop = mount( + this.root, + '.cmp-stop', + new IconButton({ icon: Icons.Stop, tooltip: 'Stop' }) + ); + this.stop.setEnabled(false); + this.stop.onClick = () => api.stop(); + + this.playPause = mount( + this.root, + '.cmp-play-pause', + new IconButton({ icon: Icons.Play, tooltip: 'Play/Pause' }) + ); + this.playPause.setEnabled(false); + this.playPause.onClick = () => api.playPause(); + + const speed = mount( + this.root, + '.cmp-speed', + new Dropdown({ + icon: Icons.Search, + label: '1x', + tooltip: 'Playback speed', + items: SPEED_ITEMS, + initialValue: 1 + }) + ); + speed.onSelect = (value, item) => { + api.playbackSpeed = value; + speed.setLabel(item.label); + }; + + this.loadingProgress = mount(this.loadingSlot, '.cmp-loading', new LoadingProgress()); + + // --- right group --- + this.outputDevice = mount( + this.root, + '.cmp-output-device', + new Dropdown({ + icon: Icons.OutputDevice, + tooltip: 'Output device', + items: [{ value: '', label: 'Default' }], + onOpen: async () => { + const devices = await api.enumerateOutputDevices(); + this.outputDevices = devices; + return [ + { value: '', label: 'Default' }, + ...devices.map(d => ({ value: d.deviceId, label: d.label + (d.isDefault ? ' (default)' : '') })) + ]; + } + }) + ); + this.outputDevice.onSelect = async value => { + if (!value) { + await api.setOutputDevice(null); + return; + } + const device = this.outputDevices.find(d => d.deviceId === value); + if (device) { + await api.setOutputDevice(device); + } + }; + + this.countIn = mount( + this.root, + '.cmp-count-in', + new ToggleButton({ icon: Icons.CountIn, tooltip: 'Count-In' }) + ); + this.countIn.setEnabled(false); + this.countIn.onChange = on => { + api.countInVolume = on ? 1 : 0; + }; + + this.metronome = mount( + this.root, + '.cmp-metronome', + new ToggleButton({ icon: Icons.Metronome, tooltip: 'Metronome' }) + ); + this.metronome.setEnabled(false); + this.metronome.onChange = on => { + api.metronomeVolume = on ? 1 : 0; + }; + + this.loop = mount( + this.root, + '.cmp-loop', + new ToggleButton({ icon: Icons.Loop, tooltip: 'Loop' }) + ); + this.loop.setEnabled(false); + this.loop.onChange = on => { + api.isLooping = on; + }; + + const print = mount( + this.root, + '.cmp-print', + new IconButton({ icon: Icons.Print, tooltip: 'Print' }) + ); + print.onClick = () => api.print(); + + const downloadGp = mount( + this.root, + '.cmp-download-gp', + new IconButton({ icon: Icons.DownloadGp, tooltip: 'Download GP' }) + ); + downloadGp.onClick = () => exportGp7(api); + + const downloadAudio = mount( + this.root, + '.cmp-download-audio', + new IconButton({ icon: Icons.DownloadAudio, tooltip: 'Download Audio Data' }) + ); + downloadAudio.onClick = async () => { + await exportAudio(api, this.options.trackList?.getItems() ?? []); + }; + + const zoom = mount( + this.root, + '.cmp-zoom', + new Dropdown({ + icon: Icons.Search, + label: '100%', + tooltip: 'Zoom', + items: ZOOM_ITEMS, + initialValue: 1 + }) + ); + zoom.onSelect = (value, item) => { + api.settings.display.scale = value; + zoom.setLabel(item.label); + api.updateSettings(); + api.render(); + }; + + const layout = mount( + this.root, + '.cmp-layout', + new Dropdown({ + label: 'Layout', + tooltip: 'Layout mode', + items: LAYOUT_ITEMS, + initialValue: api.settings.display.layoutMode + }) + ); + layout.onSelect = value => { + api.settings.display.layoutMode = value; + api.updateSettings(); + api.render(); + }; + + const scroll = mount( + this.root, + '.cmp-scroll', + new Dropdown({ + label: 'Scroll', + tooltip: 'Scroll mode', + items: SCROLL_ITEMS, + initialValue: api.settings.player.scrollMode + }) + ); + scroll.onSelect = value => { + api.settings.player.scrollMode = value; + switch (value) { + case alphaTab.ScrollMode.Continuous: + case alphaTab.ScrollMode.OffScreen: + api.settings.player.scrollOffsetX = -10; + api.settings.player.scrollOffsetY = -10; + break; + case alphaTab.ScrollMode.Smooth: + api.settings.player.scrollOffsetX = -50; + api.settings.player.scrollOffsetY = -100; + break; + } + api.updateSettings(); + api.render(); + }; + + // --- subscriptions --- + this.subscriptions.push( + api.scoreLoaded.on(score => { + this.titleEl.textContent = score.title; + this.artistEl.textContent = score.artist; + }) + ); + this.subscriptions.push( + api.playerStateChanged.on(args => { + this.playPause.setIcon( + args.state === alphaTab.synth.PlayerState.Playing ? Icons.Pause : Icons.Play + ); + }) + ); + this.subscriptions.push( + api.playerPositionChanged.on(args => { + const sec = (args.currentTime / 1000) | 0; + if (sec === this.previousTime) { + return; + } + this.previousTime = sec; + this.timePositionEl.textContent = `${formatDuration(args.currentTime)} / ${formatDuration(args.endTime)}`; + }) + ); + this.subscriptions.push( + api.soundFontLoad.on(args => { + this.loadingSlot.classList.remove('hidden'); + this.loadingProgress.setValue(args.loaded / Math.max(1, args.total)); + }) + ); + this.subscriptions.push( + api.soundFontLoaded.on(() => { + this.loadingSlot.classList.add('hidden'); + }) + ); + this.subscriptions.push( + api.playerReady.on(() => { + this.playPause.setEnabled(true); + this.stop.setEnabled(true); + this.metronome.setEnabled(true); + this.countIn.setEnabled(true); + this.loop.setEnabled(true); + }) + ); + } + + dispose(): void { + for (const u of this.subscriptions) { + u(); + } + this.subscriptions = []; + this.root.remove(); + } +} + +function formatDuration(milliseconds: number): string { + let seconds = milliseconds / 1000; + const minutes = (seconds / 60) | 0; + seconds = (seconds - minutes * 60) | 0; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; +} diff --git a/packages/playground/src/components/Waveform.ts b/packages/playground/src/components/Waveform.ts new file mode 100644 index 000000000..2e1c8ce27 --- /dev/null +++ b/packages/playground/src/components/Waveform.ts @@ -0,0 +1,184 @@ +import * as alphaTab from '@coderline/alphatab'; +import { type Mountable, css, html, injectStyles, parseHtml } from '../util/Dom'; + +injectStyles( + 'Waveform', + css` + .at-waveform { + position: relative; + background: #f7f7f7; + border-top: 1px solid rgba(0, 0, 0, 0.12); + cursor: pointer; + } + .at-waveform.hidden { display: none; } + .at-waveform > canvas { + width: 100%; + opacity: 0.5; + display: block; + } + .at-waveform > .at-waveform-cursor { + position: absolute; + top: 0; + left: 0; + bottom: 0; + width: 1px; + background: var(--at-cursor-beat); + } +` +); + +export class Waveform implements Mountable { + readonly root: HTMLElement; + private cursor: HTMLElement; + private canvas: HTMLCanvasElement | null = null; + private currentScore: alphaTab.model.Score | null = null; + private audioElement: HTMLAudioElement | null = null; + private updateCursor = () => this.refreshCursor(); + private unsubScoreLoaded: () => void; + private unsubPlayerReady: () => void; + private unsubStateChanged: () => void; + + constructor(private api: alphaTab.AlphaTabApi) { + this.root = parseHtml(html` + + `); + this.cursor = this.root.querySelector('.at-waveform-cursor')!; + this.root.addEventListener('click', e => { + if (this.audioElement) { + const rect = this.root.getBoundingClientRect(); + const percent = (e.clientX - rect.left) / rect.width; + this.audioElement.currentTime = this.audioElement.duration * percent; + } + }); + + this.unsubScoreLoaded = api.scoreLoaded.on(() => this.refresh()); + this.unsubPlayerReady = api.playerReady.on(() => this.refresh()); + this.unsubStateChanged = api.playerStateChanged.on(() => this.refresh()); + } + + private async refresh(): Promise { + const score = this.api.score; + const player = this.api.player; + if (!score || !player || !score.backingTrack || this.api.actualPlayerMode !== alphaTab.PlayerMode.EnabledBackingTrack) { + this.hide(); + return; + } + + const output = player.output as alphaTab.synth.IAudioElementBackingTrackSynthOutput; + if (typeof output.audioElement === 'undefined') { + this.hide(); + return; + } + this.bindAudio(output.audioElement); + + if (score === this.currentScore) { + return; + } + this.currentScore = score; + this.root.classList.remove('hidden'); + await this.draw(score.backingTrack); + } + + private bindAudio(audio: HTMLAudioElement): void { + if (this.audioElement === audio) { + return; + } + this.unbindAudio(); + this.audioElement = audio; + audio.addEventListener('timeupdate', this.updateCursor); + audio.addEventListener('durationchange', this.updateCursor); + audio.addEventListener('seeked', this.updateCursor); + } + + private unbindAudio(): void { + if (!this.audioElement) { + return; + } + this.audioElement.removeEventListener('timeupdate', this.updateCursor); + this.audioElement.removeEventListener('durationchange', this.updateCursor); + this.audioElement.removeEventListener('seeked', this.updateCursor); + this.audioElement = null; + } + + private hide(): void { + this.root.classList.add('hidden'); + this.unbindAudio(); + this.currentScore = null; + } + + private refreshCursor(): void { + if (!this.audioElement || !this.audioElement.duration) { + return; + } + this.cursor.style.left = `${(this.audioElement.currentTime / this.audioElement.duration) * 100}%`; + } + + private async draw(backingTrack: alphaTab.model.BackingTrack): Promise { + const buffer = backingTrack.rawAudioFile; + if (!buffer) { + return; + } + + const audioContext = new AudioContext(); + const data = await audioContext.decodeAudioData(structuredClone(buffer.buffer) as ArrayBuffer); + + const top = data.getChannelData(0); + const bottom = data.numberOfChannels > 1 ? data.getChannelData(1) : top; + const length = top.length; + + if (!this.canvas) { + this.canvas = document.createElement('canvas'); + this.root.insertBefore(this.canvas, this.cursor); + } + const width = this.root.offsetWidth || 600; + const height = 80; + this.canvas.width = width; + this.canvas.height = height; + const ctx = this.canvas.getContext('2d'); + if (!ctx) { + return; + } + const pixelRatio = window.devicePixelRatio || 1; + const halfHeight = height / 2; + const barWidth = 2 * pixelRatio; + const barGap = 1 * pixelRatio; + const barIndexScale = width / (barWidth + barGap) / length; + + ctx.beginPath(); + let prevX = 0; + let maxTop = 0; + let maxBottom = 0; + for (let i = 0; i <= length; i++) { + const x = Math.round(i * barIndexScale); + if (x > prevX) { + const topBarHeight = Math.round(maxTop * halfHeight); + const bottomBarHeight = Math.round(maxBottom * halfHeight); + const barHeight = topBarHeight + bottomBarHeight || 1; + ctx.roundRect(prevX * (barWidth + barGap), halfHeight - topBarHeight, barWidth, barHeight, 2); + prevX = x; + maxTop = 0; + maxBottom = 0; + } + const mt = Math.abs(top[i] || 0); + const mb = Math.abs(bottom[i] || 0); + if (mt > maxTop) { + maxTop = mt; + } + if (mb > maxBottom) { + maxBottom = mb; + } + } + ctx.fillStyle = '#436d9d'; + ctx.fill(); + } + + dispose(): void { + this.unsubScoreLoaded(); + this.unsubPlayerReady(); + this.unsubStateChanged(); + this.unbindAudio(); + this.root.remove(); + } +} diff --git a/packages/playground/src/components/alphatex-editor/MonacoEditor.ts b/packages/playground/src/components/alphatex-editor/MonacoEditor.ts new file mode 100644 index 000000000..bb75769e2 --- /dev/null +++ b/packages/playground/src/components/alphatex-editor/MonacoEditor.ts @@ -0,0 +1,98 @@ +import * as alphaTab from '@coderline/alphatab'; +import { registerAlphaTexGrammar } from '@coderline/alphatab-monaco/alphatex'; +import { basicEditorLspIntegration } from '@coderline/alphatab-monaco/lsp'; +import { addTextMateGrammarSupport } from '@coderline/alphatab-monaco/textmate'; +import * as monaco from 'monaco-editor'; +// @ts-expect-error worker import handled by Vite +import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; +import { type Mountable, css, html, injectStyles, parseHtml } from '../../util/Dom'; + +injectStyles( + 'MonacoEditor', + css` + .at-monaco { height: 100%; } +` +); + +let monacoSetupPromise: Promise | null = null; + +async function setupMonaco(): Promise { + if (!monacoSetupPromise) { + monacoSetupPromise = (async () => { + self.MonacoEnvironment = { + getWorker: () => new editorWorker() + }; + const onigurumaWasm = await fetchArrayBuffer( + new URL('vscode-oniguruma/release/onig.wasm', import.meta.url).toString() + ); + const textMateSupport = addTextMateGrammarSupport(onigurumaWasm); + await registerAlphaTexGrammar(textMateSupport); + })(); + } + return monacoSetupPromise; +} + +async function fetchArrayBuffer(url: string): Promise { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to fetch ${url}: ${res.status}`); + } + return res.arrayBuffer(); +} + +export interface MonacoEditorProps { + initialCode: string; +} + +export class MonacoEditor implements Mountable { + readonly root: HTMLElement; + readonly ready: Promise; + private editor: monaco.editor.IStandaloneCodeEditor | null = null; + + onChange: ((tex: string) => void) | null = null; + + constructor(private props: MonacoEditorProps) { + this.root = parseHtml(html`
`); + this.ready = this.init(); + } + + private async init(): Promise { + await setupMonaco(); + this.editor = monaco.editor.create(this.root, { + value: this.props.initialCode, + language: 'alphatex', + automaticLayout: true + }); + this.editor.onDidChangeModelContent(() => { + this.onChange?.(this.editor!.getModel()!.getValue()); + }); + await basicEditorLspIntegration( + this.editor, + new Worker(new URL('../../../alphatexLanguageServerWrap.ts', import.meta.url), { type: 'module' }), + { + logger: { + error: m => alphaTab.Logger.error('LanguageServer', m), + info: m => alphaTab.Logger.info('LanguageServer', m), + log: m => alphaTab.Logger.debug('LanguageServer', m), + warn: m => alphaTab.Logger.warning('LanguageServer', m) + }, + clientInfo: { name: 'alphaTab Playground', version: 'latest' }, + languageId: 'alphatex' + } + ); + } + + getValue(): string { + return this.editor?.getModel()?.getValue() ?? this.props.initialCode; + } + + setValue(value: string): void { + this.editor?.getModel()?.setValue(value); + } + + dispose(): void { + this.editor?.dispose(); + this.editor = null; + this.root.remove(); + } +} diff --git a/packages/playground/src/components/primitives/Dropdown.ts b/packages/playground/src/components/primitives/Dropdown.ts new file mode 100644 index 000000000..e6d1f58e3 --- /dev/null +++ b/packages/playground/src/components/primitives/Dropdown.ts @@ -0,0 +1,270 @@ +import { createPopper, type Instance as PopperInstance } from '@popperjs/core'; +import { type Mountable, css, escapeHtml, html, injectStyles, parseHtml } from '../../util/Dom'; +import type { IconNode } from '../../util/Icons'; +import { icon as renderIcon } from '../../util/Icons'; +import { Tooltip } from './Tooltip'; + +injectStyles( + 'Dropdown', + css` + .at-dropdown { + position: relative; + display: inline-flex; + } + .at-dropdown-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + height: 40px; + min-width: 40px; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + font: inherit; + transition: background-color 0.15s ease-in-out; + } + .at-dropdown-toggle:hover:not([disabled]), + .at-dropdown.open > .at-dropdown-toggle { background: var(--at-accent-hover); } + .at-dropdown-toggle[disabled] { opacity: 0.4; cursor: default; } + .at-dropdown-toggle > .at-dropdown-icon { display: inline-flex; } + .at-dropdown-toggle > .at-dropdown-icon > svg { width: 16px; height: 16px; } + .at-dropdown-toggle > .at-dropdown-label { font-size: 12px; } + .at-dropdown-toggle > .at-dropdown-caret > svg { width: 12px; height: 12px; } + + .at-dropdown-menu { + position: fixed; + top: 0; + left: 0; + z-index: 20000; + min-width: 140px; + background: #fff; + color: #212529; + border-radius: 4px; + box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.2), + 0 4px 5px 0 rgba(0, 0, 0, 0.14), + 0 1px 10px 0 rgba(0, 0, 0, 0.12); + padding: 4px 0; + margin: 0; + list-style: none; + opacity: 0; + visibility: hidden; + transition: opacity 0.12s ease-in-out; + } + .at-dropdown-menu.open { opacity: 1; visibility: visible; } + .at-dropdown-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + cursor: pointer; + white-space: nowrap; + background: transparent; + border: 0; + color: inherit; + font: inherit; + text-align: left; + width: 100%; + } + .at-dropdown-item:hover { background: rgba(0, 0, 0, 0.06); } + .at-dropdown-item.active { background: rgba(0, 0, 0, 0.08); font-weight: 500; } + .at-dropdown-item > .at-dropdown-item-icon > svg { width: 16px; height: 16px; } +` +); + +export interface DropdownItem { + value: T; + label: string; + icon?: IconNode; +} + +export interface DropdownProps { + icon?: IconNode; + label?: string; + tooltip?: string; + items?: DropdownItem[]; + initialValue?: T; + placement?: 'top' | 'bottom'; + /** Called the first time the dropdown opens; can populate items dynamically. */ + onOpen?: () => DropdownItem[] | Promise[]> | undefined; +} + +export class Dropdown implements Mountable { + readonly root: HTMLElement; + private toggle: HTMLButtonElement; + private labelEl: HTMLElement; + private menu: HTMLElement; + private popper: PopperInstance | null = null; + private items: DropdownItem[]; + private currentValue: T | undefined; + private isOpen = false; + private tooltip: Tooltip | null = null; + private outsideClick = (e: MouseEvent) => { + if (!this.root.contains(e.target as Node) && !this.menu.contains(e.target as Node)) { + this.close(); + } + }; + private onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && this.isOpen) { + e.preventDefault(); + this.close(); + this.toggle.focus(); + } + }; + + onSelect: ((value: T, item: DropdownItem) => void) | null = null; + + constructor(private props: DropdownProps) { + this.items = props.items ?? []; + this.currentValue = props.initialValue; + + const iconHtml = props.icon ? '' : ''; + const labelHtml = props.label ? `${escapeHtml(props.label)}` : ''; + this.root = parseHtml(html` +
+ +
+ `); + // restore raw HTML overrides since `html` escaped the slot strings above + const toggleEl = this.root.querySelector('.at-dropdown-toggle')!; + toggleEl.innerHTML = `${iconHtml}${labelHtml}`; + this.toggle = toggleEl; + this.labelEl = this.toggle.querySelector('.at-dropdown-label')!; + if (props.icon) { + this.toggle.querySelector('.at-dropdown-icon')!.appendChild(renderIcon(props.icon)); + } + // small caret + const caretSlot = this.toggle.querySelector('.at-dropdown-caret')!; + caretSlot.appendChild(this.makeCaret()); + + this.menu = parseHtml(html``); + this.renderItems(); + + if (props.tooltip) { + this.tooltip = new Tooltip(this.toggle, props.tooltip); + } + + this.toggle.addEventListener('click', e => { + e.stopPropagation(); + if (this.isOpen) { + this.close(); + } else { + this.open(); + } + }); + } + + private makeCaret(): SVGSVGElement { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 12 12'); + svg.setAttribute('width', '12'); + svg.setAttribute('height', '12'); + svg.setAttribute('aria-hidden', 'true'); + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + path.setAttribute('d', 'M2 4 L6 8 L10 4'); + path.setAttribute('fill', 'none'); + path.setAttribute('stroke', 'currentColor'); + path.setAttribute('stroke-width', '1.5'); + path.setAttribute('stroke-linecap', 'round'); + path.setAttribute('stroke-linejoin', 'round'); + svg.appendChild(path); + return svg; + } + + setItems(items: DropdownItem[]): void { + this.items = items; + this.renderItems(); + } + + setLabel(label: string): void { + this.labelEl.textContent = label; + } + + setValue(value: T): void { + this.currentValue = value; + this.menu.querySelectorAll('.at-dropdown-item').forEach(el => { + const v = (el as HTMLElement).dataset.value; + el.classList.toggle('active', v === String(value)); + }); + } + + setEnabled(enabled: boolean): void { + this.toggle.disabled = !enabled; + } + + private async open(): Promise { + if (this.props.onOpen) { + const result = await this.props.onOpen(); + if (Array.isArray(result)) { + this.setItems(result); + } + } + if (!this.menu.parentElement) { + document.body.appendChild(this.menu); + } + if (!this.popper) { + this.popper = createPopper(this.toggle, this.menu, { + placement: this.props.placement === 'bottom' ? 'bottom-start' : 'top-start', + modifiers: [{ name: 'offset', options: { offset: [0, 4] } }] + }); + } else { + this.popper.update(); + } + this.isOpen = true; + this.root.classList.add('open'); + this.menu.classList.add('open'); + this.toggle.setAttribute('aria-expanded', 'true'); + document.addEventListener('mousedown', this.outsideClick, true); + document.addEventListener('keydown', this.onKey, true); + } + + private close(): void { + this.isOpen = false; + this.root.classList.remove('open'); + this.menu.classList.remove('open'); + this.toggle.setAttribute('aria-expanded', 'false'); + document.removeEventListener('mousedown', this.outsideClick, true); + document.removeEventListener('keydown', this.onKey, true); + } + + private renderItems(): void { + this.menu.replaceChildren(); + for (const item of this.items) { + const el = parseHtml(html``); + el.dataset.value = String(item.value); + if (item.icon) { + const iconWrap = document.createElement('span'); + iconWrap.className = 'at-dropdown-item-icon'; + iconWrap.appendChild(renderIcon(item.icon)); + el.appendChild(iconWrap); + } + const labelWrap = document.createElement('span'); + labelWrap.className = 'at-dropdown-item-label'; + labelWrap.textContent = item.label; + el.appendChild(labelWrap); + if (this.currentValue !== undefined && String(this.currentValue) === String(item.value)) { + el.classList.add('active'); + } + el.addEventListener('click', e => { + e.stopPropagation(); + this.setValue(item.value); + this.close(); + this.onSelect?.(item.value, item); + }); + this.menu.appendChild(el); + } + } + + dispose(): void { + this.close(); + this.tooltip?.dispose(); + this.popper?.destroy(); + this.menu.remove(); + this.root.remove(); + } +} diff --git a/packages/playground/src/components/primitives/IconButton.ts b/packages/playground/src/components/primitives/IconButton.ts new file mode 100644 index 000000000..1c8ab8458 --- /dev/null +++ b/packages/playground/src/components/primitives/IconButton.ts @@ -0,0 +1,94 @@ +import { type Mountable, css, html, injectStyles, parseHtml } from '../../util/Dom'; +import type { IconNode } from '../../util/Icons'; +import { icon as renderIcon } from '../../util/Icons'; +import { Tooltip } from './Tooltip'; + +injectStyles( + 'IconButton', + css` + .at-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 6px 10px; + height: 40px; + min-width: 40px; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + border-radius: 0; + font: inherit; + transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out; + } + .at-icon-btn:hover:not([disabled]) { background: var(--at-accent-hover); } + .at-icon-btn[disabled] { opacity: 0.4; cursor: default; } + .at-icon-btn .at-icon-btn-icon { display: inline-flex; } + .at-icon-btn .at-icon-btn-icon > svg { width: 16px; height: 16px; } + .at-icon-btn .at-icon-btn-label { font-size: 12px; } + .at-icon-btn .at-icon-btn-label:empty { display: none; } +` +); + +export interface IconButtonProps { + icon: IconNode; + tooltip?: string; + label?: string; + ariaLabel?: string; +} + +export class IconButton implements Mountable { + readonly root: HTMLButtonElement; + private iconSlot: HTMLElement; + private labelSlot: HTMLElement; + private tooltip: Tooltip | null = null; + + onClick: ((e: MouseEvent) => void) | null = null; + + constructor(props: IconButtonProps) { + this.root = parseHtml(html` + + `) as HTMLButtonElement; + this.iconSlot = this.root.querySelector('.at-icon-btn-icon')!; + this.labelSlot = this.root.querySelector('.at-icon-btn-label')!; + this.setIcon(props.icon); + if (props.tooltip) { + this.tooltip = new Tooltip(this.root, props.tooltip); + } + this.root.addEventListener('click', e => { + if (this.root.disabled) { + return; + } + this.onClick?.(e); + }); + } + + setIcon(node: IconNode): void { + this.iconSlot.replaceChildren(renderIcon(node)); + } + + setLabel(label: string): void { + this.labelSlot.textContent = label; + } + + setTooltip(text: string): void { + if (this.tooltip) { + this.tooltip.setText(text); + } else if (text) { + this.tooltip = new Tooltip(this.root, text); + } + } + + setEnabled(enabled: boolean): void { + this.root.disabled = !enabled; + } + + dispose(): void { + this.tooltip?.dispose(); + this.root.remove(); + } +} diff --git a/packages/playground/src/components/primitives/LoadingProgress.ts b/packages/playground/src/components/primitives/LoadingProgress.ts new file mode 100644 index 000000000..53a0c5ca4 --- /dev/null +++ b/packages/playground/src/components/primitives/LoadingProgress.ts @@ -0,0 +1,97 @@ +import { type Mountable, css, html, injectStyles, parseHtml } from '../../util/Dom'; + +injectStyles( + 'LoadingProgress', + css` + .at-loading-progress { + position: relative; + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 8px; + color: inherit; + } + .at-loading-progress::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 50%; + border: 3px solid #eee; + } + .at-loading-progress > .at-loading-progress-half { + position: absolute; + top: 0; + width: 50%; + height: 100%; + overflow: hidden; + } + .at-loading-progress > .at-loading-progress-half.left { left: 0; } + .at-loading-progress > .at-loading-progress-half.right { right: 0; } + .at-loading-progress > .at-loading-progress-half > .at-loading-progress-arc { + position: absolute; + top: 0; + width: 100%; + height: 100%; + border: 3px solid var(--at-accent); + } + .at-loading-progress > .at-loading-progress-half.left > .at-loading-progress-arc { + left: 100%; + border-top-right-radius: 16px; + border-bottom-right-radius: 16px; + border-left: 0; + transform-origin: center left; + transform: rotate(0deg); + } + .at-loading-progress > .at-loading-progress-half.right > .at-loading-progress-arc { + left: -100%; + border-top-left-radius: 16px; + border-bottom-left-radius: 16px; + border-right: 0; + transform-origin: center right; + transform: rotate(0deg); + } + .at-loading-progress > .at-loading-progress-value { + position: relative; + z-index: 1; + } +` +); + +export class LoadingProgress implements Mountable { + readonly root: HTMLElement; + private leftArc: HTMLElement; + private rightArc: HTMLElement; + private valueLabel: HTMLElement; + + constructor() { + this.root = parseHtml(html` +
+ + + 0 +
+ `); + this.leftArc = this.root.querySelector('.left > .at-loading-progress-arc')!; + this.rightArc = this.root.querySelector('.right > .at-loading-progress-arc')!; + this.valueLabel = this.root.querySelector('.at-loading-progress-value')!; + } + + setValue(value: number): void { + const percent = Math.max(0, Math.min(1, value)) * 100; + if (percent <= 50) { + this.rightArc.style.transform = `rotate(${(percent / 100) * 360}deg)`; + this.leftArc.style.transform = 'rotate(0deg)'; + } else { + this.rightArc.style.transform = 'rotate(180deg)'; + this.leftArc.style.transform = `rotate(${((percent - 50) / 100) * 360}deg)`; + } + this.valueLabel.textContent = String(Math.floor(percent)); + this.root.setAttribute('aria-valuenow', String(Math.floor(percent))); + } + + dispose(): void { + this.root.remove(); + } +} diff --git a/packages/playground/src/components/primitives/ProgressBar.ts b/packages/playground/src/components/primitives/ProgressBar.ts new file mode 100644 index 000000000..85b2d8644 --- /dev/null +++ b/packages/playground/src/components/primitives/ProgressBar.ts @@ -0,0 +1,51 @@ +import { type Mountable, css, html, injectStyles, parseHtml } from '../../util/Dom'; + +injectStyles( + 'ProgressBar', + css` + .at-progress-bar { + position: relative; + height: 4px; + background: #d9d9d9; + cursor: pointer; + transition: height 0.1s ease; + } + .at-progress-bar:hover { height: 15px; } + .at-progress-bar > .at-progress-bar-fill { + height: 100%; + background: var(--at-accent); + width: 0; + } +` +); + +export class ProgressBar implements Mountable { + readonly root: HTMLElement; + private fill: HTMLElement; + + onClickPercent: ((percent: number) => void) | null = null; + + constructor() { + this.root = parseHtml(html` +
+
+
+ `); + this.fill = this.root.querySelector('.at-progress-bar-fill')!; + this.root.addEventListener('click', e => { + const rect = this.root.getBoundingClientRect(); + const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + this.onClickPercent?.(percent); + }); + } + + setValue(value: number): void { + const v = Math.max(0, Math.min(1, value)); + this.fill.style.width = `${(v * 100).toFixed(2)}%`; + this.root.setAttribute('aria-valuenow', v.toFixed(3)); + } + + dispose(): void { + this.root.remove(); + } +} diff --git a/packages/playground/src/components/primitives/Slider.ts b/packages/playground/src/components/primitives/Slider.ts new file mode 100644 index 000000000..a3696d4db --- /dev/null +++ b/packages/playground/src/components/primitives/Slider.ts @@ -0,0 +1,74 @@ +import { type Mountable, css, html, injectStyles, parseHtml } from '../../util/Dom'; + +injectStyles( + 'Slider', + css` + .at-slider { + appearance: none; + background: #d3d3d3; + outline: none; + opacity: 0.7; + transition: opacity 0.2s; + height: 5px; + margin: 0; + } + .at-slider:hover { opacity: 1; } + .at-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--at-accent); + cursor: pointer; + border: 0; + } + .at-slider::-moz-range-thumb { + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--at-accent); + cursor: pointer; + border: 0; + } +` +); + +export interface SliderProps { + min?: number; + max?: number; + step?: number; + initialValue?: number; +} + +export class Slider implements Mountable { + readonly root: HTMLInputElement; + + onInput: ((value: number) => void) | null = null; + + constructor(props: SliderProps = {}) { + const min = props.min ?? 0; + const max = props.max ?? 100; + const step = props.step ?? 1; + const value = props.initialValue ?? min; + this.root = parseHtml(html` + + `) as HTMLInputElement; + this.root.addEventListener('input', () => { + this.onInput?.(this.root.valueAsNumber); + }); + this.root.addEventListener('click', e => e.stopPropagation()); + } + + getValue(): number { + return this.root.valueAsNumber; + } + + setValue(value: number): void { + this.root.valueAsNumber = value; + } + + dispose(): void { + this.root.remove(); + } +} diff --git a/packages/playground/src/components/primitives/Spinner.ts b/packages/playground/src/components/primitives/Spinner.ts new file mode 100644 index 000000000..e6e057c6e --- /dev/null +++ b/packages/playground/src/components/primitives/Spinner.ts @@ -0,0 +1,36 @@ +import { type Mountable, css, html, injectStyles, parseHtml } from '../../util/Dom'; + +injectStyles( + 'Spinner', + css` + @keyframes at-spinner-rotate { to { transform: rotate(360deg); } } + .at-spinner { + display: inline-block; + width: 3rem; + height: 3rem; + vertical-align: middle; + border: 0.25em solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: at-spinner-rotate 0.75s linear infinite; + color: var(--at-accent); + } + .at-spinner.small { width: 1rem; height: 1rem; border-width: 0.15em; } +` +); + +export interface SpinnerProps { + small?: boolean; +} + +export class Spinner implements Mountable { + readonly root: HTMLElement; + + constructor(props: SpinnerProps = {}) { + this.root = parseHtml(html`
`); + } + + dispose(): void { + this.root.remove(); + } +} diff --git a/packages/playground/src/components/primitives/ToggleButton.ts b/packages/playground/src/components/primitives/ToggleButton.ts new file mode 100644 index 000000000..1f2d0e5ea --- /dev/null +++ b/packages/playground/src/components/primitives/ToggleButton.ts @@ -0,0 +1,45 @@ +import { css, injectStyles } from '../../util/Dom'; +import { IconButton, type IconButtonProps } from './IconButton'; + +injectStyles( + 'ToggleButton', + css` + .at-icon-btn.at-toggle-active { + background: var(--at-accent-hover); + color: #fff; + } +` +); + +export interface ToggleButtonProps extends IconButtonProps { + initialActive?: boolean; +} + +export class ToggleButton extends IconButton { + private active: boolean; + + onChange: ((active: boolean) => void) | null = null; + + constructor(props: ToggleButtonProps) { + super(props); + this.active = props.initialActive ?? false; + this.root.classList.add('at-toggle'); + this.root.classList.toggle('at-toggle-active', this.active); + this.root.setAttribute('aria-pressed', String(this.active)); + + this.onClick = () => { + this.setActive(!this.active); + this.onChange?.(this.active); + }; + } + + isActive(): boolean { + return this.active; + } + + setActive(active: boolean): void { + this.active = active; + this.root.classList.toggle('at-toggle-active', active); + this.root.setAttribute('aria-pressed', String(active)); + } +} diff --git a/packages/playground/src/components/primitives/Tooltip.ts b/packages/playground/src/components/primitives/Tooltip.ts new file mode 100644 index 000000000..72531a033 --- /dev/null +++ b/packages/playground/src/components/primitives/Tooltip.ts @@ -0,0 +1,89 @@ +import { createPopper, type Instance as PopperInstance } from '@popperjs/core'; +import { type Mountable, css, html, injectStyles, parseHtml } from '../../util/Dom'; + +injectStyles( + 'Tooltip', + css` + .at-tooltip { + position: fixed; + top: 0; + left: 0; + z-index: 20000; + background: rgba(33, 37, 41, 0.95); + color: #fff; + padding: 4px 8px; + font-size: 12px; + border-radius: 4px; + pointer-events: none; + opacity: 0; + transition: opacity 0.12s ease-in-out; + max-width: 220px; + white-space: nowrap; + } + .at-tooltip.visible { opacity: 1; } +` +); + +export interface TooltipOptions { + placement?: 'top' | 'bottom' | 'left' | 'right'; +} + +export class Tooltip implements Mountable { + readonly root: HTMLElement; + private popper: PopperInstance | null = null; + private mouseEnter = () => this.show(); + private mouseLeave = () => this.hide(); + private focusIn = () => this.show(); + private focusOut = () => this.hide(); + + constructor( + private target: HTMLElement, + private text: string, + private options: TooltipOptions = {} + ) { + this.root = parseHtml(html``); + this.target.addEventListener('mouseenter', this.mouseEnter); + this.target.addEventListener('mouseleave', this.mouseLeave); + this.target.addEventListener('focusin', this.focusIn); + this.target.addEventListener('focusout', this.focusOut); + } + + setText(text: string): void { + this.text = text; + this.root.textContent = text; + } + + private show(): void { + if (!this.text) { + return; + } + if (!this.root.parentElement) { + document.body.appendChild(this.root); + } + if (!this.popper) { + this.popper = createPopper(this.target, this.root, { + placement: this.options.placement ?? 'top', + modifiers: [{ name: 'offset', options: { offset: [0, 6] } }] + }); + } else { + this.popper.update(); + } + this.root.classList.add('visible'); + } + + private hide(): void { + this.root.classList.remove('visible'); + } + + dispose(): void { + this.target.removeEventListener('mouseenter', this.mouseEnter); + this.target.removeEventListener('mouseleave', this.mouseLeave); + this.target.removeEventListener('focusin', this.focusIn); + this.target.removeEventListener('focusout', this.focusOut); + if (this.popper) { + this.popper.destroy(); + this.popper = null; + } + this.root.remove(); + } +} diff --git a/packages/playground/src/components/recorder/DrumPadPanel.ts b/packages/playground/src/components/recorder/DrumPadPanel.ts new file mode 100644 index 000000000..132985555 --- /dev/null +++ b/packages/playground/src/components/recorder/DrumPadPanel.ts @@ -0,0 +1,191 @@ +import { type Mountable, css, html, injectStyles, parseHtml } from '../../util/Dom'; + +injectStyles( + 'DrumPadPanel', + css` + .at-drum-pad-panel { + position: fixed; + bottom: 72px; + right: 16px; + padding: 0 12px 12px 12px; + background: rgba(33, 37, 41, 0.92); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + z-index: 1000; + color: #fff; + font-family: system-ui, sans-serif; + } + .at-drum-pad-panel > .at-drum-pad-handle { + padding: 8px 10px; + margin-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.15); + cursor: move; + user-select: none; + font-size: 11px; + text-align: center; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + } + .at-drum-pad-panel .at-drum-pad-state-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: #6c757d; + } + .at-drum-pad-panel.recording .at-drum-pad-state-dot { + background: #e53935; + box-shadow: 0 0 6px #e53935; + } + .at-drum-pad-panel > .at-drum-pad-grid { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 8px; + opacity: 0.4; + pointer-events: none; + } + .at-drum-pad-panel.recording > .at-drum-pad-grid { + opacity: 1; + pointer-events: auto; + } + .at-drum-pad-panel .at-drum-pad-btn { + padding: 8px 10px; + min-width: 68px; + background: #4a5056; + color: #fff; + border: 1px solid #6c757d; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + line-height: 1.2; + white-space: pre; + font-family: inherit; + transition: background 0.12s ease-out, border-color 0.12s ease-out; + } + .at-drum-pad-panel .at-drum-pad-btn.flash { + background: #ff7043; + border-color: #ffab91; + } +` +); + +export interface DrumPad { + key: string; + midiNote: number; + label: string; +} + +export const DEFAULT_DRUM_PADS: DrumPad[] = [ + { key: 'a', midiNote: 36, label: 'Kick' }, + { key: 's', midiNote: 38, label: 'Snare' }, + { key: 'd', midiNote: 42, label: 'Hi-Hat' }, + { key: 'f', midiNote: 46, label: 'Open HH' }, + { key: 'g', midiNote: 44, label: 'HH Pedal' }, + { key: 'h', midiNote: 45, label: 'Low Tom' }, + { key: 'j', midiNote: 47, label: 'Mid Tom' }, + { key: 'k', midiNote: 50, label: 'Hi Tom' }, + { key: 'l', midiNote: 49, label: 'Crash' }, + { key: ';', midiNote: 51, label: 'Ride' } +]; + +const FLASH_MS = 120; + +export class DrumPadPanel implements Mountable { + readonly root: HTMLElement; + private gridEl: HTMLElement; + private handleEl: HTMLElement; + private stateLabelEl: HTMLElement; + private btnByMidi = new Map(); + private padByKey = new Map(); + + private dragOffsetX = 0; + private dragOffsetY = 0; + private onPointerDown = (e: PointerEvent) => { + const rect = this.root.getBoundingClientRect(); + this.dragOffsetX = e.clientX - rect.left; + this.dragOffsetY = e.clientY - rect.top; + this.root.style.left = `${rect.left}px`; + this.root.style.top = `${rect.top}px`; + this.root.style.right = 'auto'; + this.root.style.bottom = 'auto'; + this.handleEl.setPointerCapture(e.pointerId); + }; + private onPointerMove = (e: PointerEvent) => { + if (!this.handleEl.hasPointerCapture(e.pointerId)) { + return; + } + this.root.style.left = `${e.clientX - this.dragOffsetX}px`; + this.root.style.top = `${e.clientY - this.dragOffsetY}px`; + }; + private onPointerUp = (e: PointerEvent) => { + this.handleEl.releasePointerCapture(e.pointerId); + }; + private onKeyDown = (e: KeyboardEvent) => { + if (e.repeat) { + return; + } + const pad = this.padByKey.get(e.key.toLowerCase()); + if (!pad) { + return; + } + e.preventDefault(); + this.onHit?.(pad.midiNote); + }; + + onHit: ((midiNote: number) => void) | null = null; + + constructor(pads: DrumPad[] = DEFAULT_DRUM_PADS) { + this.root = parseHtml(html` +
+
+ + Paused — press Play to record +
+
+
+ `); + this.handleEl = this.root.querySelector('.at-drum-pad-handle')!; + this.stateLabelEl = this.root.querySelector('.at-drum-pad-state-label')!; + this.gridEl = this.root.querySelector('.at-drum-pad-grid')!; + + for (const pad of pads) { + this.padByKey.set(pad.key, pad); + const btn = parseHtml( + html`` + ) as HTMLButtonElement; + // textContent preserves the literal newline + btn.textContent = `${pad.label}\n[${pad.key.toUpperCase()}]`; + btn.addEventListener('click', () => this.onHit?.(pad.midiNote)); + this.gridEl.appendChild(btn); + this.btnByMidi.set(pad.midiNote, btn); + } + + this.handleEl.addEventListener('pointerdown', this.onPointerDown); + this.handleEl.addEventListener('pointermove', this.onPointerMove); + this.handleEl.addEventListener('pointerup', this.onPointerUp); + document.addEventListener('keydown', this.onKeyDown, true); + } + + setRecording(recording: boolean): void { + this.root.classList.toggle('recording', recording); + this.stateLabelEl.textContent = recording + ? 'Recording — hit pads or press keys' + : 'Paused — press Play to record'; + } + + flash(midiNote: number): void { + const btn = this.btnByMidi.get(midiNote); + if (!btn) { + return; + } + btn.classList.add('flash'); + window.setTimeout(() => btn.classList.remove('flash'), FLASH_MS); + } + + dispose(): void { + document.removeEventListener('keydown', this.onKeyDown, true); + this.root.remove(); + } +} diff --git a/packages/playground/src/components/recorder/RhythmConfig.ts b/packages/playground/src/components/recorder/RhythmConfig.ts new file mode 100644 index 000000000..537218f98 --- /dev/null +++ b/packages/playground/src/components/recorder/RhythmConfig.ts @@ -0,0 +1,18 @@ +export interface RhythmConfig { + timeSignatureNumerator: number; + timeSignatureDenominator: number; + /** Grid slots inside one beat (quarter). */ + subdivisionsPerBeat: number; + /** Length = numerator * subdivisionsPerBeat. Values are display weights (4=quarter, 8=eighth, 16=16th, 32=32nd). */ + beatMask: number[]; + /** Slots whose weight exceeds this threshold are not drawn in the overlay. */ + displayWeightThreshold: number; +} + +export const RHYTHM_4_4_STRAIGHT: RhythmConfig = { + timeSignatureNumerator: 4, + timeSignatureDenominator: 4, + subdivisionsPerBeat: 4, + beatMask: Array.from({ length: 4 }, () => [4, 16, 8, 16]).flat(), + displayWeightThreshold: 16 +}; diff --git a/packages/playground/src/components/recorder/RhythmGridOverlay.ts b/packages/playground/src/components/recorder/RhythmGridOverlay.ts new file mode 100644 index 000000000..7041e888b --- /dev/null +++ b/packages/playground/src/components/recorder/RhythmGridOverlay.ts @@ -0,0 +1,92 @@ +import type * as alphaTab from '@coderline/alphatab'; +import { type Mountable, css, html, injectStyles, parseHtml } from '../../util/Dom'; +import type { RhythmConfig } from './RhythmConfig'; + +injectStyles( + 'RhythmGridOverlay', + css` + .at-rhythm-grid { + position: absolute; + inset: 0; + pointer-events: none; + z-index: 6; + } + .at-rhythm-grid > .at-rhythm-tick { + position: absolute; + } + .at-rhythm-grid > .at-rhythm-tick.main { + background: rgba(0, 120, 220, 0.55); + } + .at-rhythm-grid > .at-rhythm-tick.sub { + background: rgba(0, 120, 220, 0.25); + } +` +); + +const TICK_LENGTH = 12; + +export class RhythmGridOverlay implements Mountable { + readonly root: HTMLElement; + private unsubPostRender: () => void; + + constructor( + private api: alphaTab.AlphaTabApi, + private config: RhythmConfig + ) { + this.root = parseHtml(html`
`); + this.unsubPostRender = api.postRenderFinished.on(() => this.redraw()); + } + + setConfig(config: RhythmConfig): void { + this.config = config; + this.redraw(); + } + + private redraw(): void { + this.root.replaceChildren(); + const lookup = this.api.renderer?.boundsLookup; + if (!lookup) { + return; + } + for (const system of lookup.staffSystems) { + for (const masterBarBounds of system.bars) { + for (const barBounds of masterBarBounds.bars) { + const beats = barBounds.beats; + const barTop = barBounds.realBounds.y; + const barBottom = barBounds.realBounds.y + barBounds.realBounds.h; + for (let i = 0; i < beats.length && i < this.config.beatMask.length; i++) { + const weight = this.config.beatMask[i]; + if (weight > this.config.displayWeightThreshold) { + continue; + } + const isMain = weight === 4; + const width = isMain ? 2 : 1; + const x = beats[i].realBounds.x; + const cls = isMain ? 'at-rhythm-tick main' : 'at-rhythm-tick sub'; + + const top = document.createElement('div'); + top.className = cls; + top.style.left = `${x}px`; + top.style.top = `${barTop - TICK_LENGTH}px`; + top.style.width = `${width}px`; + top.style.height = `${TICK_LENGTH}px`; + this.root.appendChild(top); + + const bottom = document.createElement('div'); + bottom.className = cls; + bottom.style.left = `${x}px`; + bottom.style.top = `${barBottom}px`; + bottom.style.width = `${width}px`; + bottom.style.height = `${TICK_LENGTH}px`; + this.root.appendChild(bottom); + } + } + } + } + } + + dispose(): void { + this.unsubPostRender(); + this.root.remove(); + } +} diff --git a/packages/playground/src/components/youtube-sync/YoutubeSync.ts b/packages/playground/src/components/youtube-sync/YoutubeSync.ts new file mode 100644 index 000000000..622ad5c37 --- /dev/null +++ b/packages/playground/src/components/youtube-sync/YoutubeSync.ts @@ -0,0 +1,191 @@ +import type * as alphaTab from '@coderline/alphatab'; +import { type Mountable, css, html, injectStyles, parseHtml } from '../../util/Dom'; + +injectStyles( + 'YoutubeSync', + css` + .at-youtube-wrap { + display: flex; + justify-content: center; + height: 360px; + background: #000; + } + .at-youtube-wrap > .at-youtube { width: 640px; height: 360px; } +` +); + +// Minimal YouTube IFrame API surface used by this component. +interface YTPlayer { + getDuration(): number; + getPlaybackRate(): number; + setPlaybackRate(value: number): void; + getVolume(): number; + setVolume(value: number): void; + seekTo(seconds: number): void; + playVideo(): void; + pauseVideo(): void; + getCurrentTime(): number; + destroy?(): void; +} +interface YTEvent { + data: number; +} +interface YTPlayerCtor { + new ( + host: HTMLElement, + options: { + height: string; + width: string; + videoId: string; + playerVars?: { autoplay?: 0 | 1 }; + events?: { + onReady?: (e: YTEvent) => void; + onStateChange?: (e: YTEvent) => void; + onPlaybackRateChange?: (e: YTEvent) => void; + onError?: (e: YTEvent) => void; + }; + } + ): YTPlayer; +} +interface YTNamespace { + Player: YTPlayerCtor; +} + +declare global { + interface Window { + YT?: YTNamespace; + onYouTubePlayerAPIReady?: () => void; + } +} + +const YT_API_URL = 'https://www.youtube.com/player_api'; +let ytApiPromise: Promise | null = null; + +function loadYouTubeApi(): Promise { + if (ytApiPromise) { + return ytApiPromise; + } + ytApiPromise = new Promise((resolve, reject) => { + if (window.YT?.Player) { + resolve(); + return; + } + const previous = window.onYouTubePlayerAPIReady; + window.onYouTubePlayerAPIReady = () => { + previous?.(); + resolve(); + }; + const tag = document.createElement('script'); + tag.src = YT_API_URL; + tag.onerror = e => reject(e); + document.head.appendChild(tag); + }); + return ytApiPromise; +} + +export interface YoutubeSyncProps { + videoId: string; +} + +export class YoutubeSync implements Mountable { + readonly root: HTMLElement; + private playerEl: HTMLElement; + private player: YTPlayer | null = null; + private currentTimeInterval: number | undefined; + + constructor( + private api: alphaTab.AlphaTabApi, + private props: YoutubeSyncProps + ) { + this.root = parseHtml(html` +
+
+
+ `); + this.playerEl = this.root.querySelector('.at-youtube')!; + this.bind(); + } + + private async bind(): Promise { + await loadYouTubeApi(); + const ready = Promise.withResolvers(); + this.player = new window.YT!.Player(this.playerEl, { + height: '360', + width: '640', + videoId: this.props.videoId, + playerVars: { autoplay: 0 }, + events: { + onReady: () => ready.resolve(), + onStateChange: e => this.onStateChange(e.data), + onPlaybackRateChange: e => { + this.api.playbackSpeed = e.data; + }, + onError: e => ready.reject(e) + } + }); + await ready.promise; + + const output = this.api.player?.output as alphaTab.synth.IExternalMediaSynthOutput | undefined; + if (!output) { + return; + } + const player = this.player!; + const handler: alphaTab.synth.IExternalMediaHandler = { + get backingTrackDuration() { + return player.getDuration() * 1000; + }, + get playbackRate() { + return player.getPlaybackRate(); + }, + set playbackRate(value: number) { + player.setPlaybackRate(value); + }, + get masterVolume() { + return player.getVolume() / 100; + }, + set masterVolume(value: number) { + player.setVolume(value * 100); + }, + seekTo: time => player.seekTo(time / 1000), + play: () => player.playVideo(), + pause: () => player.pauseVideo() + }; + output.handler = handler; + } + + private onStateChange(state: number): void { + // YT.PlayerState: -1=unstarted, 0=ended, 1=playing, 2=paused, 3=buffering, 5=cued + switch (state) { + case 1: // playing + this.currentTimeInterval = window.setInterval(() => { + const output = this.api.player?.output as alphaTab.synth.IExternalMediaSynthOutput | undefined; + if (output && this.player) { + output.updatePosition(this.player.getCurrentTime() * 1000); + } + }, 50); + this.api.play(); + break; + case 0: // ended + this.clearInterval(); + this.api.stop(); + break; + case 2: // paused + this.clearInterval(); + this.api.pause(); + break; + } + } + + private clearInterval(): void { + if (this.currentTimeInterval !== undefined) { + window.clearInterval(this.currentTimeInterval); + this.currentTimeInterval = undefined; + } + } + + dispose(): void { + this.clearInterval(); + this.player?.destroy?.(); + this.root.remove(); + } +} diff --git a/packages/playground/src/styles/common.css b/packages/playground/src/styles/common.css new file mode 100644 index 000000000..d3994198d --- /dev/null +++ b/packages/playground/src/styles/common.css @@ -0,0 +1,83 @@ +@import url('@fontsource/noto-sans/300.css'); +@import url('@fontsource/noto-sans/400.css'); +@import url('@fontsource/noto-sans/500.css'); +@import url('@fontsource/noto-sans/700.css'); + +@import url('@fontsource/noto-sans/300-italic.css'); +@import url('@fontsource/noto-sans/400-italic.css'); +@import url('@fontsource/noto-sans/500-italic.css'); +@import url('@fontsource/noto-sans/700-italic.css'); + +@import url('@fontsource/noto-serif/300.css'); +@import url('@fontsource/noto-serif/400.css'); +@import url('@fontsource/noto-serif/500.css'); +@import url('@fontsource/noto-serif/700.css'); + +@import url('@fontsource/noto-serif/300-italic.css'); +@import url('@fontsource/noto-serif/400-italic.css'); +@import url('@fontsource/noto-serif/500-italic.css'); +@import url('@fontsource/noto-serif/700-italic.css'); + +:root { + --at-accent: #4972a1; + --at-accent-hover: #5588c7; + --at-footer-bg: #436d9d; + --at-footer-fg: #ffffff; + --at-divider: rgba(255, 255, 255, 0.18); + --at-sidebar-bg: #f7f7f7; + --at-overlay-bg: rgba(0, 0, 0, 0.5); + --at-track-active-bg: rgba(0, 0, 0, 0.03); + --at-cursor-beat: rgba(64, 64, 255, 0.75); + --at-cursor-bar: rgba(255, 242, 0, 0.25); + --at-selection: rgba(64, 64, 255, 0.2); + --at-highlight: #0078ff; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + font-family: 'Noto Sans', sans-serif; + font-size: 14px; + height: 100vh; +} + +body { + display: flex; + flex-direction: column; + align-content: center; + justify-content: center; + flex-wrap: wrap; +} + +body > * { + overflow: hidden; +} + +button { + font-family: inherit; +} + +.at-cursor-bar { + background: var(--at-cursor-bar); +} + +.at-selection div { + background: var(--at-selection); +} + +.at-cursor-beat { + background: var(--at-cursor-beat); + width: 3px; +} + +.at-highlight * { + fill: var(--at-highlight); + stroke: var(--at-highlight); +} diff --git a/packages/playground/src/util/Demos.ts b/packages/playground/src/util/Demos.ts new file mode 100644 index 000000000..a972bbcc2 --- /dev/null +++ b/packages/playground/src/util/Demos.ts @@ -0,0 +1,38 @@ +export interface DemoEntry { + href: string; + title: string; + description: string; +} + +export const DEMOS: DemoEntry[] = [ + { + href: '/demos/control/', + title: 'Control', + description: + 'The full-featured player: track sidebar, transport bar, layout/scroll/zoom pickers, downloads, drag-drop loading.' + }, + { + href: '/demos/recorder/', + title: 'Drum Recorder', + description: + 'Record drum hits onto a percussion staff while the player runs. Demonstrates dynamic score extension and tick-cache updates.' + }, + { + href: '/demos/alphatex-editor/', + title: 'AlphaTex Editor', + description: + 'Monaco editor on the left, live alphaTab rendering on the right. Round-trips between AlphaTex source and Score model with LSP support.' + }, + { + href: '/demos/youtube-sync/', + title: 'YouTube Sync', + description: + 'Plays an alphaTab score in step with a YouTube video using the EnabledExternalMedia player mode.' + }, + { + href: '/demos/test-results/', + title: 'Visual Test Results', + description: + 'Compare expected vs. actual screenshots from visual regression runs. Drop a results zip or use the dev-server endpoint.' + } +]; diff --git a/packages/playground/src/util/Dom.ts b/packages/playground/src/util/Dom.ts new file mode 100644 index 000000000..ba82c9f03 --- /dev/null +++ b/packages/playground/src/util/Dom.ts @@ -0,0 +1,64 @@ +export interface Mountable { + readonly root: HTMLElement; +} + +export function escapeHtml(value: unknown): string { + return String(value).replace(/[&<>"']/g, c => { + switch (c) { + case '&': + return '&'; + case '<': + return '<'; + case '>': + return '>'; + case '"': + return '"'; + case "'": + return '''; + default: + return c; + } + }); +} + +export function html(strings: TemplateStringsArray, ...values: unknown[]): string { + return String.raw({ raw: strings }, ...values.map(v => escapeHtml(v))); +} + +export function css(strings: TemplateStringsArray, ...values: unknown[]): string { + return String.raw({ raw: strings }, ...values); +} + +export function parseHtml(markup: string): HTMLElement { + const t = document.createElement('template'); + t.innerHTML = markup.trim(); + const el = t.content.firstElementChild; + if (!(el instanceof HTMLElement)) { + throw new Error('parseHtml: template did not produce an HTMLElement'); + } + //