{"readonly"}{" "}{"actualPlayerMode"}{":"}{" "}{"PlayerMode"}{";"}{"PlayerMode"}{" "}{"ActualPlayerMode"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"}"}{"val"}{" "}{"actualPlayerMode"}{":"}{" "}{"PlayerMode"}{"loadMidiForScore"}{"("}{")"}{":"}{" "}{"void"}{"void"}{" "}{"LoadMidiForScore"}{"("}{")"}{"fun"}{" "}{"loadMidiForScore"}{"("}{")"}{":"}{" "}{"Unit"}{"midiEventsPlayed"}{":"}{" "}{"IEventEmitterOfT"}{"<"}{"MidiEventsPlayedEventArgs"}{">"}{";"}{"readonly"}{" "}{"midiEventsPlayed"}{":"}{" "}{"IEventEmitterOfT"}{"<"}{"MidiEventsPlayedEventArgs"}{">"}{";"}{"IEventEmitterOfT"}{"<"}{"MidiEventsPlayedEventArgs"}{">"}{" "}{"MidiEventsPlayed"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"set"}{";"}{" "}{"}"}{"IEventEmitterOfT"}{"<"}{"MidiEventsPlayedEventArgs"}{">"}{" "}{"MidiEventsPlayed"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"}"}{"var"}{" "}{"midiEventsPlayed"}{":"}{" "}{"IEventEmitterOfT"}{"<"}{"MidiEventsPlayedEventArgs"}{">"}{"val"}{" "}{"midiEventsPlayed"}{":"}{" "}{"IEventEmitterOfT"}{"<"}{"MidiEventsPlayedEventArgs"}{">"}{"playbackRangeChanged"}{":"}{" "}{"IEventEmitterOfT"}{"<"}{"PlaybackRangeChangedEventArgs"}{">"}{";"}{"readonly"}{" "}{"playbackRangeChanged"}{":"}{" "}{"IEventEmitterOfT"}{"<"}{"PlaybackRangeChangedEventArgs"}{">"}{";"}{"IEventEmitterOfT"}{"<"}{"PlaybackRangeChangedEventArgs"}{">"}{" "}{"PlaybackRangeChanged"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"set"}{";"}{" "}{"}"}{"IEventEmitterOfT"}{"<"}{"PlaybackRangeChangedEventArgs"}{">"}{" "}{"PlaybackRangeChanged"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"}"}{"var"}{" "}{"playbackRangeChanged"}{":"}{" "}{"IEventEmitterOfT"}{"<"}{"PlaybackRangeChangedEventArgs"}{">"}{"val"}{" "}{"playbackRangeChanged"}{":"}{" "}{"IEventEmitterOfT"}{"<"}{"PlaybackRangeChangedEventArgs"}{">"}
-{"player"}{":"}{" "}{"IAlphaSynth"}{" "}{"|"}{" "}{"null"}{";"}
+{"readonly"}{" "}{"player"}{":"}{" "}{"IAlphaSynth"}{" "}{"|"}{" "}{"null"}{";"}
-{"IAlphaSynth"}{"?"}{" "}{"Player"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"set"}{";"}{" "}{"}"}
+{"IAlphaSynth"}{"?"}{" "}{"Player"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"}"}
-{"var"}{" "}{"player"}{":"}{" "}{"IAlphaSynth"}{"?"}
+{"val"}{" "}{"player"}{":"}{" "}{"IAlphaSynth"}{"?"}
diff --git a/docs/reference/api/playerfinished.mdx b/docs/reference/api/playerfinished.mdx
index 481b7ee..e40181c 100644
--- a/docs/reference/api/playerfinished.mdx
+++ b/docs/reference/api/playerfinished.mdx
@@ -23,13 +23,13 @@ This event is fired when the playback of the whole song finished. This event is
-{"playerFinished"}{":"}{" "}{"IEventEmitter"}{";"}
+{"readonly"}{" "}{"playerFinished"}{":"}{" "}{"IEventEmitter"}{";"}
-{"IEventEmitter"}{" "}{"PlayerFinished"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"set"}{";"}{" "}{"}"}
+{"IEventEmitter"}{" "}{"PlayerFinished"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"}"}
-{"var"}{" "}{"playerFinished"}{":"}{" "}{"IEventEmitter"}
+{"val"}{" "}{"playerFinished"}{":"}{" "}{"IEventEmitter"}
diff --git a/docs/reference/api/playerpositionchanged.mdx b/docs/reference/api/playerpositionchanged.mdx
index 0e61cb9..611424a 100644
--- a/docs/reference/api/playerpositionchanged.mdx
+++ b/docs/reference/api/playerpositionchanged.mdx
@@ -23,13 +23,13 @@ This event is fired when the current playback position of the song changed.
-{"playerPositionChanged"}{":"}{" "}{"IEventEmitterOfT"}{"<"}{"PositionChangedEventArgs"}{">"}{";"}
+{"readonly"}{" "}{"playerPositionChanged"}{":"}{" "}{"IEventEmitterOfT"}{"<"}{"PositionChangedEventArgs"}{">"}{";"}
-{"IEventEmitterOfT"}{"<"}{"PositionChangedEventArgs"}{">"}{" "}{"PlayerPositionChanged"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"set"}{";"}{" "}{"}"}
+{"IEventEmitterOfT"}{"<"}{"PositionChangedEventArgs"}{">"}{" "}{"PlayerPositionChanged"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"}"}
-{"var"}{" "}{"playerPositionChanged"}{":"}{" "}{"IEventEmitterOfT"}{"<"}{"PositionChangedEventArgs"}{">"}
+{"val"}{" "}{"playerPositionChanged"}{":"}{" "}{"IEventEmitterOfT"}{"<"}{"PositionChangedEventArgs"}{">"}
diff --git a/docs/reference/api/playerready.mdx b/docs/reference/api/playerready.mdx
index fde4d6a..98dcc77 100644
--- a/docs/reference/api/playerready.mdx
+++ b/docs/reference/api/playerready.mdx
@@ -24,13 +24,13 @@ all background workers are started, the audio output is initialized, a soundfont
-{"playerReady"}{":"}{" "}{"IEventEmitter"}{";"}
+{"readonly"}{" "}{"playerReady"}{":"}{" "}{"IEventEmitter"}{";"}
-{"IEventEmitter"}{" "}{"PlayerReady"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"set"}{";"}{" "}{"}"}
+{"IEventEmitter"}{" "}{"PlayerReady"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"}"}
-{"var"}{" "}{"playerReady"}{":"}{" "}{"IEventEmitter"}
+{"val"}{" "}{"playerReady"}{":"}{" "}{"IEventEmitter"}
diff --git a/docs/reference/api/playerstatechanged.mdx b/docs/reference/api/playerstatechanged.mdx
index 5f0f733..6aba476 100644
--- a/docs/reference/api/playerstatechanged.mdx
+++ b/docs/reference/api/playerstatechanged.mdx
@@ -23,13 +23,13 @@ This event is fired when the playback state changed.
-{"playerStateChanged"}{":"}{" "}{"IEventEmitterOfT"}{"<"}{"PlayerStateChangedEventArgs"}{">"}{";"}
+{"readonly"}{" "}{"playerStateChanged"}{":"}{" "}{"IEventEmitterOfT"}{"<"}{"PlayerStateChangedEventArgs"}{">"}{";"}
-{"IEventEmitterOfT"}{"<"}{"PlayerStateChangedEventArgs"}{">"}{" "}{"PlayerStateChanged"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"set"}{";"}{" "}{"}"}
+{"IEventEmitterOfT"}{"<"}{"PlayerStateChangedEventArgs"}{">"}{" "}{"PlayerStateChanged"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"}"}
-{"var"}{" "}{"playerStateChanged"}{":"}{" "}{"IEventEmitterOfT"}{"<"}{"PlayerStateChangedEventArgs"}{">"}
+{"val"}{" "}{"playerStateChanged"}{":"}{" "}{"IEventEmitterOfT"}{"<"}{"PlayerStateChangedEventArgs"}{">"}
diff --git a/docs/reference/api/settingsupdated.mdx b/docs/reference/api/settingsupdated.mdx
new file mode 100644
index 0000000..621deab
--- /dev/null
+++ b/docs/reference/api/settingsupdated.mdx
@@ -0,0 +1,72 @@
+---
+title: settingsUpdated
+description: "This event is fired when a settings update was requested."
+sidebar_custom_props:
+ kind: event
+ category: Events - Core
+ since: 1.6.0
+---
+
+import { ParameterTable, ParameterRow } from '@site/src/components/ParameterTable';
+import CodeBlock from '@theme/CodeBlock';
+import Tabs from "@theme/Tabs";
+import TabItem from "@theme/TabItem";
+import { CodeBadge } from '@site/src/components/CodeBadge';
+import { SinceBadge } from '@site/src/components/SinceBadge';
+import DynHeading from '@site/src/components/DynHeading';
+import Link from '@docusaurus/Link';
+
+
+
+### Description
+This event is fired when a settings update was requested.
+
+
+
+{"settingsUpdated"}{":"}{" "}{"IEventEmitter"}{";"}
+
+
+{"IEventEmitter"}{" "}{"SettingsUpdated"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"set"}{";"}{" "}{"}"}
+
+
+{"var"}{" "}{"settingsUpdated"}{":"}{" "}{"IEventEmitter"}
+
+
+
+
+## Examples
+
+
+
+```js
+const api = new alphaTab.AlphaTabApi(document.querySelector('#alphaTab'));
+api.settingsUpdated.on(() => {
+ updateSettingsUI(api.settings);
+});
+```
+
+
+```cs
+var api = new AlphaTabApi(...);
+api.SettingsUpdated.On(() =>
+{
+ UpdateSettingsUI(api.settings);
+});
+```
+
+
+```kotlin
+val api = AlphaTabApi(...)
+api.SettingsUpdated.on {
+ updateSettingsUI(api.settings)
+}
+```
+
+
diff --git a/docs/reference/api/soundfontloaded.mdx b/docs/reference/api/soundfontloaded.mdx
index 7e325c7..785845e 100644
--- a/docs/reference/api/soundfontloaded.mdx
+++ b/docs/reference/api/soundfontloaded.mdx
@@ -23,13 +23,13 @@ This event is fired when the SoundFont needed for playback was loaded.
-{"soundFontLoaded"}{":"}{" "}{"IEventEmitter"}{";"}
+{"readonly"}{" "}{"soundFontLoaded"}{":"}{" "}{"IEventEmitter"}{";"}
-{"IEventEmitter"}{" "}{"SoundFontLoaded"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"set"}{";"}{" "}{"}"}
+{"IEventEmitter"}{" "}{"SoundFontLoaded"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"}"}
-{"var"}{" "}{"soundFontLoaded"}{":"}{" "}{"IEventEmitter"}
+{"val"}{" "}{"soundFontLoaded"}{":"}{" "}{"IEventEmitter"}
diff --git a/docs/reference/api/uifacade.mdx b/docs/reference/api/uifacade.mdx
index 178ea70..8b39341 100644
--- a/docs/reference/api/uifacade.mdx
+++ b/docs/reference/api/uifacade.mdx
@@ -1,9 +1,10 @@
---
title: uiFacade
-description: "Gets the UI facade to use for interacting with the user interface."
+description: "The UI facade used for interacting with the user interface (like the browser)."
sidebar_custom_props:
kind: property
category: Properties - Core
+ since: 0.9.4
---
import { ParameterTable, ParameterRow } from '@site/src/components/ParameterTable';
@@ -15,12 +16,13 @@ import { SinceBadge } from '@site/src/components/SinceBadge';
import DynHeading from '@site/src/components/DynHeading';
import Link from '@docusaurus/Link';
+
import { PropertyDescription } from '@site/src/components/PropertyDescription';
### Description
-Gets the UI facade to use for interacting with the user interface.
+The UI facade used for interacting with the user interface (like the browser). The implementation depends on the platform alphaTab is running in (e.g. the web version in the browser, WPF in .net etc.)
diff --git a/docs/reference/settings/player/playermode.mdx b/docs/reference/settings/player/playermode.mdx
new file mode 100644
index 0000000..40d8851
--- /dev/null
+++ b/docs/reference/settings/player/playermode.mdx
@@ -0,0 +1,58 @@
+---
+title: player.playerMode
+description: "Whether the player should be enabled and which mode it should use."
+sidebar_custom_props:
+ category: Player
+ since: 1.6.0
+---
+
+import { ParameterTable, ParameterRow } from '@site/src/components/ParameterTable';
+import CodeBlock from '@theme/CodeBlock';
+import Tabs from "@theme/Tabs";
+import TabItem from "@theme/TabItem";
+import { CodeBadge } from '@site/src/components/CodeBadge';
+import { SinceBadge } from '@site/src/components/SinceBadge';
+import DynHeading from '@site/src/components/DynHeading';
+import Link from '@docusaurus/Link';
+
+import { SettingsHeader } from '@site/src/components/SettingsHeader';
+
+
+
+### Description
+Whether the player should be enabled and which mode it should use. This setting configures whether the player feature is enabled or not. Depending on the platform enabling the player needs some additional actions of the developer.
+
+**Synthesizer**
+
+If the synthesizer is used (via {"EnabledAutomatic"} or {"EnabledSynthesizer"}) a sound font is needed so that the midi synthesizer can produce the audio samples.
+
+For the JavaScript version the [player.soundFont](/docs/reference/settings/player/soundfont) property must be set to the URL of the sound font that should be used or it must be loaded manually via API.
+For .net manually the soundfont must be loaded.
+
+**Backing Track**
+
+For a built-in backing track of the input file no additional data needs to be loaded (assuming everything is filled via the input file).
+Otherwise the `score.backingTrack` needs to be filled before loading and the related sync points need to be configured.
+
+**External Media**
+
+For synchronizing alphaTab with an external media no data needs to be loaded into alphaTab. The configured sync points on the MasterBars are used
+as reference to synchronize the external media with the internal time axis. Then the related APIs on the AlphaTabApi object need to be used
+to update the playback state and exterrnal audio position during playback.
+
+**User Interface**
+
+AlphaTab does not ship a default UI for the player. The API must be hooked up to some UI controls to allow the user to interact with the player.
+
+
+
+{"playerMode"}{":"}{" "}{"PlayerMode"}{" "}{"="}{" "}{"PlayerMode.Disabled"}{";"}
+
+
+{"PlayerMode"}{" "}{"PlayerMode"}{" "}{"{"}{" "}{"get"}{";"}{" "}{"set"}{";"}{" "}{"}"}{" "}{"="}{" "}{"PlayerMode.Disabled"}
+
+
+{"var"}{" "}{"playerMode"}{":"}{" "}{"PlayerMode"}{" "}{"="}{" "}{"PlayerMode.Disabled"}
+
+
+
diff --git a/docs/showcase/effects.mdx b/docs/showcase/effects.mdx
index b768cfb..24da117 100644
--- a/docs/showcase/effects.mdx
+++ b/docs/showcase/effects.mdx
@@ -1,6 +1,5 @@
---
title: Effects and Annotations
-full_width: true
---
import { AlphaTab } from '@site/src/components/AlphaTab';
diff --git a/docs/showcase/general.mdx b/docs/showcase/general.mdx
index f19d516..ca2ce91 100644
--- a/docs/showcase/general.mdx
+++ b/docs/showcase/general.mdx
@@ -1,6 +1,5 @@
---
title: General
-full_width: true
---
import { AlphaTab } from '@site/src/components/AlphaTab';
diff --git a/docs/showcase/guitar-tabs.mdx b/docs/showcase/guitar-tabs.mdx
index d81b444..93790cb 100644
--- a/docs/showcase/guitar-tabs.mdx
+++ b/docs/showcase/guitar-tabs.mdx
@@ -1,6 +1,5 @@
---
title: Guitar Tabs
-full_width: true
---
import { AlphaTab } from '@site/src/components/AlphaTab';
diff --git a/docs/showcase/introduction.mdx b/docs/showcase/introduction.mdx
index 07c1250..9c4c4cb 100644
--- a/docs/showcase/introduction.mdx
+++ b/docs/showcase/introduction.mdx
@@ -1,6 +1,5 @@
---
title: Introduction
-full_width: true
---
import { AlphaTab } from '@site/src/components/AlphaTab';
diff --git a/docs/showcase/layouts.mdx b/docs/showcase/layouts.mdx
index 9bb063e..e64c8db 100644
--- a/docs/showcase/layouts.mdx
+++ b/docs/showcase/layouts.mdx
@@ -1,6 +1,5 @@
---
title: Layouts
-full_width: true
---
import { AlphaTab } from '@site/src/components/AlphaTab';
diff --git a/docs/showcase/music-notation.mdx b/docs/showcase/music-notation.mdx
index 69f5c9f..0e8d5bf 100644
--- a/docs/showcase/music-notation.mdx
+++ b/docs/showcase/music-notation.mdx
@@ -1,6 +1,5 @@
---
title: Music Notation
-full_width: true
---
import { AlphaTab } from '@site/src/components/AlphaTab';
diff --git a/docs/showcase/special-notes.mdx b/docs/showcase/special-notes.mdx
index c0bc3a8..8e49309 100644
--- a/docs/showcase/special-notes.mdx
+++ b/docs/showcase/special-notes.mdx
@@ -1,6 +1,5 @@
---
title: Special Notes
-full_width: true
---
import { AlphaTab } from '@site/src/components/AlphaTab';
diff --git a/docs/showcase/special-tracks.mdx b/docs/showcase/special-tracks.mdx
index f699639..d5b77af 100644
--- a/docs/showcase/special-tracks.mdx
+++ b/docs/showcase/special-tracks.mdx
@@ -1,6 +1,5 @@
---
title: Special Tracks
-full_width: true
---
import { AlphaTab } from '@site/src/components/AlphaTab';
diff --git a/docusaurus.config.ts b/docusaurus.config.ts
index 0a234cf..ffbff68 100644
--- a/docusaurus.config.ts
+++ b/docusaurus.config.ts
@@ -220,6 +220,12 @@ const config: Config = {
position: "left",
label: "Showcase",
},
+ {
+ type: "doc",
+ docId: "playground/playground",
+ position: "left",
+ label: "Playground",
+ },
// Right
{
type: "dropdown",
diff --git a/package-lock.json b/package-lock.json
index 8d8f162..e459c19 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,12 +18,15 @@
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@mdx-js/react": "^3.1.0",
+ "@uidotdev/usehooks": "^2.4.1",
+ "@uiw/react-color": "^2.5.5",
+ "@uiw/react-color-chrome": "^2.5.5",
"clsx": "^2.1.1",
"docusaurus-lunr-search": "^3.6.0",
"prism-react-renderer": "^2.4.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
- "react-tooltip": "^5.28.0",
+ "react-tooltip": "^5.28.1",
"webpack": "^5.98.0"
},
"devDependencies": {
@@ -5716,6 +5719,410 @@
"resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz",
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="
},
+ "node_modules/@uidotdev/usehooks": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.1.tgz",
+ "integrity": "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==",
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "react": ">=18.0.0",
+ "react-dom": ">=18.0.0"
+ }
+ },
+ "node_modules/@uiw/color-convert": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/color-convert/-/color-convert-2.5.5.tgz",
+ "integrity": "sha512-sNKhJe3h/nMxxoB3NEr02RTjkSE85sLIwQyl1lVK4scVCQumPbz1giWcWlYnSRLBHmrYYat7xShrDf/+Uc3UAg==",
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0"
+ }
+ },
+ "node_modules/@uiw/react-color": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color/-/react-color-2.5.5.tgz",
+ "integrity": "sha512-dWho/6QvHgK34j7oi7/0fA21ozMaFNJyA2Uj87Lw5JZv3Br4ZtX39fBXBqlE53VpfuTFLAJoCB9Xun5XD39rJw==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5",
+ "@uiw/react-color-alpha": "2.5.5",
+ "@uiw/react-color-block": "2.5.5",
+ "@uiw/react-color-chrome": "2.5.5",
+ "@uiw/react-color-circle": "2.5.5",
+ "@uiw/react-color-colorful": "2.5.5",
+ "@uiw/react-color-compact": "2.5.5",
+ "@uiw/react-color-editable-input": "2.5.5",
+ "@uiw/react-color-editable-input-hsla": "2.5.5",
+ "@uiw/react-color-editable-input-rgba": "2.5.5",
+ "@uiw/react-color-github": "2.5.5",
+ "@uiw/react-color-hue": "2.5.5",
+ "@uiw/react-color-material": "2.5.5",
+ "@uiw/react-color-name": "2.5.5",
+ "@uiw/react-color-saturation": "2.5.5",
+ "@uiw/react-color-shade-slider": "2.5.5",
+ "@uiw/react-color-sketch": "2.5.5",
+ "@uiw/react-color-slider": "2.5.5",
+ "@uiw/react-color-swatch": "2.5.5",
+ "@uiw/react-color-wheel": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-alpha": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-alpha/-/react-color-alpha-2.5.5.tgz",
+ "integrity": "sha512-J0SpMtpZFMCaSy1DmWCsVyoWZ0IH6JdSCd7558WdJbKqD/Wt9tzGjZeH9SWb9fGAPFhxoNqh8RCD2mAij4dsYA==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5",
+ "@uiw/react-drag-event-interactive": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-block": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-block/-/react-color-block-2.5.5.tgz",
+ "integrity": "sha512-7V14Wk9MvMZZUaM+OQ2twislWEgt/g/aB0/tadJ++lIRj9+gBZaHn+TATuizkCHMwcbcI6On0Kes+jdxGelqcA==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5",
+ "@uiw/react-color-editable-input": "2.5.5",
+ "@uiw/react-color-swatch": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-chrome": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-chrome/-/react-color-chrome-2.5.5.tgz",
+ "integrity": "sha512-wCOpyEo5BUNsMD0Z9mtp3PEyL+ZM1BcSelWSBPxg5D8CRBI0FcJ+aek4y9GK8Ypb1xqi7ngbeR+P78T0+uC/tg==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5",
+ "@uiw/react-color-alpha": "2.5.5",
+ "@uiw/react-color-editable-input": "2.5.5",
+ "@uiw/react-color-editable-input-hsla": "2.5.5",
+ "@uiw/react-color-editable-input-rgba": "2.5.5",
+ "@uiw/react-color-github": "2.5.5",
+ "@uiw/react-color-hue": "2.5.5",
+ "@uiw/react-color-saturation": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-circle": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-circle/-/react-color-circle-2.5.5.tgz",
+ "integrity": "sha512-J9rRotuAFOLeHvMLQG6UFHAW8RfPK2PXeJcke+GTFtNf9JH/Jn6GePO+stV8URQ8/pbugJPJLUSq4oyzA84gYw==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5",
+ "@uiw/react-color-swatch": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-colorful": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-colorful/-/react-color-colorful-2.5.5.tgz",
+ "integrity": "sha512-F1Z9Z3uhDUuZWjLpv0YFxn29USqvTd9E6uYHYl7hdAHBjtAUKhWUhUCsq0YsRwieHvof9jYhMhUMAu6/Z7WcIA==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5",
+ "@uiw/react-color-alpha": "2.5.5",
+ "@uiw/react-color-hue": "2.5.5",
+ "@uiw/react-color-saturation": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-compact": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-compact/-/react-color-compact-2.5.5.tgz",
+ "integrity": "sha512-y6XZIE0NFiE0L7D3L2E5OrvXkGUd99WAsCLbeGgTSBGKB9OU4+9w79A06TXplr3uowA3TsRCMhOkkpFIL08zOA==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5",
+ "@uiw/react-color-editable-input": "2.5.5",
+ "@uiw/react-color-editable-input-rgba": "2.5.5",
+ "@uiw/react-color-swatch": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-editable-input": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-editable-input/-/react-color-editable-input-2.5.5.tgz",
+ "integrity": "sha512-Y44woxk/3c3r7D+7xh8UX/JKqst2TiiMMB5c55AQYKSQtxFsxbJG7g2pzYXxX41s9JEugkyrxbxAJWRP9pRJGg==",
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-editable-input-hsla": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-editable-input-hsla/-/react-color-editable-input-hsla-2.5.5.tgz",
+ "integrity": "sha512-cnKwW44zQsu/UDXE5DD+079F3x3UI4gNOJS3ozJaqsEyJOogfkbKHY9yfdxFSulkek9T02fRRKoHpchkKsQmaw==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5",
+ "@uiw/react-color-editable-input-rgba": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-editable-input-rgba": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-editable-input-rgba/-/react-color-editable-input-rgba-2.5.5.tgz",
+ "integrity": "sha512-pX7k4BIf5kOXnNDTLJhRFUjUHaxBXVyWYNgZtQLAQF8TxFBFAdbrZDXIcgYm1QeLUcGUPUrL73n3sNgqmS7+3g==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5",
+ "@uiw/react-color-editable-input": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-github": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-github/-/react-color-github-2.5.5.tgz",
+ "integrity": "sha512-ve7QOWbMlMRizX/2d7yvr0xFo1k6sVFABz7hj69dYXYp5lTcoYH4sujuwD4ic2igSVoZTexQyVlkaK4vMXUQdg==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5",
+ "@uiw/react-color-swatch": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-hue": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-hue/-/react-color-hue-2.5.5.tgz",
+ "integrity": "sha512-PxofC2303OthiHf7EOOz1+U+Oa8jmIpUY6SnOFdXIpgKMxaiTk+UUvV9uZRXyJ3onweNhK/zAN+8uEuSkQkpTg==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5",
+ "@uiw/react-color-alpha": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-material": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-material/-/react-color-material-2.5.5.tgz",
+ "integrity": "sha512-HXVO0DEOIbyw0dYt0NeXVCx2ewJl+rpuVgvVQFgFtHq2MFcKtd88nIFU/qs6iwnAZ1s9ZYP5nUig8wOvyBPkrw==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5",
+ "@uiw/react-color-editable-input": "2.5.5",
+ "@uiw/react-color-editable-input-rgba": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-name": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-name/-/react-color-name-2.5.5.tgz",
+ "integrity": "sha512-xsJ+FriWCmMJq65nTjaKAUflKw+wh9BN4zZIup4x30PDlzELx2VssIxJrWwazX4kXryurg9+pRfhcwAHL5dOVA==",
+ "dependencies": {
+ "colors-named": "^1.0.1",
+ "colors-named-hex": "^1.0.1"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0"
+ }
+ },
+ "node_modules/@uiw/react-color-saturation": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-saturation/-/react-color-saturation-2.5.5.tgz",
+ "integrity": "sha512-InHxYWpyTYwpHZXjsoG6tKI7QObCva/TfjobbPhB2UMYxpL07u8l9RiB0IOvejIybWuZDPVqjueawV7+wesepA==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5",
+ "@uiw/react-drag-event-interactive": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-shade-slider": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-shade-slider/-/react-color-shade-slider-2.5.5.tgz",
+ "integrity": "sha512-UI61p56Wf5vJuJuMuitB4+YtNi/+XiqeP0jgAL7PXz610D4XCFHodJRq77DKThVWPFAIGNnZJAiyf/sJ22CKtw==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5",
+ "@uiw/react-color-alpha": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-sketch": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-sketch/-/react-color-sketch-2.5.5.tgz",
+ "integrity": "sha512-SXbD4qHMKQSEAS7Qf/bjScOuU4J30UJYPBd32BXcEspWiiyDS85ApUZd417AmS4DHvQw6TBUSidgXSEcibRUiw==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5",
+ "@uiw/react-color-alpha": "2.5.5",
+ "@uiw/react-color-editable-input": "2.5.5",
+ "@uiw/react-color-editable-input-rgba": "2.5.5",
+ "@uiw/react-color-hue": "2.5.5",
+ "@uiw/react-color-saturation": "2.5.5",
+ "@uiw/react-color-swatch": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-slider": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-slider/-/react-color-slider-2.5.5.tgz",
+ "integrity": "sha512-epu/15opKC4l3zxS08hoPWr6uYLSqv9wn07qRrErFghDvBFczKGB8DPsRkauU58H2L3wJ4puSi/S52GqQ1ff/A==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5",
+ "@uiw/react-color-alpha": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-swatch": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-swatch/-/react-color-swatch-2.5.5.tgz",
+ "integrity": "sha512-4VceKPE7W7S4esG0dToEC0ogexABFBHBnNuS6CHPYSOEnyozM2FpxlixzFUwyf1PFOKKKLZDYHl9GqZX1dpNgw==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-color-wheel": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-color-wheel/-/react-color-wheel-2.5.5.tgz",
+ "integrity": "sha512-9waqaTdJgeZS5oc85NlKTVeVBSmBU2JypECoYPKqxRPlwetEWo/gJazAyU0HCpZqU5TucPRYUz6shRTz1On+0A==",
+ "dependencies": {
+ "@uiw/color-convert": "2.5.5",
+ "@uiw/react-drag-event-interactive": "2.5.5"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
+ "node_modules/@uiw/react-drag-event-interactive": {
+ "version": "2.5.5",
+ "resolved": "https://registry.npmjs.org/@uiw/react-drag-event-interactive/-/react-drag-event-interactive-2.5.5.tgz",
+ "integrity": "sha512-RO31lRPh0pDFM++dTT42K5zrJsYb5kGy502Ou5aTIP/XJg05+Xy5QpF4o4EPsuzNJ1GOpPYg+V5FsvafQ+LONQ==",
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ },
+ "peerDependencies": {
+ "@babel/runtime": ">=7.19.0",
+ "react": ">=16.9.0",
+ "react-dom": ">=16.9.0"
+ }
+ },
"node_modules/@ungap/structured-clone": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz",
@@ -6968,6 +7375,28 @@
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="
},
+ "node_modules/colors-named": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/colors-named/-/colors-named-1.0.2.tgz",
+ "integrity": "sha512-2ANq2r393PV9njYUD66UdfBcxR1slMqRA3QRTWgCx49JoCJ+kOhyfbQYxKJbPZQIhZUcNjVOs5AlyY1WwXec3w==",
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ }
+ },
+ "node_modules/colors-named-hex": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/colors-named-hex/-/colors-named-hex-1.0.2.tgz",
+ "integrity": "sha512-k6kq1e1pUCQvSVwIaGFq2l0LrkAPQZWyeuZn1Z8nOiYSEZiKoFj4qx690h2Kd34DFl9Me0gKS6MUwAMBJj8nuA==",
+ "engines": {
+ "node": ">=14.16"
+ },
+ "funding": {
+ "url": "https://jaywcjlove.github.io/#/sponsor"
+ }
+ },
"node_modules/combine-promises": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/combine-promises/-/combine-promises-1.2.0.tgz",
@@ -16852,9 +17281,9 @@
}
},
"node_modules/react-tooltip": {
- "version": "5.28.0",
- "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.28.0.tgz",
- "integrity": "sha512-R5cO3JPPXk6FRbBHMO0rI9nkUG/JKfalBSQfZedZYzmqaZQgq7GLzF8vcCWx6IhUCKg0yPqJhXIzmIO5ff15xg==",
+ "version": "5.28.1",
+ "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.28.1.tgz",
+ "integrity": "sha512-ZA4oHwoIIK09TS7PvSLFcRlje1wGZaxw6xHvfrzn6T82UcMEfEmHVCad16Gnr4NDNDh93HyN037VK4HDi5odfQ==",
"dependencies": {
"@floating-ui/dom": "^1.6.1",
"classnames": "^2.3.0"
diff --git a/package.json b/package.json
index 4bca40c..985621b 100644
--- a/package.json
+++ b/package.json
@@ -26,12 +26,15 @@
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@mdx-js/react": "^3.1.0",
+ "@uidotdev/usehooks": "^2.4.1",
+ "@uiw/react-color": "^2.5.5",
+ "@uiw/react-color-chrome": "^2.5.5",
"clsx": "^2.1.1",
"docusaurus-lunr-search": "^3.6.0",
"prism-react-renderer": "^2.4.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
- "react-tooltip": "^5.28.0",
+ "react-tooltip": "^5.28.1",
"webpack": "^5.98.0"
},
"devDependencies": {
diff --git a/sidebars.ts b/sidebars.ts
index c212494..0b9a2fc 100644
--- a/sidebars.ts
+++ b/sidebars.ts
@@ -179,7 +179,7 @@ const sidebars: SidebarsConfig = {
"formats/capella",
],
},
- ],
+ ]
};
module.exports = sidebars;
diff --git a/src/components/AlphaTabFull/index.tsx b/src/components/AlphaTabFull/index.tsx
index 623a7ac..a680e20 100644
--- a/src/components/AlphaTabFull/index.tsx
+++ b/src/components/AlphaTabFull/index.tsx
@@ -1,13 +1,12 @@
'use client';
import * as alphaTab from "@coderline/alphatab";
-import React, { useEffect, useRef, useState } from "react";
+import React, { useRef, useState } from "react";
import { PlayerControlsGroup } from "./player-controls-group";
import { TrackItem } from "./track-item";
import styles from "./styles.module.scss";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import * as solid from "@fortawesome/free-solid-svg-icons";
-import environment from "@site/src/environment";
import { useAlphaTab, useAlphaTabEvent } from "@site/src/hooks";
import { openFile } from "@site/src/utils";
diff --git a/src/components/AlphaTabFull/player-controls-group.tsx b/src/components/AlphaTabFull/player-controls-group.tsx
index b455eef..f3ae275 100644
--- a/src/components/AlphaTabFull/player-controls-group.tsx
+++ b/src/components/AlphaTabFull/player-controls-group.tsx
@@ -13,7 +13,7 @@ import { openFile } from "@site/src/utils";
export interface PlayerControlsGroupProps {
api: alphaTab.AlphaTabApi;
- onLayoutChange: (
+ onLayoutChange?: (
layoutMode: alphaTab.LayoutMode,
scrollMode: alphaTab.ScrollMode
) => void;
diff --git a/src/components/AlphaTabPlayground/index.tsx b/src/components/AlphaTabPlayground/index.tsx
new file mode 100644
index 0000000..581738a
--- /dev/null
+++ b/src/components/AlphaTabPlayground/index.tsx
@@ -0,0 +1,97 @@
+'use client';
+
+import type * as alphaTab from '@coderline/alphatab';
+import React, { useEffect, useState } from 'react';
+import { useAlphaTab, useAlphaTabEvent } from '@site/src/hooks';
+import styles from './styles.module.scss';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import * as solid from '@fortawesome/free-solid-svg-icons';
+import { openFile } from '@site/src/utils';
+import { PlayerControlsGroup, SidePanel } from './player-controls-group';
+import { PlaygroundSettings } from './playground-settings';
+import { Tooltip } from 'react-tooltip';
+import { PlaygroundTrackSelector } from './track-selector';
+
+interface AlphaTabPlaygroundProps {
+ settings?: alphaTab.json.SettingsJson;
+}
+
+export const AlphaTabPlayground: React.FC = ({ settings }) => {
+ const viewPortRef = React.createRef();
+ const [isLoading, setLoading] = useState(true);
+ const [sidePanel, setSidePanel] = useState(SidePanel.None);
+
+ const [api, element] = useAlphaTab(s => {
+ s.core.engine = 'svg';
+ s.player.scrollElement = viewPortRef.current!;
+ s.player.scrollOffsetY = -10;
+ s.player.enablePlayer = true;
+ if (settings) {
+ s.fillFromJson(settings);
+ }
+ });
+
+ useAlphaTabEvent(api, 'renderFinished', () => {
+ setLoading(false);
+ });
+
+ const onDragOver = (e: React.DragEvent) => {
+ e.stopPropagation();
+ e.preventDefault();
+ if (e.dataTransfer) {
+ e.dataTransfer.dropEffect = 'link';
+ }
+ };
+
+ const onDrop = (e: React.DragEvent) => {
+ e.stopPropagation();
+ e.preventDefault();
+ if (e.dataTransfer) {
+ const files = e.dataTransfer.files;
+ if (files.length === 1) {
+ openFile(api!, files[0]);
+ }
+ }
+ };
+
+ return (
+ <>
+
+ {isLoading && (
+
+
+
+
+
+ )}
+
+ {api && api?.score && (
+ setSidePanel(SidePanel.None)}
+ isOpen={sidePanel === SidePanel.Settings}
+ />
+ )}
+
+ {api && api?.score && (
+ setSidePanel(SidePanel.None)}
+ isOpen={sidePanel === SidePanel.TrackSelector}
+ />
+ )}
+
+
+
+
+
+
+
+
+ {api && }
+
+
+
+ >
+ );
+};
diff --git a/src/components/AlphaTabPlayground/player-controls-group.tsx b/src/components/AlphaTabPlayground/player-controls-group.tsx
new file mode 100644
index 0000000..1bfe2fd
--- /dev/null
+++ b/src/components/AlphaTabPlayground/player-controls-group.tsx
@@ -0,0 +1,154 @@
+import * as alphaTab from '@coderline/alphatab';
+import type React from 'react';
+import { useState } from 'react';
+import styles from './styles.module.scss';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import * as solid from '@fortawesome/free-solid-svg-icons';
+import { useAlphaTabEvent } from '@site/src/hooks';
+import { openFile } from '@site/src/utils';
+import { PlayerProgressIndicator } from '../AlphaTabFull/player-progress-indicator';
+
+export interface PlayerControlsGroupProps {
+ sidePanel: SidePanel;
+ onSidePanelChange: (sidePanel: SidePanel) => void;
+ api: alphaTab.AlphaTabApi;
+}
+
+export enum SidePanel {
+ None = 0,
+ Settings = 1,
+ TrackSelector = 2
+}
+
+export const PlayerControlsGroup: React.FC = ({ api, sidePanel, onSidePanelChange }) => {
+ const [soundFontLoadPercentage, setSoundFontLoadPercentage] = useState(0);
+ const [isPlaying, setPlaying] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [endTime, setEndTime] = useState(1);
+
+ useAlphaTabEvent(api, 'soundFontLoad', e => {
+ setSoundFontLoadPercentage(e.loaded / e.total);
+ });
+
+ useAlphaTabEvent(api, 'soundFontLoaded', () => {
+ setSoundFontLoadPercentage(1);
+ });
+ useAlphaTabEvent(api, 'playerStateChanged', e => {
+ setPlaying(e.state === alphaTab.synth.PlayerState.Playing);
+ });
+ useAlphaTabEvent(api, 'playerPositionChanged', e => {
+ // reduce number of UI updates to second changes.
+ const previousCurrentSeconds = (currentTime / 1000) | 0;
+ const newCurrentSeconds = (e.currentTime / 1000) | 0;
+
+ if (e.endTime === endTime && (previousCurrentSeconds === newCurrentSeconds || newCurrentSeconds === 0)) {
+ return;
+ }
+
+ setEndTime(e.endTime);
+ setCurrentTime(e.currentTime);
+ });
+
+ const open = () => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.gp,.gp3,.gp4,.gp5,.gpx,.musicxml,.mxml,.xml,.capx';
+ input.onchange = () => {
+ if (input.files?.length === 1) {
+ openFile(api, input.files[0]);
+ }
+ };
+ document.body.appendChild(input);
+ input.click();
+ document.body.removeChild(input);
+ };
+
+ const formatDuration = (milliseconds: number) => {
+ 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')}`;
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+ {api.score && (
+
+ {api.score.title}
+ -
+ {api.score.artist}
+
+ )}
+
+
+ {formatDuration(currentTime)} / {formatDuration(endTime)}
+
+
+
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/components/AlphaTabPlayground/playground-settings.tsx b/src/components/AlphaTabPlayground/playground-settings.tsx
new file mode 100644
index 0000000..15c05c8
--- /dev/null
+++ b/src/components/AlphaTabPlayground/playground-settings.tsx
@@ -0,0 +1,886 @@
+'use client';
+
+import * as alphaTab from '@coderline/alphatab';
+import type React from 'react';
+import styles from './styles.module.scss';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import * as solid from '@fortawesome/free-solid-svg-icons';
+import { createContext, useContext, useEffect, useId, useState } from 'react';
+import Chrome from '@uiw/react-color-chrome';
+import { useDebounce } from '@uidotdev/usehooks';
+import { rgbaToHexa } from '@uiw/react-color';
+
+type SettingsContextProps = {
+ api: alphaTab.AlphaTabApi;
+ onSettingsUpdated(): void;
+};
+
+const SettingsContext = createContext(null!);
+
+type TypeScriptEnum = { [key: number | string]: number | string };
+
+type ValueAccessor = {
+ getValue(context: SettingsContextProps): any;
+ setValue(context: SettingsContextProps, value: any): void;
+};
+type ControlProps = ValueAccessor & { inputId: string };
+
+type ButtonGroupButtonSchema = { label: string; value: any };
+
+type ButtonGroupSchema = { type: 'button-group'; buttons: ButtonGroupButtonSchema[] };
+type NumberInputSchema = { type: 'number-input'; min?: number; max?: number; step?: number };
+type BooleanToggleSchema = { type: 'boolean-toggle' };
+type NumberRangeSchema = { type: 'number-range'; min: number; max: number; step: number };
+type EnumDropDownSchema = { type: 'enum-dropdown'; enumType: TypeScriptEnum };
+type ColorPickerSchema = { type: 'color-picker' };
+type FontPickerSchema = { type: 'font-picker' };
+
+type SettingSchema = {
+ label: string;
+ control:
+ | ButtonGroupSchema
+ | EnumDropDownSchema
+ | NumberRangeSchema
+ | NumberInputSchema
+ | BooleanToggleSchema
+ | ColorPickerSchema
+ | FontPickerSchema;
+ prepareValue?(value: any): any;
+} & ValueAccessor;
+type SettingsGroupSchema = { title: string; settings: SettingSchema[] };
+
+type UpdateSettingsOptions = {
+ prepareValue?: (value: any) => any;
+ afterUpdate?: (context: SettingsContextProps) => any;
+ callRender?: boolean;
+ callUpdateSettings?: boolean;
+};
+
+function updateSettings(
+ context: SettingsContextProps,
+ update: (settings: alphaTab.Settings) => void,
+ options?: UpdateSettingsOptions
+) {
+ const api = context.api;
+ update(api.settings);
+ if (options?.callUpdateSettings ?? true) {
+ api.updateSettings();
+ }
+ if (options?.callRender ?? true) {
+ api.render();
+ }
+ context.onSettingsUpdated();
+ options?.afterUpdate?.(context);
+}
+
+const factory = {
+ settingAccessors(setting: string, updateOptions?: UpdateSettingsOptions) {
+ const parts = setting.split('.');
+ return {
+ getValue(context: SettingsContextProps) {
+ let setting: any = context.api.settings;
+ for (let i = 0; i < parts.length - 1; i++) {
+ setting = setting[parts[i]];
+ }
+ return setting[parts[parts.length - 1]];
+ },
+ setValue(context: SettingsContextProps, value) {
+ updateSettings(
+ context,
+ s => {
+ for (let i = 0; i < parts.length - 1; i++) {
+ s = s[parts[i]];
+ }
+ if (updateOptions?.prepareValue) {
+ value = updateOptions?.prepareValue(value);
+ }
+ s[parts[parts.length - 1]] = value;
+ },
+ updateOptions
+ );
+ }
+ };
+ },
+ apiAccessors(setting: string) {
+ return {
+ getValue(context: SettingsContextProps) {
+ return context.api[setting];
+ },
+ setValue(context: SettingsContextProps, value) {
+ context.api[setting] = value;
+ context.onSettingsUpdated();
+ }
+ };
+ },
+ stylesheetAccessors(setting: string) {
+ return {
+ getValue(context: SettingsContextProps) {
+ return context.api.score!.stylesheet[setting];
+ },
+ setValue(context: SettingsContextProps, value) {
+ context.api.score!.stylesheet[setting] = value;
+ context.onSettingsUpdated();
+ context.api.render();
+ }
+ };
+ },
+
+ numberRange(
+ label: string,
+ setting: string,
+ min: number,
+ max: number,
+ step: number,
+ updateOptions?: UpdateSettingsOptions
+ ): SettingSchema {
+ return {
+ label: label,
+ ...factory.settingAccessors(setting, updateOptions),
+ control: { type: 'number-range', min, max, step }
+ };
+ },
+
+ numberRangeNegativeDisabled(
+ label: string,
+ setting: string,
+ min: number,
+ max: number,
+ step: number,
+ updateOptions?: UpdateSettingsOptions
+ ): SettingSchema {
+ return {
+ label: label,
+ ...factory.settingAccessors(setting, {
+ callRender: updateOptions?.callRender,
+ callUpdateSettings: updateOptions?.callUpdateSettings,
+ prepareValue(value: any) {
+ if (updateOptions?.prepareValue) {
+ value = updateOptions.prepareValue(value);
+ }
+ return value < 0 ? value : value + 1;
+ }
+ }),
+ control: { type: 'number-range', min, max, step }
+ };
+ },
+
+ numberInput(
+ label: string,
+ setting: string,
+ min?: number,
+ max?: number,
+ step?: number,
+ updateOptions?: UpdateSettingsOptions
+ ): SettingSchema {
+ return {
+ label: label,
+ ...factory.settingAccessors(setting, updateOptions),
+ control: { type: 'number-input', min, max, step }
+ };
+ },
+
+ toggle(label: string, setting: string, updateOptions?: UpdateSettingsOptions): SettingSchema {
+ return {
+ label: label,
+ ...factory.settingAccessors(setting, updateOptions),
+ control: { type: 'boolean-toggle' }
+ };
+ },
+
+ colorPicker(label: string, setting: string, updateOptions?: UpdateSettingsOptions): SettingSchema {
+ return {
+ label: label,
+ ...factory.settingAccessors(setting, updateOptions),
+ control: { type: 'color-picker' }
+ };
+ },
+
+ fontPicker(label: string, setting: string, updateOptions?: UpdateSettingsOptions): SettingSchema {
+ return {
+ label: label,
+ ...factory.settingAccessors(setting, updateOptions),
+ control: { type: 'font-picker' }
+ };
+ },
+
+ enumDropDown(
+ label: string,
+ setting: string,
+ enumType: TypeScriptEnum,
+ updateOptions?: UpdateSettingsOptions
+ ): SettingSchema {
+ return {
+ label: label,
+ ...factory.settingAccessors(setting, updateOptions),
+ control: { type: 'enum-dropdown', enumType }
+ };
+ },
+
+ buttonGroup(
+ label: string,
+ setting: string,
+ buttons: [string, string][],
+ updateOptions?: UpdateSettingsOptions
+ ): SettingSchema {
+ return {
+ label: label,
+ ...factory.settingAccessors(setting, updateOptions),
+ control: { type: 'button-group', buttons: buttons.map(b => ({ label: b[0], value: b[1] })) }
+ };
+ }
+};
+
+// maybe we can auto-generate this for all settings?
+function buildSettingsGroups(): SettingsGroupSchema[] {
+ const noRerender: UpdateSettingsOptions = {
+ callRender: false,
+ callUpdateSettings: true
+ };
+ const withMidiGenerate: UpdateSettingsOptions = {
+ callRender: false,
+ callUpdateSettings: false,
+ afterUpdate(context) {
+ context.api.loadMidiForScore();
+ }
+ };
+ return [
+ {
+ title: 'Display ▸ General',
+ settings: [
+ factory.buttonGroup('Render Engine', 'core.engine', [
+ ['SVG', 'svg'],
+ ['HTML5', 'html5']
+ ]),
+ factory.numberRange('Scale', 'display.scale', 0.25, 2, 0.25),
+ factory.numberRange('Stretch', 'display.stretchForce', 0.25, 2, 0.25),
+ factory.enumDropDown('Layout', 'display.layoutMode', alphaTab.LayoutMode),
+ factory.numberRangeNegativeDisabled('Bars per System', 'display.barsPerRow', -1, 20, 1),
+ factory.numberInput('Start Bar', 'display.startBar', 1, undefined, 1),
+ factory.numberInput('Bar Count', 'display.barCount', -1, undefined, 1),
+ factory.toggle('Justify Last System', 'display.justifyLastSystem'),
+ factory.enumDropDown('Systems Layout Mode', 'display.systemsLayoutMode', alphaTab.SystemsLayoutMode)
+ ]
+ },
+ {
+ title: 'Display ▸ Colors',
+ settings: [
+ factory.colorPicker('Staff Line', 'display.resources.staffLineColor'),
+ factory.colorPicker('Bar Separator', 'display.resources.barSeparatorColor'),
+ factory.colorPicker('Bar Number', 'display.resources.barNumberColor'),
+ factory.colorPicker('Main Glyphs', 'display.resources.mainGlyphColor'),
+ factory.colorPicker('Secondary Glyphs', 'display.resources.secondaryGlyphColor'),
+ factory.colorPicker('Score Info', 'display.resources.scoreInfoColor')
+ // TODO: advanced coloring
+ ]
+ },
+ {
+ title: 'Display ▸ Fonts',
+ settings: [
+ factory.fontPicker('Copyright', 'display.resources.copyrightFont'),
+ factory.fontPicker('Title', 'display.resources.titleFont'),
+ factory.fontPicker('Subtitle', 'display.resources.subTitleFont'),
+ factory.fontPicker('Words', 'display.resources.wordsFont'),
+ factory.fontPicker('Effects', 'display.resources.effectFont'),
+ factory.fontPicker('Timer', 'display.resources.timerFont'),
+ factory.fontPicker('Directions', 'display.resources.directionsFont'),
+ factory.fontPicker('Fretboard Numbers', 'display.resources.fretboardNumberFont'),
+ factory.fontPicker('Numbered Notation', 'display.resources.numberedNotationFont'),
+ factory.fontPicker('Guitar Tabs', 'display.resources.tablatureFont'),
+ factory.fontPicker('Grace Notes', 'display.resources.graceFont'),
+ factory.fontPicker('Bar Numbers', 'display.resources.barNumberFont'),
+ factory.fontPicker('Inline Fingering', 'display.resources.inlineFingeringFont'),
+ factory.fontPicker('Markers', 'display.resources.markerFont')
+ ]
+ },
+ {
+ title: 'Display ▸ Paddings',
+ settings: [
+ {
+ label: 'Horizontal',
+ getValue(context: SettingsContextProps) {
+ return context.api.settings.display.padding[0];
+ },
+ setValue(context: SettingsContextProps, value) {
+ updateSettings(context, s => {
+ s.display.padding[0] = value;
+ });
+ },
+ control: { type: 'number-input', min: 0, step: 1 }
+ },
+ {
+ label: 'Vertical',
+ getValue(context: SettingsContextProps) {
+ return context.api.settings.display.padding[1];
+ },
+ setValue(context: SettingsContextProps, value) {
+ updateSettings(context, s => {
+ s.display.padding[1] = value;
+ });
+ },
+ control: { type: 'number-input', min: 0, step: 1 }
+ },
+ factory.numberInput('First System Top', 'display.firstSystemPaddingTop', 0),
+ factory.numberInput('Other Systems Top', 'display.systemPaddingTop', 0),
+ factory.numberInput('Last System Bottom', 'display.lastSystemPaddingBottom', 0),
+ factory.numberInput('Other Systems Bottom', 'display.systemPaddingBottom', 0),
+ factory.numberInput('System Label Left', 'display.systemLabelPaddingLeft', 0),
+ factory.numberInput('System Label Right', 'display.systemLabelPaddingRight', 0),
+ factory.numberInput('Accolade Bar Right', 'display.accoladeBarPaddingRight', 0),
+ factory.numberInput('Notation Staff Top', 'display.notationStaffPaddingTop', 0),
+ factory.numberInput('Notation Staff Bottom', 'display.notationStaffPaddingBottom', 0),
+ factory.numberInput('Effect Staff Top', 'display.effectStaffPaddingTop', 0),
+ factory.numberInput('Effect Staff Bottom', 'display.effectStaffPaddingBottom', 0),
+ factory.numberInput('First Staff Left', 'display.firstStaffPaddingLeft', 0),
+ factory.numberInput('Other Staves Left', 'display.staffPaddingLeft', 0)
+ ]
+ },
+ {
+ title: 'Notation',
+ settings: [
+ factory.enumDropDown('Fingering', 'notation.fingeringMode', alphaTab.FingeringMode),
+ // TODO: elements
+ factory.enumDropDown('Tab Rhythm Stems', 'notation.rhythmMode', alphaTab.TabRhythmMode),
+ factory.numberInput('⤷ Height', 'notation.rhythmHeight', 1),
+ factory.toggle('Small Grace Notes in Tabs', 'notation.smallGraceTabNotes'),
+ factory.toggle('Extend Bend Arrows on Tied Notes', 'notation.extendBendArrowsOnTiedNotes'),
+ factory.toggle('Extend Line Effects to Beat End', 'notation.extendLineEffectsToBeatEnd'),
+ factory.numberInput('Slur Height', 'notation.slurHeight', 1)
+ ]
+ },
+ {
+ title: 'Player',
+ settings: [
+ {
+ label: 'Volume',
+ ...factory.apiAccessors('masterVolume'),
+ control: { type: 'number-range', min: 0, max: 1, step: 0.1 }
+ },
+ {
+ label: 'Metronome Volume',
+ ...factory.apiAccessors('metronomeVolume'),
+ control: { type: 'number-range', min: 0, max: 1, step: 0.1 }
+ },
+ {
+ label: 'Count-In Volume',
+ ...factory.apiAccessors('countInVolume'),
+ control: { type: 'number-range', min: 0, max: 1, step: 0.1 }
+ },
+ {
+ label: 'Playback Speed',
+ ...factory.apiAccessors('playbackSpeed'),
+ control: { type: 'number-range', min: 0.1, max: 3, step: 0.1 }
+ },
+ {
+ label: 'Looping',
+ ...factory.apiAccessors('looping'),
+ control: { type: 'boolean-toggle' }
+ },
+ factory.enumDropDown('Player Mode', 'player.playerMode', alphaTab.PlayerMode, noRerender),
+ factory.toggle('Show Cursors', 'player.enableCursor', noRerender),
+ factory.toggle('Animated Beat Cursor', 'player.enableAnimatedBeatCursor', noRerender),
+ factory.toggle('Highlight Notes', 'player.enableElementHighlighting', noRerender),
+ factory.toggle('Enable User Interaction', 'player.enableUserInteraction', noRerender),
+ factory.numberInput(
+ 'Scroll Offset X',
+ 'player.scrollOffsetX',
+ undefined,
+ undefined,
+ undefined,
+ noRerender
+ ),
+ factory.numberInput(
+ 'Scroll Offset Y',
+ 'player.scrollOffsetY',
+ undefined,
+ undefined,
+ undefined,
+ noRerender
+ ),
+ factory.enumDropDown('Scroll Mode', 'player.scrollMode', alphaTab.ScrollMode, noRerender),
+ factory.numberInput(
+ 'Song-Book Bend Duration',
+ 'player.songBookBendDuration',
+ 0.1,
+ undefined,
+ 0.1,
+ withMidiGenerate
+ ),
+ factory.numberInput(
+ 'Song-Book Dip Duration',
+ 'player.songBookDipDuration',
+ 0.1,
+ undefined,
+ 0.1,
+ withMidiGenerate
+ ),
+ factory.numberInput(
+ 'Vibrato Note Wide Length',
+ 'player.vibrato.noteWideLength',
+ 0.1,
+ undefined,
+ 0.1,
+ withMidiGenerate
+ ),
+ factory.numberInput(
+ 'Vibrato Note Wide Amplitude',
+ 'player.vibrato.noteWideAmplitude',
+ 0.1,
+ undefined,
+ 0.1,
+ withMidiGenerate
+ ),
+ factory.numberInput(
+ 'Vibrato Note Slight Length',
+ 'player.vibrato.noteSlightLength',
+ 0.1,
+ undefined,
+ 0.1,
+ withMidiGenerate
+ ),
+ factory.numberInput(
+ 'Vibrato Note Slight Amplitude',
+ 'player.vibrato.noteSlightAmplitude',
+ 0.1,
+ undefined,
+ 0.1,
+ withMidiGenerate
+ ),
+ factory.numberInput('Vibrato Beat Wide Length', 'player.vibrato.beatWideLength', 0.1, undefined, 0.1),
+ factory.numberInput(
+ 'Vibrato Beat Wide Amplitude',
+ 'player.vibrato.beatWideAmplitude',
+ 0.1,
+ undefined,
+ 0.1,
+ withMidiGenerate
+ ),
+ factory.numberInput(
+ 'Vibrato Beat Slight Length',
+ 'player.vibrato.beatSlightLength',
+ 0.1,
+ undefined,
+ 0.1,
+ withMidiGenerate
+ ),
+ factory.numberInput(
+ 'Vibrato Beat Slight Amplitude',
+ 'player.vibrato.beatSlightAmplitude',
+ 0.1,
+ undefined,
+ 0.1,
+ withMidiGenerate
+ ),
+ factory.numberInput('Slide Simple Pitch Offset', 'player.slide.simpleSlidePitchOffset', 1),
+ factory.numberInput(
+ 'Slide Simple Duration Ratio',
+ 'player.slide.simpleSlidePitchOffset',
+ 0.1,
+ 1,
+ 0.1,
+ withMidiGenerate
+ ),
+ factory.numberInput(
+ 'Slide Shift Duration Ratio',
+ 'player.slide.shiftSlideDurationRatio',
+ 0.1,
+ 1,
+ 0.1,
+ withMidiGenerate
+ ),
+ factory.toggle('Play Swing', 'player.playTripletFeel', withMidiGenerate)
+ ]
+ },
+ {
+ title: 'Stylesheet',
+ settings: [
+ {
+ label: 'Hide Dynamics',
+ ...factory.stylesheetAccessors('hideDynamics'),
+ control: { type: 'boolean-toggle' }
+ },
+ {
+ label: 'Bracket Extend Mode',
+ ...factory.stylesheetAccessors('bracketExtendMode'),
+ control: { type: 'enum-dropdown', enumType: alphaTab.model.BracketExtendMode }
+ },
+ {
+ label: 'System Sign Separator',
+ ...factory.stylesheetAccessors('useSystemSignSeparator'),
+ control: { type: 'boolean-toggle' }
+ },
+ {
+ label: 'Show Guitar Tuning',
+ ...factory.stylesheetAccessors('globalDisplayTuning'),
+ control: { type: 'boolean-toggle' }
+ },
+ {
+ label: 'Show Chord Diagrams',
+ ...factory.stylesheetAccessors('globalDisplayChordDiagramsOnTop'),
+ control: { type: 'boolean-toggle' }
+ },
+ {
+ label: 'Single-Track Name Policy',
+ ...factory.stylesheetAccessors('singleTrackTrackNamePolicy'),
+ control: { type: 'enum-dropdown', enumType: alphaTab.model.TrackNamePolicy }
+ },
+ {
+ label: 'Multi-Track Name Policy',
+ ...factory.stylesheetAccessors('multiTrackTrackNamePolicy'),
+ control: { type: 'enum-dropdown', enumType: alphaTab.model.TrackNamePolicy }
+ },
+ {
+ label: 'First System Track Name Format',
+ ...factory.stylesheetAccessors('firstSystemTrackNameMode'),
+ control: { type: 'enum-dropdown', enumType: alphaTab.model.TrackNameMode }
+ },
+ {
+ label: 'First System Track Name Orientation',
+ ...factory.stylesheetAccessors('firstSystemTrackNameOrientation'),
+ control: { type: 'enum-dropdown', enumType: alphaTab.model.TrackNameOrientation }
+ },
+ {
+ label: 'Other Systems Track Name Format',
+ ...factory.stylesheetAccessors('otherSystemsTrackNameMode'),
+ control: { type: 'enum-dropdown', enumType: alphaTab.model.TrackNameMode }
+ },
+ {
+ label: 'Other Systems Track Name Orientation',
+ ...factory.stylesheetAccessors('otherSystemsTrackNameOrientation'),
+ control: { type: 'enum-dropdown', enumType: alphaTab.model.TrackNameMode }
+ },
+ {
+ label: 'Multi-Bar Rests (on Multi-Track)',
+ ...factory.stylesheetAccessors('otherSystemsTrackNameOrientation'),
+ control: { type: 'enum-dropdown', enumType: alphaTab.model.TrackNameMode }
+ },
+ {
+ label: 'Multi-Bar Rests',
+ getValue(context: SettingsContextProps) {
+ return context.api.score!.stylesheet.multiTrackMultiBarRest;
+ },
+ setValue(context: SettingsContextProps, value) {
+ context.api.score!.stylesheet.multiTrackMultiBarRest = value;
+ if (value) {
+ context.api.score!.stylesheet.perTrackMultiBarRest = new Set(
+ context.api.score!.tracks.map(t => t.index)
+ );
+ } else {
+ context.api.score!.stylesheet.perTrackMultiBarRest = null;
+ }
+ context.onSettingsUpdated();
+ context.api.render();
+ },
+ control: { type: 'boolean-toggle' }
+ }
+ ]
+ }
+ ];
+}
+
+const EnumDropDown: React.FC = ({ enumType, inputId, getValue, setValue }) => {
+ const settings = useContext(SettingsContext)!;
+ const enumValues: { value: number; label: string }[] = [];
+ for (const value of Object.values(enumType)) {
+ if (typeof value === 'string') {
+ const key = enumType[value] as number;
+ enumValues.push({ value: key, label: value });
+ }
+ }
+
+ return (
+
+
+
+ );
+};
+
+const NumberRange: React.FC = ({ min, max, step, inputId, getValue, setValue }) => {
+ const settings = useContext(SettingsContext)!;
+ const value = getValue(settings);
+ return (
+
+ setValue(settings, (e.target as HTMLInputElement).valueAsNumber)}
+ onClick={e => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ />
+
+ );
+};
+
+const NumberInput: React.FC = ({ min, max, step, inputId, getValue, setValue }) => {
+ const settings = useContext(SettingsContext)!;
+ return (
+ setValue(settings, (e.target as HTMLInputElement).valueAsNumber)}
+ onClick={e => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ />
+ );
+};
+
+const ColorPicker: React.FC = ({ inputId, getValue, setValue }) => {
+ const [isOpen, setOpen] = useState(false);
+ const settings = useContext(SettingsContext)!;
+ const [color, setColor] = useState(getValue(settings) as alphaTab.model.Color);
+
+ const debouncedColor = useDebounce(color, 300);
+
+ function dismissDropdown(e: MouseEvent) {
+ const isInDropDown = (e.target as HTMLElement).closest('.dropdown__menu');
+ if (!isInDropDown) {
+ setOpen(false);
+ }
+ }
+
+ useEffect(() => {
+ setValue(settings, color);
+ }, [debouncedColor]);
+
+ useEffect(() => {
+ document.addEventListener('click', dismissDropdown);
+ return () => {
+ document.removeEventListener('click', dismissDropdown);
+ };
+ }, []);
+
+ return (
+
+
+
+
+ -
+
{
+ setColor(
+ new alphaTab.model.Color(
+ color.rgba.r,
+ color.rgba.g,
+ color.rgba.b,
+ color.rgba.a * 255
+ )
+ );
+ }}
+ />
+
+
+
+
+ );
+};
+
+const FontPicker: React.FC = ({ inputId, getValue, setValue }) => {
+ const settings = useContext(SettingsContext)!;
+ return (
+ {
+ const c = alphaTab.model.Font.fromJson((e.target as HTMLInputElement).value);
+ if (c) {
+ setValue(settings, c);
+ }
+ }}
+ />
+ );
+};
+
+const BooleanToggle: React.FC = ({ inputId, getValue, setValue }) => {
+ const settings = useContext(SettingsContext)!;
+ return (
+ <>
+
+ >
+ );
+};
+
+const ButtonGroupButton: React.FC = ({ label, value, getValue, setValue }) => {
+ const settings = useContext(SettingsContext)!;
+ return (
+
+ );
+};
+
+const ButtonGroup: React.FC = ({ inputId, buttons, getValue, setValue }) => {
+ return (
+
+ {buttons.map(b => (
+
+ ))}
+
+ );
+};
+
+const Setting: React.FC = ({ label, control, getValue, setValue }) => {
+ const id = useId();
+ const renderControl = () => {
+ switch (control.type) {
+ case 'button-group':
+ return ;
+ case 'enum-dropdown':
+ return ;
+ case 'number-range':
+ return ;
+ case 'number-input':
+ return ;
+ case 'boolean-toggle':
+ return ;
+ case 'color-picker':
+ return ;
+ case 'font-picker':
+ return ;
+ }
+ };
+
+ return (
+
+
+ {renderControl()}
+
+ );
+};
+
+const SettingsGroup: React.FC = ({ title, settings }) => {
+ return (
+
+ {title}
+ {settings.map(s => (
+
+ ))}
+
+ );
+};
+
+export interface PlaygroundSettingsProps {
+ api: alphaTab.AlphaTabApi;
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export const PlaygroundSettings: React.FC = ({ api, isOpen: areSettingsOpen, onClose }) => {
+ const [settingsVersion, setSettingsVersion] = useState(0);
+ const settingsGroups = buildSettingsGroups();
+
+ return (
+ v + 1);
+ }
+ }}>
+
+
+
+ {settingsGroups.map(g => (
+
+ ))}
+
+
+ Tools
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/components/AlphaTabPlayground/styles.module.scss b/src/components/AlphaTabPlayground/styles.module.scss
new file mode 100644
index 0000000..5883379
--- /dev/null
+++ b/src/components/AlphaTabPlayground/styles.module.scss
@@ -0,0 +1,403 @@
+/* Styles for control */
+.at-wrap {
+ position: relative;
+ width: 100%;
+ margin: 0 auto;
+ border-top: 1px solid rgba(0, 0, 0, 0.12);
+ background: #fff;
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+}
+
+.at-content {
+ flex: 1 1 auto;
+ overflow: hidden;
+ position: relative;
+}
+
+
+.at-viewport {
+ overflow-y: auto;
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ padding-right: 0;
+}
+
+.at-overlay {
+ position: absolute;
+ display: flex;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ z-index: 1010;
+ backdrop-filter: blur(3px);
+ background: rgba(0, 0, 0, 0.5);
+ justify-content: center;
+ align-items: flex-start;
+}
+
+.at-overlay-content {
+ margin-top: 20px;
+ background: #fff;
+ box-shadow: 0px 5px 10px 0px rgba(0, 0, 0, 0.3);
+ padding: 10px;
+}
+
+.at-overlay-content>.spinner-border {
+ color: #4972a1;
+}
+
+
+.at-footer {
+ flex: 0 0 auto;
+ background: #436d9d;
+}
+
+.at-footer {
+ color: #fff;
+}
+
+
+.at-player {
+ display: flex;
+ justify-content: space-between;
+}
+
+.at-player>div {
+ display: flex;
+ justify-content: flex-start;
+ align-content: center;
+ align-items: center;
+}
+
+.at-player-left,
+.at-player-right {
+ >* {
+ margin-right: 4px;
+ }
+
+ >button {
+ cursor: pointer;
+ color: #fff;
+ display: flex;
+ text-align: center;
+ box-sizing: content-box;
+ align-items: center;
+ justify-content: center;
+ background: none;
+ border: 0;
+ padding: 0;
+ transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out,
+ border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
+ padding: 0.8rem;
+ gap: 0.2rem;
+ font-size: initial;
+
+ &:hover,
+ &.active {
+ background: #5588c7;
+ }
+
+ &.disabled:hover,
+ &.disabled:active {
+ color: rgba(0, 0, 0, 0.3);
+ }
+
+ i {
+ vertical-align: top;
+ font-size: 16px;
+ }
+ }
+
+ > :global(.dropdown) {
+ >span {
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 0.5rem;
+ }
+
+ svg {
+ margin-right: 0.25rem;
+ }
+
+ &:hover {
+ background: #5588c7;
+ }
+
+ > :global(.dropdown__menu) {
+ color: initial;
+ top: auto;
+ z-index: 1300;
+ bottom: calc(100% - var(--ifm-navbar-item-padding-vertical) - 1rem);
+ }
+ }
+}
+
+.at-time-position {
+ font-weight: bold;
+}
+
+.at-time-slider {
+ height: 4px;
+ background: #d9d9d9;
+}
+
+.at-time-slider-value {
+ height: 4px;
+ background: #6ba5e4;
+ width: 0;
+}
+
+.at-speed-value {
+ font-size: 0.8rem;
+ margin: 0 0.5em;
+}
+
+.progress {
+ position: relative;
+ height: 40px;
+ padding-top: 4px;
+}
+
+.progress .progress-value {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: bold;
+ position: absolute;
+ top: 0;
+ left: 0;
+ font-size: 8px;
+}
+
+.at-settings {
+ position: absolute;
+ top: 1rem;
+ right: 1rem;
+ width: 25vw;
+ bottom: calc(1rem + 40px);
+ background: #f7f7f7;
+ border: 1px solid rgba(0, 0, 0, 0.12);
+ box-shadow: var(--ifm-navbar-shadow);
+ overflow-y: auto;
+ overflow-x: visible;
+ opacity: 0;
+ transition: all 0.2s ease-in-out;
+ transform: translateX(50px);
+ font-size: 80%;
+ border-radius: var(--ifm-alert-border-radius);
+ z-index: 0;
+
+ &.open {
+ z-index: 1001;
+ opacity: 1;
+ transform: translateX(0);
+ }
+
+
+ &>.at-settings-group {
+ border-bottom: 2px dashed #00000033;
+ }
+
+}
+
+.at-settings-close {
+ position: absolute;
+ top: 0.5rem;
+ right: 0.5rem;
+ padding: 0.4rem 0.5rem;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
+
+
+.at-settings>* {
+ padding: 0.5rem;
+}
+
+.at-settings {
+ &>.track-item:nth-child(even) {
+ background: #ebedf0;
+ }
+}
+
+.track-item {
+ &>.settings-item:not(.track-item-info) {
+ padding-left: 0.5rem;
+ }
+}
+
+.track-item-info {
+ &>.settings-item-label {
+ font-weight: bold;
+ }
+}
+
+.settings-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 0.2rem;
+}
+
+.settings-item-label {
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+ font-size: 90%;
+
+ & label {
+ cursor: pointer;
+ }
+}
+
+.settings-item-control {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+
+ & button {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ & button.icon-button {
+ width: 1.5rem;
+ height: 1.5rem;
+ padding: 0;
+ }
+
+ & input[type=text],
+ & input[type=number],
+ & select {
+ appearance: none;
+ background: var(--ifm-navbar-search-input-background-color) no-repeat 0.75rem center / 1rem 1rem;
+ border: none;
+ border-radius: var(--ifm-global-radius);
+ color: var(--ifm-navbar-search-input-color);
+ cursor: text;
+ display: inline-block;
+ padding: 0.4rem;
+ width: 8rem;
+ text-align: center;
+ }
+
+ & .select {
+ cursor: initial;
+ display: grid;
+ align-items: center;
+
+ &:after {
+ content: "";
+ width: 0.8em;
+ height: 0.5em;
+ background-color: #777;
+ clip-path: polygon(100% 0%, 0 0%, 50% 100%);
+ grid-area: select;
+ justify-self: end;
+ margin-right: 0.8rem;
+ }
+
+ & select {
+ cursor: initial;
+ grid-area: select;
+ }
+ }
+}
+
+.track-item-more {
+ padding: 0.3rem;
+}
+
+.at-settings h4 {
+ margin-top: 0.2rem;
+ margin-bottom: 0.2rem;
+}
+
+.button-group {
+ display: flex;
+ align-items: center;
+ gap: 0;
+
+ &>*:not(:first-child):not(:last-child) {
+ border-radius: 0;
+ }
+
+ &>*:not(:last-child) {
+ border-right: 1px solid rgba(255, 255, 255, 0.5);
+ }
+
+ &>*:first-child {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+ }
+
+ &>*:last-child {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+ }
+}
+
+.toggle {
+ display: grid;
+ width: 2rem;
+ height: 1.2rem;
+ grid-template-columns: 1fr;
+ grid-template-rows: 1fr;
+}
+
+.toggle>* {
+ grid-area: 1 / 1 / 2 / 2;
+}
+
+.toggle>input {
+ opacity: 0;
+ width: 0;
+ height: 0;
+}
+
+.toggle>span {
+ cursor: pointer;
+ background-color: #ccc;
+ border-radius: 1rem;
+ transition: background-color 0.3s;
+ position: relative;
+}
+
+.toggle>span::before {
+ content: "";
+ position: absolute;
+ top: 0.2rem;
+ bottom: 0.2rem;
+ aspect-ratio: 1 / 1;
+ left: 0.2rem;
+ background-color: white;
+ border-radius: 50%;
+ transition: transform 0.3s;
+}
+
+.toggle input:checked+span {
+ background-color: var(--ifm-color-primary);
+}
+
+.toggle input:checked+span::before {
+ transform: translateX(0.8rem);
+}
+
+
+.color-picker {
+ & :global(.dropdown) > button {
+ font-weight: normal;
+ }
+}
\ No newline at end of file
diff --git a/src/components/AlphaTabPlayground/track-item.tsx b/src/components/AlphaTabPlayground/track-item.tsx
new file mode 100644
index 0000000..d9d79ef
--- /dev/null
+++ b/src/components/AlphaTabPlayground/track-item.tsx
@@ -0,0 +1,252 @@
+import type * as alphaTab from '@coderline/alphatab';
+import type React from 'react';
+import { useState } from 'react';
+import styles from './styles.module.scss';
+import { useEffectNoMount } from '@site/src/hooks';
+
+type StaffOptions = {
+ showSlash: boolean;
+ showNumbered: boolean;
+ showTablature: boolean;
+ showStandardNotation: boolean;
+};
+
+export interface StaffItemProps {
+ api: alphaTab.AlphaTabApi;
+ staff: alphaTab.model.Staff;
+}
+
+export const StaffItem: React.FC = ({ api, staff }) => {
+ const [staffOptions, _setStaffOptions] = useState({
+ showNumbered: staff.showNumbered,
+ showSlash: staff.showSlash,
+ showTablature: staff.showTablature,
+ showStandardNotation: staff.showStandardNotation
+ });
+
+ useEffectNoMount(() => {
+ for (const key in staffOptions) {
+ staff[key] = staffOptions[key];
+ }
+ api.render();
+ }, [api, staff, staffOptions]);
+
+ const setStaffOptions = (updater: (current: StaffOptions) => StaffOptions) => {
+ _setStaffOptions(value => {
+ const newValue = updater(value);
+ if (!Array.from(Object.keys(newValue)).some(k => newValue[k])) {
+ return value;
+ }
+ return newValue;
+ });
+ };
+
+ return (
+
+ Staff {staff.index + 1}
+
+
+
+
+
+
+
+ );
+};
+
+export interface TrackItemProps {
+ api: alphaTab.AlphaTabApi;
+ track: alphaTab.model.Track;
+ isSelected: boolean;
+}
+
+export const TrackItem: React.FC = ({ api, track, isSelected }) => {
+ const [isMute, setMute] = useState(track.playbackInfo.isMute);
+ useEffectNoMount(() => {
+ track.playbackInfo.isMute = isMute;
+ api.changeTrackMute([track], isMute);
+ }, [api, track, isMute]);
+
+ const [isSolo, setSolo] = useState(track.playbackInfo.isSolo);
+ useEffectNoMount(() => {
+ track.playbackInfo.isSolo = isSolo;
+ api.changeTrackSolo([track], isSolo);
+ }, [api, track, isSolo]);
+
+ const [volume, setVolume] = useState(track.playbackInfo.volume);
+ useEffectNoMount(() => {
+ api.changeTrackVolume([track], volume / track.playbackInfo.volume);
+ }, [api, track, volume]);
+
+ const onTrackSelect = (selected: boolean) => {
+ let newTracks: alphaTab.model.Track[];
+ if (selected) {
+ newTracks = [...api.tracks, track];
+ } else {
+ newTracks = api.tracks.filter(t => t !== track);
+ if (newTracks.length === 0) {
+ return;
+ }
+ }
+
+ newTracks.sort((a, b) => a.index - b.index);
+ api.renderTracks(newTracks);
+ };
+
+ const [transposeAudio, setTransposeAudio] = useState(0);
+ const [transposeFull, setTransposeFull] = useState(0);
+
+ useEffectNoMount(() => {
+ api.changeTrackTranspositionPitch([track], transposeAudio);
+ }, [api, track, transposeAudio]);
+
+ useEffectNoMount(() => {
+ const pitches = api.settings.notation.transpositionPitches;
+ while (pitches.length < track.index + 1) {
+ pitches.push(0);
+ }
+ pitches[track.index] = transposeFull;
+ api.updateSettings();
+ api.render();
+ }, [api, track, transposeFull]);
+
+ return (
+
+
+
+ onTrackSelect(e.target.checked)}
+ />
+
+
+
+
+
+
+
+
+
+ Volume
+
+ setVolume((e.target as HTMLInputElement).valueAsNumber)}
+ onClick={e => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ />
+
+
+
+
+
+ Transpose Full
+
+
+ setTransposeFull((e.target as HTMLInputElement).valueAsNumber)}
+ onClick={e => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ />
+
+
+
+
+
+ Transpose Audio
+
+
+ setTransposeAudio((e.target as HTMLInputElement).valueAsNumber)}
+ onClick={e => {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ />
+
+
+
+ {track.staves.map(s => (
+
+ ))}
+
+ );
+};
diff --git a/src/components/AlphaTabPlayground/track-selector.tsx b/src/components/AlphaTabPlayground/track-selector.tsx
new file mode 100644
index 0000000..1b73b6f
--- /dev/null
+++ b/src/components/AlphaTabPlayground/track-selector.tsx
@@ -0,0 +1,57 @@
+import type * as alphaTab from '@coderline/alphatab';
+import type React from 'react';
+import styles from './styles.module.scss';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import * as solid from '@fortawesome/free-solid-svg-icons';
+import { useEffect, useState } from 'react';
+import { useAlphaTabEvent } from '@site/src/hooks';
+import { TrackItem } from './track-item';
+
+export interface PlaygroundTrackSelectorProps {
+ api: alphaTab.AlphaTabApi;
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export const PlaygroundTrackSelector: React.FC = ({
+ api,
+ isOpen: areSettingsOpen,
+ onClose
+}) => {
+ const [score, setScore] = useState(api.score);
+ const [selectedTracks, setSelectedTracks] = useState(new Map());
+
+ useAlphaTabEvent(api, 'renderStarted', isResize => {
+ const selectedTracks = new Map();
+ for (const t of api!.tracks) {
+ selectedTracks.set(t.index, t);
+ }
+
+ setSelectedTracks(selectedTracks);
+ });
+
+ useAlphaTabEvent(api, 'scoreLoaded', score => {
+ setScore(score);
+ });
+
+ useEffect(() => {
+ setScore(api.score);
+ }, [api.score]);
+
+ return (
+
+
+
+ Tracks
+
+ {score?.tracks.map(t => (
+
+ ))}
+
+ );
+};
diff --git a/src/css/custom.scss b/src/css/custom.scss
index 8ca8112..dc960f7 100644
--- a/src/css/custom.scss
+++ b/src/css/custom.scss
@@ -24,6 +24,9 @@
@import "@fontsource/noto-serif/500-italic.css";
@import "@fontsource/noto-serif/700-italic.css";
+@import 'react-tooltip/dist/react-tooltip.css';
+
+
/* You can override the default Infima variables here. */
:root {
--ifm-color-primary: #426d9d;
@@ -69,6 +72,7 @@ a {
img.thumbnail {
max-width: 400px;
transition: transform 0.2s;
+
&:hover {
transform: scale(1.5);
}
@@ -110,8 +114,7 @@ li.types-item .menu__link {
width: 24px;
height: 24px;
display: flex;
- background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E")
- no-repeat;
+ background: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E") no-repeat;
margin-right: 0.25rem;
}
@@ -170,9 +173,80 @@ li.types-item .menu__link {
text-decoration: underline dotted;
}
-
+
& a:hover {
text-decoration: underline;
}
}
}
+
+
+body.playground {
+ --doc-sidebar-hidden-width: 0px;
+
+ .theme-doc-breadcrumbs {
+ display: none;
+ }
+
+ footer.footer {
+ display: none;
+ }
+
+ .theme-doc-markdown header h1 {
+ display: none;
+ }
+
+ main>.container {
+ max-width: initial !important;
+ height: calc(100vh - var(--ifm-navbar-height));
+ overflow: hidden;
+ display: flex;
+ padding: 0 !important;
+
+
+ &>.row {
+ width: 100%;
+ margin: 0;
+
+ &>.col {
+ max-width: initial !important;
+ display: flex;
+ flex-direction: column;
+ padding: 0;
+
+ &>div:last-child {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+
+ & article {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+
+ & .theme-doc-markdown {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+
+ & p {
+ margin: 0;
+ }
+ }
+ }
+
+ & nav.pagination-nav {
+ display: none;
+ }
+
+ & .theme-doc-footer {
+ display: none;
+ }
+ }
+ }
+ }
+
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/hooks.ts b/src/hooks.ts
index fadf4f6..77ab6a0 100644
--- a/src/hooks.ts
+++ b/src/hooks.ts
@@ -1,13 +1,14 @@
-import React, { useEffect, useState } from "react";
+import React, { DependencyList, EffectCallback, useEffect, useRef, useState } from "react";
import * as alphaTab from "@coderline/alphatab";
import environment from "./environment";
+import { dependencies } from "webpack";
export function useAlphaTab(
settingsInit: (settings: alphaTab.Settings) => void,
): [
- api: alphaTab.AlphaTabApi | undefined,
- elementRef: React.RefObject
-] {
+ api: alphaTab.AlphaTabApi | undefined,
+ elementRef: React.RefObject
+ ] {
const [api, setApi] = useState();
const element = React.createRef();
@@ -33,17 +34,17 @@ export function useAlphaTab(
[]
);
- useEffect(() => {});
+ useEffect(() => { });
return [api, element];
}
export type AlphaTabApiEvents = {
[K in keyof alphaTab.AlphaTabApi as alphaTab.AlphaTabApi[K] extends
- | alphaTab.IEventEmitter
- | alphaTab.IEventEmitterOfT
- ? K
- : never]: alphaTab.AlphaTabApi[K];
+ | alphaTab.IEventEmitter
+ | alphaTab.IEventEmitterOfT
+ ? K
+ : never]: alphaTab.AlphaTabApi[K];
};
export function useAlphaTabEvent<
@@ -57,5 +58,22 @@ export function useAlphaTabEvent<
api[event].off(handler as any);
};
}
- }, [api]);
+ }, [api, event, handler]);
}
+
+export const useIsMount = () => {
+ const isMountRef = useRef(true);
+ useEffect(() => {
+ isMountRef.current = false;
+ }, []);
+ return isMountRef.current;
+};
+
+export const useEffectNoMount = (effect: EffectCallback, deps?: DependencyList) => {
+ const isMount = useIsMount();
+ useEffect(() => {
+ if (!isMount) {
+ effect();
+ }
+ }, deps)
+};
\ No newline at end of file
From 7353b143dfc416f55d9bb8262b5a03d16f1f093b Mon Sep 17 00:00:00 2001
From: Daniel Kuschny
Date: Thu, 22 May 2025 06:19:05 +0200
Subject: [PATCH 02/17] feat: Base implementation for media sync editor (#132)
---
.vscode/settings.json | 4 +-
docs/playground/playground.mdx | 4 +-
package-lock.json | 49 +-
package.json | 4 +-
src/components/AlphaTabPlayground/index.tsx | 23 +-
.../AlphaTabPlayground/media-sync-editor.tsx | 921 ++++++++++++++++++
.../player-controls-group.tsx | 28 +-
.../AlphaTabPlayground/styles.module.scss | 139 ++-
src/hooks.ts | 7 +-
static/files/canon-full.gp | Bin 0 -> 118158 bytes
10 files changed, 1154 insertions(+), 25 deletions(-)
create mode 100644 src/components/AlphaTabPlayground/media-sync-editor.tsx
create mode 100644 static/files/canon-full.gp
diff --git a/.vscode/settings.json b/.vscode/settings.json
index f85eede..b34a6f3 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -25,5 +25,7 @@
"titleBar.inactiveBackground": "#21573299",
"titleBar.inactiveForeground": "#e7e7e799"
},
- "peacock.color": "#215732"
+ "peacock.color": "#215732",
+ "editor.snippetSuggestions": "bottom",
+ "emmet.showSuggestionsAsSnippets": true,
}
\ No newline at end of file
diff --git a/docs/playground/playground.mdx b/docs/playground/playground.mdx
index 53e29ba..78f9e98 100644
--- a/docs/playground/playground.mdx
+++ b/docs/playground/playground.mdx
@@ -11,8 +11,8 @@ import { AlphaTabPlayground } from "@site/src/components/AlphaTabPlayground";
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index e459c19..8bc166e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,16 +8,18 @@
"name": "alphatab-website",
"version": "0.0.0",
"dependencies": {
- "@coderline/alphatab": "^1.5.0-alpha.1394",
+ "@coderline/alphatab": "^1.6.0-alpha.1416",
"@docusaurus/core": "^3.7.0",
"@docusaurus/preset-classic": "^3.7.0",
"@docusaurus/theme-mermaid": "^3.7.0",
"@fontsource/noto-sans": "^5.1.1",
"@fontsource/noto-serif": "^5.1.1",
+ "@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@mdx-js/react": "^3.1.0",
+ "@react-hook/resize-observer": "^2.0.2",
"@uidotdev/usehooks": "^2.4.1",
"@uiw/react-color": "^2.5.5",
"@uiw/react-color-chrome": "^2.5.5",
@@ -1917,9 +1919,9 @@
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ=="
},
"node_modules/@coderline/alphatab": {
- "version": "1.5.0-alpha.1394",
- "resolved": "https://registry.npmjs.org/@coderline/alphatab/-/alphatab-1.5.0-alpha.1394.tgz",
- "integrity": "sha512-R4Dnvs0xvgCZEPc/jsHZc9VaGlGx7OWFghJBeLlwes6j1Sh55dpnV+kiDKZP+/AlUplQhYfzNqKOIWSNyAAuJw==",
+ "version": "1.6.0-alpha.1416",
+ "resolved": "https://registry.npmjs.org/@coderline/alphatab/-/alphatab-1.6.0-alpha.1416.tgz",
+ "integrity": "sha512-bIACxHyaTAnxtzz5I6Kr3a9LR6VLpvhm/o8GiZZeDc6wVtLEz+0a9sKCRBepSfz0+HVi09YzDMvhL9vEQrEbcw==",
"engines": {
"node": ">=6.0.0"
}
@@ -4232,6 +4234,17 @@
"node": ">=6"
}
},
+ "node_modules/@fortawesome/free-brands-svg-icons": {
+ "version": "6.7.2",
+ "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.7.2.tgz",
+ "integrity": "sha512-zu0evbcRTgjKfrr77/2XX+bU+kuGfjm0LbajJHVIgBWNIDzrhpRxiCPNT8DW5AdmSsq7Mcf9D1bH0aSeSUSM+Q==",
+ "dependencies": {
+ "@fortawesome/fontawesome-common-types": "6.7.2"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz",
@@ -4823,6 +4836,34 @@
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz",
"integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw=="
},
+ "node_modules/@react-hook/latest": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@react-hook/latest/-/latest-1.0.3.tgz",
+ "integrity": "sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg==",
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/@react-hook/passive-layout-effect": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@react-hook/passive-layout-effect/-/passive-layout-effect-1.2.1.tgz",
+ "integrity": "sha512-IwEphTD75liO8g+6taS+4oqz+nnroocNfWVHWz7j+N+ZO2vYrc6PV1q7GQhuahL0IOR7JccFTsFKQ/mb6iZWAg==",
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
+ "node_modules/@react-hook/resize-observer": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/@react-hook/resize-observer/-/resize-observer-2.0.2.tgz",
+ "integrity": "sha512-tzKKzxNpfE5TWmxuv+5Ae3IF58n0FQgQaWJmcbYkjXTRZATXxClnTprQ2uuYygYTpu1pqbBskpwMpj6jpT1djA==",
+ "dependencies": {
+ "@react-hook/latest": "^1.0.2",
+ "@react-hook/passive-layout-effect": "^1.2.0"
+ },
+ "peerDependencies": {
+ "react": ">=18"
+ }
+ },
"node_modules/@sideway/address": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
diff --git a/package.json b/package.json
index 985621b..ea739f3 100644
--- a/package.json
+++ b/package.json
@@ -16,16 +16,18 @@
"generate-alphatabdoc": "tsx scripts/generate-alphatabdoc.mts"
},
"dependencies": {
- "@coderline/alphatab": "^1.5.0-alpha.1394",
+ "@coderline/alphatab": "^1.6.0-alpha.1416",
"@docusaurus/core": "^3.7.0",
"@docusaurus/preset-classic": "^3.7.0",
"@docusaurus/theme-mermaid": "^3.7.0",
"@fontsource/noto-sans": "^5.1.1",
"@fontsource/noto-serif": "^5.1.1",
+ "@fortawesome/free-brands-svg-icons": "^6.7.2",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@mdx-js/react": "^3.1.0",
+ "@react-hook/resize-observer": "^2.0.2",
"@uidotdev/usehooks": "^2.4.1",
"@uiw/react-color": "^2.5.5",
"@uiw/react-color-chrome": "^2.5.5",
diff --git a/src/components/AlphaTabPlayground/index.tsx b/src/components/AlphaTabPlayground/index.tsx
index 581738a..ecbff9a 100644
--- a/src/components/AlphaTabPlayground/index.tsx
+++ b/src/components/AlphaTabPlayground/index.tsx
@@ -1,16 +1,17 @@
'use client';
-import type * as alphaTab from '@coderline/alphatab';
-import React, { useEffect, useState } from 'react';
+import * as alphaTab from '@coderline/alphatab';
+import React, { useState } from 'react';
import { useAlphaTab, useAlphaTabEvent } from '@site/src/hooks';
import styles from './styles.module.scss';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import * as solid from '@fortawesome/free-solid-svg-icons';
import { openFile } from '@site/src/utils';
-import { PlayerControlsGroup, SidePanel } from './player-controls-group';
+import { BottomPanel, PlayerControlsGroup, SidePanel } from './player-controls-group';
import { PlaygroundSettings } from './playground-settings';
import { Tooltip } from 'react-tooltip';
import { PlaygroundTrackSelector } from './track-selector';
+import { MediaSyncEditor } from './media-sync-editor';
interface AlphaTabPlaygroundProps {
settings?: alphaTab.json.SettingsJson;
@@ -20,12 +21,13 @@ export const AlphaTabPlayground: React.FC = ({ settings
const viewPortRef = React.createRef();
const [isLoading, setLoading] = useState(true);
const [sidePanel, setSidePanel] = useState(SidePanel.None);
+ const [bottomPanel, setBottomPanel] = useState(BottomPanel.None);
const [api, element] = useAlphaTab(s => {
s.core.engine = 'svg';
s.player.scrollElement = viewPortRef.current!;
s.player.scrollOffsetY = -10;
- s.player.enablePlayer = true;
+ s.player.playerMode = alphaTab.PlayerMode.EnabledAutomatic;
if (settings) {
s.fillFromJson(settings);
}
@@ -88,7 +90,18 @@ export const AlphaTabPlayground: React.FC = ({ settings
- {api && }
+ {api && api?.score && bottomPanel === BottomPanel.MediaSyncEditor && (
+
+ )}
+ {api && (
+
+ )}
diff --git a/src/components/AlphaTabPlayground/media-sync-editor.tsx b/src/components/AlphaTabPlayground/media-sync-editor.tsx
new file mode 100644
index 0000000..cb4f68c
--- /dev/null
+++ b/src/components/AlphaTabPlayground/media-sync-editor.tsx
@@ -0,0 +1,921 @@
+'use client';
+
+import * as alphaTab from '@coderline/alphatab';
+import type React from 'react';
+import styles from './styles.module.scss';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import * as solid from '@fortawesome/free-solid-svg-icons';
+import * as brands from '@fortawesome/free-brands-svg-icons';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import useResizeObserver from '@react-hook/resize-observer';
+import { useAlphaTabEvent } from '@site/src/hooks';
+
+export type MediaSyncEditorProps = {
+ api: alphaTab.AlphaTabApi;
+ score: alphaTab.model.Score;
+};
+
+type MasterBarMarker = {
+ label: string;
+ syncTime: number;
+
+ synthTime: number;
+ synthBpm: number;
+ synthTickDuration: number;
+
+ masterBarIndex: number;
+ occurence: number;
+ modifiedTempo?: number;
+
+ isStartMarker: boolean;
+ isEndMarker: boolean;
+};
+
+type SyncPointInfo = {
+ endTick: number;
+ endTime: number;
+ sampleRate: number;
+ leftSamples: Float32Array;
+ rightSamples: Float32Array;
+ masterBarMarkers: MasterBarMarker[];
+};
+
+// TODO: handle intermediate tempo changes and sync points
+
+function ticksToMillis(tick: number, bpm: number): number {
+ return (tick * 60000.0) / (bpm * 960);
+}
+
+async function buildSyncPointInfo(api: alphaTab.AlphaTabApi, createInitialSyncPoints: boolean): Promise {
+ const tickCache = api.tickCache;
+ if (!tickCache || !api.score?.backingTrack?.rawAudioFile) {
+ return {
+ endTick: 0,
+ endTime: 0,
+ sampleRate: 0,
+ leftSamples: new Float32Array(0),
+ rightSamples: new Float32Array(0),
+ masterBarMarkers: []
+ };
+ }
+
+ const audioContext = new AudioContext();
+ const buffer = await audioContext.decodeAudioData(api.score!.backingTrack!.rawAudioFile.buffer.slice(0));
+ const rawSamples: Float32Array[] =
+ buffer.numberOfChannels === 1
+ ? [buffer.getChannelData(0), buffer.getChannelData(0)]
+ : [buffer.getChannelData(0), buffer.getChannelData(1)];
+
+ const sampleRate = audioContext.sampleRate;
+ const endTime = rawSamples[0].length / sampleRate;
+
+ await audioContext.close();
+
+ return {
+ endTick: api.tickCache.masterBars.at(-1)!.end,
+ masterBarMarkers: buildMasterBarMarkers(api, createInitialSyncPoints),
+ sampleRate,
+ leftSamples: rawSamples[0],
+ rightSamples: rawSamples[1],
+ endTime
+ };
+}
+function buildMasterBarMarkers(api: alphaTab.AlphaTabApi, createInitialSyncPoints: boolean): MasterBarMarker[] {
+ const markers: MasterBarMarker[] = [];
+
+ if (createInitialSyncPoints) {
+ // create initial sync points for all tempo changes to ensure the song and the
+ // backing track roughly align
+ let synthBpm = 0;
+ let synthTimePosition = 0;
+ let synthTickPosition = 0;
+
+ const occurences = new Map();
+ for (const masterBar of api.tickCache!.masterBars) {
+ const occurence = occurences.get(masterBar.masterBar.index) ?? 0;
+ occurences.set(masterBar.masterBar.index, occurence + 1);
+
+ for (const changes of masterBar.tempoChanges) {
+ const absoluteTick = changes.tick;
+ const tickOffset = absoluteTick - synthTickPosition;
+ if (tickOffset > 0) {
+ const timeOffset = ticksToMillis(tickOffset, synthBpm);
+
+ synthTickPosition = absoluteTick;
+ synthTimePosition += timeOffset;
+ }
+
+ if (changes.tempo !== synthBpm && changes.tick === masterBar.start) {
+ const syncPoint = new alphaTab.model.Automation();
+ syncPoint.ratioPosition = 0;
+ syncPoint.type = alphaTab.model.AutomationType.SyncPoint;
+ syncPoint.syncPointValue = new alphaTab.model.SyncPointData();
+ syncPoint.syncPointValue.barOccurence = occurence;
+ syncPoint.syncPointValue.millisecondOffset = synthTimePosition;
+ syncPoint.syncPointValue.modifiedTempo = changes.tempo;
+ masterBar.masterBar.addSyncPoint(syncPoint);
+
+ synthBpm = changes.tempo;
+ }
+ }
+
+ const tickOffset = masterBar.end - synthTickPosition;
+ const timeOffset = ticksToMillis(tickOffset, synthBpm);
+ synthTickPosition += tickOffset;
+ synthTimePosition += timeOffset;
+ }
+ }
+
+ const occurences = new Map();
+ let syncBpm = api.score!.tempo;
+ let syncLastTick = 0;
+ let syncLastMillisecondOffset = 0;
+
+ let synthBpm = api.score!.tempo;
+ let synthTimePosition = 0;
+ let synthTickPosition = 0;
+
+ for (const masterBar of api.tickCache!.masterBars) {
+ const occurence = occurences.get(masterBar.masterBar.index) ?? 1;
+ occurences.set(masterBar.masterBar.index, occurence + 1);
+
+ const occurenceLabel = occurence > 1 ? ` (${occurence})` : '';
+ const startSyncPoint = masterBar.masterBar.syncPoints?.find(m => m.ratioPosition === 0);
+
+ let syncedStartTime: number;
+ if (startSyncPoint) {
+ syncedStartTime = startSyncPoint.syncPointValue!.millisecondOffset;
+ syncBpm = startSyncPoint.syncPointValue!.modifiedTempo;
+ syncLastMillisecondOffset = syncedStartTime;
+ syncLastTick = masterBar.start;
+ } else {
+ const tickOffset = masterBar.start - syncLastTick;
+ syncedStartTime = syncLastMillisecondOffset + ticksToMillis(tickOffset, syncBpm);
+ }
+
+ const isStartMarker = masterBar.masterBar.index === 0 && occurence === 1;
+ const newMarker: MasterBarMarker = {
+ label: isStartMarker ? 'Start' : `${masterBar.masterBar.index + 1}${occurenceLabel}`,
+ masterBarIndex: masterBar.masterBar.index,
+ synthTickDuration: masterBar.end - masterBar.start,
+ occurence: occurence,
+ syncTime: syncedStartTime / 1000,
+ synthTime: synthTimePosition / 1000,
+ synthBpm: masterBar.tempoChanges.length > 0 ? masterBar.tempoChanges[0].tempo : synthBpm,
+ modifiedTempo: startSyncPoint?.syncPointValue?.modifiedTempo,
+ isStartMarker,
+ isEndMarker: false
+ };
+ markers.push(newMarker);
+
+ for (const changes of masterBar.tempoChanges) {
+ const absoluteTick = changes.tick;
+ const tickOffset = absoluteTick - synthTickPosition;
+ if (tickOffset > 0) {
+ const timeOffset = ticksToMillis(tickOffset, synthBpm);
+
+ synthTickPosition = absoluteTick;
+ synthTimePosition += timeOffset;
+ }
+
+ synthBpm = changes.tempo;
+ }
+
+ const tickOffset = masterBar.end - synthTickPosition;
+ const timeOffset = ticksToMillis(tickOffset, synthBpm);
+ synthTickPosition += tickOffset;
+ synthTimePosition += timeOffset;
+ }
+
+ const lastMasterBar = api.tickCache!.masterBars.at(-1)!;
+
+ const endSyncPoint = lastMasterBar.masterBar.syncPoints?.find(m => m.ratioPosition === 1);
+
+ const tickOffset = lastMasterBar.end - syncLastTick;
+ const endSyncPointTime = endSyncPoint
+ ? endSyncPoint.syncPointValue!.millisecondOffset
+ : syncLastMillisecondOffset + ticksToMillis(tickOffset, syncBpm);
+
+ markers.push({
+ label: 'End',
+ masterBarIndex: lastMasterBar.masterBar.index,
+ synthTickDuration: 0,
+ occurence: occurences.get(lastMasterBar.masterBar.index)!,
+ syncTime: endSyncPointTime / 1000,
+ synthTime: synthTimePosition / 1000,
+ synthBpm,
+ modifiedTempo: endSyncPoint?.syncPointValue?.modifiedTempo ?? synthBpm,
+ isStartMarker: false,
+ isEndMarker: true
+ });
+
+ return markers;
+}
+
+const pixelPerSeconds = 100;
+const leftPadding = 15;
+const barNumberHeight = 20;
+const arrowHeight = 20;
+const timeAxisHeight = 20;
+const timeAxiSubSecondTickHeight = 5;
+const barWidth = 1;
+const timeAxisLineColor = '#A5A5A5';
+const waveFormColor = '#436d9d99';
+const font = '12px "Noto Sans"';
+const dragLimit = 10;
+const dragThreshold = 5;
+const scrollThresholdPercent = 0.2;
+
+function timePositionToX(timePosition: number, zoom: number): number {
+ const zoomedPixelPerSecond = pixelPerSeconds * zoom;
+ return timePosition * zoomedPixelPerSecond + leftPadding;
+}
+
+type MarkerDragInfo = {
+ startX: number;
+ startY: number;
+ endX: number;
+};
+
+function computeMarkerInlineStyle(
+ m: MasterBarMarker,
+ zoom: number,
+ draggingMarker: MasterBarMarker | null,
+ draggingMarkerInfo: MarkerDragInfo | null
+): React.CSSProperties {
+ let left = timePositionToX(m.syncTime, zoom);
+
+ if (m === draggingMarker && draggingMarkerInfo) {
+ const deltaX = draggingMarkerInfo.endX - draggingMarkerInfo.startX;
+ left += deltaX;
+ }
+
+ return {
+ left: `${left}px`
+ };
+}
+
+function updateSyncPointsAfterModification(modifiedIndex: number, s: SyncPointInfo, isDelete: boolean) {
+ // find previous and next sync point (or start/end of the song)
+ let startIndexForUpdate = Math.max(0, modifiedIndex - 1);
+ while (startIndexForUpdate > 0 && !s.masterBarMarkers[startIndexForUpdate].modifiedTempo) {
+ startIndexForUpdate--;
+ }
+
+ let nextIndexForUpdate = Math.min(s.masterBarMarkers.length - 1, modifiedIndex + 1);
+ while (
+ nextIndexForUpdate < s.masterBarMarkers.length - 1 &&
+ !s.masterBarMarkers[nextIndexForUpdate].modifiedTempo
+ ) {
+ nextIndexForUpdate++;
+ }
+
+ const modifiedMarker = s.masterBarMarkers[modifiedIndex];
+
+ // update from previous to current
+ if (startIndexForUpdate < modifiedIndex) {
+ const previousMarker = { ...s.masterBarMarkers[startIndexForUpdate] };
+ s.masterBarMarkers[startIndexForUpdate] = previousMarker;
+ const synthDuration = modifiedMarker.synthTime - previousMarker.synthTime;
+ const syncedDuration = modifiedMarker.syncTime - previousMarker.syncTime;
+ const newBpmBefore = (synthDuration / syncedDuration) * previousMarker.synthBpm;
+ previousMarker.modifiedTempo = newBpmBefore;
+
+ let syncedTimePosition = previousMarker.syncTime;
+ for (let i = startIndexForUpdate; i < modifiedIndex; i++) {
+ const marker = { ...s.masterBarMarkers[i] };
+ s.masterBarMarkers[i] = marker;
+
+ marker.syncTime = syncedTimePosition;
+ syncedTimePosition += ticksToMillis(marker.synthTickDuration, newBpmBefore) / 1000;
+ }
+ }
+
+ if (!isDelete) {
+ const nextMarker = s.masterBarMarkers[nextIndexForUpdate];
+ const synthDuration = nextMarker.synthTime - modifiedMarker.synthTime;
+ const syncedDuration = nextMarker.syncTime - modifiedMarker.syncTime;
+ const newBpmAfter = (synthDuration / syncedDuration) * modifiedMarker.synthBpm;
+ modifiedMarker.modifiedTempo = newBpmAfter;
+
+ let syncedTimePosition =
+ modifiedMarker.syncTime + ticksToMillis(modifiedMarker.synthTickDuration, newBpmAfter) / 1000;
+ for (let i = modifiedIndex + 1; i < nextIndexForUpdate; i++) {
+ const marker = { ...s.masterBarMarkers[i] };
+ s.masterBarMarkers[i] = marker;
+ marker.syncTime = syncedTimePosition;
+
+ syncedTimePosition += ticksToMillis(marker.synthTickDuration, newBpmAfter) / 1000;
+ }
+ }
+}
+
+type UndoStack = {
+ undo: SyncPointInfo[];
+ redo: SyncPointInfo[];
+};
+
+export const MediaSyncEditor: React.FC = ({ api, score }) => {
+ const markerCanvas = useRef(null);
+ const waveFormCanvas = useRef(null);
+ const syncArea = useRef(null);
+
+ const [canvasSize, setCanvasSize] = useState([0, 0]);
+ const [virtualWidth, setVirtualWidth] = useState(0);
+ const [zoom, setZoom] = useState(1);
+
+ const [syncPointInfo, setSyncPointInfo] = useState({
+ endTick: 0,
+ endTime: 0,
+ sampleRate: 44100,
+ leftSamples: new Float32Array(0),
+ rightSamples: new Float32Array(0),
+ masterBarMarkers: []
+ });
+
+ const [draggingMarker, setDraggingMarker] = useState(null);
+ const [draggingMarkerInfo, setDraggingMarkerInfo] = useState(null);
+ const [undoStack, setUndoStack] = useState({ undo: [], redo: [] });
+ const [shouldStoreToUndo, setStoreToUndo] = useState(false);
+ const [shouldApplySyncPoints, setApplySyncPoints] = useState(false);
+ const [shouldCreateInitialSyncPoints, setCreateInitialSyncPoints] = useState(false);
+
+ const [audioElement, setAudioElement] = useState(null);
+ const [playbackTime, setPlaybackTime] = useState(0);
+
+ useEffect(() => {
+ setAudioElement(
+ (api.player!.output as alphaTab.synth.IAudioElementBackingTrackSynthOutput)?.audioElement ?? null
+ );
+ }, [api.player!.output]);
+
+ useAlphaTabEvent(
+ api,
+ 'midiLoad',
+ () => {
+ setAudioElement(
+ (api.player!.output as alphaTab.synth.IAudioElementBackingTrackSynthOutput)?.audioElement ?? null
+ );
+ buildSyncPointInfo(api, shouldCreateInitialSyncPoints).then(x => setSyncPointInfo(x));
+ setCreateInitialSyncPoints(false);
+ },
+ [shouldCreateInitialSyncPoints]
+ );
+
+ useEffect(() => {
+ if (syncArea.current) {
+ const xPos = timePositionToX(playbackTime, zoom);
+ const canvasWidth = canvasSize[0];
+ const threshold = canvasWidth * scrollThresholdPercent;
+ const scrollOffset = syncArea.current.scrollLeft;
+
+ // is out of screen?
+ if (xPos < scrollOffset + threshold || (xPos - scrollOffset) > (canvasWidth - threshold)) {
+ syncArea.current.scrollTo({
+ left: xPos - canvasWidth / 2,
+ behavior: 'smooth'
+ });
+ }
+ }
+ }, [api, playbackTime, canvasSize, syncArea]);
+
+ useEffect(() => {
+ const updateWaveFormCursor = () => {
+ setPlaybackTime(audioElement!.currentTime);
+ };
+
+ let timeUpdate: number = 0;
+
+ if (audioElement) {
+ console.log('Audio element', audioElement);
+ audioElement.addEventListener('timeupdate', updateWaveFormCursor);
+ audioElement.addEventListener('durationchange', updateWaveFormCursor);
+ audioElement.addEventListener('seeked', updateWaveFormCursor);
+ timeUpdate = window.setInterval(() => {
+ if (audioElement) {
+ setPlaybackTime(audioElement.currentTime);
+ }
+ }, 50);
+ updateWaveFormCursor();
+ }
+
+ return () => {
+ if (audioElement) {
+ console.log('unregister Audio element', audioElement);
+ audioElement.removeEventListener('timeupdate', updateWaveFormCursor);
+ audioElement.removeEventListener('durationchange', updateWaveFormCursor);
+ audioElement.removeEventListener('seeked', updateWaveFormCursor);
+ window.clearInterval(timeUpdate);
+ }
+ };
+ }, [audioElement]);
+
+ useEffect(() => {
+ if (!syncPointInfo) {
+ return;
+ }
+ if (shouldStoreToUndo) {
+ setUndoStack(s => ({
+ undo: [...s.undo, syncPointInfo],
+ redo: []
+ }));
+ setStoreToUndo(false);
+ }
+
+ const syncPointLookup = new Map();
+ for (const m of syncPointInfo.masterBarMarkers) {
+ if (m.modifiedTempo) {
+ let syncPoints = syncPointLookup.get(m.masterBarIndex);
+ if (!syncPoints) {
+ syncPoints = [];
+ syncPointLookup.set(m.masterBarIndex, syncPoints);
+ }
+
+ const automation = new alphaTab.model.Automation();
+ automation.ratioPosition = m.isEndMarker ? 1 : 0;
+ automation.type = alphaTab.model.AutomationType.SyncPoint;
+ automation.syncPointValue = new alphaTab.model.SyncPointData();
+ automation.syncPointValue.modifiedTempo = m.modifiedTempo;
+ automation.syncPointValue.millisecondOffset = m.syncTime * 1000;
+ automation.syncPointValue.barOccurence = m.occurence - 1;
+ syncPoints.push(automation);
+ }
+ }
+
+ if (shouldApplySyncPoints) {
+ console.log('Apply Sync points', syncPointLookup);
+
+ // remember and set again the tick position after sync point update
+ // this will ensure the cursor and player seek accordingly with keeping the cursor
+ // where it is currently shown on the notation.
+ const tickPosition = api.tickPosition;
+ for (const masterBar of score.masterBars) {
+ masterBar.syncPoints = syncPointLookup.get(masterBar.index);
+ }
+ api.updateSyncPoints();
+ api.tickPosition = tickPosition;
+ setApplySyncPoints(false);
+ }
+ }, [syncPointInfo]);
+
+ const undo = () => {
+ setUndoStack(s => {
+ const newStack = { ...s };
+ if (newStack.undo.length > 0) {
+ const undoState = newStack.undo.pop()!;
+ newStack.redo.push(undoState);
+ setApplySyncPoints(true);
+ setSyncPointInfo(newStack.undo.at(-1)!);
+ }
+ return newStack;
+ });
+ };
+
+ const redo = () => {
+ setUndoStack(s => {
+ const newStack = { ...s };
+ if (newStack.redo.length > 0) {
+ const redoState = newStack.redo.pop()!;
+ newStack.undo.push(redoState);
+ setApplySyncPoints(true);
+ setSyncPointInfo(redoState);
+ }
+ return newStack;
+ });
+ };
+
+ const toggleMarker = (marker: MasterBarMarker, e: React.MouseEvent) => {
+ e.stopPropagation();
+ e.preventDefault();
+ setStoreToUndo(true);
+ setApplySyncPoints(true);
+ setSyncPointInfo(s => {
+ if (!s) {
+ return s;
+ }
+
+ // no removal of start and end marker
+ if (marker.isStartMarker || marker.isEndMarker) {
+ return s;
+ }
+
+ const markerIndex = s!.masterBarMarkers.indexOf(marker);
+ if (markerIndex === -1) {
+ return s;
+ }
+
+ const newS = { ...s, masterBarMarkers: [...s.masterBarMarkers] };
+ if (marker.modifiedTempo) {
+ newS.masterBarMarkers[markerIndex] = { ...marker, modifiedTempo: undefined };
+ updateSyncPointsAfterModification(markerIndex, newS, true);
+ } else {
+ updateSyncPointsAfterModification(markerIndex, newS, false);
+ }
+
+ return newS;
+ });
+ };
+
+ const mouseUpListener = useCallback(
+ (e: MouseEvent) => {
+ if (draggingMarker) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ const deltaX = draggingMarkerInfo!.endX - draggingMarkerInfo!.startX;
+ if (deltaX > dragThreshold || draggingMarker.modifiedTempo !== undefined) {
+ setStoreToUndo(true);
+ setApplySyncPoints(true);
+ setSyncPointInfo(s => {
+ if (!s) {
+ return s;
+ }
+
+ const markerIndex = s.masterBarMarkers.findIndex(m => m === draggingMarker);
+
+ const zoomedPixelPerSecond = pixelPerSeconds * zoom;
+ const deltaTime = deltaX / zoomedPixelPerSecond;
+
+ const newTimePosition = draggingMarker.syncTime + deltaTime;
+
+ const newS = { ...s, masterBarMarkers: [...s.masterBarMarkers] };
+
+ // move the marker to the new position
+ newS.masterBarMarkers[markerIndex] = {
+ ...newS.masterBarMarkers[markerIndex],
+ syncTime: Math.max(0, newTimePosition)
+ };
+
+ updateSyncPointsAfterModification(markerIndex, newS, false);
+ return newS;
+ });
+ setDraggingMarker(null);
+ setDraggingMarkerInfo(null);
+ }
+ }
+ },
+ [draggingMarker, draggingMarkerInfo]
+ );
+
+ const mouseMoveListener = useCallback(
+ (e: MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ setDraggingMarkerInfo(s => {
+ if (!s || !syncPointInfo) {
+ return s;
+ }
+
+ const index = syncPointInfo.masterBarMarkers.indexOf(draggingMarker!);
+ if (index === -1) {
+ return s;
+ }
+
+ let pageX = e.pageX;
+ if (index < syncPointInfo.masterBarMarkers.length - 1) {
+ const deltaX = pageX - s.startX;
+ const thisX = timePositionToX(draggingMarker!.syncTime, zoom);
+ const newX = thisX + deltaX;
+
+ let nextMarkerIndex = index + 1;
+ while (
+ nextMarkerIndex < syncPointInfo.masterBarMarkers.length - 1 &&
+ !syncPointInfo.masterBarMarkers[nextMarkerIndex].modifiedTempo
+ ) {
+ nextMarkerIndex++;
+ }
+
+ const nextMarker = syncPointInfo.masterBarMarkers[nextMarkerIndex];
+ const nextX = timePositionToX(nextMarker.syncTime, zoom);
+ const maxX = nextX - dragLimit;
+
+ if (newX > maxX) {
+ pageX = s.startX + (maxX - thisX);
+ }
+ }
+
+ return { ...s, endX: pageX };
+ });
+ },
+ [draggingMarker, syncPointInfo]
+ );
+
+ useEffect(() => {
+ if (draggingMarker) {
+ document.addEventListener('mouseup', mouseUpListener);
+ document.addEventListener('mousemove', mouseMoveListener);
+ }
+
+ return () => {
+ document.removeEventListener('mouseup', mouseUpListener);
+ document.removeEventListener('mousemove', mouseMoveListener);
+ };
+ }, [draggingMarker, mouseUpListener, mouseMoveListener]);
+
+ const startMarkerDrag = (marker: MasterBarMarker, e: React.MouseEvent) => {
+ if (e.button !== 0 || marker.modifiedTempo === undefined) {
+ return;
+ }
+ e.preventDefault();
+ e.stopPropagation();
+ setDraggingMarkerInfo(() => ({ startX: e.pageX, startY: e.pageY, endX: e.pageX }));
+ setDraggingMarker(() => marker);
+ };
+
+ useEffect(() => {
+ setUndoStack({
+ undo: [],
+ redo: []
+ });
+ setStoreToUndo(true);
+ buildSyncPointInfo(api, shouldCreateInitialSyncPoints).then(x => setSyncPointInfo(x));
+ setCreateInitialSyncPoints(false);
+ }, [api]);
+
+ const drawWaveform = () => {
+ const can = waveFormCanvas.current;
+ if (!syncPointInfo || !can) {
+ return;
+ }
+
+ const ctx = can.getContext('2d')!;
+ ctx.clearRect(0, 0, can.width, can.height);
+ ctx.save();
+
+ const waveFormY = barNumberHeight + arrowHeight;
+ const halfHeight = ((can.height - waveFormY - timeAxisHeight) / 2) | 0;
+
+ // frame
+ ctx.fillStyle = timeAxisLineColor;
+ ctx.fillRect(0, waveFormY + 2 * halfHeight, can.width, 1);
+ ctx.fillRect(0, barNumberHeight, can.width, 1);
+ ctx.fillRect(0, waveFormY, can.width, 1);
+ ctx.fillRect(0, waveFormY + halfHeight, can.width, 1);
+
+ // waveform
+ ctx.translate(-syncArea.current!.scrollLeft, 0);
+
+ ctx.beginPath();
+
+ const startX = syncArea.current!.scrollLeft;
+ const endX = startX + can.width;
+
+ const zoomedPixelPerSecond = pixelPerSeconds * zoom;
+ const samplesPerPixel = syncPointInfo.sampleRate / zoomedPixelPerSecond;
+
+ for (let x = startX; x < endX; x += barWidth) {
+ const startSample = (x * samplesPerPixel) | 0;
+ const endSample = ((x + barWidth) * samplesPerPixel) | 0;
+
+ let maxTop = 0;
+ let maxBottom = 0;
+ for (let sample = startSample; sample <= endSample; sample++) {
+ // TODO: a logarithmic scale would be better here to scale 0-1 better as visible waveform
+ // for now we multiply it for a good scale (unlikely we have a sound with 1 which is very loud)
+ const visibilityFactor = 5;
+ const magnitudeTop = Math.min(Math.abs(syncPointInfo.leftSamples[sample] * visibilityFactor || 0), 1);
+ const magnitudeBottom = Math.min(Math.abs(syncPointInfo.rightSamples[sample] * visibilityFactor || 0), 1);
+ if (magnitudeTop > maxTop) {
+ maxTop = magnitudeTop;
+ }
+ if (magnitudeBottom > maxBottom) {
+ maxBottom = magnitudeBottom;
+ }
+ }
+
+ const topBarHeight = Math.round(maxTop * halfHeight);
+ const bottomBarHeight = Math.round(maxBottom * halfHeight);
+ const barHeight = topBarHeight + bottomBarHeight || 1;
+ ctx.rect(x, waveFormY + (halfHeight - topBarHeight), barWidth, barHeight);
+ }
+
+ ctx.fillStyle = waveFormColor;
+ ctx.fill();
+
+ // time axis
+ ctx.save();
+
+ ctx.fillStyle = timeAxisLineColor;
+ ctx.font = font;
+ ctx.textAlign = 'left';
+ ctx.textBaseline = 'bottom';
+
+ const timeAxisY = waveFormY + 2 * halfHeight;
+ const leftTime = Math.floor((startX - leftPadding) / zoomedPixelPerSecond);
+ const rightTime = Math.ceil(endX / zoomedPixelPerSecond);
+
+ let time = leftTime;
+ while (time <= rightTime) {
+ const timeX = timePositionToX(time, zoom);
+ ctx.fillRect(timeX, timeAxisY, 1, timeAxisHeight);
+
+ const minutes = Math.floor(time / 60);
+ const seconds = Math.floor(time - minutes * 60);
+
+ const minutesText = minutes.toString().padStart(2, '0');
+ const secondsText = seconds.toString().padStart(2, '0');
+
+ ctx.fillText(`${minutesText}:${secondsText}`, timeX + 3, timeAxisY + timeAxisHeight);
+
+ const nextSecond = time + 1;
+ while (time < nextSecond) {
+ const subSecondX = timePositionToX(time, zoom);
+ ctx.fillRect(subSecondX, timeAxisY, 1, timeAxiSubSecondTickHeight);
+
+ time += 0.1;
+ }
+
+ time = Math.floor(time + 0.5);
+ }
+
+ ctx.restore();
+ ctx.restore();
+ };
+
+ useEffect(() => {
+ drawWaveform();
+ }, [canvasSize, virtualWidth]);
+
+ useResizeObserver(syncArea, entry => {
+ setCanvasSize(s => [entry.contentRect.width, entry.contentRect.height]);
+ });
+
+ useEffect(() => {
+ if (syncPointInfo) {
+ setVirtualWidth(s => pixelPerSeconds * syncPointInfo.endTime * zoom);
+ }
+ drawWaveform();
+ }, [markerCanvas, syncPointInfo, zoom]);
+
+ useEffect(() => {
+ if (shouldCreateInitialSyncPoints) {
+ // clear any potential sync points
+ for (const m of score.masterBars) {
+ m.syncPoints = undefined;
+ }
+ api.updateSettings();
+ api.loadMidiForScore();
+ }
+ }, [shouldCreateInitialSyncPoints]);
+
+ const onLoadAudioFile = () => {
+ const input = document.createElement('input');
+ input.type = 'file';
+ input.accept = '.mp3,.ogg,*.wav,*.flac,*.aac';
+ input.onchange = () => {
+ if (input.files?.length === 1) {
+ const reader = new FileReader();
+ reader.onload = e => {
+ // setup backing track
+ score.backingTrack = new alphaTab.model.BackingTrack();
+ score.backingTrack.rawAudioFile = new Uint8Array(e.target!.result as ArrayBuffer);
+
+ // create a fresh set of sync points upon load (start->end)
+ setCreateInitialSyncPoints(true);
+ };
+ reader.readAsArrayBuffer(input.files[0]);
+ }
+ };
+ document.body.appendChild(input);
+ input.click();
+ document.body.removeChild(input);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ drawWaveform()}>
+
+
+
+
+ {syncPointInfo.masterBarMarkers.map(m => (
+ toggleMarker(m, e)}
+ onMouseDown={e => {
+ startMarkerDrag(m, e);
+ }}>
+ {m.label}
+
+
+ {!m.isEndMarker && m.modifiedTempo && (
+ {m.modifiedTempo.toFixed(1)} bpm
+ )}
+
+
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/src/components/AlphaTabPlayground/player-controls-group.tsx b/src/components/AlphaTabPlayground/player-controls-group.tsx
index 1bfe2fd..8284923 100644
--- a/src/components/AlphaTabPlayground/player-controls-group.tsx
+++ b/src/components/AlphaTabPlayground/player-controls-group.tsx
@@ -11,6 +11,8 @@ import { PlayerProgressIndicator } from '../AlphaTabFull/player-progress-indicat
export interface PlayerControlsGroupProps {
sidePanel: SidePanel;
onSidePanelChange: (sidePanel: SidePanel) => void;
+ bottomPanel: BottomPanel;
+ onBottomPanelChange: (sidePanel: BottomPanel) => void;
api: alphaTab.AlphaTabApi;
}
@@ -20,7 +22,18 @@ export enum SidePanel {
TrackSelector = 2
}
-export const PlayerControlsGroup: React.FC = ({ api, sidePanel, onSidePanelChange }) => {
+export enum BottomPanel {
+ None = 0,
+ MediaSyncEditor = 1
+}
+
+export const PlayerControlsGroup: React.FC = ({
+ api,
+ sidePanel,
+ onSidePanelChange,
+ bottomPanel,
+ onBottomPanelChange
+}) => {
const [soundFontLoadPercentage, setSoundFontLoadPercentage] = useState(0);
const [isPlaying, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
@@ -121,6 +134,19 @@ export const PlayerControlsGroup: React.FC = ({ api, s
+