diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..ba16bcb
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,23 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ cache: npm
+
+ - run: npm ci
+
+ - run: npm run typecheck
+
+ - run: npm test
diff --git a/.gitignore b/.gitignore
index f2d741f..68e32e5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,12 @@
.DS_Store
-*.streamDeckPlugin
\ No newline at end of file
+*.streamDeckPlugin
+
+# Node.js
+node_modules/
+
+# Stream Deck build output
+*.sdPlugin/bin
+*.sdPlugin/logs
+
+# SDK log output when run from the repo root
+/logs/
diff --git a/README.md b/README.md
index 39f9e57..a34e79a 100644
--- a/README.md
+++ b/README.md
@@ -6,39 +6,46 @@ To download the current official release, visit our page on the [Elgato marketpl
If you're interested in developing a software intergration with Meld Studio, you can find our API documentation [here](./WebChannelAPI.md). If you have any questions, please join our [discord](https://discord.gg/ZHpeXEw4) and reach out in [`#support`](https://discord.com/channels/1019227275897614337/1019584582913364101).
+## Architecture
+
+The plugin is built on Elgato's official [Node.js Stream Deck SDK](https://docs.elgato.com/streamdeck/sdk/introduction/getting-started/) (`@elgato/streamdeck`). It runs as a single long-lived Node process, so there is exactly **one** connection to Meld Studio for the whole plugin, shared by every action and every property inspector.
+
+- `src/plugin.ts` — entry point; registers the 14 actions and starts the Meld connection.
+- `src/meld/` — the Meld service layer: a single QWebChannel-over-WebSocket connection (`connection.ts`) with automatic reconnect and a track-observer registry, a typed session model (`session.ts` / `types.ts`), and typed events (`events.ts`).
+- `src/actions/` — one file per action, all extending `MeldAction` (`base.ts`), which turns Meld/session/reconnect events into a single declarative `render()` call.
+- `src/ui-bridge.ts` — answers property-inspector requests for session data from the one connection; the inspectors under `co.meldstudio.streamdeck.sdPlugin/ui/` never talk to Meld directly.
+
+The build (`rollup`) bundles `src/plugin.ts` into `co.meldstudio.streamdeck.sdPlugin/bin/plugin.js`.
+
+Requires Stream Deck **7.1+** and bundles the **Node 24** runtime (see `manifest.json`).
+
## Development
We primarily develop on macOS, so these instructions are tailored to macOS. Feel free to submit a PR to update them for Windows if that's your flavor of OS.
-1) Check out the repository.
+1) Check out the repository and run `npm install`.
2) Run `scripts/install.sh` (optionally, `--fresh` to remove and reinstall).
- This will update the plugin in `~/Library/Application\ Support/com.elgato.StreamDeck/Plugins/` if it exists.
+ This builds the plugin (`npm run build`) and syncs it into
+ `~/Library/Application\ Support/com.elgato.StreamDeck/Plugins/`.
-3) Code away.
-4) Run `scripts/install.sh` again to update the install.
+3) Code away. Type-check with `npm run typecheck`.
+4) Run `scripts/install.sh` again to rebuild and update the install, or use
+ `npm run watch` to rebuild on save and restart the plugin automatically.
See below for debugging information.
## Debugging:
-1. To enable debugging on macOS, run: `defaults write com.elgato.StreamDeck html_remote_debugging_enabled -bool YES` and restart Stream Deck app.
-2. Navigation to `http://localhost:23654/` in Chrome.
-3. Click on one of the two entries:
+The plugin runs in Node, so it is debugged as a Node process, while property inspectors remain web pages.
-
+- **Plugin (Node):** the manifest enables the Node inspector (`"Nodejs": { "Debug": "enabled" }`). Attach a debugger / open `chrome://inspect` in Chrome to connect to the plugin process. Logs are written to `co.meldstudio.streamdeck.sdPlugin/logs/`.
+- **Property inspectors (web):** run `defaults write com.elgato.StreamDeck html_remote_debugging_enabled -bool YES` and restart the Stream Deck app, then navigate to `http://localhost:23654/` in Chrome to inspect the inspector pages.
-* The blue link shows the Property Inspector page and console log.
-* The red link shows the CodePath button press page and console.
-
-Which should yield you:
+

-From here you can refresh the plugin source from the debug console UI.
-
-For more details: https://streamdecklabs.com/debugging-your-javascript-plugin/
-
## Packaging
Elgato provides a CLI packaging tool.
@@ -46,9 +53,11 @@ Elgato provides a CLI packaging tool.
[https://docs.elgato.com/sdk/plugins/packaging]
```
-$ npm -g install @elgato/cli
-$ streamdeck validate co.meldstudio.streamdeck.sdPlugin
-$ streamdeck pack co.meldstudio.streamdeck.sdPlugin
+$ npm install
+$ npm run build
+$ npx streamdeck validate co.meldstudio.streamdeck.sdPlugin
+$ npx streamdeck pack co.meldstudio.streamdeck.sdPlugin
```
-This outputs a file `co.meldstudio.streamdeck.streamDeckPlugin` which can be installed directly.
+Or simply `npm run pack`, which builds and packs in one step.
+
diff --git a/WebChannelAPI.md b/WebChannelAPI.md
index 032546a..d81ba62 100644
--- a/WebChannelAPI.md
+++ b/WebChannelAPI.md
@@ -503,7 +503,7 @@ _Side Effects_: Once streaming starts, the `channel`'s `isStreaming` property wi
meld.toggleStream();
```
-#### **`registerTrackObserver(trackId: string, context: string)`**
+#### **`registerTrackObserver(context: string, trackId: string)`**
Registers an observer for changes to a specific track.
@@ -519,7 +519,7 @@ const trackId = /* desired track id */;
meld.registerTrackObserver("documentation-context", trackId);
```
-#### **`unregisterTrackObserver(trackId: string, context: string)`**
+#### **`unregisterTrackObserver(context: string, trackId: string)`**
Unregisters an observer for a specific track.
@@ -616,6 +616,25 @@ meld.sendCommand("meld.screenshot");
| `meld.replay.show` | Show replay layer for last clip. |
| `meld.replay.dismiss` | Dismiss all active replay layers. |
+#### **`sendCommandWithArgs(commandName: string, args: any[])`**
+
+Triggers a custom command with positional arguments.
+
+**Parameters**:
+
+| property | type | description |
+| -------- | ---------- | ----------------------------------- |
+| commandName | `string` | Command to trigger in Meld. |
+| args | `any[]` | Positional arguments for the command. |
+
+```js
+meld.sendCommandWithArgs("meld.recordClip", [30]);
+```
+
+`meld.recordClip` accepts at most one argument: a finite, non-negative clip
+duration in seconds. Its advertised range is 0–90 seconds; omitting the
+argument, or passing `0`, records the maximum available duration.
+
#### **`sendStreamEvent(type: string, data?: any)`**
Dispatches a widget-specific event to Meld Studio (e.g., stopwatch, countdown, confetti, subathon timer, wheel spin).
diff --git a/co.meldstudio.streamdeck.sdPlugin/README.md b/co.meldstudio.streamdeck.sdPlugin/README.md
index 7836293..c771c64 100644
--- a/co.meldstudio.streamdeck.sdPlugin/README.md
+++ b/co.meldstudio.streamdeck.sdPlugin/README.md
@@ -12,9 +12,10 @@ And we're not done yet! We are continually working to bring you even more capabi
## What's New
-Version 0.6.2.1
-- Added Widget control actions for controlling the counter widget in Meld Studio.
-- Added Mute and Cue controls to the Replay Clip action.
+Version 0.6.4.1
+- Added configurable durations to the Record Clip action. Choose a 10, 15, 30, 60, or 90 second preset, or enter a custom duration from 0 to 90 seconds.
+- Improved action state synchronization and recovery after Meld Studio reconnects and session restores.
+- Fixed reversed active/inactive icon states on the Cue, Layer Visibility, Effect Visibility, Record, Go Live, and Change Scene actions so thirdparty icon packs indicate correctly with Meld Studio actions.
## Helpful Links
@@ -22,7 +23,7 @@ https://meldstudio.co/
## Information
-Stream Deck 6.0 and later, macOS 12.3 and later.
+Stream Deck 7.1 and later, macOS 12.3 and later.
Languages: English, German
## Support Links
@@ -34,6 +35,22 @@ hi@meldstudio.co
# Change History:
------------
+## 2026-08-12 (v0.6.4.1)
+### Changes:
+-----------
+- Refactored the plugin onto Elgato's official Node.js Stream Deck SDK.
+- Optimized Meld Studio interactions for all actions.
+- Added configurable durations to the Record Clip action. Choose a 10, 15, 30, 60, or 90 second preset, or enter a custom duration from 0 to 90 seconds.
+- Improved action state synchronization and recovery after Meld Studio reconnects and session restores.
+- Fixed reversed active/inactive icon states on the Cue, Layer Visibility, Effect Visibility, Record, Go Live, and Change Scene actions so thirdparty icon packs indicate correctly with Meld Studio actions.
+
+
+## 2026-05-13 (v0.6.3.2)
+### Changes:
+-----------
+- Fixed a bug in how dials and scene change actions store state.
+
+
## 2026-01-22 (v0.6.2.1)
### Changes:
-----------
@@ -91,6 +108,3 @@ hi@meldstudio.co
### Changes:
-----------
- Initial implementation of 6 core features (Scene Selection, Layer Visibility, Effect Visibility, Mute Audio, Stream, and Record).
-
-
-
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/clip/inspector.html b/co.meldstudio.streamdeck.sdPlugin/actions/clip/inspector.html
deleted file mode 100644
index 70017f5..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/clip/inspector.html
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
-
-
-
- co.meldstudio.streamdeck.clip Property Inspector
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/clip/plugin.js b/co.meldstudio.streamdeck.sdPlugin/actions/clip/plugin.js
deleted file mode 100644
index cccade0..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/clip/plugin.js
+++ /dev/null
@@ -1,15 +0,0 @@
-// Copyright (c) 2024 Meld Studio, Inc.
-// Licensed under the MIT license. See LICENSE file in the project root for details.
-
-class Clip extends MeldStudioPlugin {
- constructor() {
- super("co.meldstudio.streamdeck.clip");
-
- this.action.onKeyUp(({ action, context, device, event, payload }) => {
- if ($MS.meld?.sendCommand)
- $MS.meld.sendCommand("meld.recordClip");
- });
- }
-}
-
-const clip = new Clip();
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/media/inspector.html b/co.meldstudio.streamdeck.sdPlugin/actions/media/inspector.html
deleted file mode 100644
index cc3b97b..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/media/inspector.html
+++ /dev/null
@@ -1,70 +0,0 @@
-
-
-
-
-
-
- co.meldstudio.streamdeck.media Property Inspector
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/media/plugin.js b/co.meldstudio.streamdeck.sdPlugin/actions/media/plugin.js
deleted file mode 100644
index 6b1b6ef..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/media/plugin.js
+++ /dev/null
@@ -1,144 +0,0 @@
-// Copyright (c) 2024 Meld Studio, Inc.
-// Licensed under the MIT license. See LICENSE file in the project root for details.
-
-class Media extends MeldStudioPlugin {
- mediaState = {}
-
- setLocalState (context, action, isPlaying) {
- let path = isPlaying
- ? 'assets/Media/Key Icon/media-controls-pause'
- : 'assets/Media/Key Icon/media-controls-play'
-
- if (action === 'restart') {
- path = 'assets/Media/Key Icon/media-controls-skip-back'
- } else if (action === 'play') {
- path = 'assets/Media/Key Icon/media-controls-play'
- } else if (action === 'pause') {
- path = 'assets/Media/Key Icon/media-controls-pause'
- }
-
- $SD.setImage(context, path, 0)
- }
-
- findMediaLayer (context, session) {
- if (!session?.items) return null
-
- const settings = this.getSettings(context)
- const { scene: sceneId, layer: layerId } = settings
-
- // If no scene/layer selected, return null
- if (!sceneId || !layerId) return null
-
- const layerItem = session.items[layerId]
-
- // Verify the layer exists, belongs to the selected scene, and has mediaSource
- if (
- !layerItem ||
- layerItem.type !== 'layer' ||
- layerItem.parent !== sceneId ||
- !layerItem.mediaSource
- ) {
- return null
- }
-
- return {
- layerId,
- layerItem
- }
- }
-
- mediaPlay (context, mediaAction, mediaInfo) {
- this.setLocalState(context, mediaAction, true)
- if (!mediaInfo?.layerId) return
- if ($MS.meld?.callFunction) {
- $MS.meld.callFunction(mediaInfo.layerId, 'play')
- }
- }
-
- mediaPause (context, mediaAction, mediaInfo) {
- this.setLocalState(context, mediaAction, false)
- if (!mediaInfo?.layerId) return
- if ($MS.meld?.callFunction) {
- $MS.meld.callFunction(mediaInfo.layerId, 'pause')
- }
- }
-
- mediaRestart (context, mediaAction, mediaInfo) {
- this.setLocalState(context, mediaAction, false)
- if (!mediaInfo?.layerId) return
- if ($MS.meld?.callFunctionWithArgs) {
- $MS.meld.callFunctionWithArgs(mediaInfo.layerId, 'seekTo', [0])
- }
- }
-
- mediaToggle (context, mediaAction, mediaInfo) {
- if (!mediaInfo?.layerId) return
- const isPlaying = mediaInfo.layerItem?.isPlaying
- if (isPlaying) {
- this.mediaPause(context, mediaAction, mediaInfo)
- } else {
- this.mediaPlay(context, mediaAction, mediaInfo)
- }
- }
-
- constructor () {
- super('co.meldstudio.streamdeck.media')
-
- const actionHandlers = {
- play: (context, mediaAction, mediaInfo) =>
- this.mediaPlay(context, mediaAction, mediaInfo),
- pause: (context, mediaAction, mediaInfo) =>
- this.mediaPause(context, mediaAction, mediaInfo),
- restart: (context, mediaAction, mediaInfo) =>
- this.mediaRestart(context, mediaAction, mediaInfo),
- toggle: (context, mediaAction, mediaInfo) =>
- this.mediaToggle(context, mediaAction, mediaInfo)
- }
-
- this.action.onKeyUp(({ context }) => {
- const { action: mediaAction } = this.getSettings(context)
- const mediaInfo = this.mediaState[context]
-
- const action = mediaAction || 'toggle'
- const handler = actionHandlers[action]
- if (handler) {
- handler(context, mediaAction, mediaInfo)
- }
- })
-
- this.action.onKeyDown(() => {})
- }
-
- onSessionChanged (session) {
- this.forAllContexts(context => {
- const mediaLayer = this.findMediaLayer(context, session)
- const { action: mediaAction } = this.getSettings(context)
-
- if (!mediaLayer) {
- this.mediaState[context] = null
- return this.setLocalState(context, mediaAction, false)
- }
-
- const { layerId, layerItem } = mediaLayer
- this.mediaState[context] = {
- layerId,
- layerItem
- }
-
- this.setLocalState(context, mediaAction, layerItem.isPlaying)
- })
- }
-
- onReceivedSettings (context, newSettings, oldSettings) {
- const { action: mediaAction } = newSettings
- if (mediaAction !== oldSettings.action) {
- this.setLocalState(
- context,
- mediaAction,
- this.mediaState[context]?.layerItem?.isPlaying ?? false
- )
- }
- }
-}
-
-const media = new Media()
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/replay/inspector.html b/co.meldstudio.streamdeck.sdPlugin/actions/replay/inspector.html
deleted file mode 100644
index 66bff64..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/replay/inspector.html
+++ /dev/null
@@ -1,71 +0,0 @@
-
-
-
-
-
- co.meldstudio.streamdeck.replay Property Inspector
-
-
-
-
-
-
-
Action
-
-
- Show Replay
-
-
- Dismiss Replay
-
-
- Toggle Mute
-
- Mute
- Unmute
-
- Toggle Cue
-
- Cue
- Uncue
-
-
-
-
-
-
- Connecting to Meld Studio...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/replay/plugin.js b/co.meldstudio.streamdeck.sdPlugin/actions/replay/plugin.js
deleted file mode 100644
index 70226f2..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/replay/plugin.js
+++ /dev/null
@@ -1,224 +0,0 @@
-// Copyright (c) 2024 Meld Studio, Inc.
-// Licensed under the MIT license. See LICENSE file in the project root for details.
-
-function toDb (gain) {
- let dB = 20 * Math.log10(gain)
- if (!isFinite(dB)) {
- dB = -60
- }
- return dB
-}
-
-function toGain (dB) {
- if (!isFinite(dB) || dB < -60) dB = -60
- let gain = Math.pow(10, dB / 20)
-
- gain = gain <= 0.001 ? 0 : gain
- gain = gain > 1 ? 1 : gain
- return gain
-}
-
-class Replay extends MeldStudioPlugin {
- replayState = {}
- muted = true
- monitoring = false
-
- setLocalState (context, action) {
- let path
-
- if (['toggle', 'mute', 'unmute'].includes(action)) {
- const muted = this.replayState?.muted ?? true
- if (action === 'mute') {
- path = 'assets/ReplayMute/replay-clip-muted'
- } else if (action === 'unmute') {
- path = 'assets/ReplayMute/replay-clip-unmuted'
- } else {
- // toggle action shows current state
- path = muted
- ? 'assets/ReplayMute/replay-clip-muted'
- : 'assets/ReplayMute/replay-clip-unmuted'
- }
- } else if (['toggle_cue', 'cue', 'uncue'].includes(action)) {
- const monitoring = this.replayState?.monitoring ?? false
- if (action === 'cue') {
- path = 'assets/ReplayCUE/on'
- } else if (action === 'uncue') {
- path = 'assets/ReplayCUE/off'
- } else {
- // toggle_cue action shows current state
- path = monitoring
- ? 'assets/ReplayCUE/on'
- : 'assets/ReplayCUE/off'
- }
- } else if (action === 'volume_up') {
- path = 'assets/ReplayVolume/replay-clip-volumeUp'
- } else if (action === 'volume_down') {
- path = 'assets/ReplayVolume/replay-clip-volumeDown'
- } else if (action === 'dismiss') {
- path = 'assets/Replay/Key Icon/dismiss-clip-controls'
- } else {
- path = 'assets/Replay/Key Icon/replay-clip-controls'
- }
-
- $SD.setImage(context, path, 0)
- }
-
- findReplayTrack (session) {
- if (!session?.items) return null
-
- // Convert items object to array with id property
- const replayTracks = Object.entries(session.items)
- .filter(
- ([, item]) => item.name === 'Replay Clip' && item.type === 'track'
- )
- .map(([id, item]) => ({
- id,
- ...item
- }))
-
- if (replayTracks.length === 0) return null
-
- const replayTrack = replayTracks[0]
-
- return replayTrack
- ? {
- layerId: replayTrack.parent,
- trackId: replayTrack.id,
- trackItem: replayTrack
- }
- : null
- }
-
- replayShow () {
- if (this.replayState?.layerId) return
- if ($MS.meld?.sendCommand) $MS.meld.sendCommand('meld.replay.show')
- }
-
- replayDismiss () {
- if ($MS.meld?.sendCommand) $MS.meld.sendCommand('meld.replay.dismiss')
- }
-
- replayToggleMute () {
- if (!this.replayState?.trackId) return
- if ($MS.meld?.toggleMute) $MS.meld.toggleMute(this.replayState.trackId)
- }
-
- replayMute () {
- if (!this.replayState?.trackId) return
- if ($MS.meld?.setMuted) {
- $MS.meld.setMuted(this.replayState.trackId, true)
- this.replayState.muted = true
- }
- }
-
- replayUnmute () {
- if (!this.replayState?.trackId) return
- if ($MS.meld?.setMuted) {
- $MS.meld.setMuted(this.replayState.trackId, false)
- this.replayState.muted = false
- }
- }
-
- replayToggleCue () {
- if (!this.replayState?.trackId) return
- if ($MS.meld?.toggleMonitor)
- $MS.meld.toggleMonitor(this.replayState.trackId)
- }
-
- replayCue () {
- if (!this.replayState?.trackId) return
- if ($MS.meld?.setProperty) {
- $MS.meld.setProperty(this.replayState.trackId, 'monitoring', true)
- this.replayState.monitoring = false
- }
- }
-
- replayUncue () {
- if (!this.replayState?.trackId) return
- if ($MS.meld?.setProperty) {
- $MS.meld.setProperty(this.replayState.trackId, 'monitoring', false)
- this.replayState.monitoring = false
- }
- }
-
- replayVolumeUp () {
- if (!this.replayState?.trackId) return
- const stepsize = 3.0 // 3dB step
- let gain = this.replayState.gain ?? 0.0
-
- let dB = toDb(gain)
- dB += stepsize
- gain = toGain(dB)
-
- if ($MS.meld?.setGain) $MS.meld.setGain(this.replayState.trackId, gain)
- }
-
- replayVolumeDown () {
- if (!this.replayState?.trackId) return
- const stepsize = -3.0 // -3dB step
- let gain = this.replayState.gain ?? 0.0
-
- let dB = toDb(gain)
- dB += stepsize
- gain = toGain(dB)
-
- if ($MS.meld?.setGain) $MS.meld.setGain(this.replayState.trackId, gain)
- }
-
- constructor () {
- super('co.meldstudio.streamdeck.replay')
-
- const actionHandlers = {
- replay: () => this.replayShow(),
- dismiss: () => this.replayDismiss(),
- toggle: () => this.replayToggleMute(),
- mute: () => this.replayMute(),
- unmute: () => this.replayUnmute(),
- toggle_cue: () => this.replayToggleCue(),
- cue: () => this.replayCue(),
- uncue: () => this.replayUncue(),
- volume_up: () => this.replayVolumeUp(),
- volume_down: () => this.replayVolumeDown()
- }
-
- this.action.onKeyUp(({ context }) => {
- const { action: replay_action } = this.getSettings(context)
-
- const action = replay_action || 'replay'
- const handler = actionHandlers[action]
-
- if (handler) handler()
- })
- }
-
- onReceivedSettings (context, newSettings, oldSettings) {
- const { action: replay_action } = newSettings
- this.setLocalState(context, replay_action)
- }
-
- onSessionChanged (session) {
- const replayTrack = this.findReplayTrack(session)
- if (!replayTrack) {
- this.replayState = {
- muted: true,
- monitoring: false
- }
- return
- }
-
- const { layerId, trackId, trackItem } = replayTrack
- this.replayState = {
- layerId,
- trackId,
- muted: trackItem.muted,
- monitoring: trackItem.monitoring,
- gain: trackItem.gain ?? 0.0
- }
-
- this.forAllContexts((context, settings) => {
- this.setLocalState(context, settings.action)
- })
- }
-}
-
-const replay = new Replay()
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/screenshot/inspector.html b/co.meldstudio.streamdeck.sdPlugin/actions/screenshot/inspector.html
deleted file mode 100644
index 9fe3b0c..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/screenshot/inspector.html
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
-
-
-
- co.meldstudio.streamdeck.screenshot Property Inspector
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/screenshot/plugin.js b/co.meldstudio.streamdeck.sdPlugin/actions/screenshot/plugin.js
deleted file mode 100644
index 8f383d1..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/screenshot/plugin.js
+++ /dev/null
@@ -1,18 +0,0 @@
-// Copyright (c) 2024 Meld Studio, Inc.
-// Licensed under the MIT license. See LICENSE file in the project root for details.
-
-class Screenshot extends MeldStudioPlugin {
- constructor() {
- super("co.meldstudio.streamdeck.screenshot");
-
- this.action.onKeyUp(({ action, context, device, event, payload }) => {
- if ($MS.meld?.sendCommand)
- $MS.meld.sendCommand("meld.screenshot");
- // Keep backword compatibility for now.
- else if ($MS.meld?.sendEvent)
- $MS.meld.sendEvent("co.meldstudio.events.screenshot");
- });
- }
-}
-
-const screenshot = new Screenshot();
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/show-scene/inspector.html b/co.meldstudio.streamdeck.sdPlugin/actions/show-scene/inspector.html
deleted file mode 100644
index 88e539f..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/show-scene/inspector.html
+++ /dev/null
@@ -1,46 +0,0 @@
-
-
-
-
-
-
- co.meldstudio.streamdeck.show-scene Property Inspector
-
-
-
-
-
-
-
-
-
Connecting to Meld Studio...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/show-scene/plugin.js b/co.meldstudio.streamdeck.sdPlugin/actions/show-scene/plugin.js
deleted file mode 100644
index ca4f08c..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/show-scene/plugin.js
+++ /dev/null
@@ -1,48 +0,0 @@
-// Copyright (c) 2024 Meld Studio, Inc.
-// Licensed under the MIT license. See LICENSE file in the project root for details.
-
-class ShowScene extends MeldStudioPlugin {
- sceneRequested = null
-
- constructor () {
- super('co.meldstudio.streamdeck.show-scene')
-
- this.action.onKeyUp(({ action, context, device, event, payload }) => {
- const { scene } = this.getSettings(context)
- if (!scene) return
-
- this.sceneRequested = scene
- this.updateState(context)
-
- if ($MS.meld?.showScene) $MS.meld.showScene(scene)
- })
- }
-
- onSessionChanged (session) {
- this.sceneRequested = null
-
- this.forAllContexts((context, settings) => {
- this.updateState(context)
- })
- }
-
- updateState (context) {
- const { scene } = this.getSettings(context)
- const session = $MS?.meld?.session
-
- const state = (() => {
- if (!scene) return 0
- if (!session || !session?.items) return 0
-
- const item = session.items[scene]
-
- if (!item) return 0
- if (scene == this.sceneRequested) return 1
- return item.current ? 1 : 0
- })()
-
- $SD.setState(context, state)
- }
-}
-
-const showScene = new ShowScene()
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-effect/inspector.html b/co.meldstudio.streamdeck.sdPlugin/actions/toggle-effect/inspector.html
deleted file mode 100644
index 5f5b347..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-effect/inspector.html
+++ /dev/null
@@ -1,69 +0,0 @@
-
-
-
-
-
- co.meldstudio.streamdeck.toggle-effect Property Inspector
-
-
-
-
-
-
-
-
-
-
Action
-
- Toggle
- Show
- Hide
-
-
-
-
-
Connecting to Meld Studio...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-effect/plugin.js b/co.meldstudio.streamdeck.sdPlugin/actions/toggle-effect/plugin.js
deleted file mode 100644
index 0f6697c..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-effect/plugin.js
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (c) 2024 Meld Studio, Inc.
-// Licensed under the MIT license. See LICENSE file in the project root for details.
-
-class ToggleEffect extends MeldStudioPlugin {
- effectState = {};
-
- setLocalState(context, shown) {
- const state = shown ? 1 : 0;
- $SD.setState(context, state);
- }
-
- constructor() {
- super("co.meldstudio.streamdeck.toggle-effect");
-
- this.action.onKeyUp(({ action, context, device, event, payload }) => {
- const { scene, layer, effect, action: toggle_action } = this.getSettings(
- context
- );
- if (!scene || !layer || !effect) return;
-
- if (!toggle_action || toggle_action === "toggle") {
- if ($MS.meld?.toggleEffect) $MS.meld.toggleEffect(scene, layer, effect);
- } else {
- const action_show = toggle_action === "show";
- const state_show = this.effectState[context];
-
- // show | state | action
- // 1 | 0 | 1
- // 0 | 1 | 1
- // 1 | 1 | 0
- // 0 | 0 | 0
-
- const shouldToggle = action_show ^ state_show;
- if (!shouldToggle) this.setLocalState(context, state_show);
- if (shouldToggle && $MS.meld?.toggleEffect)
- $MS.meld.toggleEffect(scene, layer, effect);
- }
- });
-
- $MS.on("sessionChanged", (session) => {
- this.forAllContexts((context, settings) => {
- const { effect } = settings;
- if (!effect) return;
- if (!session.items[effect]) return $SD.setState(context, 0);
-
- const enabled = session.items[effect].enabled;
- this.effectState[context] = enabled;
-
- const state = enabled ? 1 : 0;
- $SD.setState(context, state);
- });
- });
- }
-}
-
-const toggleEffect = new ToggleEffect();
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-layer/inspector.html b/co.meldstudio.streamdeck.sdPlugin/actions/toggle-layer/inspector.html
deleted file mode 100644
index 29d1629..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-layer/inspector.html
+++ /dev/null
@@ -1,60 +0,0 @@
-
-
-
-
-
- co.meldstudio.streamdeck.toggle-layer Property Inspector
-
-
-
-
-
-
-
-
-
-
Connecting to Meld Studio...
-
-
-
-
Action
-
- Toggle
- Show
- Hide
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-layer/plugin.js b/co.meldstudio.streamdeck.sdPlugin/actions/toggle-layer/plugin.js
deleted file mode 100644
index 4a9e164..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-layer/plugin.js
+++ /dev/null
@@ -1,54 +0,0 @@
-// Copyright (c) 2024 Meld Studio, Inc.
-// Licensed under the MIT license. See LICENSE file in the project root for details.
-
-class ToggleLayer extends MeldStudioPlugin {
- layerState = {};
-
- setLocalState(context, shown) {
- const state = shown ? 1 : 0;
- $SD.setState(context, state);
- }
-
- constructor() {
- super("co.meldstudio.streamdeck.toggle-layer");
-
- this.action.onKeyUp(({ action, context, device, event, payload }) => {
- const { scene, layer, action: toggle_action } = this.getSettings(context);
- if (!scene || !layer) return;
-
- if (!toggle_action || toggle_action === "toggle") {
- if ($MS.meld?.toggleLayer) $MS.meld.toggleLayer(scene, layer);
- } else {
- const action_show = toggle_action === "show";
- const state_show = this.layerState[context];
-
- // show | state | action
- // 1 | 0 | 1
- // 0 | 1 | 1
- // 1 | 1 | 0
- // 0 | 0 | 0
-
- const shouldToggle = action_show ^ state_show;
- if (!shouldToggle) this.setLocalState(context, state_show);
- if (shouldToggle && $MS.meld?.toggleLayer)
- $MS.meld.toggleLayer(scene, layer);
- }
- });
-
- $MS.on("sessionChanged", (session) => {
- this.forAllContexts((context, settings) => {
- const { layer } = settings;
- if (!layer) return;
- if (!session?.items || !session?.items[layer])
- return $SD.setState(context, 0);
-
- const visible = session.items[layer].visible;
- this.layerState[context] = visible;
-
- this.setLocalState(context, visible);
- });
- });
- }
-}
-
-const toggleLayer = new ToggleLayer();
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-monitor/inspector.html b/co.meldstudio.streamdeck.sdPlugin/actions/toggle-monitor/inspector.html
deleted file mode 100644
index ed7007c..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-monitor/inspector.html
+++ /dev/null
@@ -1,54 +0,0 @@
-
-
-
-
-
-
- co.meldstudio.streamdeck.toggle-monitor Property Inspector
-
-
-
-
-
-
-
-
Action
-
- Toggle
- Cue
- Uncue
-
-
-
-
-
Connecting to Meld Studio...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-monitor/plugin.js b/co.meldstudio.streamdeck.sdPlugin/actions/toggle-monitor/plugin.js
deleted file mode 100644
index b2352fc..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-monitor/plugin.js
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright (c) 2024 Meld Studio, Inc.
-// Licensed under the MIT license. See LICENSE file in the project root for details.
-
-class ToggleMonitor extends MeldStudioPlugin {
- cueState = {};
-
- setLocalState(context, cued) {
- const state = cued ? 1 : 0;
- $SD.setState(context, state);
- }
-
- constructor() {
- super("co.meldstudio.streamdeck.toggle-monitor");
-
- this.action.onKeyUp(({ action, context, device, event, payload }) => {
- const { track, action: toggle_action } = this.getSettings(context);
- if (!track) return;
-
- if (!toggle_action || toggle_action === "toggle") {
- if ($MS.meld?.toggleMonitor) $MS.meld.toggleMonitor(track);
- } else {
- const action_cue = toggle_action === "cue";
- const state_cue = this.cueState[context];
-
- // cue | state | action
- // 1 | 0 | 1
- // 0 | 1 | 1
- // 1 | 1 | 0
- // 0 | 0 | 0
-
- if ($MS.meld?.setProperty) {
- $MS.meld.setProperty(track, "monitoring", action_cue);
- this.setLocalState(context, action_cue);
- } else {
- const shouldToggle = action_cue ^ state_cue;
- if (!shouldToggle) this.setLocalState(context, state_cue);
- if (shouldToggle && $MS.meld?.toggleMonitor)
- $MS.meld.toggleMonitor(track);
- }
- }
- });
-
- $MS.on("sessionChanged", (session) => {
- this.forAllContexts((context, settings) => {
- const { track, action } = settings;
- if (!track) return;
- if (!session.items[track]) return $SD.setState(context, 0);
-
- const cued = session.items[track].monitoring;
- this.cueState[context] = cued;
-
- this.setLocalState(context, cued);
- });
- });
- }
-}
-
-const toggleMonitor = new ToggleMonitor();
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-mute/inspector.html b/co.meldstudio.streamdeck.sdPlugin/actions/toggle-mute/inspector.html
deleted file mode 100644
index e2e61e1..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-mute/inspector.html
+++ /dev/null
@@ -1,54 +0,0 @@
-
-
-
-
-
-
- co.meldstudio.streamdeck.toggle-mute Property Inspector
-
-
-
-
-
-
-
-
Action
-
- Toggle
- Mute
- Unmute
-
-
-
-
-
Connecting to Meld Studio...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-mute/plugin.js b/co.meldstudio.streamdeck.sdPlugin/actions/toggle-mute/plugin.js
deleted file mode 100644
index 28cb768..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-mute/plugin.js
+++ /dev/null
@@ -1,60 +0,0 @@
-// Copyright (c) 2024 Meld Studio, Inc.
-// Licensed under the MIT license. See LICENSE file in the project root for details.
-
-class ToggleMute extends MeldStudioPlugin {
- muteState = {}
-
- setLocalState(context, muted) {
- const state = muted ? 0 : 1;
- $SD.setState(context, state);
- }
-
- constructor() {
- super("co.meldstudio.streamdeck.toggle-mute");
-
- this.action.onKeyUp(({ action, context, device, event, payload }) => {
- const { track, action: toggle_action } = this.getSettings(context);
- if (!track) return;
-
- if (!toggle_action || toggle_action === "toggle") {
- if ($MS.meld?.toggleMute) $MS.meld.toggleMute(track);
- } else {
- const action_mute = toggle_action === "mute";
- const state_mute = this.muteState[context];
-
- // mute | state | action
- // 1 | 0 | 1
- // 0 | 1 | 1
- // 1 | 1 | 0
- // 0 | 0 | 0
-
- if ($MS.meld?.setMuted) {
- $MS.meld.setMuted(track, action_mute);
- this.setLocalState(context, action_mute);
- } else if ($MS.meld?.setProperty) {
- $MS.meld.setProperty(track, "muted", action_mute);
- this.setLocalState(context, action_mute);
- } else {
- const shouldToggle = action_mute ^ state_mute;
- if (!shouldToggle) this.setLocalState(context, state_mute);
- if (shouldToggle && $MS.meld?.toggleMute) $MS.meld.toggleMute(track);
- }
- }
- });
-
- $MS.on("sessionChanged", (session) => {
- this.forAllContexts((context, settings) => {
- const { track, action } = settings;
- if (!track) return;
- if (!session.items[track]) return $SD.setState(context, 1);
-
- const muted = session.items[track].muted;
- this.muteState[context] = muted;
-
- this.setLocalState(context, muted);
- });
- });
- }
-}
-
-const toggleMute = new ToggleMute();
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-record/inspector.html b/co.meldstudio.streamdeck.sdPlugin/actions/toggle-record/inspector.html
deleted file mode 100644
index cd7413d..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-record/inspector.html
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
- co.meldstudio.streamdeck.toggle-record Property Inspector
-
-
-
-
-
-
-
Connecting to Meld Studio...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-record/plugin.js b/co.meldstudio.streamdeck.sdPlugin/actions/toggle-record/plugin.js
deleted file mode 100644
index 4848304..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-record/plugin.js
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright (c) 2024 Meld Studio, Inc.
-// Licensed under the MIT license. See LICENSE file in the project root for details.
-
-class ToggleRecording extends MeldStudioPlugin {
- constructor() {
- super("co.meldstudio.streamdeck.toggle-record");
-
- this.action.onKeyUp(({ action, context, device, event, payload }) => {
- if ($MS.meld?.toggleRecord) $MS.meld.toggleRecord();
- });
-
- $MS.on("isRecordingChanged", (recording) => {
- const state = recording ? 1 : 0;
- this.forAllContexts((context) => {
- $SD.setState(context, state);
- });
- });
- }
-}
-
-const toggleRecord = new ToggleRecording();
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-stream/inspector.html b/co.meldstudio.streamdeck.sdPlugin/actions/toggle-stream/inspector.html
deleted file mode 100644
index dee9d5c..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-stream/inspector.html
+++ /dev/null
@@ -1,32 +0,0 @@
-
-
-
-
-
-
- co.meldstudio.streamdeck.toggle-stream Property Inspector
-
-
-
-
-
-
-
Connecting to Meld Studio...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-stream/plugin.js b/co.meldstudio.streamdeck.sdPlugin/actions/toggle-stream/plugin.js
deleted file mode 100644
index f014235..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/toggle-stream/plugin.js
+++ /dev/null
@@ -1,21 +0,0 @@
-// Copyright (c) 2024 Meld Studio, Inc.
-// Licensed under the MIT license. See LICENSE file in the project root for details.
-
-class ToggleStreaming extends MeldStudioPlugin {
- constructor() {
- super("co.meldstudio.streamdeck.toggle-stream");
-
- this.action.onKeyUp(({ action, context, device, event, payload }) => {
- if ($MS.meld?.toggleStream) $MS.meld.toggleStream();
- });
-
- $MS.on("isStreamingChanged", (streaming) => {
- const state = streaming ? 1 : 0;
- this.forAllContexts((context) => {
- $SD.setState(context, state);
- });
- });
- }
-}
-
-const toggleStreaming = new ToggleStreaming();
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/transition-stepper/inspector.html b/co.meldstudio.streamdeck.sdPlugin/actions/transition-stepper/inspector.html
deleted file mode 100644
index 2efedf8..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/transition-stepper/inspector.html
+++ /dev/null
@@ -1,37 +0,0 @@
-
-
-
-
-
-
- co.meldstudio.streamdeck.transition-stepper Property Inspector
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/transition-stepper/plugin.js b/co.meldstudio.streamdeck.sdPlugin/actions/transition-stepper/plugin.js
deleted file mode 100644
index f96954a..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/transition-stepper/plugin.js
+++ /dev/null
@@ -1,78 +0,0 @@
-// Copyright (c) 2024 Meld Studio, Inc.
-// Licensed under the MIT license. See LICENSE file in the project root for details.
-
-class TransitionStepper extends MeldStudioPlugin {
- stagedScene = -1;
- availableScenes = [];
-
- constructor() {
- super("co.meldstudio.streamdeck.transition-stepper");
-
- this.action.onDialRotate(({ context, payload }) => {
- if (this.availableScenes.length === 0)
- return;
-
- const nextScene = () => {
- this.stagedScene = (this.stagedScene + payload.ticks) % this.availableScenes.length;
- if (this.stagedScene < 0) this.stagedScene += this.availableScenes.length;
- return this.availableScenes[this.stagedScene];
- };
-
- let item = nextScene();
- if (!item)
- item = nextScene();
-
- if ($MS.meld?.setStagedScene) $MS.meld?.setStagedScene(item.id);
- });
-
- this.action.onDialDown(() => {
- if (this.stagedScene === -1) return;
- if ($MS.meld?.showStagedScene) $MS.meld.showStagedScene();
- });
-
- this.action.onTouchTap(({ payload }) => {
- if (!payload.hold) return;
- if (this.stagedScene === -1) return;
- if ($MS.meld?.showStagedScene) $MS.meld.showStagedScene();
- });
-
- $MS.on("sessionChanged", (session) => {
- this.availableScenes = [];
-
- for(let key in session?.items) {
- const item = session?.items[key];
-
- if (item?.type !== "scene") continue;
- if (item?.name.indexOf("(linked)") !== -1) continue;
-
- if (item?.staged)
- this.stagedScene = item.index;
-
- if (!item?.current)
- this.availableScenes[item.index] = { name: item.name, id: key };
- }
-
- if (this.stagedScene !== -1) {
- const item = this.availableScenes[this.stagedScene];
- this.forAllContexts((context) => this.setSceneName(context, item?.name));
- }
- });
- }
-
- setSceneName(context, name) {
- $SD.setFeedback(context, {
- icon: "assets/Scene Transition Control/Action Icon/sceneTransition",
- title: name ?? "Nothing Staged",
- });
- }
-
- getNameForScene(sceneId) {
- const defaultName = "No Selection";
- if (!$MS?.meld?.session?.items) return defaultName;
-
- const name = $MS.meld.session.items[sceneId]?.name;
- return name ? name : defaultName;
- }
-}
-
-const transitionStepper = new TransitionStepper();
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/volume-stepper/inspector.html b/co.meldstudio.streamdeck.sdPlugin/actions/volume-stepper/inspector.html
deleted file mode 100644
index 0fa10ec..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/volume-stepper/inspector.html
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
- co.meldstudio.streamdeck.volume-stepper Property Inspector
-
-
-
-
-
-
-
-
-
-
Meter
-
- Percent
- Decibels
-
-
-
-
-
Connecting to Meld Studio...
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/volume-stepper/plugin.js b/co.meldstudio.streamdeck.sdPlugin/actions/volume-stepper/plugin.js
deleted file mode 100644
index ed7c535..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/volume-stepper/plugin.js
+++ /dev/null
@@ -1,270 +0,0 @@
-// Copyright (c) 2024 Meld Studio, Inc.
-// Licensed under the MIT license. See LICENSE file in the project root for details.
-
-function toDb(gain) {
- let dB = 20 * Math.log10(gain);
- if (!isFinite(dB)) {
- dB = -60;
- }
- return dB;
-}
-
-function toGain(dB) {
- if (!isFinite(dB) || dB < -60) dB = -60;
- let gain = Math.pow(10, dB / 20);
-
- gain = gain <= 0.001 ? 0 : gain;
- gain = gain > 1 ? 1 : gain;
- return gain;
-}
-
-class VolumeStepper extends MeldStudioPlugin {
- trackInfo = {};
- unregisterCallbacks = {};
-
- constructor() {
- super("co.meldstudio.streamdeck.volume-stepper");
-
- this.action.onKeyUp(({ action, context, device, event, payload }) => {
- const { track } = this.getSettings(context);
- if (!track) return;
-
- if ($MS.meld?.toggleMute) $MS.meld.toggleMute(track);
- });
-
- this.action.onDialRotate(({ context, payload }) => {
- const { track, stepsize: stepString, metertype } = this.getSettings(
- context
- );
- let use_percent = metertype === "percent";
-
- const stepsize = parseFloat(stepString);
- const info = this.trackInfo[track];
-
- if (!track) return;
-
- let offset = +stepsize * payload.ticks;
- let gain = info?.gain ?? 0.0;
-
- if (use_percent) {
- gain += 0.01 * offset; // scale to percent...
- gain = Math.min(gain, 1.0);
- gain = Math.max(gain, 0.0);
- } else {
- let dB = toDb(gain);
- dB += offset;
- gain = toGain(dB);
- }
-
- // Store the new gain until the callback fires.
- this.trackInfo[track] = { ...info, gain };
-
- if ($MS.meld?.setGain) $MS.meld?.setGain(track, gain);
- });
-
- this.action.onDialDown(({ context }) => {
- const { track } = this.getSettings(context);
- if (!track) return;
-
- if ($MS.meld?.toggleMute) $MS.meld.toggleMute(track);
- });
-
- this.action.onTouchTap(({ context, payload}) => {
- if (payload.hold) return;
-
- const { tapPos } = payload;
- const { track } = this.getSettings(context);
- if (!track) return;
-
- // Indicator starts at x=76 and is 108 pixels wide.
- if (tapPos[0] > 80) {
- if (tapPos[0] > 180) return;
-
- const info = this.trackInfo[track];
- const gain = (tapPos[0] - 80) / 100;
-
- // Store the new gain until the callback fires.
- this.trackInfo[track] = { ...info, gain };
-
- if ($MS.meld?.setGain) $MS.meld?.setGain(track, gain);
- } else {
- if ($MS.meld?.toggleMonitor) $MS.meld.toggleMonitor(track);
- }
- });
-
- this.action.onWillAppear(({ context, payload }) => {
- $SD.setFeedback(context, {
- icon:
- "assets/Audio Track/Mute_Unmute Audio Track/Action Icons/audioTrack",
- title: "",
- value: "",
- indicator: {
- value: 0,
- enabled: true,
- bar_bg_c: "0:#666666,1:#666666",
- },
- });
- });
-
- this.action.onDidReceiveSettings(({ context, payload }) => {
- this.setSettings(context, payload?.settings ?? {});
-
- if ($MS.ready) {
- this.onReady(context, payload?.settings);
- } else {
- // If we lose connection, we may need to reinitialize.
- $MS.on("ready", () => {
- this.onReady(context, payload?.settings);
- });
-
- this.setGainAndMute(context, payload.settings.metertype, {
- gain: 0.0,
- muted: true,
- monitoring: false,
- });
- }
- });
-
- $MS.on("sessionChanged", (session) => {
- this.forAllContexts((context, { track, metertype }) => {
- if (!track) return;
- if (!session?.items) return;
- if (!session?.items[track]) return;
-
- const { name, muted, monitoring } = session.items[track];
-
- this.trackInfo[track] = {
- ...this.trackInfo[track],
- name,
- muted,
- monitoring,
- };
-
- this.setGainAndMute(context, metertype, this.trackInfo[track]);
- });
- });
-
- $MS.on("gainChanged", ({ trackId, gain, muted }) => {
- let info = this.trackInfo[trackId];
- info = { ...info, gain, muted };
- this.trackInfo[trackId] = info;
-
- this.forAllContexts((context, { track, metertype }) => {
- if (!track || trackId != track) return;
- this.setGainAndMute(context, metertype, info);
- });
- });
-
- $MS.on("closed", () => {
- // reset all registrations.
- this.unregisterCallbacks = {};
- });
- }
-
- setGainAndMute(context, metertype, { gain, muted, name, monitoring }) {
- // meter colors -
- // green: #6DDE92
- // orange: #FB923C
- // red: #F04A4A
-
- let use_percent = metertype === "percent";
- let volume;
- let bar_colors;
- let bar_value;
-
- if (use_percent) {
- volume = `${parseInt(gain * 100)}%`;
- bar_value = gain;
- } else {
- const dB = toDb(gain);
- volume = dB === -60 ? "-∞ dB" : `${parseInt(Math.round(dB))} dB`;
- bar_value = (dB + 60) / 60;
- }
-
- const info = (() => {
- if (!muted) {
- if (gain > 0.4)
- return {
- icon:
- "assets/Audio Track/Mute_Unmute Audio Track/Action Icons/audioTrack",
- };
- if (gain > 0.0)
- return {
- icon:
- "assets/Audio Track/Mute_Unmute Audio Track/Action Icons/audioUnmuted40",
- };
- }
- return {
- icon:
- "assets/Audio Track/Mute_Unmute Audio Track/Action Icons/audioMute",
- };
- })();
-
- $SD.setFeedback(context, {
- ...info,
- title: name ?? "Adjust Volume",
- value: volume,
- indicator: {
- value: bar_value * 100,
- enabled: true,
- bar_bg_c: muted ? "0:#666666,1:#666666" : "0:#6DDE92,1:#6DDE92",
- },
- });
- }
-
- register(context, track) {
- console.assert($MS.meld);
-
- const callbackInfo = this.unregisterCallbacks[context];
- // Only register once.
- if (callbackInfo?.track === track) return;
-
- this.maybeUnregister(context, track);
- $MS.meld.registerTrackObserver(context, track);
-
- this.unregisterCallbacks[context] = {
- callback: () => {
- if ($MS.meld) $MS.meld.unregisterTrackObserver(context, track);
- },
- track,
- };
- }
-
- maybeUnregister(context) {
- const callbackInfo = this.unregisterCallbacks[context];
- if (!callbackInfo) return;
-
- callbackInfo.callback();
- this.unregisterCallbacks[context] = undefined;
- }
-
- getNameForTrack(track) {
- const defaultName = "Adjust Volume";
- if (!$MS?.meld?.session?.items) return defaultName;
-
- const name = $MS.meld.session.items[track]?.name;
- return name ? name : defaultName;
- }
-
- connectGain(context, metertype, track) {
- this.trackInfo[track] = {
- gain: 0.0,
- muted: false,
- name: this.getNameForTrack(track),
- };
-
- this.setGainAndMute(context, metertype, this.trackInfo[track]);
- this.register(context, track);
-
- this.action.onWillDisappear(({ context }) => {
- this.maybeUnregister(context);
- });
- }
-
- onReady(context, { track, metertype }) {
- console.assert($MS.ready);
- if (this.isEncoder(context)) this.connectGain(context, metertype, track);
- }
-}
-
-const volumeStepper = new VolumeStepper();
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/widget-control/inspector.html b/co.meldstudio.streamdeck.sdPlugin/actions/widget-control/inspector.html
deleted file mode 100644
index 55725d8..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/widget-control/inspector.html
+++ /dev/null
@@ -1,236 +0,0 @@
-
-
-
-
-
-
- co.meldstudio.streamdeck.widget-control Property Inspector
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/co.meldstudio.streamdeck.sdPlugin/actions/widget-control/plugin.js b/co.meldstudio.streamdeck.sdPlugin/actions/widget-control/plugin.js
deleted file mode 100644
index 30a6d80..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/actions/widget-control/plugin.js
+++ /dev/null
@@ -1,76 +0,0 @@
-// Copyright (c) 2024 Meld Studio, Inc.
-// Licensed under the MIT license. See LICENSE file in the project root for details.
-
-const ADD_TIME_EVENT = 'SUBATHONTIMER_ADDTIME'
-const DEFAULT_EVENT = 'STOPWATCH_RESET'
-
-const WIDGET_EVENT_ICONS = {
- STOPWATCH_RESET: 'assets/Widget/Key Icon/widget-stopwatch-reset',
- STOPWATCH_PAUSE: 'assets/Widget/Key Icon/widget-stopwatch-pause',
- STOPWATCH_RESUME: 'assets/Widget/Key Icon/widget-stopwatch-resume',
- COUNTDOWN_RESET: 'assets/Widget/Key Icon/widget-countdown-reset',
- COUNTDOWN_PAUSE: 'assets/Widget/Key Icon/widget-countdown-pause',
- COUNTDOWN_RESUME: 'assets/Widget/Key Icon/widget-countdown-resume',
- CONFETTIFALL_TRIGGER: 'assets/Widget/Key Icon/widget-confettifall-trigger',
- CONFETTIPOP_TRIGGER: 'assets/Widget/Key Icon/widget-confettipop-trigger',
- SUBATHONTIMER_RESET: 'assets/Widget/Key Icon/widget-subathontimer-reset',
- SUBATHONTIMER_PAUSE: 'assets/Widget/Key Icon/widget-subathontimer-pause',
- SUBATHONTIMER_RESUME: 'assets/Widget/Key Icon/widget-subathontimer-resume',
- SUBATHONTIMER_ADDTIME: 'assets/Widget/Key Icon/widget-subathontimer-addtime',
- COUNTER_INCREMENT: 'assets/Widget/Key Icon/widget-counter-increment',
- COUNTER_DECREMENT: 'assets/Widget/Key Icon/widget-counter-decrement'
-}
-
-class WidgetControl extends MeldStudioPlugin {
- constructor () {
- super('co.meldstudio.streamdeck.widget-control')
-
- this.action.onWillAppear(({ context }) => {
- const { event: eventType } = this.getSettings(context)
- this.setKeyImage(context, eventType)
- })
-
- this.action.onKeyUp(({ context }) => {
- const { event: eventTypeSetting, amount } = this.getSettings(context)
- const eventType = eventTypeSetting || DEFAULT_EVENT
- console.log(eventType)
-
- const payload = this.buildPayload(eventType, amount)
-
- if ($MS.meld?.sendStreamEvent) {
- if (payload === undefined) {
- $MS.meld.sendStreamEvent(eventType, false)
- } else {
- $MS.meld.sendStreamEvent(eventType, payload)
- }
- }
- })
- }
-
- onReceivedSettings (context, newSettings) {
- this.setKeyImage(context, newSettings?.event)
- }
-
- setKeyImage (context, eventType) {
- const selectedEvent = eventType || DEFAULT_EVENT
- const imagePath =
- WIDGET_EVENT_ICONS[selectedEvent] ||
- WIDGET_EVENT_ICONS[DEFAULT_EVENT] ||
- 'assets/Widget/Key Icon/widget-controls-pause'
-
- $SD.setImage(context, imagePath, 0)
- }
-
- buildPayload (eventType, amountSetting) {
- if (eventType !== ADD_TIME_EVENT) return undefined
-
- const parsedAmount = Number(amountSetting)
- const amount = Number.isFinite(parsedAmount)
- ? Math.max(0, Math.round(parsedAmount))
- : 0
-
- return { amount }
- }
-}
-
-const widgetControl = new WidgetControl()
diff --git a/co.meldstudio.streamdeck.sdPlugin/app.html b/co.meldstudio.streamdeck.sdPlugin/app.html
deleted file mode 100644
index a3942b3..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/app.html
+++ /dev/null
@@ -1,49 +0,0 @@
-
-
-
-
-
- co.meldstudio.streamdeck
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/co.meldstudio.streamdeck.sdPlugin/app.js b/co.meldstudio.streamdeck.sdPlugin/app.js
deleted file mode 100644
index 6573872..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/app.js
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (c) 2024 Meld Studio, Inc.
-// Licensed under the MIT license. See LICENSE file in the project root for details.
-
-let settingsCache = {};
-
-/**
- * The first event fired when Stream Deck starts
- */
-$SD.onConnected(
- ({ actionInfo, appInfo, connection, messageType, port, uuid }) => {
- console.log("Stream Deck connected!");
- }
-);
-
-$MS.on("connected", () => {
- console.log("Connect Callback.");
-});
-
-$MS.on("ready", () => {
- console.log("Session Ready.");
-});
-
-$MS.on("closed", () => {
- console.log("Closed Callback.");
-});
\ No newline at end of file
diff --git a/co.meldstudio.streamdeck.sdPlugin/libs/css/sdpi.css b/co.meldstudio.streamdeck.sdPlugin/libs/css/sdpi.css
index 01802ef..56a19c0 100644
--- a/co.meldstudio.streamdeck.sdPlugin/libs/css/sdpi.css
+++ b/co.meldstudio.streamdeck.sdPlugin/libs/css/sdpi.css
@@ -1706,12 +1706,22 @@ input[type="range"].colortemperature::-webkit-slider-thumb {
/* adding some styles here that override sdpi things so we can use this as notes for sdpi updates*/
select {
min-width: 0px;
-
- /* this is a clunky fix for using background image as select arrow with long text options */
- -webkit-appearance: media-slider;
+ -webkit-appearance: none;
+ appearance: none;
+ background-image: url(../assets/caret.svg);
+ background-repeat: no-repeat;
+ background-position: right 6px center;
+ background-size: 12px 6px;
+ padding-right: 24px !important;
text-overflow: ellipsis;
}
+select.no-selection,
+select option.no-selection-option {
+ color: #8E8E92;
+ font-style: italic;
+}
+
/*--------- context menu ----------*/
.context-menu {
diff --git a/co.meldstudio.streamdeck.sdPlugin/libs/js/action.js b/co.meldstudio.streamdeck.sdPlugin/libs/js/action.js
deleted file mode 100644
index 78fc1ed..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/libs/js/action.js
+++ /dev/null
@@ -1,188 +0,0 @@
-///
-
-/**
- * @class Action
- * A Stream Deck plugin action, where you can register callback functions for different events
- */
-class ELGSDAction {
- UUID;
- on = EventEmitter.on;
- emit = EventEmitter.emit;
-
- constructor(UUID) {
- if (!UUID) {
- console.error(
- 'An action UUID matching the action UUID in your manifest is required when creating Actions.'
- );
- }
-
- this.UUID = UUID;
- }
-
- /**
- * Registers a callback function for the didReceiveSettings event, which fires when calling getSettings
- * @param {function} fn
- */
- onDidReceiveSettings(fn) {
- if (!fn) {
- console.error(
- 'A callback function for the didReceiveSettings event is required for onDidReceiveSettings.'
- );
- }
-
- this.on(`${this.UUID}.${Events.didReceiveSettings}`, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Registers a callback function for the keyDown event, which fires when pressing a key down
- * @param {function} fn
- */
- onKeyDown(fn) {
- if (!fn) {
- console.error('A callback function for the keyDown event is required for onKeyDown.');
- }
-
- this.on(`${this.UUID}.${Events.keyDown}`, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Registers a callback function for the keyUp event, which fires when releasing a key
- * @param {function} fn
- */
- onKeyUp(fn) {
- if (!fn) {
- console.error('A callback function for the keyUp event is required for onKeyUp.');
- }
-
- this.on(`${this.UUID}.${Events.keyUp}`, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Registers a callback function for the willAppear event, which fires when an action appears on the canvas
- * @param {function} fn
- */
- onWillAppear(fn) {
- if (!fn) {
- console.error('A callback function for the willAppear event is required for onWillAppear.');
- }
-
- this.on(`${this.UUID}.${Events.willAppear}`, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Registers a callback function for the willAppear event, which fires when an action disappears on the canvas
- * @param {function} fn
- */
- onWillDisappear(fn) {
- if (!fn) {
- console.error(
- 'A callback function for the willDisappear event is required for onWillDisappear.'
- );
- }
-
- this.on(`${this.UUID}.${Events.willDisappear}`, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Registers a callback function for the titleParametersDidChange event, which fires when a user changes the key title
- * @param {function} fn
- */
- onTitleParametersDidChange(fn) {
- if (!fn) {
- console.error(
- 'A callback function for the titleParametersDidChange event is required for onTitleParametersDidChange.'
- );
- }
-
- this.on(`${this.UUID}.${Events.titleParametersDidChange}`, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Registers a callback function for the propertyInspectorDidAppear event, which fires when the property inspector is displayed
- * @param {function} fn
- */
- onPropertyInspectorDidAppear(fn) {
- if (!fn) {
- console.error(
- 'A callback function for the propertyInspectorDidAppear event is required for onPropertyInspectorDidAppear.'
- );
- }
-
- this.on(`${this.UUID}.${Events.propertyInspectorDidAppear}`, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Registers a callback function for the propertyInspectorDidDisappear event, which fires when the property inspector is closed
- * @param {function} fn
- */
- onPropertyInspectorDidDisappear(fn) {
- if (!fn) {
- console.error(
- 'A callback function for the propertyInspectorDidDisappear event is required for onPropertyInspectorDidDisappear.'
- );
- }
-
- this.on(`${this.UUID}.${Events.propertyInspectorDidDisappear}`, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Registers a callback function for the sendToPlugin event, which fires when the property inspector uses the sendToPlugin api
- * @param {function} fn
- */
- onSendToPlugin(fn) {
- if (!fn) {
- console.error(
- 'A callback function for the sendToPlugin event is required for onSendToPlugin.'
- );
- }
-
- this.on(`${this.UUID}.${Events.sendToPlugin}`, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Registers a callback function for the onDialRotate event, which fires when a SD+ dial was rotated
- * @param {function} fn
- */
- onDialRotate(fn) {
- if (!fn) {
- console.error('A callback function for the onDialRotate event is required for onDialRotate.');
- }
- this.on(`${this.UUID}.${Events.dialRotate}`, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Registers a callback function for the onDialDown event, which fires when a SD+ dial was pressed or released
- * @param {function} fn
- */
- onDialDown(fn) {
- if (!fn) {
- console.error('A callback function for the onDialDown event is required for onDialDown.');
- }
- this.on(`${this.UUID}.${Events.dialDown}`, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Registers a callback function for the touchTap event, which fires when a SD+ touch panel was touched quickly
- * @param {function} fn
- */
- onTouchTap(fn) {
- if (!fn) {
- console.error('A callback function for the onTouchTap event is required for onTouchTap.');
- }
- this.on(`${this.UUID}.${Events.touchTap}`, (jsn) => fn(jsn));
- return this;
- }
-}
-
-const Action = ELGSDAction;
diff --git a/co.meldstudio.streamdeck.sdPlugin/libs/js/api.js b/co.meldstudio.streamdeck.sdPlugin/libs/js/api.js
deleted file mode 100644
index 9d5edad..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/libs/js/api.js
+++ /dev/null
@@ -1,349 +0,0 @@
-///
-
-class ELGSDPlugin {
- #data = {};
- #language = 'en';
- #localization;
- test = new Set();
- on = EventEmitter.on;
- emit = EventEmitter.emit;
-
- localizationLoaded = false;
- constructor () {
- // super();
- if(ELGSDPlugin.__instance) {
- return ELGSDPlugin.__instance;
- }
-
- ELGSDPlugin.__instance = this;
- const pathArr = location.pathname.split("/");
- const idx = pathArr.findIndex(f => f.endsWith('sdPlugin'));
- this.#data.__filename = pathArr[pathArr.length - 1];
- this.#data.__dirname = pathArr[pathArr.length - 2];
- this.#data.__folderpath = `${pathArr.slice(0, idx + 1).join("/")}/`;
- this.#data.__folderroot = `${pathArr.slice(0, idx).join("/")}/`;
- this.#data.__parentdir = `${pathArr.slice(0, idx-1).join("/")}/`;
- }
-
- set language(value) {
- this.#language = value;
- this.loadLocalization(this.#data.__folderpath).then(l => {
- this.emit('languageChanged', value);
- });
- }
-
- get language() {
- return this.#language;
- }
-
- set localization(value) {
- this.#localization = value;
- this.localizeUI();
- this.emit('localizationChanged', value);
- }
-
- get localization() {
- return this.#localization;
- }
-
- get __filename() {
- return this.#data.__filename;
- }
-
- get __dirname() {
- return this.#data.__dirname;
- }
-
- get __folderpath() {
- return this.#data.__folderpath;
- }
- get __folderroot() {
- return this.#data.__folderroot;
- }
- get data() {
- return this.#data;
- }
-
- /**
- * Finds the original key of the string value
- * Note: This is used by the localization UI to find the original key (not used here)
- * @param {string} str
- * @returns {string}
- */
-
- localizedString(str) {
- return Object.keys(this.localization).find(e => e == str);
- }
-
- /**
- * Returns the localized string or the original string if not found
- * @param {string} str
- * @returns {string}
- */
-
- localize(s) {
- if(typeof s === 'undefined') return '';
- let str = String(s);
- try {
- str = this.localization[str] || str;
- } catch(b) {}
- return str;
- };
-
- /**
- * Searches the document tree to find elements with data-localize attributes
- * and replaces their values with the localized string
- * @returns {}
- */
-
- localizeUI = () => {
- const el = document.querySelector('.sdpi-wrapper');
- if(!el) return console.warn("No element found to localize");
- const selectorsList = '[data-localize]';
- // see if we have any data-localize attributes
- // that means we can skip the rest of the DOM
- el.querySelectorAll(selectorsList).forEach(e => {
- const s = e.innerText.trim();
- e.innerHTML = e.innerHTML.replace(s, this.localize(s));
- if(e.placeholder && e.placeholder.length) {
- e.placeholder = this.localize(e.placeholder);
- }
- if(e.title && e.title.length) {
- e.title = this.localize(e.title);
- }
- });
- };
- /**
- * Fetches the specified language json file
- * @param {string} pathPrefix
- * @returns {Promise}
- */
-async loadLocalization(pathPrefix) {
- if (!pathPrefix) {
- pathPrefix = this.#data.__folderpath;
- }
- // here we save the promise to the JSON-reader result,
- // which we can later re-use to see, if the strings are already loaded
- this.localizationLoaded = this.readJson(`${pathPrefix}${this.language}.json`);
- const manifest = await this.localizationLoaded;
- this.localization = manifest['Localization'] ?? null;
- window.$localizedStrings = this.localization;
- this.emit('localizationLoaded', this.localization);
-
- return this.localization;
-}
-
- /**
- *
- * @param {string} path
- * @returns {Promise} json
- */
- async readJson(path) {
- if(!path) {
- console.error('A path is required to readJson.');
- }
-
- return new Promise((resolve, reject) => {
- const req = new XMLHttpRequest();
- req.onerror = reject;
- req.overrideMimeType('application/json');
- req.open('GET', path, true);
- req.onreadystatechange = (response) => {
- if(req.readyState === 4) {
- const jsonString = response?.target?.response;
- if(jsonString) {
- resolve(JSON.parse(response?.target?.response));
- } else {
- reject();
- }
- }
- };
-
- req.send();
- });
- }
-}
-
-class ELGSDApi extends ELGSDPlugin {
- port;
- uuid;
- messageType;
- actionInfo;
- websocket;
- appInfo;
- #data = {};
-
- /**
- * Connect to Stream Deck
- * @param {string} port
- * @param {string} uuid
- * @param {string} messageType
- * @param {string} appInfoString
- * @param {string} actionString
- */
- connect(port, uuid, messageType, appInfoString, actionString) {
- this.port = port;
- this.uuid = uuid;
- this.messageType = messageType;
- this.actionInfo = actionString ? JSON.parse(actionString) : null;
- this.appInfo = JSON.parse(appInfoString);
- this.language = this.appInfo?.application?.language ?? null;
-
- if(this.websocket) {
- this.websocket.close();
- this.websocket = null;
- }
-
- this.websocket = new WebSocket('ws://127.0.0.1:' + this.port);
-
- this.websocket.onopen = () => {
- const json = {
- event: this.messageType,
- uuid: this.uuid,
- };
-
- this.websocket.send(JSON.stringify(json));
-
- this.emit(Events.connected, {
- connection: this.websocket,
- port: this.port,
- uuid: this.uuid,
- actionInfo: this.actionInfo,
- appInfo: this.appInfo,
- messageType: this.messageType,
- });
- };
-
- this.websocket.onerror = (evt) => {
- const error = `WEBSOCKET ERROR: ${evt}, ${evt.data}, ${SocketErrors[evt?.code]}`;
- console.warn(error);
- this.logMessage(error);
- };
-
- this.websocket.onclose = (evt) => {
- console.warn('WEBSOCKET CLOSED:', SocketErrors[evt?.code]);
- };
-
- this.websocket.onmessage = (evt) => {
- const data = evt?.data ? JSON.parse(evt.data) : null;
-
- const {action, event} = data;
- const message = action ? `${action}.${event}` : event;
- if(message && message !== '') this.emit(message, data);
- };
- }
-
- /**
- * Write to log file
- * @param {string} message
- */
- logMessage(message) {
- if(!message) {
- console.error('A message is required for logMessage.');
- }
-
- try {
- if(this.websocket) {
- const json = {
- event: Events.logMessage,
- payload: {
- message: message,
- },
- };
- this.websocket.send(JSON.stringify(json));
- } else {
- console.error('Websocket not defined');
- }
- } catch(e) {
- console.error('Websocket not defined');
- }
- }
-
- /**
- * Send JSON payload to StreamDeck
- * @param {string} context
- * @param {string} event
- * @param {object} [payload]
- */
- send(context, event, payload = {}) {
- this.websocket && this.websocket.send(JSON.stringify({context, event, ...payload}));
- }
-
- /**
- * Save the plugin's persistent data
- * @param {object} payload
- */
- setGlobalSettings(payload) {
- this.send(this.uuid, Events.setGlobalSettings, {
- payload: payload,
- });
- }
-
- /**
- * Request the plugin's persistent data. StreamDeck does not return the data, but trigger the plugin/property inspectors didReceiveGlobalSettings event
- */
- getGlobalSettings() {
- this.send(this.uuid, Events.getGlobalSettings);
- }
-
- /**
- * Opens a URL in the default web browser
- * @param {string} url
- */
- openUrl(url) {
- if(!url) {
- console.error('A url is required for openUrl.');
- }
-
- this.send(this.uuid, Events.openUrl, {
- payload: {
- url,
- },
- });
- }
-
- /**
- * Registers a callback function for when Stream Deck is connected
- * @param {function} fn
- * @returns ELGSDStreamDeck
- */
- onConnected(fn) {
- if(!fn) {
- console.error('A callback function for the connected event is required for onConnected.');
- }
-
- this.on(Events.connected, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Registers a callback function for the didReceiveGlobalSettings event, which fires when calling getGlobalSettings
- * @param {function} fn
- */
- onDidReceiveGlobalSettings(fn) {
- if(!fn) {
- console.error(
- 'A callback function for the didReceiveGlobalSettings event is required for onDidReceiveGlobalSettings.'
- );
- }
-
- this.on(Events.didReceiveGlobalSettings, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Registers a callback function for the didReceiveSettings event, which fires when calling getSettings
- * @param {string} action
- * @param {function} fn
- */
- onDidReceiveSettings(action, fn) {
- if(!fn) {
- console.error(
- 'A callback function for the didReceiveSettings event is required for onDidReceiveSettings.'
- );
- }
-
- this.on(`${action}.${Events.didReceiveSettings}`, (jsn) => fn(jsn));
- return this;
- }
-}
diff --git a/co.meldstudio.streamdeck.sdPlugin/libs/js/constants.js b/co.meldstudio.streamdeck.sdPlugin/libs/js/constants.js
deleted file mode 100644
index 59f0bec..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/libs/js/constants.js
+++ /dev/null
@@ -1,81 +0,0 @@
-/**
- * Errors received from WebSocket
- */
-const SocketErrors = {
- 0: 'The connection has not yet been established',
- 1: 'The connection is established and communication is possible',
- 2: 'The connection is going through the closing handshake',
- 3: 'The connection has been closed or could not be opened',
- 1000: 'Normal Closure. The purpose for which the connection was established has been fulfilled.',
- 1001: 'Going Away. An endpoint is "going away", such as a server going down or a browser having navigated away from a page.',
- 1002: 'Protocol error. An endpoint is terminating the connection due to a protocol error',
- 1003: "Unsupported Data. An endpoint received a type of data it doesn't support.",
- 1004: '--Reserved--. The specific meaning might be defined in the future.',
- 1005: 'No Status. No status code was actually present.',
- 1006: 'Abnormal Closure. The connection was closed abnormally, e.g., without sending or receiving a Close control frame',
- 1007: 'Invalid frame payload data. The connection was closed, because the received data was not consistent with the type of the message (e.g., non-UTF-8 [http://tools.ietf.org/html/rfc3629]).',
- 1008: 'Policy Violation. The connection was closed, because current message data "violates its policy". This reason is given either if there is no other suitable reason, or if there is a need to hide specific details about the policy.',
- 1009: 'Message Too Big. Connection closed because the message is too big for it to process.',
- 1010: "Mandatory Extension. Connection is terminated the connection because the server didn't negotiate one or more extensions in the WebSocket handshake.",
- 1011: 'Internl Server Error. Connection closed because it encountered an unexpected condition that prevented it from fulfilling the request.',
- 1015: "TLS Handshake. The connection was closed due to a failure to perform a TLS handshake (e.g., the server certificate can't be verified).",
-};
-
-/**
- * Events used for communicating with Stream Deck
- */
-const Events = {
- didReceiveSettings: 'didReceiveSettings',
- didReceiveGlobalSettings: 'didReceiveGlobalSettings',
- keyDown: 'keyDown',
- keyUp: 'keyUp',
- willAppear: 'willAppear',
- willDisappear: 'willDisappear',
- titleParametersDidChange: 'titleParametersDidChange',
- deviceDidConnect: 'deviceDidConnect',
- deviceDidDisconnect: 'deviceDidDisconnect',
- applicationDidLaunch: 'applicationDidLaunch',
- applicationDidTerminate: 'applicationDidTerminate',
- systemDidWakeUp: 'systemDidWakeUp',
- propertyInspectorDidAppear: 'propertyInspectorDidAppear',
- propertyInspectorDidDisappear: 'propertyInspectorDidDisappear',
- sendToPlugin: 'sendToPlugin',
- sendToPropertyInspector: 'sendToPropertyInspector',
- connected: 'connected',
- setImage: 'setImage',
- setXYWHImage: 'setXYWHImage',
- setTitle: 'setTitle',
- setState: 'setState',
- showOk: 'showOk',
- showAlert: 'showAlert',
- openUrl: 'openUrl',
- setGlobalSettings: 'setGlobalSettings',
- getGlobalSettings: 'getGlobalSettings',
- setSettings: 'setSettings',
- getSettings: 'getSettings',
- registerPropertyInspector: 'registerPropertyInspector',
- registerPlugin: 'registerPlugin',
- logMessage: 'logMessage',
- switchToProfile: 'switchToProfile',
- dialRotate: 'dialRotate',
- dialDown: 'dialDown',
- touchTap: 'touchTap',
- setFeedback: 'setFeedback',
- setFeedbackLayout: 'setFeedbackLayout',
-};
-
-/**
- * Constants used for Stream Deck
- */
-const Constants = {
- dataLocalize: '[data-localize]',
- hardwareAndSoftware: 0,
- hardwareOnly: 1,
- softwareOnly: 2,
-};
-
-const DestinationEnum = {
- HARDWARE_AND_SOFTWARE: 0,
- HARDWARE_ONLY: 1,
- SOFTWARE_ONLY: 2,
-};
diff --git a/co.meldstudio.streamdeck.sdPlugin/libs/js/dynamic-styles.js b/co.meldstudio.streamdeck.sdPlugin/libs/js/dynamic-styles.js
deleted file mode 100644
index 901c4ce..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/libs/js/dynamic-styles.js
+++ /dev/null
@@ -1,70 +0,0 @@
-const fadeColor = function (col, amt) {
- const min = Math.min,
- max = Math.max;
- const num = parseInt(col.replace(/#/g, ''), 16);
- const r = min(255, max((num >> 16) + amt, 0));
- const g = min(255, max((num & 0x0000ff) + amt, 0));
- const b = min(255, max(((num >> 8) & 0x00ff) + amt, 0));
- return '#' + (g | (b << 8) | (r << 16)).toString(16).padStart(6, 0);
-};
-
-$PI.onConnected(({appInfo}) => {
- if (!appInfo?.colors) return;
- const clrs = appInfo.colors;
- const node = document.getElementById('#sdpi-dynamic-styles') || document.createElement('style');
- if (!clrs.mouseDownColor) clrs.mouseDownColor = fadeColor(clrs.highlightColor, -100);
- const clr = clrs.highlightColor.slice(0, 7);
- const clr1 = fadeColor(clr, 100);
- const clr2 = fadeColor(clr, 60);
- const metersActiveColor = fadeColor(clr, -60);
-
- node.setAttribute('id', 'sdpi-dynamic-styles');
- node.innerHTML = `
-
- input[type="radio"]:checked + label span,
- input[type="checkbox"]:checked + label span {
- background-color: ${clrs.highlightColor};
- }
-
- input[type="radio"]:active:checked + label span,
- input[type="checkbox"]:active:checked + label span {
- background-color: ${clrs.mouseDownColor};
- }
-
- input[type="radio"]:active + label span,
- input[type="checkbox"]:active + label span {
- background-color: ${clrs.buttonPressedBorderColor};
- }
-
- td.selected,
- td.selected:hover,
- li.selected:hover,
- li.selected {
- color: white;
- background-color: ${clrs.highlightColor};
- }
-
- .sdpi-file-label > label:active,
- .sdpi-file-label.file:active,
- label.sdpi-file-label:active,
- label.sdpi-file-info:active,
- input[type="file"]::-webkit-file-upload-button:active,
- button:active {
- border: 1pt solid ${clrs.buttonPressedBorderColor};
- background-color: ${clrs.buttonPressedBackgroundColor};
- color: ${clrs.buttonPressedTextColor};
- border-color: ${clrs.buttonPressedBorderColor};
- }
-
- ::-webkit-progress-value,
- meter::-webkit-meter-optimum-value {
- background: linear-gradient(${clr2}, ${clr1} 20%, ${clr} 45%, ${clr} 55%, ${clr2})
- }
-
- ::-webkit-progress-value:active,
- meter::-webkit-meter-optimum-value:active {
- background: linear-gradient(${clr}, ${clr2} 20%, ${metersActiveColor} 45%, ${metersActiveColor} 55%, ${clr})
- }
- `;
- document.body.appendChild(node);
-});
diff --git a/co.meldstudio.streamdeck.sdPlugin/libs/js/events.js b/co.meldstudio.streamdeck.sdPlugin/libs/js/events.js
deleted file mode 100644
index cc18825..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/libs/js/events.js
+++ /dev/null
@@ -1,38 +0,0 @@
-/** ELGEvents
- * Publish/Subscribe pattern to quickly signal events to
- * the plugin, property inspector and data.
- */
-
-const ELGEvents = {
- eventEmitter: function (name, fn) {
- const eventList = new Map();
-
- const on = (name, fn) => {
- if (!eventList.has(name)) eventList.set(name, ELGEvents.pubSub());
-
- return eventList.get(name).sub(fn);
- };
-
- const has = name => eventList.has(name);
-
- const emit = (name, data) => eventList.has(name) && eventList.get(name).pub(data);
-
- return Object.freeze({on, has, emit, eventList});
- },
-
- pubSub: function pubSub() {
- const subscribers = new Set();
-
- const sub = fn => {
- subscribers.add(fn);
- return () => {
- subscribers.delete(fn);
- };
- };
-
- const pub = data => subscribers.forEach(fn => fn(data));
- return Object.freeze({pub, sub});
- }
-};
-
-const EventEmitter = ELGEvents.eventEmitter();
\ No newline at end of file
diff --git a/co.meldstudio.streamdeck.sdPlugin/libs/js/inspector.js b/co.meldstudio.streamdeck.sdPlugin/libs/js/inspector.js
deleted file mode 100644
index 70703ca..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/libs/js/inspector.js
+++ /dev/null
@@ -1,166 +0,0 @@
-// Copyright (c) 2024 Meld Studio, Inc.
-// Licensed under the MIT license. See LICENSE file in the project root for details.
-
-class MeldStudioPropertyInspector {
- settings = {}
- callbacks = {}
-
- createOption (opt) {
- const item = document.createElement('option')
-
- item.innerHTML = opt.text
- item.value = opt.value
- item.selected = opt.selected
-
- return item
- }
-
- // Get all items of a specific type.
-
- getItemsByType (type, currentValue, parent, keep_filter) {
- const items = $MS.meld?.session?.items ?? {}
-
- let selectedItems = [
- {
- text: '[No Selection]',
- value: '',
- selected: currentValue == ''
- }
- ]
-
- for (let key in items) {
- const item = items[key]
-
- if (keep_filter && !keep_filter(type, item)) continue
-
- if (item.type != type) continue
- if (parent && item.parent != parent) continue
-
- selectedItems.push({
- text: item.name,
- value: key,
- selected: currentValue == key
- })
- }
-
- return selectedItems
- }
-
- getValue (key) {
- return this.settings[key] ?? ''
- }
-
- // Iterates over the items in the session and filters down
- // each pass to the current type, filtered by the keep_filter function is provided.
-
- updateSelection (elements, keep_filter) {
- if (!this.callbacks[elements]) {
- this.callbacks[elements] = $MS.on('sessionChanged', session => {
- this.updateSelection(elements)
- })
- }
-
- if (!$MS.meld) {
- $MS.on('ready', () => {
- this.updateSelection(elements)
- })
- return
- }
-
- let last = ''
- for (let elid of elements) {
- const value = this.getValue(elid)
- const element = document.getElementById(elid)
-
- let available = this.getItemsByType(elid, value, last, keep_filter)
-
- element.innerHTML = ''
- element.disabled = false
-
- for (let opt of available) {
- element.appendChild(this.createOption(opt))
- }
-
- last = value
- }
- }
-
- initializeSelection (action, elements, data_provided = true, keep_filter = null) {
- for (let id of elements) {
- const el = document.getElementById(id)
- console.assert(el.id, 'Select element not found')
- if (!el) continue
-
- if (this.settings[el.id] === undefined) this.settings[el.id] = ''
-
- el.onchange = () => {
- if (!this.settings) return
- this.settings = { ...this.settings, [id]: el.value }
- $PI.setSettings(this.settings)
- if (data_provided) this.updateSelection(elements, keep_filter)
- }
- }
-
- $PI.onDidReceiveSettings(action, ({ action, payload }) => {
- const { settings } = payload
- this.settings = settings
-
- for (let field of elements) {
- const dom_field = document.getElementById(field)
-
- if (settings[field] === undefined) continue
-
- dom_field.value = settings[field]
- }
-
- if (data_provided) this.updateSelection(elements, keep_filter)
- })
-
- $PI.getSettings()
- }
-
- initializeText (action, elements) {
- for (let id of elements) {
- const el = document.getElementById(id)
- console.assert(el.id, 'Input element not found')
- if (!el) continue
-
- this.settings[el.id] = ''
-
- el.onchange = () => {
- if (!this.settings) return
- this.settings = { ...this.settings, [id]: el.value }
- $PI.setSettings(this.settings)
- }
- }
-
- $PI.onDidReceiveSettings(action, ({ action, payload }) => {
- const { settings } = payload
- this.settings = settings
-
- for (let field of elements) {
- const dom_field = document.getElementById(field)
- if (settings[field] !== undefined) {
- dom_field.value = settings[field]
- } else {
- // Use the default value specified by the field.
- this.settings[field] = dom_field.value
- }
- }
- })
- }
-
- watchConnections (id) {
- $MS.on('connected', () => {
- document.getElementById(id).style = 'display: none;'
- })
-
- $MS.on('closed', () => {
- document.getElementById(id).style = ''
- })
-
- if ($MS.connected) document.getElementById(id).style = 'display: none;'
- }
-}
-
-const $MSPI = new MeldStudioPropertyInspector()
diff --git a/co.meldstudio.streamdeck.sdPlugin/libs/js/meldstudio.js b/co.meldstudio.streamdeck.sdPlugin/libs/js/meldstudio.js
deleted file mode 100644
index 2f2b627..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/libs/js/meldstudio.js
+++ /dev/null
@@ -1,143 +0,0 @@
-// Copyright (c) 2024 Meld Studio, Inc.
-// Licensed under the MIT license. See LICENSE file in the project root for details.
-
-const MeldStudioEventEmitter = ELGEvents.eventEmitter()
-
-class MeldStudio {
- on = MeldStudioEventEmitter.on
- emit = MeldStudioEventEmitter.emit
-
- connected = false
- ready = false
-
- constructor () {
- this.connected = false
- this.initConnection()
- }
-
- initConnection () {
- const address = '127.0.0.1'
- const port = 13376
-
- this.socket = new WebSocket(`ws://${address}:${port}`)
-
- this.socket.onopen = () => {
- console.log('MeldStudio Connected!')
-
- this.connected = true
- this.emit('connected')
-
- this.channel = new QWebChannel(this.socket, channel => {
- this.meld = channel.objects.meld
-
- this.meld.sessionChanged.connect(() => {
- console.log('Session Changed')
- this.emit('sessionChanged', this.meld.session)
- })
-
- this.meld.isStreamingChanged.connect(() => {
- console.log('Streaming Changed')
- this.emit('isStreamingChanged', this.meld.isStreaming)
- })
-
- this.meld.isRecordingChanged.connect(() => {
- console.log('Recording Changed')
- this.emit('isRecordingChanged', this.meld.isRecording)
- })
-
- this.meld.gainUpdated.connect((trackId, gain, muted) => {
- this.emit('gainChanged', { trackId, gain, muted })
- })
-
- this.ready = true
- this.emit('ready')
- this.emit('sessionChanged', this.meld.session)
- this.emit('isStreamingChanged', this.meld.isStreaming)
- this.emit('isRecordingChanged', this.meld.isRecording)
- })
- }
-
- this.socket.onclose = () => {
- this.ready = false
- this.connected = false
- this.meld = {}
-
- this.emit('closed')
-
- setTimeout(() => {
- this.initConnection()
- }, 1000)
- }
- }
-}
-
-const $MS = new MeldStudio()
-
-class MeldStudioPlugin {
- action
- contexts = {}
-
- constructor (UUID) {
- this.action = new Action(UUID)
-
- this.action.onWillAppear(({ context, payload }) => {
- this.contexts[context] = {
- isEncoder: payload.controller == 'Encoder',
- settings: {}
- }
- $SD.getSettings(context)
- })
-
- this.action.onDidReceiveSettings(({ context, payload }) => {
- const oldSettings = this.getSettings(context)
- this.setSettings(context, payload?.settings ?? {})
-
- if ($MS.ready) {
- this.onReady(context, payload?.settings, oldSettings)
- }
-
- // If we lose connection, we may need to reinitialize.
- $MS.on('ready', () => {
- this.onReady(context, payload?.settings, oldSettings)
- })
-
- this.onReceivedSettings(context, payload?.settings, oldSettings)
- })
-
- $MS.on('sessionChanged', session => {
- this.onSessionChanged(session)
- })
- }
-
- onReceivedSettings (context, newSettings, oldSettings) {}
-
- onSessionChanged (session) {}
-
- onReady () {
- if (!$MS.meld?.session) return
- this.onSessionChanged($MS.meld.session)
- }
-
- setSettings (context, settings) {
- this.contexts[context].settings = settings
- }
-
- isEncoder (context) {
- return this.contexts[context].isEncoder
- }
-
- getSettings (context) {
- return this.contexts[context].settings
- }
-
- updateState (context, state) {
- $SD.setImage(context, state.image, 0)
- $SD.setTitle(context, state.title)
- }
-
- forAllContexts (fn) {
- for (const context in this.contexts) {
- fn(context, this.getSettings(context))
- }
- }
-}
diff --git a/co.meldstudio.streamdeck.sdPlugin/libs/js/property-inspector.js b/co.meldstudio.streamdeck.sdPlugin/libs/js/property-inspector.js
deleted file mode 100644
index d8b6705..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/libs/js/property-inspector.js
+++ /dev/null
@@ -1,82 +0,0 @@
-///
-///
-
-class ELGSDPropertyInspector extends ELGSDApi {
- constructor() {
- super();
- if (ELGSDPropertyInspector.__instance) {
- return ELGSDPropertyInspector.__instance;
- }
-
- ELGSDPropertyInspector.__instance = this;
- }
-
- /**
- * Registers a callback function for when Stream Deck sends data to the property inspector
- * @param {string} actionUUID
- * @param {function} fn
- * @returns ELGSDStreamDeck
- */
- onSendToPropertyInspector(actionUUID, fn) {
- if (typeof actionUUID != 'string') {
- console.error('An action UUID string is required for onSendToPropertyInspector.');
- }
-
- if (!fn) {
- console.error(
- 'A callback function for the sendToPropertyInspector event is required for onSendToPropertyInspector.'
- );
- }
-
- this.on(`${actionUUID}.${Events.sendToPropertyInspector}`, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Send payload from the property inspector to the plugin
- * @param {object} payload
- */
- sendToPlugin(payload) {
- this.send(this.uuid, Events.sendToPlugin, {
- action: this?.actionInfo?.action,
- payload: payload || null,
- });
- }
-
- /**
- * Save the actions's persistent data.
- * @param {object} payload
- */
- setSettings(payload) {
- this.send(this.uuid, Events.setSettings, {
- action: this?.actionInfo?.action,
- payload: payload || null,
- });
- }
-
- /**
- * Request the actions's persistent data. StreamDeck does not return the data, but trigger the actions's didReceiveSettings event
- */
- getSettings() {
- this.send(this.uuid, Events.getSettings);
- }
-}
-
-const $PI = new ELGSDPropertyInspector();
-
-/**
- * connectElgatoStreamDeckSocket
- * This is the first function StreamDeck Software calls, when
- * establishing the connection to the plugin or the Property Inspector
- * @param {string} port - The socket's port to communicate with StreamDeck software.
- * @param {string} uuid - A unique identifier, which StreamDeck uses to communicate with the plugin
- * @param {string} messageType - Identifies, if the event is meant for the property inspector or the plugin.
- * @param {string} appInfoString - Information about the host (StreamDeck) application
- * @param {string} actionInfo - Context is an internal identifier used to communicate to the host application.
- */
-function connectElgatoStreamDeckSocket(port, uuid, messageType, appInfoString, actionInfo) {
- const delay = window?.initialConnectionDelay || 0;
- setTimeout(() => {
- $PI.connect(port, uuid, messageType, appInfoString, actionInfo);
- }, delay);
-}
diff --git a/co.meldstudio.streamdeck.sdPlugin/libs/js/prototypes.js b/co.meldstudio.streamdeck.sdPlugin/libs/js/prototypes.js
deleted file mode 100644
index e3b84fe..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/libs/js/prototypes.js
+++ /dev/null
@@ -1,26 +0,0 @@
-/** reaches out for a magical global localization object, hopefully loaded by $SD and swaps the string **/
-String.prototype.lox = function () {
- var a = String(this);
- try {
- a = $localizedStrings[a] || a;
- } catch (b) {
- }
- return a;
-};
-
-String.prototype.sprintf = function (inArr) {
- let i = 0;
- const args = inArr && Array.isArray(inArr) ? inArr : arguments;
- return this.replace(/%s/g, function () {
- return args[i++];
- });
-};
-
-WebSocket.prototype.sendJSON = function (jsn, log) {
- if (log) {
- console.log('SendJSON', this, jsn);
- }
- // if (this.readyState) {
- this.send(JSON.stringify(jsn));
- // }
-};
diff --git a/co.meldstudio.streamdeck.sdPlugin/libs/js/qwebchannel.js b/co.meldstudio.streamdeck.sdPlugin/libs/js/qwebchannel.js
deleted file mode 100644
index 56fa66a..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/libs/js/qwebchannel.js
+++ /dev/null
@@ -1,455 +0,0 @@
-// Copyright (C) 2016 The Qt Company Ltd.
-// Copyright (C) 2016 Klarälvdalens Datakonsult AB, a KDAB Group company, info@kdab.com, author Milian Wolff
-// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
-
-"use strict";
-
-var QWebChannelMessageTypes = {
- signal: 1,
- propertyUpdate: 2,
- init: 3,
- idle: 4,
- debug: 5,
- invokeMethod: 6,
- connectToSignal: 7,
- disconnectFromSignal: 8,
- setProperty: 9,
- response: 10,
-};
-
-var QWebChannel = function(transport, initCallback, converters)
-{
- if (typeof transport !== "object" || typeof transport.send !== "function") {
- console.error("The QWebChannel expects a transport object with a send function and onmessage callback property." +
- " Given is: transport: " + typeof(transport) + ", transport.send: " + typeof(transport.send));
- return;
- }
-
- var channel = this;
- this.transport = transport;
-
- var converterRegistry =
- {
- Date : function(response) {
- if (typeof response === "string"
- && response.match(
- /^-?\d+-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d*)?([-+\u2212](\d{2}):(\d{2})|Z)?$/)) {
- var date = new Date(response);
- if (!isNaN(date))
- return date;
- }
- return undefined; // Return undefined if current converter is not applicable
- }
- };
-
- this.usedConverters = [];
-
- this.addConverter = function(converter)
- {
- if (typeof converter === "string") {
- if (converterRegistry.hasOwnProperty(converter))
- this.usedConverters.push(converterRegistry[converter]);
- else
- console.error("Converter '" + converter + "' not found");
- } else if (typeof converter === "function") {
- this.usedConverters.push(converter);
- } else {
- console.error("Invalid converter object type " + typeof converter);
- }
- }
-
- if (Array.isArray(converters)) {
- for (const converter of converters)
- this.addConverter(converter);
- } else if (converters !== undefined) {
- this.addConverter(converters);
- }
-
- this.send = function(data)
- {
- if (typeof(data) !== "string") {
- data = JSON.stringify(data);
- }
- channel.transport.send(data);
- }
-
- this.transport.onmessage = function(message)
- {
- var data = message.data;
- if (typeof data === "string") {
- data = JSON.parse(data);
- }
- switch (data.type) {
- case QWebChannelMessageTypes.signal:
- channel.handleSignal(data);
- break;
- case QWebChannelMessageTypes.response:
- channel.handleResponse(data);
- break;
- case QWebChannelMessageTypes.propertyUpdate:
- channel.handlePropertyUpdate(data);
- break;
- default:
- console.error("invalid message received:", message.data);
- break;
- }
- }
-
- this.execCallbacks = {};
- this.execId = 0;
- this.exec = function(data, callback)
- {
- if (!callback) {
- // if no callback is given, send directly
- channel.send(data);
- return;
- }
- if (channel.execId === Number.MAX_VALUE) {
- // wrap
- channel.execId = Number.MIN_VALUE;
- }
- if (data.hasOwnProperty("id")) {
- console.error("Cannot exec message with property id: " + JSON.stringify(data));
- return;
- }
- data.id = channel.execId++;
- channel.execCallbacks[data.id] = callback;
- channel.send(data);
- };
-
- this.objects = {};
-
- this.handleSignal = function(message)
- {
- var object = channel.objects[message.object];
- if (object) {
- object.signalEmitted(message.signal, message.args);
- } else {
- console.warn("Unhandled signal: " + message.object + "::" + message.signal);
- }
- }
-
- this.handleResponse = function(message)
- {
- if (!message.hasOwnProperty("id")) {
- console.error("Invalid response message received: ", JSON.stringify(message));
- return;
- }
- channel.execCallbacks[message.id](message.data);
- delete channel.execCallbacks[message.id];
- }
-
- this.handlePropertyUpdate = function(message)
- {
- message.data.forEach(data => {
- var object = channel.objects[data.object];
- if (object) {
- object.propertyUpdate(data.signals, data.properties);
- } else {
- console.warn("Unhandled property update: " + data.object + "::" + data.signal);
- }
- });
- channel.exec({type: QWebChannelMessageTypes.idle});
- }
-
- this.debug = function(message)
- {
- channel.send({type: QWebChannelMessageTypes.debug, data: message});
- };
-
- channel.exec({type: QWebChannelMessageTypes.init}, function(data) {
- for (const objectName of Object.keys(data)) {
- new QObject(objectName, data[objectName], channel);
- }
-
- // now unwrap properties, which might reference other registered objects
- for (const objectName of Object.keys(channel.objects)) {
- channel.objects[objectName].unwrapProperties();
- }
-
- if (initCallback) {
- initCallback(channel);
- }
- channel.exec({type: QWebChannelMessageTypes.idle});
- });
-};
-
-function QObject(name, data, webChannel)
-{
- this.__id__ = name;
- webChannel.objects[name] = this;
-
- // List of callbacks that get invoked upon signal emission
- this.__objectSignals__ = {};
-
- // Cache of all properties, updated when a notify signal is emitted
- this.__propertyCache__ = {};
-
- var object = this;
-
- // ----------------------------------------------------------------------
-
- this.unwrapQObject = function(response)
- {
- for (const converter of webChannel.usedConverters) {
- var result = converter(response);
- if (result !== undefined)
- return result;
- }
-
- if (response instanceof Array) {
- // support list of objects
- return response.map(qobj => object.unwrapQObject(qobj))
- }
- if (!(response instanceof Object))
- return response;
-
- if (!response["__QObject*__"] || response.id === undefined) {
- var jObj = {};
- for (const propName of Object.keys(response)) {
- jObj[propName] = object.unwrapQObject(response[propName]);
- }
- return jObj;
- }
-
- var objectId = response.id;
- if (webChannel.objects[objectId])
- return webChannel.objects[objectId];
-
- if (!response.data) {
- console.error("Cannot unwrap unknown QObject " + objectId + " without data.");
- return;
- }
-
- var qObject = new QObject( objectId, response.data, webChannel );
- qObject.destroyed.connect(function() {
- if (webChannel.objects[objectId] === qObject) {
- delete webChannel.objects[objectId];
- // reset the now deleted QObject to an empty {} object
- // just assigning {} though would not have the desired effect, but the
- // below also ensures all external references will see the empty map
- // NOTE: this detour is necessary to workaround QTBUG-40021
- Object.keys(qObject).forEach(name => delete qObject[name]);
- }
- });
- // here we are already initialized, and thus must directly unwrap the properties
- qObject.unwrapProperties();
- return qObject;
- }
-
- this.unwrapProperties = function()
- {
- for (const propertyIdx of Object.keys(object.__propertyCache__)) {
- object.__propertyCache__[propertyIdx] = object.unwrapQObject(object.__propertyCache__[propertyIdx]);
- }
- }
-
- function addSignal(signalData, isPropertyNotifySignal)
- {
- var signalName = signalData[0];
- var signalIndex = signalData[1];
- object[signalName] = {
- connect: function(callback) {
- if (typeof(callback) !== "function") {
- console.error("Bad callback given to connect to signal " + signalName);
- return;
- }
-
- object.__objectSignals__[signalIndex] = object.__objectSignals__[signalIndex] || [];
- object.__objectSignals__[signalIndex].push(callback);
-
- // only required for "pure" signals, handled separately for properties in propertyUpdate
- if (isPropertyNotifySignal)
- return;
-
- // also note that we always get notified about the destroyed signal
- if (signalName === "destroyed" || signalName === "destroyed()" || signalName === "destroyed(QObject*)")
- return;
-
- // and otherwise we only need to be connected only once
- if (object.__objectSignals__[signalIndex].length == 1) {
- webChannel.exec({
- type: QWebChannelMessageTypes.connectToSignal,
- object: object.__id__,
- signal: signalIndex
- });
- }
- },
- disconnect: function(callback) {
- if (typeof(callback) !== "function") {
- console.error("Bad callback given to disconnect from signal " + signalName);
- return;
- }
- // This makes a new list. This is important because it won't interfere with
- // signal processing if a disconnection happens while emittig a signal
- object.__objectSignals__[signalIndex] = (object.__objectSignals__[signalIndex] || []).filter(function(c) {
- return c != callback;
- });
- if (!isPropertyNotifySignal && object.__objectSignals__[signalIndex].length === 0) {
- // only required for "pure" signals, handled separately for properties in propertyUpdate
- webChannel.exec({
- type: QWebChannelMessageTypes.disconnectFromSignal,
- object: object.__id__,
- signal: signalIndex
- });
- }
- }
- };
- }
-
- /**
- * Invokes all callbacks for the given signalname. Also works for property notify callbacks.
- */
- function invokeSignalCallbacks(signalName, signalArgs)
- {
- var connections = object.__objectSignals__[signalName];
- if (connections) {
- connections.forEach(function(callback) {
- callback.apply(callback, signalArgs);
- });
- }
- }
-
- this.propertyUpdate = function(signals, propertyMap)
- {
- // update property cache
- for (const propertyIndex of Object.keys(propertyMap)) {
- var propertyValue = propertyMap[propertyIndex];
- object.__propertyCache__[propertyIndex] = this.unwrapQObject(propertyValue);
- }
-
- for (const signalName of Object.keys(signals)) {
- // Invoke all callbacks, as signalEmitted() does not. This ensures the
- // property cache is updated before the callbacks are invoked.
- invokeSignalCallbacks(signalName, signals[signalName]);
- }
- }
-
- this.signalEmitted = function(signalName, signalArgs)
- {
- invokeSignalCallbacks(signalName, this.unwrapQObject(signalArgs));
- }
-
- function addMethod(methodData)
- {
- var methodName = methodData[0];
- var methodIdx = methodData[1];
-
- // Fully specified methods are invoked by id, others by name for host-side overload resolution
- var invokedMethod = methodName[methodName.length - 1] === ')' ? methodIdx : methodName
-
- object[methodName] = function() {
- var args = [];
- var callback;
- var errCallback;
- for (var i = 0; i < arguments.length; ++i) {
- var argument = arguments[i];
- if (typeof argument === "function")
- callback = argument;
- else
- args.push(argument);
- }
-
- var result;
- // during test, webChannel.exec synchronously calls the callback
- // therefore, the promise must be constucted before calling
- // webChannel.exec to ensure the callback is set up
- if (!callback && (typeof(Promise) === 'function')) {
- result = new Promise(function(resolve, reject) {
- callback = resolve;
- errCallback = reject;
- });
- }
-
- webChannel.exec({
- "type": QWebChannelMessageTypes.invokeMethod,
- "object": object.__id__,
- "method": invokedMethod,
- "args": args
- }, function(response) {
- if (response !== undefined) {
- var result = object.unwrapQObject(response);
- if (callback) {
- (callback)(result);
- }
- } else if (errCallback) {
- (errCallback)();
- }
- });
-
- return result;
- };
- }
-
- function bindGetterSetter(propertyInfo)
- {
- var propertyIndex = propertyInfo[0];
- var propertyName = propertyInfo[1];
- var notifySignalData = propertyInfo[2];
- // initialize property cache with current value
- // NOTE: if this is an object, it is not directly unwrapped as it might
- // reference other QObject that we do not know yet
- object.__propertyCache__[propertyIndex] = propertyInfo[3];
-
- if (notifySignalData) {
- if (notifySignalData[0] === 1) {
- // signal name is optimized away, reconstruct the actual name
- notifySignalData[0] = propertyName + "Changed";
- }
- addSignal(notifySignalData, true);
- }
-
- Object.defineProperty(object, propertyName, {
- configurable: true,
- get: function () {
- var propertyValue = object.__propertyCache__[propertyIndex];
- if (propertyValue === undefined) {
- // This shouldn't happen
- console.warn("Undefined value in property cache for property \"" + propertyName + "\" in object " + object.__id__);
- }
-
- return propertyValue;
- },
- set: function(value) {
- if (value === undefined) {
- console.warn("Property setter for " + propertyName + " called with undefined value!");
- return;
- }
- object.__propertyCache__[propertyIndex] = value;
- var valueToSend = value;
- webChannel.exec({
- "type": QWebChannelMessageTypes.setProperty,
- "object": object.__id__,
- "property": propertyIndex,
- "value": valueToSend
- });
- }
- });
-
- }
-
- // ----------------------------------------------------------------------
-
- data.methods.forEach(addMethod);
-
- data.properties.forEach(bindGetterSetter);
-
- data.signals.forEach(function(signal) { addSignal(signal, false); });
-
- Object.assign(object, data.enums);
-}
-
-QObject.prototype.toJSON = function() {
- if (this.__id__ === undefined) return {};
- return {
- id: this.__id__,
- "__QObject*__": true
- };
-};
-
-//required for use with nodejs
-if (typeof module === 'object') {
- module.exports = {
- QWebChannel: QWebChannel
- };
-}
diff --git a/co.meldstudio.streamdeck.sdPlugin/libs/js/stream-deck.js b/co.meldstudio.streamdeck.sdPlugin/libs/js/stream-deck.js
deleted file mode 100644
index 9c74c2b..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/libs/js/stream-deck.js
+++ /dev/null
@@ -1,295 +0,0 @@
-///
-///
-
-var $localizedStrings = $localizedStrings || {};
-
-/**
- * @class StreamDeck
- * StreamDeck object containing all required code to establish
- * communication with SD-Software and the Property Inspector
- */
-class ELGSDStreamDeck extends ELGSDApi {
- constructor() {
- super();
- if (ELGSDStreamDeck.__instance) {
- return ELGSDStreamDeck.__instance;
- }
-
- ELGSDStreamDeck.__instance = this;
- }
-
- /**
- * Display alert triangle on actions key
- * @param {string} context
- */
- showAlert(context) {
- if (!context) {
- console.error('A context is required to showAlert on the key.');
- }
-
- this.send(context, Events.showAlert);
- }
-
- /**
- * Display ok check mark on actions key
- * @param {string} context
- */
- showOk(context) {
- if (!context) {
- console.error('A context is required to showOk on the key.');
- }
-
- this.send(context, Events.showOk);
- }
-
- /**
- * Save the actions's persistent data.
- * @param context
- * @param {object} payload
- */
- setSettings(context, payload) {
- this.send(context, Events.setSettings, {
- action: this?.actionInfo?.action,
- payload: payload || null,
- targetContext: context,
- });
- }
-
- /**
- * Request the actions's persistent data. StreamDeck does not return the data, but trigger the actions's didReceiveSettings event
- * @param {string} [context]
- */
- getSettings(context) {
- this.send(context, Events.getSettings);
- }
-
- /**
- * Set the state of the actions
- * @param {string} context
- * @param {number} [state]
- */
- setState(context, state) {
- if (!context) {
- console.error('A context is required when using setState.');
- }
-
- this.send(context, Events.setState, {
- payload: {
- state: state || 0,
- },
- });
- }
-
- /**
- * Set the title of the action's key
- * @param {string} context
- * @param {string} title
- * @param [target]
- */
- setTitle(context, title = '', target = Constants.hardwareAndSoftware) {
- if (!context) {
- console.error('A key context is required for setTitle.');
- }
-
- this.send(context, Events.setTitle, {
- payload: {
- title: title ? `${title}` : '',
- target,
- },
- });
- }
-
- /**
- *
- * @param {string} context
- * @param {number} [target]
- */
- clearTitle(context, target) {
- if (!context) {
- console.error('A key context is required to clearTitle.');
- }
-
- this.setTitle(context, null, target);
- }
-
- /**
- * Send payload to property inspector
- * @param {string} context
- * @param {object} payload
- * @param {string} [action]
- */
- sendToPropertyInspector(context, payload = null, action = null) {
- if (typeof context != 'string') {
- console.error('A key context is required to sendToPropertyInspector.');
- }
-
- this.send(context, Events.sendToPropertyInspector, {
- action,
- payload,
- });
- }
-
- /**
- * Set the actions key image
- * @param {string} context
- * @param {string} [image]
- * @param {number} [state]
- * @param {number} [target]
- */
- setImage(context, image, state, target = Constants.hardwareAndSoftware) {
- if (!context) {
- console.error('A key context is required for setImage.');
- }
-
- this.send(context, Events.setImage, {
- payload: {
- image,
- target,
- state,
- },
- });
- }
-
- /**
- * Set the properties of the layout on the Stream Deck + touch display
- * @param {*} context
- * @param {*} payload
- */
- setFeedback(context, payload) {
- if (!context) {
- console.error('A context is required for setFeedback.');
- }
-
- this.send(context, Events.setFeedback, {
- payload,
- });
- }
-
- /**
- * Set the active layout by ID or path for the Stream Deck + touch display
- * @param {*} context
- * @param {*} layout
- */
- setFeedbackLayout(context, layout) {
- if (!context) {
- console.error('A context is required for setFeedbackLayout.');
- }
-
- this.send(context, Events.setFeedbackLayout, {
- payload: { layout },
- });
- }
-
- /**
- * Registers a callback function for the deviceDidConnect event, which fires when a device is plugged in
- * @param {function} fn
- * @returns ELGSDStreamDeck
- */
- onDeviceDidConnect(fn) {
- if (!fn) {
- console.error(
- 'A callback function for the deviceDidConnect event is required for onDeviceDidConnect.'
- );
- }
-
- this.on(Events.deviceDidConnect, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Registers a callback function for the deviceDidDisconnect event, which fires when a device is unplugged
- * @param {function} fn
- * @returns ELGSDStreamDeck
- */
- onDeviceDidDisconnect(fn) {
- if (!fn) {
- console.error(
- 'A callback function for the deviceDidDisconnect event is required for onDeviceDidDisconnect.'
- );
- }
-
- this.on(Events.deviceDidDisconnect, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Registers a callback function for the applicationDidLaunch event, which fires when the application starts
- * @param {function} fn
- * @returns ELGSDStreamDeck
- */
- onApplicationDidLaunch(fn) {
- if (!fn) {
- console.error(
- 'A callback function for the applicationDidLaunch event is required for onApplicationDidLaunch.'
- );
- }
-
- this.on(Events.applicationDidLaunch, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Registers a callback function for the applicationDidTerminate event, which fires when the application exits
- * @param {function} fn
- * @returns ELGSDStreamDeck
- */
- onApplicationDidTerminate(fn) {
- if (!fn) {
- console.error(
- 'A callback function for the applicationDidTerminate event is required for onApplicationDidTerminate.'
- );
- }
-
- this.on(Events.applicationDidTerminate, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Registers a callback function for the systemDidWakeUp event, which fires when the computer wakes
- * @param {function} fn
- * @returns ELGSDStreamDeck
- */
- onSystemDidWakeUp(fn) {
- if (!fn) {
- console.error(
- 'A callback function for the systemDidWakeUp event is required for onSystemDidWakeUp.'
- );
- }
-
- this.on(Events.systemDidWakeUp, (jsn) => fn(jsn));
- return this;
- }
-
- /**
- * Switches to a readonly profile or returns to previous profile
- * @param {string} device
- * @param {string} [profile]
- */
- switchToProfile(device, profile) {
- if (!device) {
- console.error('A device id is required for switchToProfile.');
- }
-
- this.send(this.uuid, Events.switchToProfile, { device, payload: { profile } });
- }
-}
-
-const $SD = new ELGSDStreamDeck();
-
-/**
- * connectElgatoStreamDeckSocket
- * This is the first function StreamDeck Software calls, when
- * establishing the connection to the plugin or the Property Inspector
- * @param {string} port - The socket's port to communicate with StreamDeck software.
- * @param {string} uuid - A unique identifier, which StreamDeck uses to communicate with the plugin
- * @param {string} messageType - Identifies, if the event is meant for the property inspector or the plugin.
- * @param {string} appInfoString - Information about the host (StreamDeck) application
- * @param {string} actionInfo - Context is an internal identifier used to communicate to the host application.
- */
-function connectElgatoStreamDeckSocket(port, uuid, messageType, appInfoString, actionInfo) {
- const delay = window?.initialConnectionDelay || 0;
-
- setTimeout(() => {
- $SD.connect(port, uuid, messageType, appInfoString, actionInfo);
- }, delay);
-}
diff --git a/co.meldstudio.streamdeck.sdPlugin/libs/js/timers.js b/co.meldstudio.streamdeck.sdPlugin/libs/js/timers.js
deleted file mode 100644
index 5d74e36..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/libs/js/timers.js
+++ /dev/null
@@ -1,95 +0,0 @@
-/* global ESDTimerWorker */
-/*eslint no-unused-vars: "off"*/
-/*eslint-env es6*/
-
-let ESDTimerWorker = new Worker(URL.createObjectURL(
- new Blob([timerFn.toString().replace(/^[^{]*{\s*/, '').replace(/\s*}[^}]*$/, '')], {type: 'text/javascript'})
-));
-ESDTimerWorker.timerId = 1;
-ESDTimerWorker.timers = {};
-const ESDDefaultTimeouts = {
- timeout: 0,
- interval: 10
-};
-
-Object.freeze(ESDDefaultTimeouts);
-
-function _setTimer(callback, delay, type, params) {
- const id = ESDTimerWorker.timerId++;
- ESDTimerWorker.timers[id] = {callback, params};
- ESDTimerWorker.onmessage = (e) => {
- if (ESDTimerWorker.timers[e.data.id]) {
- if (e.data.type === 'clearTimer') {
- delete ESDTimerWorker.timers[e.data.id];
- } else {
- const cb = ESDTimerWorker.timers[e.data.id].callback;
- if (cb && typeof cb === 'function') cb(...ESDTimerWorker.timers[e.data.id].params);
- }
- }
- };
- ESDTimerWorker.postMessage({type, id, delay});
- return id;
-}
-
-function _setTimeoutESD(...args) {
- let [callback, delay = 0, ...params] = [...args];
- return _setTimer(callback, delay, 'setTimeout', params);
-}
-
-function _setIntervalESD(...args) {
- let [callback, delay = 0, ...params] = [...args];
- return _setTimer(callback, delay, 'setInterval', params);
-}
-
-function _clearTimeoutESD(id) {
- ESDTimerWorker.postMessage({type: 'clearTimeout', id}); // ESDTimerWorker.postMessage({type: 'clearInterval', id}); = same thing
- delete ESDTimerWorker.timers[id];
-}
-
-window.setTimeout = _setTimeoutESD;
-window.setInterval = _setIntervalESD;
-window.clearTimeout = _clearTimeoutESD; //timeout and interval share the same timer-pool
-window.clearInterval = _clearTimeoutESD;
-
-/** This is our worker-code
- * It is executed in it's own (global) scope
- * which is wrapped above @ `let ESDTimerWorker`
- */
-
-function timerFn() {
- /*eslint indent: ["error", 4, { "SwitchCase": 1 }]*/
-
- let timers = {};
- let debug = false;
- let supportedCommands = ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval'];
-
- function log(e) {
- console.log('Worker-Info::Timers', timers);
- }
-
- function clearTimerAndRemove(id) {
- if (timers[id]) {
- if (debug) console.log('clearTimerAndRemove', id, timers[id], timers);
- clearTimeout(timers[id]);
- delete timers[id];
- postMessage({type: 'clearTimer', id: id});
- if (debug) log();
- }
- }
-
- onmessage = function (e) {
- // first see, if we have a timer with this id and remove it
- // this automatically fulfils clearTimeout and clearInterval
- supportedCommands.includes(e.data.type) && timers[e.data.id] && clearTimerAndRemove(e.data.id);
- if (e.data.type === 'setTimeout') {
- timers[e.data.id] = setTimeout(() => {
- postMessage({id: e.data.id});
- clearTimerAndRemove(e.data.id); //cleaning up
- }, Math.max(e.data.delay || 0));
- } else if (e.data.type === 'setInterval') {
- timers[e.data.id] = setInterval(() => {
- postMessage({id: e.data.id});
- }, Math.max(e.data.delay || ESDDefaultTimeouts.interval));
- }
- };
-}
diff --git a/co.meldstudio.streamdeck.sdPlugin/libs/js/utils.js b/co.meldstudio.streamdeck.sdPlugin/libs/js/utils.js
deleted file mode 100644
index a7b84cc..0000000
--- a/co.meldstudio.streamdeck.sdPlugin/libs/js/utils.js
+++ /dev/null
@@ -1,94 +0,0 @@
-class Utils {
- /**
- * Returns the value from a form using the form controls name property
- * @param {Element | string} form
- * @returns
- */
- static getFormValue(form) {
- if (typeof form === 'string') {
- form = document.querySelector(form);
- }
-
- const elements = form?.elements;
-
- if (!elements) {
- console.error('Could not find form!');
- }
-
- const formData = new FormData(form);
- let formValue = {};
-
- formData.forEach((value, key) => {
- if (!Reflect.has(formValue, key)) {
- formValue[key] = value;
- return;
- }
- if (!Array.isArray(formValue[key])) {
- formValue[key] = [formValue[key]];
- }
- formValue[key].push(value);
- });
-
- return formValue;
- }
-
- /**
- * Sets the value of form controls using their name attribute and the jsn object key
- * @param {*} jsn
- * @param {Element | string} form
- */
- static setFormValue(jsn, form) {
- if (!jsn) {
- return;
- }
-
- if (typeof form === 'string') {
- form = document.querySelector(form);
- }
-
- const elements = form?.elements;
-
- if (!elements) {
- console.error('Could not find form!');
- }
-
- Array.from(elements)
- .filter((element) => element?.name)
- .forEach((element) => {
- const { name, type } = element;
- const value = name in jsn ? jsn[name] : null;
- const isCheckOrRadio = type === 'checkbox' || type === 'radio';
-
- if (value === null) return;
-
- if (isCheckOrRadio) {
- const isSingle = value === element.value;
- if (isSingle || (Array.isArray(value) && value.includes(element.value))) {
- element.checked = true;
- }
- } else {
- element.value = value ?? '';
- }
- });
- }
-
- /**
- * This provides a slight delay before processing rapid events
- * @param {number} wait - delay before processing function (recommended time 150ms)
- * @param {function} fn
- * @returns
- */
- static debounce(wait, fn) {
- let timeoutId = null;
- return (...args) => {
- window.clearTimeout(timeoutId);
- timeoutId = window.setTimeout(() => {
- fn.apply(null, args);
- }, wait);
- };
- }
-
- static delay(wait) {
- return new Promise((fn) => setTimeout(fn, wait));
- }
-}
diff --git a/co.meldstudio.streamdeck.sdPlugin/manifest.json b/co.meldstudio.streamdeck.sdPlugin/manifest.json
index 8abe653..3738e9f 100644
--- a/co.meldstudio.streamdeck.sdPlugin/manifest.json
+++ b/co.meldstudio.streamdeck.sdPlugin/manifest.json
@@ -1,14 +1,17 @@
{
- "SDKVersion": 2,
+ "SDKVersion": 3,
"Category": "Meld Studio",
"CategoryIcon": "assets/Category Icon/Meld Studio",
"Name": "Meld Studio",
"Icon": "assets/AppIcon/meldstudio",
"Author": "MeldStudio",
- "CodePath": "app.html",
+ "CodePath": "bin/plugin.js",
+ "Nodejs": {
+ "Version": "24"
+ },
"Description": "Meld Studio 💙 Stream Deck. Take your stream to new heights with powerful new capabilities at your fingertips. Trigger transitions, manage audio tracks, toggle filter effects on and off, and much more.",
"URL": "https://meldstudio.co",
- "Version": "0.6.2.1",
+ "Version": "0.6.4.1",
"UUID": "co.meldstudio.streamdeck",
"OS": [
{
@@ -21,7 +24,7 @@
}
],
"Software": {
- "MinimumVersion": "6.4"
+ "MinimumVersion": "7.1"
},
"Actions": [
{
@@ -29,16 +32,17 @@
"Name": "Change Scene",
"States": [
{
- "Image": "assets/Scenes/Change to Scene/Key Icon/sceneOff"
+ "Image": "assets/Scenes/Change to Scene/Key Icon/sceneOn"
},
{
- "Image": "assets/Scenes/Change to Scene/Key Icon/sceneOn"
+ "Image": "assets/Scenes/Change to Scene/Key Icon/sceneOff"
}
],
"SupportedInMultiActions": true,
+ "DisableAutomaticStates": true,
"Tooltip": "Change scenes!",
"UUID": "co.meldstudio.streamdeck.show-scene",
- "PropertyInspectorPath": "actions/show-scene/inspector.html"
+ "PropertyInspectorPath": "ui/show-scene.html"
},
{
"Icon": "assets/Screenshot/iconScreenshot",
@@ -51,7 +55,7 @@
"SupportedInMultiActions": true,
"Tooltip": "Take a screenshot!",
"UUID": "co.meldstudio.streamdeck.screenshot",
- "PropertyInspectorPath": "actions/screenshot/inspector.html"
+ "PropertyInspectorPath": "ui/screenshot.html"
},
{
"Icon": "assets/Clip/action icon/clip-action",
@@ -62,41 +66,43 @@
}
],
"SupportedInMultiActions": true,
- "Tooltip": "Record a 90 second clip to disk!",
+ "Tooltip": "Record a configurable-duration clip to disk!",
"UUID": "co.meldstudio.streamdeck.clip",
- "PropertyInspectorPath": "actions/clip/inspector.html"
+ "PropertyInspectorPath": "ui/clip.html"
},
{
"Icon": "assets/Audio Track/Mute_Unmute Audio Track/Action Icons/audioTrack",
"Name": "Mute Track",
"States": [
{
- "Image": "assets/Audio Track/Mute_Unmute Audio Track/Key Icons/mutedTrack"
+ "Image": "assets/Audio Track/Mute_Unmute Audio Track/Key Icons/audio-unmuted"
},
{
- "Image": "assets/Audio Track/Mute_Unmute Audio Track/Key Icons/audio-unmuted"
+ "Image": "assets/Audio Track/Mute_Unmute Audio Track/Key Icons/mutedTrack"
}
],
"SupportedInMultiActions": true,
+ "DisableAutomaticStates": true,
"Tooltip": "Mute an audio track!",
"UUID": "co.meldstudio.streamdeck.toggle-mute",
- "PropertyInspectorPath": "actions/toggle-mute/inspector.html"
+ "PropertyInspectorPath": "ui/toggle-mute.html"
},
{
"Icon": "assets/Cue/action icon/cueAudio",
"Name": "Cue Track",
"States": [
{
- "Image": "assets/Cue/key icon/cueOff"
+ "Image": "assets/Cue/key icon/cueOn"
},
{
- "Image": "assets/Cue/key icon/cueOn"
+ "Image": "assets/Cue/key icon/cueOff"
}
],
"SupportedInMultiActions": true,
+ "DisableAutomaticStates": true,
"Tooltip": "Monitor an audio track!",
"UUID": "co.meldstudio.streamdeck.toggle-monitor",
- "PropertyInspectorPath": "actions/toggle-monitor/inspector.html"
+ "PropertyInspectorPath": "ui/toggle-monitor.html"
},
{
"Icon": "assets/Audio Track/Mute_Unmute Audio Track/Action Icons/audioTrack",
@@ -120,7 +126,7 @@
},
"Tooltip": "Adjust the volume of a specified audio track.",
"UUID": "co.meldstudio.streamdeck.volume-stepper",
- "PropertyInspectorPath": "actions/volume-stepper/inspector.html"
+ "PropertyInspectorPath": "ui/volume-stepper.html"
},
{
"Icon": "assets/Scene Transition Control/Action Icon/sceneTransition",
@@ -144,71 +150,75 @@
},
"Tooltip": "Select a staging scene and trigger a scene transition",
"UUID": "co.meldstudio.streamdeck.transition-stepper",
- "PropertyInspectorPath": "actions/transition-stepper/inspector.html"
+ "PropertyInspectorPath": "ui/transition-stepper.html"
},
{
"Icon": "assets/Layers/Layer Visibility/Action Icon/iconLayerVisibility",
"Name": "Layer Visibility",
"States": [
{
- "Image": "assets/Layers/Layer Visibility/Key Icon/layerHide"
+ "Image": "assets/Layers/Layer Visibility/Key Icon/layerShow"
},
{
- "Image": "assets/Layers/Layer Visibility/Key Icon/layerShow"
+ "Image": "assets/Layers/Layer Visibility/Key Icon/layerHide"
}
],
"SupportedInMultiActions": true,
+ "DisableAutomaticStates": true,
"Tooltip": "Toggle layer visibility!",
"UUID": "co.meldstudio.streamdeck.toggle-layer",
- "PropertyInspectorPath": "actions/toggle-layer/inspector.html"
+ "PropertyInspectorPath": "ui/toggle-layer.html"
},
{
"Icon": "assets/Effects/Action Icon/iconEffectVisibility",
"Name": "Effect Visibility",
"States": [
{
- "Image": "assets/Effects/Key Icons/hideEffect"
+ "Image": "assets/Effects/Key Icons/showEffect"
},
{
- "Image": "assets/Effects/Key Icons/showEffect"
+ "Image": "assets/Effects/Key Icons/hideEffect"
}
],
"SupportedInMultiActions": true,
+ "DisableAutomaticStates": true,
"Tooltip": "Toggle effect visibility!",
"UUID": "co.meldstudio.streamdeck.toggle-effect",
- "PropertyInspectorPath": "actions/toggle-effect/inspector.html"
+ "PropertyInspectorPath": "ui/toggle-effect.html"
},
{
"Icon": "assets/Record/Action Icon/iconRecord",
"Name": "Record",
"States": [
{
- "Image": "assets/Record/Key Icon/startRecord"
+ "Image": "assets/Record/Key Icon/stopRecord"
},
{
- "Image": "assets/Record/Key Icon/stopRecord"
+ "Image": "assets/Record/Key Icon/startRecord"
}
],
"SupportedInMultiActions": true,
+ "DisableAutomaticStates": true,
"Tooltip": "Start or stop recording.",
"UUID": "co.meldstudio.streamdeck.toggle-record",
- "PropertyInspectorPath": "actions/toggle-record/inspector.html"
+ "PropertyInspectorPath": "ui/toggle-record.html"
},
{
"Icon": "assets/Go live/Action Icons/goLive",
"Name": "Go Live",
"States": [
{
- "Image": "assets/Go live/Key Icons/goLive"
+ "Image": "assets/Go live/Key Icons/goOffiline"
},
{
- "Image": "assets/Go live/Key Icons/goOffiline"
+ "Image": "assets/Go live/Key Icons/goLive"
}
],
"SupportedInMultiActions": true,
+ "DisableAutomaticStates": true,
"Tooltip": "Go Live or go offline.",
"UUID": "co.meldstudio.streamdeck.toggle-stream",
- "PropertyInspectorPath": "actions/toggle-stream/inspector.html"
+ "PropertyInspectorPath": "ui/toggle-stream.html"
},
{
"Icon": "assets/Replay/Action Icon/replay-clip-controls",
@@ -221,7 +231,7 @@
"SupportedInMultiActions": true,
"Tooltip": "Control replay clip audio and display.",
"UUID": "co.meldstudio.streamdeck.replay",
- "PropertyInspectorPath": "actions/replay/inspector.html"
+ "PropertyInspectorPath": "ui/replay.html"
},
{
"Icon": "assets/Media/Action Icon/media-controls-action",
@@ -234,7 +244,7 @@
"SupportedInMultiActions": true,
"Tooltip": "Control media layer playback.",
"UUID": "co.meldstudio.streamdeck.media",
- "PropertyInspectorPath": "actions/media/inspector.html"
+ "PropertyInspectorPath": "ui/media.html"
},
{
"Icon": "assets/Widget/Action Icon/widget-controls-action",
@@ -247,7 +257,7 @@
"SupportedInMultiActions": true,
"Tooltip": "Trigger events for Meld Studio widgets.",
"UUID": "co.meldstudio.streamdeck.widget-control",
- "PropertyInspectorPath": "actions/widget-control/inspector.html"
+ "PropertyInspectorPath": "ui/widget-control.html"
}
]
-}
\ No newline at end of file
+}
diff --git a/co.meldstudio.streamdeck.sdPlugin/ui/clip.html b/co.meldstudio.streamdeck.sdPlugin/ui/clip.html
new file mode 100644
index 0000000..a303061
--- /dev/null
+++ b/co.meldstudio.streamdeck.sdPlugin/ui/clip.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+ co.meldstudio.streamdeck.clip Property Inspector
+
+
+
+
+
+
+
Seconds
+
+ 10
+ 15
+ 30
+ 60
+ 90
+ Custom…
+
+
+
+
+
+
+
+
+
+
diff --git a/co.meldstudio.streamdeck.sdPlugin/ui/lib/meld-pi.js b/co.meldstudio.streamdeck.sdPlugin/ui/lib/meld-pi.js
new file mode 100644
index 0000000..68b88c2
--- /dev/null
+++ b/co.meldstudio.streamdeck.sdPlugin/ui/lib/meld-pi.js
@@ -0,0 +1,298 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+//
+// Thin property-inspector runtime. Unlike the old inspectors, this NEVER opens a
+// connection to Meld Studio — it speaks only the Stream Deck property-inspector
+// protocol and asks the plugin for session data. The plugin (the single source
+// of truth) answers and pushes live updates, so dropdowns stay in sync with the
+// keys without a second, drifting connection.
+
+(function () {
+ let ws;
+ let uuid;
+ let actionInfo = {};
+ let currentSettings = {};
+ let session = { items: {}, connected: false };
+
+ const settingsListeners = [];
+ const sessionListeners = [];
+ const readyListeners = [];
+
+ /** Entry point invoked by the Stream Deck application. */
+ function connectElgatoStreamDeckSocket(inPort, inUUID, inRegisterEvent, _inInfo, inActionInfo) {
+ uuid = inUUID;
+ try {
+ actionInfo = inActionInfo ? JSON.parse(inActionInfo) : {};
+ } catch {
+ actionInfo = {};
+ }
+ currentSettings = (actionInfo && actionInfo.payload && actionInfo.payload.settings) || {};
+
+ ws = new WebSocket("ws://127.0.0.1:" + inPort);
+
+ ws.onopen = function () {
+ ws.send(JSON.stringify({ event: inRegisterEvent, uuid: inUUID }));
+ readyListeners.forEach((fn) => fn());
+ MeldPI.requestSession();
+ };
+
+ ws.onmessage = function (event) {
+ let msg;
+ try {
+ msg = JSON.parse(event.data);
+ } catch {
+ return;
+ }
+
+ if (msg.event === "didReceiveSettings") {
+ currentSettings = (msg.payload && msg.payload.settings) || {};
+ settingsListeners.forEach((fn) => fn(currentSettings));
+ } else if (msg.event === "sendToPropertyInspector") {
+ const payload = msg.payload || {};
+ if (payload.event === "meld.session") {
+ session = { items: payload.items || {}, connected: !!payload.connected };
+ sessionListeners.forEach((fn) => fn(session));
+ }
+ }
+ };
+ }
+
+ /** Builds an `` element. */
+ function option(text, value, selected) {
+ const el = document.createElement("option");
+ el.textContent = text;
+ el.value = value;
+ if (selected) {
+ el.selected = true;
+ }
+ return el;
+ }
+
+ /** Collects session items of a type, optionally filtered by parent / predicate. */
+ function itemsByType(items, type, parent, keepFilter) {
+ const result = [];
+ for (const id of Object.keys(items)) {
+ const item = items[id];
+ if (keepFilter && !keepFilter(type, item)) {
+ continue;
+ }
+ if (item.type !== type) {
+ continue;
+ }
+ if (parent && item.parent !== parent) {
+ continue;
+ }
+ result.push({ id, name: item.name });
+ }
+ return result;
+ }
+
+ const MeldPI = {
+ /** The action's manifest UUID. */
+ get actionUUID() {
+ return actionInfo.action;
+ },
+
+ /** Registers a callback for when the socket is connected. */
+ onReady(fn) {
+ readyListeners.push(fn);
+ },
+
+ /** The current, in-memory settings object. */
+ getSettings() {
+ return currentSettings;
+ },
+
+ /** Persists settings to Stream Deck. */
+ setSettings(settings) {
+ currentSettings = settings;
+ if (ws) {
+ ws.send(JSON.stringify({ event: "setSettings", context: uuid, payload: settings }));
+ }
+ },
+
+ /** Merges a partial update into settings and persists it. */
+ patchSettings(patch) {
+ this.setSettings(Object.assign({}, currentSettings, patch));
+ },
+
+ /** Registers a callback for `didReceiveSettings`. */
+ onDidReceiveSettings(fn) {
+ settingsListeners.push(fn);
+ },
+
+ /** Requests a fresh session snapshot from the plugin. */
+ requestSession() {
+ this.sendToPlugin({ event: "meld.getSession" });
+ },
+
+ /** Registers a callback for session snapshots (fires immediately if known). */
+ onSession(fn) {
+ sessionListeners.push(fn);
+ fn(session);
+ },
+
+ /** Sends an arbitrary payload to the plugin. */
+ sendToPlugin(payload) {
+ if (ws) {
+ ws.send(JSON.stringify({ event: "sendToPlugin", context: uuid, action: actionInfo.action, payload }));
+ }
+ },
+
+ /** Opens a URL in the user's browser via Stream Deck. */
+ openUrl(url) {
+ if (ws) {
+ ws.send(JSON.stringify({ event: "openUrl", payload: { url } }));
+ }
+ },
+
+ /**
+ * Binds a chain of session-driven `` elements whose ids match item
+ * types (`scene`, `layer`, `effect`, `track`). Each select after the first is
+ * filtered to children of the previous select's value.
+ * @param {string[]} ids Ordered element ids (also used as item types).
+ * @param {(type:string,item:object)=>boolean} [keepFilter] Optional predicate.
+ */
+ bindSessionSelects(ids, keepFilter) {
+ const rebuild = () => {
+ let parent = "";
+ ids.forEach((id, position) => {
+ const el = document.getElementById(id);
+ if (!el) {
+ return;
+ }
+ const current = currentSettings[id] || "";
+ el.innerHTML = "";
+ el.disabled = false;
+ const emptyOption = option("No Selection", "", current === "");
+ emptyOption.className = "no-selection-option";
+ el.appendChild(emptyOption);
+ // The first select lists all items of its type; each later one lists
+ // only children of the previous selection — and nothing at all while
+ // that selection is "No Selection", so a mismatched child can never
+ // be picked.
+ if (position === 0 || parent) {
+ for (const item of itemsByType(session.items, id, parent, keepFilter)) {
+ el.appendChild(option(item.name, item.id, item.id === current));
+ }
+ }
+ el.value = current;
+ if (el.selectedIndex === -1) {
+ // The stored value isn't in the (possibly refiltered) list — e.g. a
+ // child saved under a different parent. Show "No Selection"
+ // instead of a blank control, and don't filter children by it.
+ el.selectedIndex = 0;
+ }
+ el.classList.toggle("no-selection", el.value === "");
+ parent = el.value;
+ });
+ };
+
+ ids.forEach((id, index) => {
+ const el = document.getElementById(id);
+ if (!el) {
+ return;
+ }
+ el.addEventListener("change", () => {
+ // Changing a parent invalidates every downstream selection (they were
+ // children of the old value); clear them in the same patch so settings
+ // never hold a mismatched chain like {scene: B, layer: }.
+ const patch = { [id]: el.value };
+ for (const child of ids.slice(index + 1)) {
+ patch[child] = "";
+ }
+ MeldPI.patchSettings(patch);
+ rebuild();
+ });
+ });
+
+ this.onDidReceiveSettings(rebuild);
+ this.onSession(rebuild);
+ rebuild();
+ },
+
+ /**
+ * Binds a static `` / ` ` whose value persists to settings.
+ * @param {string} id Element id (and settings key).
+ * @param {string} [fallback] Default value when no setting is present.
+ */
+ bindField(id, fallback) {
+ const el = document.getElementById(id);
+ if (!el) {
+ return;
+ }
+ const apply = () => {
+ const value = currentSettings[id];
+ if (value !== undefined && value !== null) {
+ el.value = value;
+ } else if (fallback !== undefined) {
+ el.value = fallback;
+ currentSettings = Object.assign({}, currentSettings, { [id]: fallback });
+ }
+ };
+ el.addEventListener("change", () => {
+ MeldPI.patchSettings({ [id]: el.value });
+ });
+ this.onDidReceiveSettings(apply);
+ apply();
+ },
+
+ /**
+ * Binds a standard preset select plus a conditionally visible custom input.
+ * The input id is also the persisted settings key.
+ * @param {string} id Custom input id and settings key.
+ * @param {string} presetId Select containing preset values and `custom`.
+ * @param {string} customRowId Row to reveal for manual entry.
+ * @param {string[]} presets Values represented directly by select options.
+ * @param {string} fallback Value shown when the setting is absent.
+ */
+ bindEditablePreset(id, presetId, customRowId, presets, fallback) {
+ const presetEl = document.getElementById(presetId);
+ const customEl = document.getElementById(id);
+ const customRow = document.getElementById(customRowId);
+ if (!presetEl || !customEl || !customRow) {
+ return;
+ }
+ const apply = (settings) => {
+ const duration = settings[id] != null ? String(settings[id]) : fallback;
+ const isPreset = presets.includes(duration);
+ presetEl.value = isPreset ? duration : "custom";
+ customEl.value = duration;
+ customRow.style.display = isPreset ? "none" : "";
+ };
+ presetEl.addEventListener("change", () => {
+ const custom = presetEl.value === "custom";
+ customRow.style.display = custom ? "" : "none";
+ if (!custom) {
+ // Keep the hidden editor synchronized so reopening Custom can never
+ // display a value different from the one the action will execute.
+ customEl.value = presetEl.value;
+ MeldPI.patchSettings({ [id]: presetEl.value });
+ }
+ });
+ customEl.addEventListener("change", () => {
+ MeldPI.patchSettings({ [id]: customEl.value });
+ });
+ this.onDidReceiveSettings(apply);
+ apply(currentSettings);
+ },
+
+ /**
+ * Shows/hides a "Connecting to Meld Studio..." element based on the plugin's
+ * connection status.
+ * @param {string} loadingId Element id of the connecting indicator.
+ */
+ watchConnection(loadingId) {
+ const el = document.getElementById(loadingId);
+ if (!el) {
+ return;
+ }
+ this.onSession((s) => {
+ el.style.display = s.connected ? "none" : "";
+ });
+ },
+ };
+
+ window.connectElgatoStreamDeckSocket = connectElgatoStreamDeckSocket;
+ window.MeldPI = MeldPI;
+})();
diff --git a/co.meldstudio.streamdeck.sdPlugin/ui/media.html b/co.meldstudio.streamdeck.sdPlugin/ui/media.html
new file mode 100644
index 0000000..6f43f2c
--- /dev/null
+++ b/co.meldstudio.streamdeck.sdPlugin/ui/media.html
@@ -0,0 +1,58 @@
+
+
+
+
+
+
+ co.meldstudio.streamdeck.media Property Inspector
+
+
+
+
+
+
+
+
+
Action
+
+ Toggle Play/Pause
+ Play
+ Pause
+ Restart
+
+
+
+
+
Connecting to Meld Studio...
+
+
+
+
+
+
+
+
diff --git a/co.meldstudio.streamdeck.sdPlugin/ui/replay.html b/co.meldstudio.streamdeck.sdPlugin/ui/replay.html
new file mode 100644
index 0000000..b6dd211
--- /dev/null
+++ b/co.meldstudio.streamdeck.sdPlugin/ui/replay.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+ co.meldstudio.streamdeck.replay Property Inspector
+
+
+
+
+
+
+
Action
+
+ Show Replay
+ Dismiss
+ Toggle Mute
+ Mute
+ Unmute
+ Toggle Cue
+ Cue
+ Uncue
+ Volume Up
+ Volume Down
+
+
+
+
+
Connecting to Meld Studio...
+
+
+
+
+
+
+
+
diff --git a/co.meldstudio.streamdeck.sdPlugin/ui/screenshot.html b/co.meldstudio.streamdeck.sdPlugin/ui/screenshot.html
new file mode 100644
index 0000000..9f7f78e
--- /dev/null
+++ b/co.meldstudio.streamdeck.sdPlugin/ui/screenshot.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+ co.meldstudio.streamdeck.screenshot Property Inspector
+
+
+
+
+
+
+
+
Press the key to capture a screenshot in Meld Studio.
+
+
+
+
+
diff --git a/co.meldstudio.streamdeck.sdPlugin/ui/show-scene.html b/co.meldstudio.streamdeck.sdPlugin/ui/show-scene.html
new file mode 100644
index 0000000..9821b3d
--- /dev/null
+++ b/co.meldstudio.streamdeck.sdPlugin/ui/show-scene.html
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+ co.meldstudio.streamdeck.show-scene Property Inspector
+
+
+
+
+
+
+
+
+
Connecting to Meld Studio...
+
+
+
+
+
+
+
+
diff --git a/co.meldstudio.streamdeck.sdPlugin/ui/toggle-effect.html b/co.meldstudio.streamdeck.sdPlugin/ui/toggle-effect.html
new file mode 100644
index 0000000..9423b4c
--- /dev/null
+++ b/co.meldstudio.streamdeck.sdPlugin/ui/toggle-effect.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+ co.meldstudio.streamdeck.toggle-effect Property Inspector
+
+
+
+
+
+
+
+
+
+
Action
+
+ Toggle
+ Show
+ Hide
+
+
+
+
+
Connecting to Meld Studio...
+
+
+
+
+
+
+
+
diff --git a/co.meldstudio.streamdeck.sdPlugin/ui/toggle-layer.html b/co.meldstudio.streamdeck.sdPlugin/ui/toggle-layer.html
new file mode 100644
index 0000000..23abbf2
--- /dev/null
+++ b/co.meldstudio.streamdeck.sdPlugin/ui/toggle-layer.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+ co.meldstudio.streamdeck.toggle-layer Property Inspector
+
+
+
+
+
+
+
+
+
Action
+
+ Toggle
+ Show
+ Hide
+
+
+
+
+
Connecting to Meld Studio...
+
+
+
+
+
+
+
+
diff --git a/co.meldstudio.streamdeck.sdPlugin/ui/toggle-monitor.html b/co.meldstudio.streamdeck.sdPlugin/ui/toggle-monitor.html
new file mode 100644
index 0000000..9ef8add
--- /dev/null
+++ b/co.meldstudio.streamdeck.sdPlugin/ui/toggle-monitor.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+ co.meldstudio.streamdeck.toggle-monitor Property Inspector
+
+
+
+
+
+
+
+
Action
+
+ Toggle
+ Cue
+ Uncue
+
+
+
+
+
Connecting to Meld Studio...
+
+
+
+
+
+
+
+
diff --git a/co.meldstudio.streamdeck.sdPlugin/ui/toggle-mute.html b/co.meldstudio.streamdeck.sdPlugin/ui/toggle-mute.html
new file mode 100644
index 0000000..e5a7b29
--- /dev/null
+++ b/co.meldstudio.streamdeck.sdPlugin/ui/toggle-mute.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+ co.meldstudio.streamdeck.toggle-mute Property Inspector
+
+
+
+
+
+
+
+
Action
+
+ Toggle
+ Mute
+ Unmute
+
+
+
+
+
Connecting to Meld Studio...
+
+
+
+
+
+
+
+
diff --git a/co.meldstudio.streamdeck.sdPlugin/ui/toggle-record.html b/co.meldstudio.streamdeck.sdPlugin/ui/toggle-record.html
new file mode 100644
index 0000000..6e6f8a7
--- /dev/null
+++ b/co.meldstudio.streamdeck.sdPlugin/ui/toggle-record.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+ co.meldstudio.streamdeck.toggle-record Property Inspector
+
+
+
+
+
+
+
+
Press the key to start or stop recording.
+
+
+
Connecting to Meld Studio...
+
+
+
+
+
+
+
+
diff --git a/co.meldstudio.streamdeck.sdPlugin/ui/toggle-stream.html b/co.meldstudio.streamdeck.sdPlugin/ui/toggle-stream.html
new file mode 100644
index 0000000..0bc83b7
--- /dev/null
+++ b/co.meldstudio.streamdeck.sdPlugin/ui/toggle-stream.html
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+ co.meldstudio.streamdeck.toggle-stream Property Inspector
+
+
+
+
+
+
+
+
Press the key to go live or go offline.
+
+
+
Connecting to Meld Studio...
+
+
+
+
+
+
+
+
diff --git a/co.meldstudio.streamdeck.sdPlugin/ui/transition-stepper.html b/co.meldstudio.streamdeck.sdPlugin/ui/transition-stepper.html
new file mode 100644
index 0000000..d660be5
--- /dev/null
+++ b/co.meldstudio.streamdeck.sdPlugin/ui/transition-stepper.html
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+ co.meldstudio.streamdeck.transition-stepper Property Inspector
+
+
+
+
+
+
+ Learn about scene switching & transitions
+
+
+
+
+
+
+
+
diff --git a/co.meldstudio.streamdeck.sdPlugin/ui/volume-stepper.html b/co.meldstudio.streamdeck.sdPlugin/ui/volume-stepper.html
new file mode 100644
index 0000000..a66318e
--- /dev/null
+++ b/co.meldstudio.streamdeck.sdPlugin/ui/volume-stepper.html
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+ co.meldstudio.streamdeck.volume-stepper Property Inspector
+
+
+
+
+
+
+
+
+
Meter
+
+ Percent
+ Decibels
+
+
+
+
+
Connecting to Meld Studio...
+
+
+
+
+
+
+
+
diff --git a/co.meldstudio.streamdeck.sdPlugin/ui/widget-control.html b/co.meldstudio.streamdeck.sdPlugin/ui/widget-control.html
new file mode 100644
index 0000000..e3881f7
--- /dev/null
+++ b/co.meldstudio.streamdeck.sdPlugin/ui/widget-control.html
@@ -0,0 +1,149 @@
+
+
+
+
+
+
+ co.meldstudio.streamdeck.widget-control Property Inspector
+
+
+
+
+
+
+
Widget
+
+ Stop Watch
+ Countdown
+ Confetti Fall
+ Confetti Pop
+ Subathon
+ Wheel Spin
+ Counter
+
+
+
+
Action
+
+ Reset
+ Pause
+ Resume
+ Reset
+ Pause
+ Resume
+ Drop it!
+ Pop it!
+ Reset
+ Pause
+ Resume
+ Add Time
+ Spin it!
+ Increment
+ Decrement
+
+
+
+
+
+
Connecting to Meld Studio...
+
+
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..d967563
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2543 @@
+{
+ "name": "meld-studio-streamdeck",
+ "version": "0.6.4.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "meld-studio-streamdeck",
+ "version": "0.6.4.1",
+ "license": "MIT",
+ "dependencies": {
+ "@elgato/streamdeck": "^2.1.0"
+ },
+ "devDependencies": {
+ "@elgato/cli": "^1.7.4",
+ "@rollup/plugin-commonjs": "^29.0.2",
+ "@rollup/plugin-node-resolve": "^15.2.2",
+ "@rollup/plugin-terser": "^1.0.0",
+ "@rollup/plugin-typescript": "^12.1.0",
+ "@tsconfig/node20": "^20.1.2",
+ "@types/node": "~24.1.0",
+ "rollup": "^4.0.2",
+ "tslib": "^2.6.2",
+ "tsx": "^4.23.1",
+ "typescript": "^5.2.2"
+ }
+ },
+ "node_modules/@elgato/cli": {
+ "version": "1.7.4",
+ "resolved": "https://registry.npmjs.org/@elgato/cli/-/cli-1.7.4.tgz",
+ "integrity": "sha512-6gD0f9p6VKNYv/INZGk4RzjBbiGSCUCiDOXxh52KyjVbnsJXNlFSKFBk8mtZIoJQHRl+5/QkZkKxXUmABxf86Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@elgato/schemas": "^0.4.14",
+ "@humanwhocodes/momoa": "^3.0.0",
+ "@zip.js/zip.js": "^2.7.54",
+ "ajv": "^8.17.1",
+ "chalk": "^5.4.1",
+ "commander": "^13.0.0",
+ "ejs": "^3.1.10",
+ "find-process": "^1.4.10",
+ "ignore": "^7.0.3",
+ "inquirer": "^12.3.2",
+ "is-interactive": "^2.0.0",
+ "lodash": "^4.17.21",
+ "log-symbols": "^7.0.0",
+ "rage-edit": "^1.2.0",
+ "semver": "^7.6.3",
+ "tar": "^7.5.3"
+ },
+ "bin": {
+ "sd": "bin/streamdeck.mjs",
+ "streamdeck": "bin/streamdeck.mjs"
+ },
+ "engines": {
+ "node": ">=20.1.0"
+ }
+ },
+ "node_modules/@elgato/schemas": {
+ "version": "0.4.15",
+ "resolved": "https://registry.npmjs.org/@elgato/schemas/-/schemas-0.4.15.tgz",
+ "integrity": "sha512-UpELAqazf52PFer+hqg/hvEhYoPHCH3R0af6P7Ih3Hk6xCCJENn7cA5TIpXAIGEWe6dIteHuo/e/iPGLUP7yXA==",
+ "license": "MIT"
+ },
+ "node_modules/@elgato/streamdeck": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@elgato/streamdeck/-/streamdeck-2.1.0.tgz",
+ "integrity": "sha512-d6wgSe93NbKAW0yT6/rabGCYUWic6nAoQoSLAXVadqTIUVmt+5Tfu7OaIGKnGER6AOn6dOpX6havRLFFFcUmOw==",
+ "license": "MIT",
+ "dependencies": {
+ "@elgato/schemas": "^0.4.14",
+ "@elgato/utils": "^0.4.4",
+ "ws": "^8.19.0"
+ },
+ "engines": {
+ "node": ">=20.5.1"
+ }
+ },
+ "node_modules/@elgato/utils": {
+ "version": "0.4.5",
+ "resolved": "https://registry.npmjs.org/@elgato/utils/-/utils-0.4.5.tgz",
+ "integrity": "sha512-kBoqM8LEhhVLv7lNftYqaecNna3T/L85QQWU56yR1iFCvqlyyD22WmBljRcQkw5ZNvib55Br3cnrsxX3ajp+dQ==",
+ "license": "MIT",
+ "dependencies": {
+ "zod": "^3.25.24"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
+ "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
+ "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
+ "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
+ "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
+ "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
+ "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
+ "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
+ "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
+ "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
+ "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
+ "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
+ "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
+ "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
+ "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
+ "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
+ "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
+ "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
+ "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
+ "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
+ "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
+ "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
+ "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@humanwhocodes/momoa": {
+ "version": "3.3.10",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/momoa/-/momoa-3.3.10.tgz",
+ "integrity": "sha512-KWiFQpSAqEIyrTXko3hFNLeQvSK8zXlJQzhhxsyVn58WFRYXST99b3Nqnu+ttOtjds2Pl2grUHGpe2NzhPynuQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/ansi": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
+ "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/checkbox": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz",
+ "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^1.0.2",
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/figures": "^1.0.15",
+ "@inquirer/type": "^3.0.10",
+ "yoctocolors-cjs": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/confirm": {
+ "version": "5.1.21",
+ "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz",
+ "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/type": "^3.0.10"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/core": {
+ "version": "10.3.2",
+ "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz",
+ "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^1.0.2",
+ "@inquirer/figures": "^1.0.15",
+ "@inquirer/type": "^3.0.10",
+ "cli-width": "^4.1.0",
+ "mute-stream": "^2.0.0",
+ "signal-exit": "^4.1.0",
+ "wrap-ansi": "^6.2.0",
+ "yoctocolors-cjs": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/editor": {
+ "version": "4.2.23",
+ "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz",
+ "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/external-editor": "^1.0.3",
+ "@inquirer/type": "^3.0.10"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/expand": {
+ "version": "4.0.23",
+ "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz",
+ "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/type": "^3.0.10",
+ "yoctocolors-cjs": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/external-editor": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz",
+ "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chardet": "^2.1.1",
+ "iconv-lite": "^0.7.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/figures": {
+ "version": "1.0.15",
+ "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz",
+ "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@inquirer/input": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz",
+ "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/type": "^3.0.10"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/number": {
+ "version": "3.0.23",
+ "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz",
+ "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/type": "^3.0.10"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/password": {
+ "version": "4.0.23",
+ "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz",
+ "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^1.0.2",
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/type": "^3.0.10"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/prompts": {
+ "version": "7.10.1",
+ "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz",
+ "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/checkbox": "^4.3.2",
+ "@inquirer/confirm": "^5.1.21",
+ "@inquirer/editor": "^4.2.23",
+ "@inquirer/expand": "^4.0.23",
+ "@inquirer/input": "^4.3.1",
+ "@inquirer/number": "^3.0.23",
+ "@inquirer/password": "^4.0.23",
+ "@inquirer/rawlist": "^4.1.11",
+ "@inquirer/search": "^3.2.2",
+ "@inquirer/select": "^4.4.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/rawlist": {
+ "version": "4.1.11",
+ "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz",
+ "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/type": "^3.0.10",
+ "yoctocolors-cjs": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/search": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz",
+ "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/figures": "^1.0.15",
+ "@inquirer/type": "^3.0.10",
+ "yoctocolors-cjs": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/select": {
+ "version": "4.4.2",
+ "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz",
+ "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^1.0.2",
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/figures": "^1.0.15",
+ "@inquirer/type": "^3.0.10",
+ "yoctocolors-cjs": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@inquirer/type": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz",
+ "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@isaacs/fs-minipass": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
+ "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "minipass": "^7.0.4"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/source-map": {
+ "version": "0.3.11",
+ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
+ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.25"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rollup/plugin-commonjs": {
+ "version": "29.0.3",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.3.tgz",
+ "integrity": "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.0.1",
+ "commondir": "^1.0.1",
+ "estree-walker": "^2.0.2",
+ "fdir": "^6.2.0",
+ "is-reference": "1.2.1",
+ "magic-string": "^0.30.3",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=16.0.0 || 14 >= 14.17"
+ },
+ "peerDependencies": {
+ "rollup": "^2.68.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-node-resolve": {
+ "version": "15.3.1",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.3.1.tgz",
+ "integrity": "sha512-tgg6b91pAybXHJQMAAwW9VuWBO6Thi+q7BCNARLwSqlmsHz0XYURtGvh/AuwSADXSI4h/2uHbs7s4FzlZDGSGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.0.1",
+ "@types/resolve": "1.20.2",
+ "deepmerge": "^4.2.2",
+ "is-module": "^1.0.0",
+ "resolve": "^1.22.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.78.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-terser": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz",
+ "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "serialize-javascript": "^7.0.3",
+ "smob": "^1.0.0",
+ "terser": "^5.17.4"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-typescript": {
+ "version": "12.3.0",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz",
+ "integrity": "sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.1.0",
+ "resolve": "^1.22.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.14.0||^3.0.0||^4.0.0",
+ "tslib": "*",
+ "typescript": ">=3.7.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ },
+ "tslib": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
+ "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tsconfig/node20": {
+ "version": "20.1.9",
+ "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.9.tgz",
+ "integrity": "sha512-IjlTv1RsvnPtUcjTqtVsZExKVq+KQx4g5pCP5tI7rAs6Xesl2qFwSz/tPDBC4JajkL/MlezBu3gPUwqRHl+RIg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.1.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
+ "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.8.0"
+ }
+ },
+ "node_modules/@types/resolve": {
+ "version": "1.20.2",
+ "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
+ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@zip.js/zip.js": {
+ "version": "2.8.26",
+ "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.26.tgz",
+ "integrity": "sha512-RQ4h9F6DOiHxpdocUDrOl6xBM+yOtz+LkUol47AVWcfebGBDpZ7w7Xvz9PS24JgXvLGiXXzSAfdCdVy1tPlaFA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "bun": ">=0.7.0",
+ "deno": ">=1.0.0",
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.17.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
+ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
+ "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/chalk": {
+ "version": "5.6.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
+ "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.17.0 || ^14.13 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chardet": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.2.0.tgz",
+ "integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/chownr": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
+ "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/cli-width": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
+ "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "13.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
+ "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/commondir": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
+ "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/ejs": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "jake": "^10.8.5"
+ },
+ "bin": {
+ "ejs": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.28.1",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
+ "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.28.1",
+ "@esbuild/android-arm": "0.28.1",
+ "@esbuild/android-arm64": "0.28.1",
+ "@esbuild/android-x64": "0.28.1",
+ "@esbuild/darwin-arm64": "0.28.1",
+ "@esbuild/darwin-x64": "0.28.1",
+ "@esbuild/freebsd-arm64": "0.28.1",
+ "@esbuild/freebsd-x64": "0.28.1",
+ "@esbuild/linux-arm": "0.28.1",
+ "@esbuild/linux-arm64": "0.28.1",
+ "@esbuild/linux-ia32": "0.28.1",
+ "@esbuild/linux-loong64": "0.28.1",
+ "@esbuild/linux-mips64el": "0.28.1",
+ "@esbuild/linux-ppc64": "0.28.1",
+ "@esbuild/linux-riscv64": "0.28.1",
+ "@esbuild/linux-s390x": "0.28.1",
+ "@esbuild/linux-x64": "0.28.1",
+ "@esbuild/netbsd-arm64": "0.28.1",
+ "@esbuild/netbsd-x64": "0.28.1",
+ "@esbuild/openbsd-arm64": "0.28.1",
+ "@esbuild/openbsd-x64": "0.28.1",
+ "@esbuild/openharmony-arm64": "0.28.1",
+ "@esbuild/sunos-x64": "0.28.1",
+ "@esbuild/win32-arm64": "0.28.1",
+ "@esbuild/win32-ia32": "0.28.1",
+ "@esbuild/win32-x64": "0.28.1"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
+ "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/filelist": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
+ "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "minimatch": "^5.0.1"
+ }
+ },
+ "node_modules/find-process": {
+ "version": "1.4.11",
+ "resolved": "https://registry.npmjs.org/find-process/-/find-process-1.4.11.tgz",
+ "integrity": "sha512-mAOh9gGk9WZ4ip5UjV0o6Vb4SrfnAmtsFNzkMRH9HQiFXVQnDyQFrSHTK5UoG6E+KV+s+cIznbtwpfN41l2nFA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "chalk": "~4.1.2",
+ "commander": "^12.1.0",
+ "loglevel": "^1.9.2"
+ },
+ "bin": {
+ "find-process": "bin/find-process.js"
+ }
+ },
+ "node_modules/find-process/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/find-process/node_modules/commander": {
+ "version": "12.1.0",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz",
+ "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "7.0.5",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
+ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/inquirer": {
+ "version": "12.11.1",
+ "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-12.11.1.tgz",
+ "integrity": "sha512-9VF7mrY+3OmsAfjH3yKz/pLbJ5z22E23hENKw3/LNSaA/sAt3v49bDRY+Ygct1xwuKT+U+cBfTzjCPySna69Qw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@inquirer/ansi": "^1.0.2",
+ "@inquirer/core": "^10.3.2",
+ "@inquirer/prompts": "^7.10.1",
+ "@inquirer/type": "^3.0.10",
+ "mute-stream": "^2.0.0",
+ "run-async": "^4.0.6",
+ "rxjs": "^7.8.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@types/node": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.2",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
+ "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-fullwidth-code-point": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+ "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-interactive": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
+ "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/is-module": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
+ "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/is-reference": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
+ "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "*"
+ }
+ },
+ "node_modules/is-unicode-supported": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
+ "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/jake": {
+ "version": "10.9.4",
+ "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
+ "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "async": "^3.2.6",
+ "filelist": "^1.0.4",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "jake": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lodash": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+ "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/log-symbols": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
+ "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-unicode-supported": "^2.0.0",
+ "yoctocolors": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/loglevel": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
+ "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6.0"
+ },
+ "funding": {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/loglevel"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/minipass": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
+ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/minizlib": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
+ "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/mute-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz",
+ "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": "^18.17.0 || >=20.5.0"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/rage-edit": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/rage-edit/-/rage-edit-1.2.0.tgz",
+ "integrity": "sha512-0RspBRc2s6We4g7hRCvT5mu7YPEnfjvQK8Tt354a2uUNJCMC7MKLvo/1mLvHUCQ/zbP6siQyp5VRZN7UCpMFZg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/run-async": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/run-async/-/run-async-4.0.6.tgz",
+ "integrity": "sha512-IoDlSLTs3Yq593mb3ZoKWKXMNu3UpObxhgA/Xuid5p4bbfi2jdY1Hj0m1K+0/tEuQTxIGMhQDqGjKb7RuxGpAQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/rxjs": {
+ "version": "7.8.2",
+ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
+ "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.1.0"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/serialize-javascript": {
+ "version": "7.0.7",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz",
+ "integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/signal-exit": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
+ "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/smob": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz",
+ "integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tar": {
+ "version": "7.5.19",
+ "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz",
+ "integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/fs-minipass": "^4.0.0",
+ "chownr": "^3.0.0",
+ "minipass": "^7.1.2",
+ "minizlib": "^3.1.0",
+ "yallist": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/terser": {
+ "version": "5.49.0",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz",
+ "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@jridgewell/source-map": "^0.3.3",
+ "acorn": "^8.15.0",
+ "commander": "^2.20.0",
+ "source-map-support": "~0.5.20"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/terser/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD"
+ },
+ "node_modules/tsx": {
+ "version": "4.23.1",
+ "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz",
+ "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "~0.28.0"
+ },
+ "bin": {
+ "tsx": "dist/cli.mjs"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.8.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
+ "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/wrap-ansi": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+ "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.0.0",
+ "string-width": "^4.1.0",
+ "strip-ansi": "^6.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ws": {
+ "version": "8.21.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
+ "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
+ "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/yoctocolors": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz",
+ "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/yoctocolors-cjs": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz",
+ "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e3a139c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,31 @@
+{
+ "name": "meld-studio-streamdeck",
+ "version": "0.6.4.1",
+ "description": "Meld Studio Stream Deck plugin (Node SDK)",
+ "license": "MIT",
+ "type": "module",
+ "scripts": {
+ "build": "rollup -c",
+ "watch": "rollup -c -w --watch.onEnd=\"streamdeck restart co.meldstudio.streamdeck\"",
+ "pack": "npm run build && streamdeck pack co.meldstudio.streamdeck.sdPlugin --force",
+ "typecheck": "tsc --noEmit && tsc -p test",
+ "test": "node --import ./test/register-hooks.js --test --test-concurrency=1 test/*.test.ts",
+ "coverage": "node --import tsx --test --test-concurrency=1 --experimental-test-coverage --test-coverage-include='src/**/*.ts' --test-coverage-exclude='src/meld/qwebchannel.ts' --test-coverage-lines=90 --test-coverage-branches=79 --test-coverage-functions=75 test/*.test.ts"
+ },
+ "devDependencies": {
+ "@elgato/cli": "^1.7.4",
+ "@rollup/plugin-commonjs": "^29.0.2",
+ "@rollup/plugin-node-resolve": "^15.2.2",
+ "@rollup/plugin-terser": "^1.0.0",
+ "@rollup/plugin-typescript": "^12.1.0",
+ "@tsconfig/node20": "^20.1.2",
+ "@types/node": "~24.1.0",
+ "rollup": "^4.0.2",
+ "tslib": "^2.6.2",
+ "tsx": "^4.23.1",
+ "typescript": "^5.2.2"
+ },
+ "dependencies": {
+ "@elgato/streamdeck": "^2.1.0"
+ }
+}
diff --git a/rollup.config.mjs b/rollup.config.mjs
new file mode 100644
index 0000000..7c68061
--- /dev/null
+++ b/rollup.config.mjs
@@ -0,0 +1,49 @@
+import commonjs from "@rollup/plugin-commonjs";
+import nodeResolve from "@rollup/plugin-node-resolve";
+import terser from "@rollup/plugin-terser";
+import typescript from "@rollup/plugin-typescript";
+import path from "node:path";
+import url from "node:url";
+
+const isWatching = !!process.env.ROLLUP_WATCH;
+const sdPlugin = "co.meldstudio.streamdeck.sdPlugin";
+
+/**
+ * @type {import('rollup').RollupOptions}
+ */
+const config = {
+ input: "src/plugin.ts",
+ output: {
+ file: `${sdPlugin}/bin/plugin.js`,
+ sourcemap: isWatching,
+ sourcemapPathTransform: (relativeSourcePath, sourcemapPath) => {
+ return url.pathToFileURL(path.resolve(path.dirname(sourcemapPath), relativeSourcePath)).href;
+ }
+ },
+ plugins: [
+ {
+ name: "watch-externals",
+ buildStart: function () {
+ this.addWatchFile(`${sdPlugin}/manifest.json`);
+ }
+ },
+ typescript({
+ mapRoot: isWatching ? "./" : undefined
+ }),
+ nodeResolve({
+ browser: false,
+ exportConditions: ["node"],
+ preferBuiltins: true
+ }),
+ commonjs(),
+ !isWatching && terser(),
+ {
+ name: "emit-module-package-file",
+ generateBundle() {
+ this.emitFile({ fileName: "package.json", source: `{ "type": "module" }`, type: "asset" });
+ }
+ }
+ ]
+};
+
+export default config;
diff --git a/scripts/install.sh b/scripts/install.sh
index 8695c6d..feed5c5 100755
--- a/scripts/install.sh
+++ b/scripts/install.sh
@@ -1,7 +1,26 @@
#!/usr/bin/env bash
+set -euo pipefail
-if [[ "$1" == "--fresh" ]]; then
+# Resolve the repo root regardless of where the script is invoked from.
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+
+if [[ "${1:-}" == "--fresh" ]]; then
rm -rf ~/Library/Application\ Support/com.elgato.StreamDeck/Plugins/co.meldstudio.streamdeck.sdPlugin
fi
-rsync -avz co.meldstudio.streamdeck.sdPlugin ~/Library/Application\ Support/com.elgato.StreamDeck/Plugins/
\ No newline at end of file
+# Build the Node plugin bundle (co.meldstudio.streamdeck.sdPlugin/bin/plugin.js).
+if [[ ! -d node_modules ]]; then
+ npm install
+fi
+npm run build
+
+# --delete drops files removed from the repo (e.g. the legacy SDKv2 tree) from
+# the installed copy; the 'logs' exclude also protects logs from deletion.
+rsync -avz --delete --exclude 'logs' co.meldstudio.streamdeck.sdPlugin ~/Library/Application\ Support/com.elgato.StreamDeck/Plugins/
+
+# The plugin is a long-lived Node process; without a restart the old bundle
+# keeps running even though the files on disk were just replaced.
+if ! npx streamdeck restart co.meldstudio.streamdeck; then
+ echo "Could not restart the plugin automatically; restart Stream Deck to load the update."
+fi
diff --git a/src/actions/base.ts b/src/actions/base.ts
new file mode 100644
index 0000000..ea32e87
--- /dev/null
+++ b/src/actions/base.ts
@@ -0,0 +1,364 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import streamDeck, {
+ type DialAction,
+ type DidReceiveSettingsEvent,
+ type KeyAction,
+ type KeyDownEvent,
+ SingletonAction,
+ type WillAppearEvent,
+ type WillDisappearEvent,
+} from "@elgato/streamdeck";
+import type { JsonObject } from "@elgato/utils";
+
+import { meld, type MeldConnection, MeldSession } from "../meld/index.js";
+
+/** Why {@link MeldAction.render} was invoked. */
+export type RenderReason =
+ | "willAppear"
+ | "didReceiveSettings"
+ | "sessionChanged"
+ | "streamingChanged"
+ | "recordingChanged"
+ | "ready"
+ | "closed";
+
+/** A visible action instance — either a key or a dial. */
+export type MeldActionInstance = DialAction | KeyAction;
+
+/** Everything an action needs to render one context deterministically. */
+export type RenderArgs = {
+ /** The action instance to update. */
+ action: MeldActionInstance;
+ /** The action instance's current settings. */
+ settings: T;
+ /** A read-only view of the latest authoritative Meld session. */
+ session: MeldSession;
+ /** Why the render was triggered. */
+ reason: RenderReason;
+};
+
+/**
+ * Base class for every Meld action.
+ *
+ * It collapses the old, race-prone `onWillAppear` → `getSettings` →
+ * `onDidReceiveSettings` → `hydrateAll` → `onHydrate` chain into a single
+ * declarative {@link render} method that the framework calls whenever anything
+ * relevant changes: the action appears, its settings change, the session
+ * changes, streaming/recording toggles, or the connection (re)connects.
+ *
+ * Subclasses implement {@link render} and, if needed, the small optional hooks.
+ * They never wire up reconnect handling themselves — the shared
+ * {@link MeldConnection} owns that.
+ */
+export abstract class MeldAction extends SingletonAction {
+ /** The shared, process-wide Meld connection. */
+ protected readonly meld: MeldConnection;
+
+ /** Latest known settings per visible context id. */
+ readonly #settings = new Map();
+ /** Contexts whose last migration write failed and must be retried. */
+ readonly #settingsPersistenceRetry = new Set();
+ /** Canonical settings writes currently being sent, keyed by visible context. */
+ readonly #settingsPersistenceInFlight = new Map }>();
+
+ /** Last state/image pushed per context, used to suppress redundant messages. */
+ readonly #lastState = new Map();
+ readonly #lastImage = new Map();
+
+ /**
+ * @param connection Meld connection to bind to. Defaults to the process-wide
+ * singleton; tests pass one pointed at a fake Meld.
+ */
+ constructor(connection: MeldConnection = meld) {
+ super();
+ this.meld = connection;
+ // One subscription set per action instance (a singleton). The connection
+ // re-emits a full snapshot on every (re)connect, so these cover hydration.
+ this.meld.on("ready", () => void this.renderAll("ready"));
+ this.meld.on("closed", () => void this.renderAll("closed"));
+ this.meld.on("sessionChanged", () => void this.renderAll("sessionChanged"));
+ this.meld.on("streamingChanged", () => void this.renderAll("streamingChanged"));
+ this.meld.on("recordingChanged", () => void this.renderAll("recordingChanged"));
+ }
+
+ /** Whether Meld is currently streaming (authoritative). */
+ protected get isStreaming(): boolean {
+ return !!this.meld.meld?.isStreaming;
+ }
+
+ /** Whether Meld is currently recording (authoritative). */
+ protected get isRecording(): boolean {
+ return !!this.meld.meld?.isRecording;
+ }
+
+ /**
+ * Renders a single context from authoritative state. Implemented by each action.
+ * @param args Action, settings, session snapshot, and the trigger reason.
+ */
+ protected abstract render(args: RenderArgs): void | Promise;
+
+ /**
+ * Optional hook invoked once when an action becomes visible, before its first
+ * {@link render}. Use for one-time setup such as initial dial feedback.
+ * @param args Render arguments for the appearing context.
+ */
+ protected onAppear?(args: RenderArgs): void | Promise;
+
+ /**
+ * Optional hook invoked when an action becomes invisible. Use for cleanup such
+ * as unregistering a track observer.
+ * @param context The disappearing context id.
+ * @param settings The context's last known settings, if any.
+ */
+ protected onDisappear?(context: string, settings: T | undefined): void | Promise;
+
+ /**
+ * Optional hook invoked when settings change, before {@link render}.
+ * @param args Render arguments plus the previous settings.
+ */
+ protected onSettingsChanged?(args: RenderArgs & { previous: T | undefined }): void | Promise;
+
+ /**
+ * Returns the canonical persisted form of an action's settings.
+ *
+ * Migrations run both when Stream Deck supplies settings and on connection
+ * updates, because parent ids can only be repaired once the corresponding Meld
+ * session is available. Implementations must preserve fields they do not own.
+ * The base class persists a changed result and suppresses identical rewrites.
+ */
+ protected migrateSettings(settings: T, _session: MeldSession): T {
+ return settings;
+ }
+
+ /** @inheritdoc */
+ override async onWillAppear(ev: WillAppearEvent): Promise {
+ const session = this.session();
+ const settings = await this.#migrateAndPersist(ev.action, ev.payload.settings, session);
+ const args: RenderArgs = {
+ action: ev.action,
+ settings,
+ session,
+ reason: "willAppear",
+ };
+ await this.onAppear?.(args);
+ await this.render(args);
+ }
+
+ /** @inheritdoc */
+ override async onWillDisappear(ev: WillDisappearEvent): Promise {
+ const settings = this.#settings.get(ev.action.id);
+ this.#settings.delete(ev.action.id);
+ this.#settingsPersistenceRetry.delete(ev.action.id);
+ this.#lastState.delete(ev.action.id);
+ this.#lastImage.delete(ev.action.id);
+ await this.onDisappear?.(ev.action.id, settings);
+ }
+
+ /** @inheritdoc */
+ override onKeyDown(ev: KeyDownEvent): void {
+ // The Stream Deck app may repaint a key on press (multi-actions, or any
+ // state the manifest doesn't cover), so the displayed state can no longer be
+ // assumed to match what we last sent. Forget it so the next setKeyState /
+ // setKeyImage always reasserts the authoritative visuals.
+ this.#lastState.delete(ev.action.id);
+ this.#lastImage.delete(ev.action.id);
+ }
+
+ /** @inheritdoc */
+ override async onDidReceiveSettings(ev: DidReceiveSettingsEvent): Promise {
+ const previous = this.#settings.get(ev.action.id);
+ const session = this.session();
+ const settings = await this.#migrateAndPersist(ev.action, ev.payload.settings, session);
+ const args: RenderArgs = {
+ action: ev.action,
+ settings,
+ session,
+ reason: "didReceiveSettings",
+ };
+ await this.onSettingsChanged?.({ ...args, previous });
+ await this.render(args);
+ }
+
+ /** A read-only view over the latest authoritative session snapshot. */
+ protected session(): MeldSession {
+ return new MeldSession(this.meld.session);
+ }
+
+ /** The cached settings for a context, if known. */
+ protected settingsFor(context: string): T | undefined {
+ return this.#settings.get(context);
+ }
+
+ /**
+ * Re-renders every visible instance of this action from the current session.
+ * @param reason Why the re-render was triggered.
+ */
+ protected async renderAll(reason: RenderReason): Promise {
+ const session = this.session();
+ for (const action of this.actions) {
+ // Contain per-context failures: renderAll is invoked fire-and-forget from
+ // connection events, and one bad context must not skip the others or
+ // surface as an unhandled rejection.
+ try {
+ let settings = this.#settings.get(action.id);
+ if (settings === undefined) {
+ settings = await action.getSettings();
+ }
+ settings = await this.#migrateAndPersist(action, settings, session);
+ await this.render({ action, settings, session, reason });
+ } catch (err) {
+ streamDeck.logger.error(`Failed to render ${action.id} (${reason})`, err);
+ }
+ }
+ }
+
+ /** Migrates, caches, and (only when changed) persists one settings object. */
+ async #migrateAndPersist(action: MeldActionInstance, settings: T, session: MeldSession): Promise {
+ const migrated = this.migrateSettings(settings, session);
+ // Claim the canonical cache entry before the write. A ready/sessionChanged
+ // pair can render concurrently; the second pass then sees the migrated value
+ // and cannot issue a duplicate setSettings command.
+ this.#settings.set(action.id, migrated);
+ if (!this.#sameSettings(settings, migrated) || this.#settingsPersistenceRetry.has(action.id)) {
+ // Lifecycle callbacks can overlap before Stream Deck echoes a setSettings
+ // write. Join an identical write, and serialize a genuinely newer one.
+ while (true) {
+ const pending = this.#settingsPersistenceInFlight.get(action.id);
+ if (!pending) {
+ break;
+ }
+ if (this.#sameSettings(pending.settings, migrated)) {
+ await pending.promise;
+ return migrated;
+ }
+ await pending.promise;
+ }
+
+ this.#settingsPersistenceRetry.delete(action.id);
+ let entry: { settings: T; promise: Promise };
+ const promise = Promise.resolve()
+ .then(() => action.setSettings(migrated))
+ .catch((err: unknown) => {
+ this.#settingsPersistenceRetry.add(action.id);
+ streamDeck.logger.error(`Failed to persist migrated settings for ${action.id}`, err);
+ })
+ .finally(() => {
+ if (this.#settingsPersistenceInFlight.get(action.id) === entry) {
+ this.#settingsPersistenceInFlight.delete(action.id);
+ }
+ });
+ entry = { settings: migrated, promise };
+ this.#settingsPersistenceInFlight.set(action.id, entry);
+ await promise;
+ }
+ return migrated;
+ }
+
+ /** Structural JSON equality, including forward-compatible fields this plugin does not own. */
+ #sameSettings(left: T, right: T): boolean {
+ return this.#sameJsonValue(left, right);
+ }
+
+ #sameJsonValue(left: unknown, right: unknown): boolean {
+ if (left === right) {
+ return true;
+ }
+ if (left === null || right === null || typeof left !== "object" || typeof right !== "object") {
+ return false;
+ }
+ if (Array.isArray(left) || Array.isArray(right)) {
+ return (
+ Array.isArray(left) &&
+ Array.isArray(right) &&
+ left.length === right.length &&
+ left.every((value, index) => this.#sameJsonValue(value, right[index]))
+ );
+ }
+ const leftObject = left as Record;
+ const rightObject = right as Record;
+ const leftKeys = Object.keys(leftObject);
+ const rightKeys = Object.keys(rightObject);
+ return (
+ leftKeys.length === rightKeys.length &&
+ leftKeys.every(
+ (key) => Object.hasOwn(rightObject, key) && this.#sameJsonValue(leftObject[key], rightObject[key]),
+ )
+ );
+ }
+
+ /**
+ * Sets a key's state, suppressing the message when the state is unchanged.
+ * @param action Key action instance.
+ * @param state State to display (0 or 1).
+ */
+ protected async setKeyState(action: MeldActionInstance, state: 0 | 1): Promise {
+ if (this.#lastState.get(action.id) === state) {
+ return;
+ }
+ // Claim the cache slot before awaiting the send. Two updates can land in one
+ // event-loop turn, and if the cache still held the pre-send value a newer
+ // state matching it would be suppressed — leaving the key latched on the
+ // older value with a cache that agrees.
+ const previous = this.#lastState.get(action.id);
+ this.#lastState.set(action.id, state);
+ try {
+ await this.asKey(action).setState(state);
+ } catch (err) {
+ // The send failed, so restore the previous value and let the next render
+ // retry — unless a newer state has already claimed the slot.
+ if (this.#lastState.get(action.id) === state) {
+ this.#restore(this.#lastState, action.id, previous);
+ }
+ streamDeck.logger.error(`Failed to set state for ${action.id}`, err);
+ }
+ }
+
+ /**
+ * Sets a key's image, suppressing the message when the image is unchanged.
+ * @param action Key action instance.
+ * @param image Image path/data, or `undefined` to fall back to the manifest.
+ */
+ protected async setKeyImage(action: MeldActionInstance, image: string | undefined): Promise {
+ if (this.#lastImage.has(action.id) && this.#lastImage.get(action.id) === image) {
+ return;
+ }
+ const had = this.#lastImage.has(action.id);
+ const previous = this.#lastImage.get(action.id);
+ this.#lastImage.set(action.id, image);
+ try {
+ await this.asKey(action).setImage(image);
+ } catch (err) {
+ if (this.#lastImage.get(action.id) === image) {
+ this.#restore(this.#lastImage, action.id, had ? previous : undefined, had);
+ }
+ streamDeck.logger.error(`Failed to set image for ${action.id}`, err);
+ }
+ }
+
+ /**
+ * Puts a dedupe-cache entry back after a failed send.
+ * @param cache The cache to restore into.
+ * @param context Context id.
+ * @param previous The value held before the failed send.
+ * @param hadEntry Whether an entry existed at all; when false the key is removed.
+ */
+ #restore(cache: Map, context: string, previous: V | undefined, hadEntry = previous !== undefined): void {
+ if (hadEntry) {
+ cache.set(context, previous as V);
+ } else {
+ cache.delete(context);
+ }
+ }
+
+ /** Narrows an instance to a {@link KeyAction}; only valid for keypad actions. */
+ protected asKey(action: MeldActionInstance): KeyAction {
+ return action as KeyAction;
+ }
+
+ /** Narrows an instance to a {@link DialAction}; only valid for encoder actions. */
+ protected asDial(action: MeldActionInstance): DialAction {
+ return action as DialAction;
+ }
+}
diff --git a/src/actions/clip.ts b/src/actions/clip.ts
new file mode 100644
index 0000000..25e3d3f
--- /dev/null
+++ b/src/actions/clip.ts
@@ -0,0 +1,51 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import { action, type KeyUpEvent } from "@elgato/streamdeck";
+
+import { MeldAction } from "./base.js";
+
+/** Settings for {@link Clip}. */
+type ClipSettings = {
+ clipDuration?: string | number | null;
+};
+
+const DEFAULT_CLIP_DURATION = 90;
+const MAX_CLIP_DURATION = 90;
+
+/** Records a configurable-duration clip to disk. */
+@action({ UUID: "co.meldstudio.streamdeck.clip" })
+export class Clip extends MeldAction {
+ protected override migrateSettings(settings: ClipSettings): ClipSettings {
+ const clipDuration = String(this.#duration(settings.clipDuration));
+ if (settings.clipDuration === clipDuration) {
+ return settings;
+ }
+ return { ...settings, clipDuration };
+ }
+
+ /** Nothing to render — the key has a single, static state. */
+ protected render(): void {}
+
+ /** @inheritdoc */
+ override onKeyUp(ev: KeyUpEvent): void {
+ const meld = this.meld.meld;
+ const settings = this.settingsFor(ev.action.id) ?? ev.payload.settings;
+ const duration = this.#duration(settings.clipDuration);
+ if (meld?.sendCommandWithArgs) {
+ meld.sendCommandWithArgs("meld.recordClip", [duration]);
+ } else {
+ // Older API v3 builds only support the argument-less maximum clip.
+ meld?.sendCommand?.("meld.recordClip");
+ }
+ }
+
+ /** Normalizes user input to Meld's advertised finite 0–90 second range. */
+ #duration(value: string | number | null | undefined): number {
+ const parsed = value == null || (typeof value === "string" && value.trim() === "") ? Number.NaN : Number(value);
+ if (!Number.isFinite(parsed)) {
+ return DEFAULT_CLIP_DURATION;
+ }
+ return Math.min(MAX_CLIP_DURATION, Math.max(0, parsed));
+ }
+}
diff --git a/src/actions/gain.ts b/src/actions/gain.ts
new file mode 100644
index 0000000..c04476f
--- /dev/null
+++ b/src/actions/gain.ts
@@ -0,0 +1,22 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+/** Lowest represented level, treated as silence. */
+export const SILENCE_DB = -60;
+
+/** Converts a linear gain (0..1) to decibels, flooring silence at -60 dB. */
+export function toDb(gain: number): number {
+ const dB = 20 * Math.log10(gain);
+ return isFinite(dB) ? dB : SILENCE_DB;
+}
+
+/** Converts decibels back to a linear gain, clamped to 0..1. */
+export function toGain(dB: number): number {
+ if (!isFinite(dB) || dB < SILENCE_DB) {
+ dB = SILENCE_DB;
+ }
+ let gain = Math.pow(10, dB / 20);
+ gain = gain <= 0.001 ? 0 : gain;
+ gain = gain > 1 ? 1 : gain;
+ return gain;
+}
diff --git a/src/actions/media.ts b/src/actions/media.ts
new file mode 100644
index 0000000..db74ed6
--- /dev/null
+++ b/src/actions/media.ts
@@ -0,0 +1,177 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import { action, type KeyUpEvent } from "@elgato/streamdeck";
+
+import type { IdentifiedItem, LayerItem, MeldConnection, MeldSession } from "../meld/index.js";
+import { MeldAction, type RenderArgs } from "./base.js";
+import { OptimisticStore } from "./optimistic.js";
+
+/** Settings for {@link Media}. */
+type MediaSettings = {
+ scene?: string;
+ layer?: string;
+ action?: string;
+};
+
+const ICON_PLAY = "assets/Media/Key Icon/media-controls-play";
+const ICON_PAUSE = "assets/Media/Key Icon/media-controls-pause";
+const ICON_RESTART = "assets/Media/Key Icon/media-controls-skip-back";
+
+/**
+ * How long a press keeps precedence over the snapshot. Meld republishes the
+ * session behind a retriggerable autosave, so the confirmation is slower than a
+ * gain echo; this outlasts it while still self-correcting a press that never
+ * took effect.
+ */
+const MEDIA_CONFIRM_TTL_MS = 3_000;
+
+/** Controls playback of a media layer (play / pause / restart / toggle). */
+@action({ UUID: "co.meldstudio.streamdeck.media" })
+export class Media extends MeldAction {
+ /**
+ * Playback state a press is waiting on, until the snapshot catches up. Meld
+ * republishes behind a debounced autosave, so without this a second press
+ * would recompute from the same stale value.
+ */
+ readonly #optimisticPlaying = new OptimisticStore(MEDIA_CONFIRM_TTL_MS, () =>
+ void this.renderAll("sessionChanged"),
+ );
+
+ constructor(connection?: MeldConnection) {
+ super(connection);
+ // Keep the last icon painted during the outage, but do not let an
+ // unconfirmed command from the stopped process override the next instance.
+ this.meld.on("closed", () => this.#optimisticPlaying.clearAll());
+ }
+
+ protected override migrateSettings(settings: MediaSettings, session: MeldSession): MediaSettings {
+ let migrated = settings.action == null ? { ...settings, action: "toggle" } : settings;
+ const layer = session.getLayer(migrated.layer);
+ if (layer && migrated.scene !== layer.parent) {
+ migrated = { ...migrated, scene: layer.parent };
+ }
+ return migrated;
+ }
+
+ /** Reflects the media layer's playback state on the key. */
+ protected async render({ action, settings, session, reason }: RenderArgs): Promise {
+ // With no snapshot there is nothing authoritative to show; keep the last
+ // icon rather than flashing "paused" through a reconnect blip. A settings
+ // change is different: the user just chose a different press mode, and for
+ // the fixed-icon modes that choice alone determines the artwork, so the key
+ // must not stay showing the previous mode until Meld comes back.
+ if (!session.isPresent && reason !== "willAppear" && reason !== "didReceiveSettings") {
+ return;
+ }
+ const layer = this.#findMediaLayer(settings, session);
+ if (layer && this.#optimisticPlaying.get(action.id) === layer.isPlaying) {
+ // The snapshot caught up with the press; stop overriding it.
+ this.#optimisticPlaying.clear(action.id);
+ }
+ const playing = this.#optimisticPlaying.get(action.id) ?? layer?.isPlaying ?? false;
+ await this.setKeyImage(action, this.#iconFor(settings.action, playing));
+ }
+
+ /** @inheritdoc */
+ protected override onDisappear(context: string): void {
+ this.#optimisticPlaying.clear(context);
+ }
+
+ /** @inheritdoc */
+ protected override onSettingsChanged({
+ action,
+ settings,
+ previous,
+ }: RenderArgs & { previous: MediaSettings | undefined }): void {
+ // A pending press belongs to the layer that was configured when it was
+ // made; applying it to a newly chosen layer would show the inverse of that
+ // clip's real state and send the wrong command next.
+ if (previous !== undefined && previous.layer !== settings.layer) {
+ this.#optimisticPlaying.clear(action.id);
+ }
+ }
+
+ /** @inheritdoc */
+ override async onKeyUp(ev: KeyUpEvent): Promise {
+ const settings = ev.payload.settings;
+ const layer = this.#findMediaLayer(settings, this.session());
+ const meld = this.meld.meld;
+
+ switch (settings.action) {
+ case "play":
+ await this.setKeyImage(ev.action, ICON_PLAY);
+ if (layer) {
+ meld?.callFunction?.(layer.id, "play");
+ }
+ break;
+ case "pause":
+ await this.setKeyImage(ev.action, ICON_PAUSE);
+ if (layer) {
+ meld?.callFunction?.(layer.id, "pause");
+ }
+ break;
+ case "restart":
+ await this.setKeyImage(ev.action, ICON_RESTART);
+ if (layer) {
+ // `skipBack` is what Meld's own restart control uses: it returns to
+ // the loop's start point on a trimmed clip, where seekTo(0) would
+ // jump outside the configured region.
+ meld?.callFunction?.(layer.id, "skipBack");
+ }
+ break;
+ default: {
+ // "toggle" (and unset): play when paused, pause when playing.
+ if (!layer) {
+ return;
+ }
+ // `isPlaying` comes from the session snapshot, which Meld only
+ // republishes behind its autosave debounce. Two presses inside that
+ // window would both read the pre-press value and send the same command
+ // twice, so the pending intent takes precedence.
+ const playing = this.#optimisticPlaying.get(ev.action.id) ?? layer.isPlaying;
+ this.#optimisticPlaying.set(ev.action.id, !playing);
+ await this.setKeyImage(ev.action, this.#iconFor(settings.action, !playing));
+ meld?.callFunction?.(layer.id, playing ? "pause" : "play");
+ break;
+ }
+ }
+ }
+
+ /** The image for a given press mode and playback state. */
+ #iconFor(mediaAction: string | undefined, isPlaying: boolean): string {
+ switch (mediaAction) {
+ case "restart":
+ return ICON_RESTART;
+ case "play":
+ return ICON_PLAY;
+ case "pause":
+ return ICON_PAUSE;
+ default:
+ return isPlaying ? ICON_PAUSE : ICON_PLAY;
+ }
+ }
+
+ /**
+ * Resolves the configured media layer.
+ *
+ * The layer id alone identifies it, and that is all Meld needs:
+ * `callFunction` resolves the object by id and never consults a scene. The
+ * stored scene is not required to match, because an upgraded profile can hold
+ * one from a previous selection — the same tolerance toggle-layer and
+ * toggle-effect apply, so the key keeps working instead of going inert.
+ * @param settings The action's settings.
+ * @param session The session to resolve against.
+ */
+ #findMediaLayer(settings: MediaSettings, session: MeldSession): IdentifiedItem | undefined {
+ const { layer } = settings;
+ if (!layer) {
+ return undefined;
+ }
+ const item = session.getLayer(layer);
+ if (!item || !item.mediaSource) {
+ return undefined;
+ }
+ return item;
+ }
+}
diff --git a/src/actions/optimistic.ts b/src/actions/optimistic.ts
new file mode 100644
index 0000000..6dd6ec8
--- /dev/null
+++ b/src/actions/optimistic.ts
@@ -0,0 +1,79 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+/** Default lifetime of an optimistic value before it is discarded (ms). */
+const DEFAULT_TTL_MS = 750;
+
+/**
+ * Holds short-lived, per-context "optimistic" values used to make dials and
+ * toggles feel responsive without letting them drift out of sync with Meld.
+ *
+ * The rule is simple and always the same: **authoritative state wins.** A user
+ * interaction stores an optimistic value so the UI updates immediately; the
+ * value self-expires after a short TTL, and any authoritative update from Meld
+ * clears it outright. This replaces the ad-hoc `trackInfo[track].gain` /
+ * `sceneRequested` bookkeeping that caused the dial-desync bug.
+ */
+export class OptimisticStore {
+ readonly #pending = new Map }>();
+ readonly #ttlMs: number;
+ readonly #onExpire?: (context: string) => void;
+
+ /**
+ * @param ttlMs Lifetime of an optimistic value before it self-expires.
+ * @param onExpire Invoked (after removal) when a value expires without being
+ * confirmed or cleared — the owner should repaint from authoritative state,
+ * since whatever was last painted optimistically may never be confirmed.
+ */
+ constructor(ttlMs: number = DEFAULT_TTL_MS, onExpire?: (context: string) => void) {
+ this.#ttlMs = ttlMs;
+ this.#onExpire = onExpire;
+ }
+
+ /**
+ * Records an optimistic value for a context, replacing any existing one and
+ * (re)starting its expiry timer.
+ * @param context Context id.
+ * @param value Optimistic value to display until Meld confirms.
+ */
+ set(context: string, value: T): void {
+ this.clear(context);
+ const timer = setTimeout(() => {
+ this.#pending.delete(context);
+ this.#onExpire?.(context);
+ }, this.#ttlMs);
+ // Do not keep the process alive purely for an optimistic-value expiry.
+ timer.unref?.();
+ this.#pending.set(context, { value, timer });
+ }
+
+ /**
+ * Returns the current optimistic value for a context, or `undefined` when none
+ * is active.
+ * @param context Context id.
+ */
+ get(context: string): T | undefined {
+ return this.#pending.get(context)?.value;
+ }
+
+ /**
+ * Drops any optimistic value for a context. Call this whenever authoritative
+ * state arrives, so Meld's value takes over.
+ * @param context Context id.
+ */
+ clear(context: string): void {
+ const entry = this.#pending.get(context);
+ if (entry) {
+ clearTimeout(entry.timer);
+ this.#pending.delete(context);
+ }
+ }
+
+ /** Clears every optimistic value (e.g. on disconnect). */
+ clearAll(): void {
+ for (const { timer } of this.#pending.values()) {
+ clearTimeout(timer);
+ }
+ this.#pending.clear();
+ }
+}
diff --git a/src/actions/replay.ts b/src/actions/replay.ts
new file mode 100644
index 0000000..80117da
--- /dev/null
+++ b/src/actions/replay.ts
@@ -0,0 +1,194 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import { action, type KeyUpEvent } from "@elgato/streamdeck";
+
+import type { MeldConnection, MeldSession, TrackItem } from "../meld/index.js";
+import { MeldAction, type RenderArgs } from "./base.js";
+import { toDb, toGain } from "./gain.js";
+
+/** Settings for {@link Replay}. */
+type ReplaySettings = {
+ action?: string;
+};
+
+/** Resolved state of the replay clip's audio track. */
+type ReplayState = {
+ layerId?: string;
+ trackId?: string;
+ muted: boolean;
+ monitoring: boolean;
+ /** Undefined until Meld reports the track's gain via `gainUpdated`. */
+ gain?: number;
+};
+
+const VOLUME_STEP_DB = 3;
+
+/** Observer context id used to follow the replay track's gain. */
+const GAIN_OBSERVER_CONTEXT = "replay-gain";
+
+/** Controls the replay clip: show/dismiss, mute/cue, and volume. */
+@action({ UUID: "co.meldstudio.streamdeck.replay" })
+export class Replay extends MeldAction {
+ /** Latest resolved replay-track state, refreshed on every render. */
+ #replay: ReplayState = { muted: true, monitoring: false };
+
+ /** Authoritative gain per track id, from `gainUpdated`. */
+ readonly #trackGain = new Map();
+
+ constructor(connection?: MeldConnection) {
+ super(connection);
+ this.meld.on("gainChanged", ({ trackId, gain, muted }) => {
+ this.#trackGain.set(trackId, gain);
+ if (this.#replay.trackId === trackId) {
+ this.#replay.muted = muted;
+ }
+ });
+ // Gain is never part of the session snapshot, so a stale cache would
+ // otherwise outlive the instance that produced it.
+ this.meld.on("closed", () => this.#trackGain.clear());
+ }
+
+ protected override migrateSettings(settings: ReplaySettings): ReplaySettings {
+ return settings.action == null ? { ...settings, action: "replay" } : settings;
+ }
+
+ /** @inheritdoc */
+ protected override onDisappear(context: string): void {
+ // The observer context is shared by every replay key, so release it only
+ // once the last one goes away.
+ const remaining = [...this.actions].filter((action) => action.id !== context);
+ if (remaining.length === 0) {
+ this.meld.unregisterTrackObserver(GAIN_OBSERVER_CONTEXT);
+ }
+ }
+
+ /** Reflects the configured replay control's icon (derived from replay state). */
+ protected render({ action, settings, session }: RenderArgs): Promise {
+ this.#replay = this.#deriveReplay(session);
+ // Observe the replay track so its gain is known before the user adjusts it;
+ // registering yields an immediate `gainUpdated` from Meld.
+ if (this.meld.ready) {
+ if (this.#replay.trackId) {
+ this.meld.registerTrackObserver(GAIN_OBSERVER_CONTEXT, this.#replay.trackId);
+ } else {
+ this.meld.unregisterTrackObserver(GAIN_OBSERVER_CONTEXT);
+ }
+ }
+ return this.setKeyImage(action, this.#iconFor(settings.action));
+ }
+
+ /** @inheritdoc */
+ override async onKeyUp(ev: KeyUpEvent): Promise {
+ const replayAction = ev.payload.settings.action || "replay";
+ const meld = this.meld.meld;
+ const { trackId, layerId } = this.#replay;
+ // Read the gain at press time rather than reusing the value captured by the
+ // last render: `gainUpdated` refreshes the cache without re-rendering, so a
+ // fader moved in Meld since then would otherwise be stepped from a stale base.
+ const gain = trackId === undefined ? undefined : this.#trackGain.get(trackId);
+
+ switch (replayAction) {
+ case "replay":
+ if (!layerId) {
+ meld?.sendCommand?.("meld.replay.show");
+ }
+ break;
+ case "dismiss":
+ meld?.sendCommand?.("meld.replay.dismiss");
+ break;
+ case "toggle":
+ if (trackId) {
+ meld?.toggleMute?.(trackId);
+ }
+ break;
+ case "mute":
+ if (trackId && meld?.setMuted) {
+ meld.setMuted(trackId, true);
+ this.#replay.muted = true;
+ }
+ break;
+ case "unmute":
+ if (trackId && meld?.setMuted) {
+ meld.setMuted(trackId, false);
+ this.#replay.muted = false;
+ }
+ break;
+ case "toggle_cue":
+ if (trackId) {
+ meld?.toggleMonitor?.(trackId);
+ }
+ break;
+ case "cue":
+ if (trackId && meld?.setProperty) {
+ meld.setProperty(trackId, "monitoring", true);
+ this.#replay.monitoring = true;
+ }
+ break;
+ case "uncue":
+ if (trackId && meld?.setProperty) {
+ meld.setProperty(trackId, "monitoring", false);
+ this.#replay.monitoring = false;
+ }
+ break;
+ case "volume_up":
+ case "volume_down":
+ // Without a known gain, stepping would start from an assumed 0 and
+ // slam the track to silence instead of nudging it.
+ if (trackId && gain !== undefined) {
+ const step = replayAction === "volume_up" ? VOLUME_STEP_DB : -VOLUME_STEP_DB;
+ const next = toGain(toDb(gain) + step);
+ this.#trackGain.set(trackId, next);
+ meld?.setGain?.(trackId, next);
+ }
+ break;
+ }
+
+ // Repaint immediately in case an optimistic mute/cue changed the icon.
+ await this.setKeyImage(ev.action, this.#iconFor(ev.payload.settings.action));
+ }
+
+ /** Finds the replay clip's audio track and its current state in the session. */
+ #deriveReplay(session: MeldSession): ReplayState {
+ const track = session.find((item) => item.type === "track" && item.name === "Replay Clip");
+ if (!track) {
+ return { muted: true, monitoring: false };
+ }
+ const t = track as TrackItem & { id: string };
+ return {
+ layerId: t.parent,
+ trackId: t.id,
+ muted: t.muted,
+ monitoring: t.monitoring,
+ gain: this.#trackGain.get(t.id),
+ };
+ }
+
+ /** Chooses the key image for a replay control based on the current state. */
+ #iconFor(replayAction: string | undefined): string {
+ switch (replayAction) {
+ case "mute":
+ return "assets/ReplayMute/replay-clip-muted";
+ case "unmute":
+ return "assets/ReplayMute/replay-clip-unmuted";
+ case "toggle":
+ return this.#replay.muted
+ ? "assets/ReplayMute/replay-clip-muted"
+ : "assets/ReplayMute/replay-clip-unmuted";
+ case "cue":
+ return "assets/ReplayCUE/on";
+ case "uncue":
+ return "assets/ReplayCUE/off";
+ case "toggle_cue":
+ return this.#replay.monitoring ? "assets/ReplayCUE/on" : "assets/ReplayCUE/off";
+ case "volume_up":
+ return "assets/ReplayVolume/replay-clip-volumeUp";
+ case "volume_down":
+ return "assets/ReplayVolume/replay-clip-volumeDown";
+ case "dismiss":
+ return "assets/Replay/Key Icon/dismiss-clip-controls";
+ default:
+ return "assets/Replay/Key Icon/replay-clip-controls";
+ }
+ }
+}
diff --git a/src/actions/screenshot.ts b/src/actions/screenshot.ts
new file mode 100644
index 0000000..23710e9
--- /dev/null
+++ b/src/actions/screenshot.ts
@@ -0,0 +1,24 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import { action, type KeyUpEvent } from "@elgato/streamdeck";
+
+import { MeldAction } from "./base.js";
+
+/** Takes a screenshot in Meld Studio. Stateless. */
+@action({ UUID: "co.meldstudio.streamdeck.screenshot" })
+export class Screenshot extends MeldAction {
+ /** Nothing to render — the key has a single, static state. */
+ protected render(): void {}
+
+ /** @inheritdoc */
+ override onKeyUp(_ev: KeyUpEvent): void {
+ const meld = this.meld.meld;
+ if (meld?.sendCommand) {
+ meld.sendCommand("meld.screenshot");
+ } else if (meld?.sendEvent) {
+ // Backwards compatibility with older Meld builds.
+ meld.sendEvent("co.meldstudio.events.screenshot");
+ }
+ }
+}
diff --git a/src/actions/session-toggle.ts b/src/actions/session-toggle.ts
new file mode 100644
index 0000000..b3b4d6c
--- /dev/null
+++ b/src/actions/session-toggle.ts
@@ -0,0 +1,225 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import type { KeyUpEvent } from "@elgato/streamdeck";
+import type { JsonObject } from "@elgato/utils";
+
+import type { MeldConnection, MeldSession } from "../meld/index.js";
+import { MeldAction, type RenderArgs } from "./base.js";
+import { OptimisticStore } from "./optimistic.js";
+
+/**
+ * How long a press keeps precedence over the session snapshot. Meld republishes
+ * behind a ~600 ms retriggerable autosave, so this has to outlast a burst of
+ * concurrent edits while still self-correcting a press that never took effect.
+ */
+const TOGGLE_CONFIRM_TTL_MS = 3_000;
+
+/**
+ * Shared behaviour for the four "toggle a boolean session property" actions:
+ * layer visibility, effect enabled, track mute, and track monitoring.
+ *
+ * Each such action reflects one authoritative boolean from the session, and
+ * supports three press modes: plain `toggle`, or forcing the property on/off.
+ * Force mode prefers a direct idempotent setter when Meld exposes one, and
+ * otherwise falls back to a toggle only when the current authoritative value
+ * differs from the desired one — so pressing "mute" when already muted is a
+ * no-op rather than an accidental unmute.
+ */
+export abstract class SessionToggleAction extends MeldAction {
+ /**
+ * The value each pending press is waiting for. Meld confirms these through the
+ * republished session, which rides a retriggerable autosave, so the wait is
+ * far longer than a gain echo; an unconfirmed press expires back to whatever
+ * the session says.
+ */
+ readonly #pending = new OptimisticStore(TOGGLE_CONFIRM_TTL_MS, () =>
+ void this.renderAll("sessionChanged"),
+ );
+
+ constructor(connection?: MeldConnection) {
+ super(connection);
+ // A pending press belongs to one Meld process. If that process exits before
+ // its debounced snapshot confirms the command, the next instance must render
+ // from its own session rather than inherit the abandoned intent.
+ this.meld.on("closed", () => this.#pending.clearAll());
+ }
+
+ /** Key state shown when the configured item is absent from the session. */
+ protected abstract readonly missingItemState: 0 | 1;
+
+ /** The `action` settings value that means "force the property on". */
+ protected abstract readonly forceOnValue: string;
+
+ /** Whether the settings identify a target item. */
+ protected abstract isConfigured(settings: T): boolean;
+
+ /** The press-mode setting (`"toggle"`, the force-on value, or its inverse). */
+ protected abstract mode(settings: T): string | undefined;
+
+ /** Persist the historical implicit press mode so upgraded profiles are explicit. */
+ protected override migrateSettings(settings: T, _session: MeldSession): T {
+ if (this.mode(settings) != null) {
+ return settings;
+ }
+ return { ...settings, action: "toggle" } as T;
+ }
+
+ /**
+ * The authoritative boolean for this action, or `null` when the target item is
+ * missing from the session.
+ */
+ protected abstract currentValue(settings: T, session: MeldSession): boolean | null;
+
+ /** Maps the authoritative boolean to the key's state (state 0 = active). */
+ protected valueToKeyState(value: boolean): 0 | 1 {
+ return value ? 0 : 1;
+ }
+
+ /** Toggles the property via Meld. */
+ protected abstract toggle(settings: T): void;
+
+ /**
+ * Whether a press should go ahead even when {@link currentValue} is `null`.
+ *
+ * Normally an unreadable value means the item is absent — from a previous Meld
+ * instance, say — and acting would send a command Meld silently drops while
+ * painting a state it never confirms. An action overrides this when it can
+ * identify a real target whose state it simply cannot read.
+ * @param _settings The action's settings.
+ */
+ protected actsWithUnknownState(_settings: T): boolean {
+ return false;
+ }
+
+ /**
+ * Attempts to set the property directly (idempotent) to {@link desired}.
+ * @returns `true` when a direct setter was available and used.
+ */
+ protected setDirect(_settings: T, _desired: boolean): boolean {
+ return false;
+ }
+
+ /** @inheritdoc */
+ override onKeyUp(ev: KeyUpEvent): void {
+ const settings = ev.payload.settings;
+ if (!this.isConfigured(settings)) {
+ return;
+ }
+ // Acting requires a live connection and an item that exists in the current
+ // session: a command for a dead id (e.g. from a previous Meld instance) is
+ // a silent no-op in Meld and would never be echoed, so repainting here
+ // would fake a confirmed state.
+ if (!this.meld.ready) {
+ return;
+ }
+ // Meld only republishes the session behind its debounced autosave, so the
+ // snapshot still describes the state before any press made in the last
+ // several hundred milliseconds. A pending press is therefore the better
+ // answer: comparing against the stale snapshot would make a second press
+ // toggle again and undo the first — pressing "show" twice would hide the
+ // layer the user asked to show.
+ const authoritative = this.currentValue(settings, this.session());
+ const pending = this.#pending.get(ev.action.id);
+ const current = pending ?? authoritative;
+ if (current === null && !this.actsWithUnknownState(settings)) {
+ return;
+ }
+
+ const mode = this.mode(settings);
+ if (!mode || mode === "toggle") {
+ const next = current === null ? null : !current;
+ this.#apply(ev.action.id, next);
+ if (next !== null) {
+ // Paint the press straight away; the confirmation is hundreds of
+ // milliseconds behind and the key would otherwise look unresponsive.
+ void this.setKeyState(ev.action, this.valueToKeyState(next));
+ }
+ this.toggle(settings);
+ return;
+ }
+
+ const desired = mode === this.forceOnValue;
+ if (this.setDirect(settings, desired)) {
+ this.#apply(ev.action.id, desired);
+ void this.setKeyState(ev.action, this.valueToKeyState(desired));
+ return;
+ }
+
+ if (current === null) {
+ // Actionable but unreadable: there is nothing to compare against, so
+ // forward the press and let Meld decide, as the pre-migration plugin did.
+ this.toggle(settings);
+ return;
+ }
+
+ // No direct setter: toggle only if we are not already in the desired state.
+ if (current !== desired) {
+ this.#apply(ev.action.id, desired);
+ void this.setKeyState(ev.action, this.valueToKeyState(desired));
+ this.toggle(settings);
+ } else {
+ void this.setKeyState(ev.action, this.valueToKeyState(current));
+ }
+ }
+
+ /** @inheritdoc */
+ protected override onDisappear(context: string): void {
+ this.#pending.clear(context);
+ }
+
+ /** @inheritdoc */
+ protected override onSettingsChanged({
+ action,
+ settings,
+ previous,
+ }: RenderArgs & { previous: T | undefined }): void {
+ // A pending press belongs to the item that was configured when it was made.
+ // Retargeting the key would otherwise display that value against the new
+ // item and let the next press move it the wrong way.
+ if (previous !== undefined && this.targetOf(previous) !== this.targetOf(settings)) {
+ this.#pending.clear(action.id);
+ }
+ }
+
+ /**
+ * The id this action acts on, used to notice a retarget.
+ * @param settings The action's settings.
+ */
+ protected abstract targetOf(settings: T): string | undefined;
+
+ /**
+ * Records the value a press is waiting for, so a follow-up press reasons from
+ * it rather than from the not-yet-republished snapshot.
+ * @param context Context id.
+ * @param value The value the press should produce, or `null` when unknown.
+ */
+ #apply(context: string, value: boolean | null): void {
+ if (value !== null) {
+ this.#pending.set(context, value);
+ }
+ }
+
+ /** @inheritdoc */
+ protected async render({ action, settings, session, reason }: RenderArgs): Promise {
+ if (!this.isConfigured(settings)) {
+ return;
+ }
+ // Nothing authoritative to show while disconnected; leave the key as-is.
+ if (!session.isPresent && reason !== "willAppear") {
+ return;
+ }
+
+ const value = this.currentValue(settings, session);
+ const pending = this.#pending.get(action.id);
+ if (pending !== undefined && value === pending) {
+ // The snapshot caught up with the press; stop overriding it.
+ this.#pending.clear(action.id);
+ }
+ const shown = this.#pending.get(action.id) ?? value;
+ if (shown === null) {
+ return void this.setKeyState(action, this.missingItemState);
+ }
+ await this.setKeyState(action, this.valueToKeyState(shown));
+ }
+}
diff --git a/src/actions/show-scene.ts b/src/actions/show-scene.ts
new file mode 100644
index 0000000..7a83c2f
--- /dev/null
+++ b/src/actions/show-scene.ts
@@ -0,0 +1,98 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import { action, type KeyUpEvent } from "@elgato/streamdeck";
+
+import { MeldAction, type RenderArgs } from "./base.js";
+import { OptimisticStore } from "./optimistic.js";
+
+/** Settings for {@link ShowScene}. */
+type ShowSceneSettings = {
+ scene?: string;
+};
+
+/**
+ * How long the key stays optimistically lit while waiting for Meld to confirm.
+ *
+ * Unlike a gain change — which Meld reports immediately over `gainUpdated` — a
+ * scene switch is only visible once the session snapshot is republished, and
+ * that rides a 600 ms retriggerable autosave: any other session edit in the
+ * meantime restarts the timer. The default optimistic TTL leaves almost no
+ * headroom over that, so the key would blink dark before the confirmation
+ * arrived. This gives the echo room while still self-correcting a press that
+ * genuinely never took effect.
+ */
+const SCENE_CONFIRM_TTL_MS = 3_000;
+
+/** Switches to a configured scene; the key lights up when that scene is live. */
+@action({ UUID: "co.meldstudio.streamdeck.show-scene" })
+export class ShowScene extends MeldAction {
+ /**
+ * Scene id optimistically requested per context, until Meld confirms it. If it
+ * expires unconfirmed (the request never took effect), repaint from
+ * authoritative state so the key doesn't stay lit for a scene that isn't live.
+ */
+ readonly #requested = new OptimisticStore(SCENE_CONFIRM_TTL_MS, (context) =>
+ void this.#repaint(context),
+ );
+
+ /** @inheritdoc */
+ override onKeyUp(ev: KeyUpEvent): void {
+ const scene = ev.payload.settings.scene;
+ // Only act (and light the key) when the request can actually reach Meld and
+ // name a scene it still has: `showScene` for an id from a previous instance
+ // is a silent no-op that is never echoed, so lighting the key here would
+ // fake a confirmation the session never gives back.
+ if (!scene || !this.meld.ready || !this.session().has(scene)) {
+ return;
+ }
+ // Optimistically light the key; the next session update confirms it.
+ this.#requested.set(ev.action.id, scene);
+ void this.setKeyState(ev.action, 0);
+ this.meld.meld?.showScene?.(scene);
+ }
+
+ /**
+ * Repaints one context from authoritative state after its own request expired.
+ *
+ * Only that context: `renderAll` would clear every other key's still-pending
+ * request too, so one key's expiry would darken another key's in-flight press.
+ * @param context The context whose request expired.
+ */
+ async #repaint(context: string): Promise {
+ for (const action of this.actions) {
+ if (action.id !== context) {
+ continue;
+ }
+ const settings = this.settingsFor(action.id);
+ if (settings) {
+ await this.render({ action, settings, session: this.session(), reason: "sessionChanged" });
+ }
+ return;
+ }
+ }
+
+ /** @inheritdoc */
+ protected async render({ action, settings, session, reason }: RenderArgs): Promise {
+ // Authoritative session state supersedes any optimistic request.
+ if (reason === "sessionChanged" || reason === "ready" || reason === "closed") {
+ this.#requested.clear(action.id);
+ }
+
+ const scene = settings.scene;
+ if (!scene) {
+ return void this.setKeyState(action, 1);
+ }
+ // No snapshot yet: leave the current visual untouched to avoid a flash.
+ if (!session.isPresent) {
+ return;
+ }
+ if (!session.has(scene)) {
+ return void this.setKeyState(action, 1);
+ }
+ if (this.#requested.get(action.id) === scene) {
+ return void this.setKeyState(action, 0);
+ }
+ await this.setKeyState(action, session.getScene(scene)?.current ? 0 : 1);
+ }
+}
diff --git a/src/actions/toggle-effect.ts b/src/actions/toggle-effect.ts
new file mode 100644
index 0000000..9007881
--- /dev/null
+++ b/src/actions/toggle-effect.ts
@@ -0,0 +1,105 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import { action } from "@elgato/streamdeck";
+
+import type { MeldSession } from "../meld/index.js";
+import { SessionToggleAction } from "./session-toggle.js";
+
+/** Settings for {@link ToggleEffect}. */
+type ToggleEffectSettings = {
+ scene?: string;
+ layer?: string;
+ effect?: string;
+ action?: string;
+};
+
+/** Toggles (or forces) an effect's enabled state within a layer. */
+@action({ UUID: "co.meldstudio.streamdeck.toggle-effect" })
+export class ToggleEffect extends SessionToggleAction {
+ protected readonly missingItemState = 1;
+ protected readonly forceOnValue = "show";
+
+ protected targetOf(settings: ToggleEffectSettings): string | undefined {
+ return settings.effect;
+ }
+
+ protected isConfigured(settings: ToggleEffectSettings): boolean {
+ // StreamDeckMapper publishes stable effect ids. Parent fields can be
+ // reconstructed from the effect in the live session.
+ return !!settings.effect;
+ }
+
+ protected mode(settings: ToggleEffectSettings): string | undefined {
+ return settings.action;
+ }
+
+ protected override migrateSettings(settings: ToggleEffectSettings, session: MeldSession): ToggleEffectSettings {
+ let migrated = super.migrateSettings(settings, session);
+ const effect = this.#resolveEffect(migrated, session);
+ const layerId = effect?.parent;
+ const sceneId = session.getLayer(layerId)?.parent;
+ if (
+ effect &&
+ layerId &&
+ sceneId &&
+ (migrated.effect !== effect.id || migrated.layer !== layerId || migrated.scene !== sceneId)
+ ) {
+ migrated = { ...migrated, scene: sceneId, layer: layerId, effect: effect.id };
+ }
+ return migrated;
+ }
+
+ protected currentValue(settings: ToggleEffectSettings, session: MeldSession): boolean | null {
+ // The stable effect id alone identifies the target. A stale scene/layer from
+ // previous selections must not hide its authoritative state.
+ const effect = this.#resolveEffect(settings, session);
+ return effect ? effect.enabled : null;
+ }
+
+ protected toggle(settings: ToggleEffectSettings): void {
+ if (!settings.effect) {
+ return;
+ }
+ const session = this.session();
+ const effect = this.#resolveEffect(settings, session);
+ if (!effect) {
+ // Never forward an id absent from the current mapper snapshot. It belongs
+ // to another project/process and Meld would silently discard it.
+ return;
+ }
+ // The session's real parent chain is authoritative, so Meld is never sent
+ // a mismatched triple even for stale stored scene/layer ids.
+ const layerId = effect.parent;
+ const sceneId = session.getLayer(layerId)?.parent ?? settings.scene;
+ if (sceneId) {
+ this.meld.meld?.toggleEffect?.(sceneId, layerId, effect.id);
+ }
+ }
+
+ /**
+ * Resolves either a stable mapper id or the historical `-`
+ * settings form. Indexed settings are immediately migrated to the stable id;
+ * commands are never sent to Meld with the indexed form. The one-time indexed
+ * lookup intentionally uses the currently serialized effect order: old indexes
+ * were never stable across reorders, but resolving the present index is the
+ * required upgrade behavior before persisting the resulting mapper id.
+ */
+ #resolveEffect(settings: ToggleEffectSettings, session: MeldSession) {
+ const direct = session.getEffect(settings.effect);
+ if (direct || !settings.effect) {
+ return direct;
+ }
+
+ const separator = settings.effect.lastIndexOf("-");
+ if (separator <= 0) {
+ return undefined;
+ }
+ const layerId = settings.effect.slice(0, separator);
+ const indexText = settings.effect.slice(separator + 1);
+ if (!/^\d+$/.test(indexText) || !session.getLayer(layerId)) {
+ return undefined;
+ }
+ return session.effects(layerId)[Number.parseInt(indexText, 10)];
+ }
+}
diff --git a/src/actions/toggle-layer.ts b/src/actions/toggle-layer.ts
new file mode 100644
index 0000000..09c8a53
--- /dev/null
+++ b/src/actions/toggle-layer.ts
@@ -0,0 +1,65 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import { action } from "@elgato/streamdeck";
+
+import type { IdentifiedItem, LayerItem, MeldSession } from "../meld/index.js";
+import { SessionToggleAction } from "./session-toggle.js";
+
+/** Settings for {@link ToggleLayer}. */
+type ToggleLayerSettings = {
+ scene?: string;
+ layer?: string;
+ action?: string;
+};
+
+/** Toggles (or forces) a layer's visibility within a scene. */
+@action({ UUID: "co.meldstudio.streamdeck.toggle-layer" })
+export class ToggleLayer extends SessionToggleAction {
+ protected readonly missingItemState = 1;
+ protected readonly forceOnValue = "show";
+
+ protected targetOf(settings: ToggleLayerSettings): string | undefined {
+ return settings.layer;
+ }
+
+ protected isConfigured(settings: ToggleLayerSettings): boolean {
+ // The layer id is the durable target. Its parent can be reconstructed from
+ // the session when an old inspector left the stored chain incomplete.
+ return !!settings.layer;
+ }
+
+ protected mode(settings: ToggleLayerSettings): string | undefined {
+ return settings.action;
+ }
+
+ protected override migrateSettings(settings: ToggleLayerSettings, session: MeldSession): ToggleLayerSettings {
+ let migrated = super.migrateSettings(settings, session);
+ const layer = session.getLayer(migrated.layer);
+ if (layer && migrated.scene !== layer.parent) {
+ migrated = { ...migrated, scene: layer.parent };
+ }
+ return migrated;
+ }
+
+ protected currentValue(settings: ToggleLayerSettings, session: MeldSession): boolean | null {
+ // The layer id alone identifies the target. Legacy profiles can hold a
+ // scene from a previous selection alongside it; the layer must keep working
+ // across the upgrade, so the stored scene is not required to match.
+ const layer = session.getLayer(settings.layer);
+ return layer ? layer.visible : null;
+ }
+
+ protected toggle(settings: ToggleLayerSettings): void {
+ if (!settings.layer) {
+ return;
+ }
+ // The layer's real parent from the session is authoritative, so Meld is
+ // never sent a mismatched scene/layer pair even for stale stored scenes.
+ const layer: IdentifiedItem | undefined = this.session().getLayer(settings.layer);
+ const sceneId = layer?.parent ?? settings.scene;
+ if (sceneId) {
+ this.meld.meld?.toggleLayer?.(sceneId, settings.layer);
+ }
+ }
+}
diff --git a/src/actions/toggle-monitor.ts b/src/actions/toggle-monitor.ts
new file mode 100644
index 0000000..28d02a7
--- /dev/null
+++ b/src/actions/toggle-monitor.ts
@@ -0,0 +1,55 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import { action } from "@elgato/streamdeck";
+
+import type { MeldSession } from "../meld/index.js";
+import { SessionToggleAction } from "./session-toggle.js";
+
+/** Settings for {@link ToggleMonitor}. */
+type ToggleMonitorSettings = {
+ track?: string;
+ action?: string;
+};
+
+/** Toggles (or forces) an audio track's monitoring (cue) state. */
+@action({ UUID: "co.meldstudio.streamdeck.toggle-monitor" })
+export class ToggleMonitor extends SessionToggleAction {
+ protected readonly missingItemState = 1;
+ protected readonly forceOnValue = "cue";
+
+ protected targetOf(settings: ToggleMonitorSettings): string | undefined {
+ return settings.track;
+ }
+
+ protected isConfigured(settings: ToggleMonitorSettings): boolean {
+ return !!settings.track;
+ }
+
+ protected mode(settings: ToggleMonitorSettings): string | undefined {
+ return settings.action;
+ }
+
+ protected currentValue(settings: ToggleMonitorSettings, session: MeldSession): boolean | null {
+ const track = session.getTrack(settings.track);
+ return track ? track.monitoring : null;
+ }
+
+ protected toggle(settings: ToggleMonitorSettings): void {
+ if (settings.track) {
+ this.meld.meld?.toggleMonitor?.(settings.track);
+ }
+ }
+
+ protected override setDirect(settings: ToggleMonitorSettings, desired: boolean): boolean {
+ const meld = this.meld.meld;
+ if (!settings.track) {
+ return false;
+ }
+ if (meld?.setProperty) {
+ meld.setProperty(settings.track, "monitoring", desired);
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/src/actions/toggle-mute.ts b/src/actions/toggle-mute.ts
new file mode 100644
index 0000000..4a7931c
--- /dev/null
+++ b/src/actions/toggle-mute.ts
@@ -0,0 +1,65 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import { action } from "@elgato/streamdeck";
+
+import type { MeldSession } from "../meld/index.js";
+import { SessionToggleAction } from "./session-toggle.js";
+
+/** Settings for {@link ToggleMute}. */
+type ToggleMuteSettings = {
+ track?: string;
+ action?: string;
+};
+
+/** Toggles (or forces) an audio track's mute state. */
+@action({ UUID: "co.meldstudio.streamdeck.toggle-mute" })
+export class ToggleMute extends SessionToggleAction {
+ // When the track is gone, the manifest's "muted" state (1) is shown.
+ protected readonly missingItemState = 1;
+ protected readonly forceOnValue = "mute";
+
+ protected targetOf(settings: ToggleMuteSettings): string | undefined {
+ return settings.track;
+ }
+
+ protected isConfigured(settings: ToggleMuteSettings): boolean {
+ return !!settings.track;
+ }
+
+ protected mode(settings: ToggleMuteSettings): string | undefined {
+ return settings.action;
+ }
+
+ protected currentValue(settings: ToggleMuteSettings, session: MeldSession): boolean | null {
+ const track = session.getTrack(settings.track);
+ return track ? track.muted : null;
+ }
+
+ /** Active (state 0) means the speaker icon: not muted. */
+ protected override valueToKeyState(muted: boolean): 0 | 1 {
+ return muted ? 1 : 0;
+ }
+
+ protected toggle(settings: ToggleMuteSettings): void {
+ if (settings.track) {
+ this.meld.meld?.toggleMute?.(settings.track);
+ }
+ }
+
+ protected override setDirect(settings: ToggleMuteSettings, desired: boolean): boolean {
+ const meld = this.meld.meld;
+ if (!settings.track) {
+ return false;
+ }
+ if (meld?.setMuted) {
+ meld.setMuted(settings.track, desired);
+ return true;
+ }
+ if (meld?.setProperty) {
+ meld.setProperty(settings.track, "muted", desired);
+ return true;
+ }
+ return false;
+ }
+}
diff --git a/src/actions/toggle-record.ts b/src/actions/toggle-record.ts
new file mode 100644
index 0000000..a7cbe60
--- /dev/null
+++ b/src/actions/toggle-record.ts
@@ -0,0 +1,26 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import { action, type KeyUpEvent } from "@elgato/streamdeck";
+import type { JsonObject } from "@elgato/utils";
+
+import { MeldAction, type RenderArgs } from "./base.js";
+
+/** Starts or stops recording, reflecting Meld's authoritative recording state. */
+@action({ UUID: "co.meldstudio.streamdeck.toggle-record" })
+export class ToggleRecord extends MeldAction {
+ /** Reflects the current recording state on the key (state 0 = recording). */
+ protected async render({ action, reason }: RenderArgs): Promise {
+ // While disconnected there is nothing authoritative to show; keep the last
+ // state rather than flashing "not recording" on a connection blip.
+ if (!this.meld.ready && reason !== "willAppear") {
+ return;
+ }
+ await this.setKeyState(action, this.isRecording ? 0 : 1);
+ }
+
+ /** @inheritdoc */
+ override onKeyUp(_ev: KeyUpEvent): void {
+ this.meld.meld?.toggleRecord?.();
+ }
+}
diff --git a/src/actions/toggle-stream.ts b/src/actions/toggle-stream.ts
new file mode 100644
index 0000000..a929e6e
--- /dev/null
+++ b/src/actions/toggle-stream.ts
@@ -0,0 +1,26 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import { action, type KeyUpEvent } from "@elgato/streamdeck";
+import type { JsonObject } from "@elgato/utils";
+
+import { MeldAction, type RenderArgs } from "./base.js";
+
+/** Goes live or offline, reflecting Meld's authoritative streaming state. */
+@action({ UUID: "co.meldstudio.streamdeck.toggle-stream" })
+export class ToggleStream extends MeldAction {
+ /** Reflects the current streaming state on the key (state 0 = live). */
+ protected async render({ action, reason }: RenderArgs): Promise {
+ // While disconnected there is nothing authoritative to show; keep the last
+ // state rather than flashing "offline" on a connection blip.
+ if (!this.meld.ready && reason !== "willAppear") {
+ return;
+ }
+ await this.setKeyState(action, this.isStreaming ? 0 : 1);
+ }
+
+ /** @inheritdoc */
+ override onKeyUp(_ev: KeyUpEvent): void {
+ this.meld.meld?.toggleStream?.();
+ }
+}
diff --git a/src/actions/transition-stepper.ts b/src/actions/transition-stepper.ts
new file mode 100644
index 0000000..1f35e4b
--- /dev/null
+++ b/src/actions/transition-stepper.ts
@@ -0,0 +1,230 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import streamDeck, { action, type DialDownEvent, type DialRotateEvent, type TouchTapEvent } from "@elgato/streamdeck";
+import type { JsonObject } from "@elgato/utils";
+
+import type { MeldSession } from "../meld/index.js";
+import { MeldAction, type MeldActionInstance, type RenderArgs } from "./base.js";
+import { OptimisticStore } from "./optimistic.js";
+
+const TRANSITION_ICON = "assets/Scene Transition Control/Action Icon/sceneTransition";
+
+/** The staged selection is shared by every visible dial, so one store key suffices. */
+const STAGED_KEY = "staged";
+
+/** A selectable staging scene. `index` is Meld's scene index, used for anchoring. */
+type StagingScene = { id: string; name: string; index: number };
+
+/**
+ * Stream Deck+ dial that cycles through the available staging scenes and, on
+ * push, transitions Meld to the selected staged scene.
+ *
+ * The list of scenes and the staged selection are both derived from the
+ * authoritative session, with two refinements mirroring the volume dial:
+ * - an {@link OptimisticStore} keeps the user's newest selection on screen while
+ * Meld echoes of older `setStagedScene` calls are still in flight, so quick
+ * rotations don't snap backwards; and
+ * - after a transition (when no selectable scene is staged) rotation continues
+ * from the just-transitioned scene's neighborhood instead of resetting to the
+ * top of the list, matching the legacy stepper.
+ */
+@action({ UUID: "co.meldstudio.streamdeck.transition-stepper" })
+export class TransitionStepper extends MeldAction {
+ /** Selectable scenes (non-current, non-linked), ordered by scene index. */
+ #scenes: StagingScene[] = [];
+
+ /** Index into {@link #scenes} of the staged scene, or -1 when none. */
+ #stagedIndex = -1;
+
+ /** Scene id rotation continues from when nothing is staged. */
+ #anchorId: string | undefined;
+
+ /** The anchor scene's last-known Meld index, for when it leaves the list. */
+ #anchorIndex = -1;
+
+ /**
+ * The user's in-flight staged selection (scene id), kept until Meld confirms
+ * it. If it expires unconfirmed, repaint from authoritative state.
+ */
+ readonly #optimisticStaged = new OptimisticStore(undefined, () => void this.renderAll("sessionChanged"));
+
+ /** @inheritdoc */
+ override onDialRotate(ev: DialRotateEvent): void {
+ const length = this.#scenes.length;
+ // Rotating while disconnected would fake a selection Meld never receives.
+ if (length === 0 || !this.meld.ready) {
+ return;
+ }
+ const ticks = ev.payload.ticks;
+
+ let position: number;
+ if (this.#stagedIndex !== -1) {
+ position = this.#stagedIndex + ticks;
+ } else {
+ // Nothing selectable is staged. If the anchor scene is still in the list
+ // (staging was cancelled), rotate relative to it like a normal selection.
+ const anchorPosition = this.#scenes.findIndex((scene) => scene.id === this.#anchorId);
+ if (anchorPosition !== -1) {
+ position = anchorPosition + ticks;
+ } else {
+ // The anchor scene left the list (typically it became current after a
+ // transition). Continue from its neighborhood: `base` is the last list
+ // position at or before the anchor, so +1 lands on the scene after the
+ // gap and -1 on the one before it.
+ let base = -1;
+ for (let i = 0; i < length; i++) {
+ if (this.#scenes[i].index <= this.#anchorIndex) {
+ base = i;
+ }
+ }
+ position = base + (ticks > 0 ? 0 : 1) + ticks;
+ }
+ }
+ position = ((position % length) + length) % length;
+
+ const scene = this.#scenes[position];
+ this.#stagedIndex = position;
+ this.#anchorId = scene.id;
+ this.#anchorIndex = scene.index;
+ this.#optimisticStaged.set(STAGED_KEY, scene.id);
+ this.meld.meld?.setStagedScene?.(scene.id);
+ void this.#paintAll(scene.name);
+ }
+
+ /** @inheritdoc */
+ override onDialDown(_ev: DialDownEvent): void {
+ this.#showStaged();
+ }
+
+ /** @inheritdoc */
+ override onTouchTap(ev: TouchTapEvent): void {
+ if (ev.payload.hold) {
+ this.#showStaged();
+ }
+ }
+
+ /** @inheritdoc */
+ protected async render({ action, session, reason }: RenderArgs): Promise {
+ if (reason === "closed") {
+ // Keep the last painted state rather than flashing on a blip; the
+ // authoritative snapshot on "ready" restores everything after reconnect.
+ return;
+ }
+ if (!session.isPresent) {
+ // No snapshot (mid-blip renders: an optimistic expiry, willAppear, or a
+ // settings change while disconnected). Rebuilding now would wipe the
+ // cached scene list the "closed" guard just preserved; only paint a
+ // freshly appearing dial so it isn't blank.
+ if (reason === "willAppear") {
+ await this.#paint(action, this.#scenes[this.#stagedIndex]?.name);
+ }
+ return;
+ }
+ this.#rebuild(session);
+ await this.#paint(action, this.#scenes[this.#stagedIndex]?.name);
+ }
+
+ /**
+ * Transitions Meld to the staged scene, if Meld actually has one staged.
+ *
+ * A local selection is not enough. `setStagedScene` silently refuses a scene
+ * that is already current or absent from the session — and the dial's list can
+ * be a snapshot behind — while `showStagedScene` swaps current and staged
+ * unconditionally. Acting on a refused selection would therefore make Meld
+ * adopt an unset staged scene and leave the session with no current scene at
+ * all, so the snapshot has to confirm the stage first.
+ */
+ #showStaged(): void {
+ if (this.#stagedIndex === -1) {
+ return;
+ }
+ const staged = this.session().scenes().find((scene) => scene.staged && !scene.current);
+ if (!staged) {
+ return;
+ }
+ this.meld.meld?.showStagedScene?.();
+ }
+
+ /** Rebuilds the scene list and staged selection from a session snapshot. */
+ #rebuild(session: MeldSession): void {
+ // Vertical-canvas twins are excluded: Meld resolves `setStagedScene` through
+ // the horizontal scene model only (unlike `showScene`), so staging one is a
+ // silent no-op and the dial would appear to select a scene it cannot reach.
+ // The `vertical` flag is the whole test — the old plugin matched a "(linked)"
+ // name suffix, which Meld deliberately stopped appending, so keeping that
+ // would only drop scenes a user happened to name that way.
+ const scenes = session
+ .scenes()
+ .filter((scene) => !scene.vertical)
+ .sort((a, b) => a.index - b.index);
+ this.#scenes = scenes
+ .filter((scene) => !scene.current)
+ .map((scene) => ({ id: scene.id, name: scene.name, index: scene.index }));
+
+ // Right after a transition the staged flag can sit on the current scene;
+ // nothing selectable is staged then, but it still moves the rotation anchor.
+ const stagedAny = scenes.find((scene) => scene.staged);
+
+ if (stagedAny) {
+ this.#anchorId = stagedAny.id;
+ this.#anchorIndex = stagedAny.index;
+ } else {
+ // Keep the anchor's index in step with Meld-side scene reorders so the
+ // neighborhood fallback stays accurate after the anchor leaves the list.
+ const anchorScene = scenes.find((scene) => scene.id === this.#anchorId);
+ if (anchorScene) {
+ this.#anchorIndex = anchorScene.index;
+ } else if (this.#anchorId !== undefined) {
+ // The anchor scene no longer exists at all (a different Meld instance
+ // or project): start fresh rather than anchoring to a foreign index.
+ this.#anchorId = undefined;
+ this.#anchorIndex = -1;
+ }
+ }
+
+ const authoritative = stagedAny && !stagedAny.current ? stagedAny : undefined;
+ let selectedId = authoritative?.id;
+
+ const optimistic = this.#optimisticStaged.get(STAGED_KEY);
+ if (optimistic !== undefined) {
+ if (authoritative?.id === optimistic) {
+ // Meld confirmed the user's selection; authoritative takes over.
+ this.#optimisticStaged.clear(STAGED_KEY);
+ } else if (this.#scenes.some((scene) => scene.id === optimistic)) {
+ // This update predates our latest setStagedScene (an echo of an older
+ // one, or an unrelated change). Keep showing the newer selection; a
+ // genuinely different authoritative value wins once the TTL expires.
+ selectedId = optimistic;
+ } else {
+ // The optimistically selected scene vanished; fall back.
+ this.#optimisticStaged.clear(STAGED_KEY);
+ }
+ }
+
+ this.#stagedIndex = selectedId ? this.#scenes.findIndex((scene) => scene.id === selectedId) : -1;
+ if (this.#stagedIndex !== -1) {
+ this.#anchorId = this.#scenes[this.#stagedIndex].id;
+ this.#anchorIndex = this.#scenes[this.#stagedIndex].index;
+ }
+ }
+
+ /** Paints the staged scene name onto a single dial. */
+ #paint(action: MeldActionInstance, name: string | undefined): Promise {
+ return this.asDial(action)
+ .setFeedback({
+ icon: TRANSITION_ICON,
+ title: name ?? "Nothing Staged",
+ })
+ .catch((err) => {
+ streamDeck.logger.error(`Failed to set feedback for ${action.id}`, err);
+ });
+ }
+
+ /** Paints the staged scene name onto every visible dial. */
+ async #paintAll(name: string | undefined): Promise {
+ for (const action of this.actions) {
+ await this.#paint(action, name);
+ }
+ }
+}
diff --git a/src/actions/volume-stepper.ts b/src/actions/volume-stepper.ts
new file mode 100644
index 0000000..92cc3ac
--- /dev/null
+++ b/src/actions/volume-stepper.ts
@@ -0,0 +1,374 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import streamDeck, {
+ action,
+ type DialDownEvent,
+ type DialRotateEvent,
+ type KeyUpEvent,
+ type TouchTapEvent,
+} from "@elgato/streamdeck";
+
+import type { IdentifiedItem, MeldConnection, TrackItem } from "../meld/index.js";
+import { MeldAction, type RenderArgs } from "./base.js";
+import { toDb, toGain } from "./gain.js";
+import { OptimisticStore } from "./optimistic.js";
+
+/** Settings for {@link VolumeStepper}. */
+type VolumeStepperSettings = {
+ track?: string;
+ stepsize?: string;
+ metertype?: string;
+};
+
+const AUDIO_ICON = "assets/Audio Track/Mute_Unmute Audio Track/Action Icons/audioTrack";
+const AUDIO_ICON_40 = "assets/Audio Track/Mute_Unmute Audio Track/Action Icons/audioUnmuted40";
+const AUDIO_ICON_MUTE = "assets/Audio Track/Mute_Unmute Audio Track/Action Icons/audioMute";
+
+/** The resolved display state for a track. */
+type TrackDisplay = {
+ gain?: number;
+ muted: boolean;
+ monitoring: boolean;
+ name?: string;
+};
+
+/**
+ * Stream Deck+ dial that adjusts an audio track's volume, with tap-to-set and
+ * push-to-mute.
+ *
+ * This is the action the whole refactor was motivated by. The desync is fixed by
+ * two things working together:
+ * - the shared {@link MeldConnection} re-registers this dial's track observer on
+ * every reconnect, so live gain updates never silently stop; and
+ * - an {@link OptimisticStore} shows the user's rotation immediately but always
+ * yields to Meld's authoritative `gainUpdated` value, which clears it.
+ */
+/**
+ * Two gains within this distance are the same value: an update this close to the
+ * pending optimistic gain is the echo confirming it. This only needs to absorb
+ * float serialization noise on the Meld round-trip — it must stay far below the
+ * smallest per-tick gain delta (≈1e-5 at -60 dB with a 0.1 dB step), or an echo
+ * of an older write could be mistaken for confirmation of a newer one.
+ */
+const GAIN_EPSILON = 1e-7;
+
+@action({ UUID: "co.meldstudio.streamdeck.volume-stepper" })
+export class VolumeStepper extends MeldAction {
+ /**
+ * Optimistic gain per context, cleared as soon as Meld confirms. If it expires
+ * unconfirmed (write dropped, disconnect mid-rotation), repaint from
+ * authoritative state so the phantom value doesn't linger on the touchscreen.
+ */
+ readonly #optimisticGain = new OptimisticStore(undefined, (context) =>
+ this.#repaintAuthoritative(context),
+ );
+
+ /** Latest authoritative gain/mute per track id, from `gainUpdated`. */
+ readonly #trackGain = new Map();
+
+ constructor(connection?: MeldConnection) {
+ super(connection);
+ // The observer cache and any unconfirmed optimistic values are only valid
+ // for the connection they were observed on: Meld emits gainUpdated solely on
+ // change, so a value cached before a disconnect would otherwise mask the
+ // fresh snapshot (and re-base rotations) after reconnect, even if the user
+ // moved the fader in Meld while we were away.
+ this.meld.on("closed", () => {
+ this.#trackGain.clear();
+ this.#optimisticGain.clearAll();
+ });
+ this.meld.on("gainChanged", ({ trackId, gain, muted }) => {
+ // Only cache tracks a visible context is bound to: an unbound track gets
+ // no further updates (e.g. an in-flight echo landing right after a
+ // rebind), so its entry could only ever go stale.
+ if (![...this.actions].some((action) => this.settingsFor(action.id)?.track === trackId)) {
+ return;
+ }
+ this.#trackGain.set(trackId, { gain, muted });
+ for (const action of this.actions) {
+ const settings = this.settingsFor(action.id);
+ if (settings?.track !== trackId) {
+ continue;
+ }
+ // During a rotation Meld echoes each of our earlier setGain calls. An
+ // echo of an OLDER write must not clobber a NEWER optimistic value —
+ // that reverts the dial mid-rotation and loses ticks (the original
+ // desync). Only treat the update as confirming the optimistic value
+ // when it matches; otherwise keep the newer guess (a genuinely
+ // external change still wins within one TTL via expiry repaint).
+ const optimistic = this.#optimisticGain.get(action.id);
+ if (optimistic !== undefined && Math.abs(optimistic - gain) > GAIN_EPSILON) {
+ continue;
+ }
+ this.#optimisticGain.clear(action.id);
+ const item = this.session().getTrack(trackId);
+ void this.#renderFeedback(action, settings, this.#resolve(action.id, trackId, item));
+ }
+ });
+ }
+
+ protected override migrateSettings(settings: VolumeStepperSettings): VolumeStepperSettings {
+ if (settings.stepsize != null && settings.metertype != null) {
+ return settings;
+ }
+ return {
+ ...settings,
+ stepsize: settings.stepsize ?? "1",
+ metertype: settings.metertype ?? "db",
+ };
+ }
+
+ /** @inheritdoc */
+ override onDialRotate(ev: DialRotateEvent): void {
+ const { track, stepsize, metertype } = ev.payload.settings;
+ if (!track) {
+ return;
+ }
+ const usePercent = metertype === "percent";
+ // A missing or blanked step size falls back to the inspector's default of 1;
+ // 0 would silently turn the dial into a no-op.
+ const step = Number.parseFloat(stepsize ?? "");
+ const offset = (Number.isFinite(step) ? step : 1) * ev.payload.ticks;
+
+ let gain = this.#currentGain(ev.action.id, track);
+ if (gain === undefined) {
+ // No authoritative gain known yet (dial shows "--"). Stepping from an
+ // assumed 0 would slam a live track toward silence, so do nothing.
+ return;
+ }
+ if (usePercent) {
+ gain = Math.min(1, Math.max(0, gain + 0.01 * offset));
+ } else {
+ gain = toGain(toDb(gain) + offset);
+ }
+
+ this.#applyGain(ev.action, ev.payload.settings, track, gain);
+ }
+
+ /** @inheritdoc */
+ override onDialDown(ev: DialDownEvent): void {
+ const track = ev.payload.settings.track;
+ if (track) {
+ this.meld.meld?.toggleMute?.(track);
+ }
+ }
+
+ /** @inheritdoc */
+ override onKeyUp(ev: KeyUpEvent): void {
+ const track = ev.payload.settings.track;
+ if (track) {
+ this.meld.meld?.toggleMute?.(track);
+ }
+ }
+
+ /** @inheritdoc */
+ override onTouchTap(ev: TouchTapEvent): void {
+ if (ev.payload.hold) {
+ return;
+ }
+ const track = ev.payload.settings.track;
+ if (!track) {
+ return;
+ }
+
+ const x = ev.payload.tapPos[0];
+ // The indicator bar spans x≈80..180; taps there set the volume directly.
+ if (x > 80) {
+ if (x > 180) {
+ return;
+ }
+ this.#applyGain(ev.action, ev.payload.settings, track, (x - 80) / 100);
+ } else {
+ this.meld.meld?.toggleMonitor?.(track);
+ }
+ }
+
+ /** @inheritdoc */
+ protected override onSettingsChanged({
+ action,
+ settings,
+ previous,
+ }: RenderArgs & { previous: VolumeStepperSettings | undefined }): void {
+ if (previous?.track !== settings.track) {
+ // A pending optimistic gain belongs to the previously bound track; never
+ // apply or display it against the new one.
+ this.#optimisticGain.clear(action.id);
+ this.#dropCacheIfUnobserved(previous?.track, action.id);
+ }
+ }
+
+ /** @inheritdoc */
+ protected override onDisappear(context: string, settings: VolumeStepperSettings | undefined): void {
+ this.meld.unregisterTrackObserver(context);
+ this.#optimisticGain.clear(context);
+ this.#dropCacheIfUnobserved(settings?.track, context);
+ }
+
+ /** @inheritdoc */
+ protected async render({ action, settings, session, reason }: RenderArgs): Promise {
+ const track = settings.track;
+ if (!track) {
+ this.meld.unregisterTrackObserver(action.id);
+ await this.#renderFeedback(action, settings, { muted: true, monitoring: false });
+ return;
+ }
+
+ // Only touch the observer registry while connected; leaving it intact while
+ // disconnected is what lets the connection re-register it on reconnect.
+ if (this.meld.ready) {
+ if (session.has(track)) {
+ this.meld.registerTrackObserver(action.id, track);
+ } else {
+ this.meld.unregisterTrackObserver(action.id);
+ // The track left this session (e.g. a project switch): its cache can
+ // only go stale while unobserved, and the snapshot is fresher if the
+ // same id ever returns.
+ this.#trackGain.delete(track);
+ }
+ }
+
+ if (reason === "closed") {
+ // Keep the last painted feedback rather than flashing on a blip.
+ return;
+ }
+
+ await this.#renderFeedback(action, settings, this.#resolve(action.id, track, session.getTrack(track)));
+ }
+
+ /** Optimistically sets a new gain, paints it, and sends it to Meld. */
+ #applyGain(
+ action: RenderArgs["action"],
+ settings: VolumeStepperSettings,
+ track: string,
+ gain: number,
+ ): void {
+ // Never paint a value that cannot reach Meld (e.g. tap-to-set mid-blip):
+ // the send would silently no-op and the phantom gain would linger.
+ if (!this.meld.ready) {
+ return;
+ }
+ this.#optimisticGain.set(action.id, gain);
+ this.meld.meld?.setGain?.(track, gain);
+ const item = this.session().getTrack(track);
+ void this.#renderFeedback(action, settings, { ...this.#resolve(action.id, track, item), gain });
+ }
+
+ /**
+ * The best-known gain for a context: the user's optimistic value, else the
+ * observer cache. The session snapshot carries no gain at all, so the
+ * observer is the only source — registering one makes Meld emit the track's
+ * current gain immediately, which is what hydrates a freshly-appeared dial.
+ */
+ #currentGain(context: string, track: string): number | undefined {
+ return this.#optimisticGain.get(context) ?? this.#trackGain.get(track)?.gain;
+ }
+
+ /** Merges optimistic gain, the observer cache, and the session snapshot. */
+ #resolve(context: string, track: string, item: IdentifiedItem | undefined): TrackDisplay {
+ if (!item) {
+ // The track is gone from the session (or there is no session): show the
+ // unknown indicator rather than a stale cached gain.
+ return { muted: true, monitoring: false };
+ }
+ const cached = this.#trackGain.get(track);
+ return {
+ gain: this.#currentGain(context, track),
+ // The gainUpdated fast path outruns full session updates, so the cache is
+ // fresher than the snapshot for mute.
+ muted: cached?.muted ?? item.muted ?? true,
+ monitoring: item.monitoring ?? false,
+ name: item.name,
+ };
+ }
+
+ /**
+ * Drops a track's observer-cache entry when no other visible context is bound
+ * to it. An unobserved track receives no `gainUpdated` events, so its cached
+ * value would silently go stale and outrank the fresher session snapshot if
+ * the track were bound again later.
+ */
+ #dropCacheIfUnobserved(track: string | undefined, exceptContext: string): void {
+ if (!track) {
+ return;
+ }
+ for (const action of this.actions) {
+ if (action.id !== exceptContext && this.settingsFor(action.id)?.track === track) {
+ return;
+ }
+ }
+ this.#trackGain.delete(track);
+ }
+
+ /** Repaints a context from authoritative state (no optimistic value). */
+ #repaintAuthoritative(context: string): void {
+ // While disconnected there is no authoritative state to restore; keep the
+ // last painted feedback (the reconnect render repaints everything).
+ if (!this.meld.ready) {
+ return;
+ }
+ for (const action of this.actions) {
+ if (action.id !== context) {
+ continue;
+ }
+ const settings = this.settingsFor(action.id);
+ const track = settings?.track;
+ if (!settings || !track) {
+ return;
+ }
+ void this.#renderFeedback(action, settings, this.#resolve(context, track, this.session().getTrack(track)));
+ return;
+ }
+ }
+
+ /** Renders the dial's touchscreen feedback for the given display state. */
+ #renderFeedback(
+ action: RenderArgs["action"],
+ settings: VolumeStepperSettings,
+ display: TrackDisplay,
+ ): Promise {
+ const usePercent = settings.metertype === "percent";
+ const { gain, muted, name } = display;
+ const hasGain = Number.isFinite(gain);
+
+ let volume: string;
+ let barValue: number;
+ if (!hasGain) {
+ volume = "--";
+ barValue = 0;
+ } else if (usePercent) {
+ // Round rather than truncate: float artifacts like 0.29*100 === 28.999…
+ // would otherwise display one percent low.
+ volume = `${Math.round((gain as number) * 100)}%`;
+ barValue = gain as number;
+ } else {
+ const dB = toDb(gain as number);
+ volume = dB === -60 ? "-∞ dB" : `${Math.round(dB)} dB`;
+ barValue = (dB + 60) / 60;
+ }
+
+ let icon = AUDIO_ICON_MUTE;
+ if (!muted && hasGain) {
+ if ((gain as number) > 0.4) {
+ icon = AUDIO_ICON;
+ } else if ((gain as number) > 0) {
+ icon = AUDIO_ICON_40;
+ }
+ }
+
+ return this.asDial(action)
+ .setFeedback({
+ icon,
+ title: name ?? "Adjust Volume",
+ value: volume,
+ indicator: {
+ value: barValue * 100,
+ enabled: true,
+ bar_bg_c: muted || !hasGain ? "0:#666666,1:#666666" : "0:#6DDE92,1:#6DDE92",
+ },
+ })
+ .catch((err) => {
+ streamDeck.logger.error(`Failed to set feedback for ${action.id}`, err);
+ });
+ }
+}
diff --git a/src/actions/widget-control.ts b/src/actions/widget-control.ts
new file mode 100644
index 0000000..689e950
--- /dev/null
+++ b/src/actions/widget-control.ts
@@ -0,0 +1,121 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import { action, type KeyUpEvent } from "@elgato/streamdeck";
+
+import { MeldAction, type RenderArgs } from "./base.js";
+
+/** Settings for {@link WidgetControl}. */
+type WidgetControlSettings = {
+ widget?: string;
+ event?: string;
+ amount?: string | number;
+};
+
+const ADD_TIME_EVENT = "SUBATHONTIMER_ADDTIME";
+const DEFAULT_EVENT = "STOPWATCH_RESET";
+const DEFAULT_WIDGET = "STOPWATCH";
+const DEFAULT_AMOUNT = "60";
+
+const EVENT_WIDGET: Record = {
+ STOPWATCH_RESET: "STOPWATCH",
+ STOPWATCH_PAUSE: "STOPWATCH",
+ STOPWATCH_RESUME: "STOPWATCH",
+ COUNTDOWN_RESET: "COUNTDOWN",
+ COUNTDOWN_PAUSE: "COUNTDOWN",
+ COUNTDOWN_RESUME: "COUNTDOWN",
+ CONFETTIFALL_TRIGGER: "CONFETTIFALL",
+ CONFETTIPOP_TRIGGER: "CONFETTIPOP",
+ SUBATHONTIMER_RESET: "SUBATHONTIMER",
+ SUBATHONTIMER_PAUSE: "SUBATHONTIMER",
+ SUBATHONTIMER_RESUME: "SUBATHONTIMER",
+ SUBATHONTIMER_ADDTIME: "SUBATHONTIMER",
+ WHEELSPIN_SPIN: "WHEELSPIN",
+ COUNTER_INCREMENT: "COUNTER",
+ COUNTER_DECREMENT: "COUNTER",
+};
+
+const DEFAULT_EVENT_BY_WIDGET: Record = {
+ STOPWATCH: "STOPWATCH_RESET",
+ COUNTDOWN: "COUNTDOWN_RESET",
+ CONFETTIFALL: "CONFETTIFALL_TRIGGER",
+ CONFETTIPOP: "CONFETTIPOP_TRIGGER",
+ SUBATHONTIMER: "SUBATHONTIMER_RESET",
+ WHEELSPIN: "WHEELSPIN_SPIN",
+ COUNTER: "COUNTER_INCREMENT",
+};
+
+/** Maps each widget stream event to its key icon. */
+const WIDGET_EVENT_ICONS: Record = {
+ STOPWATCH_RESET: "assets/Widget/Key Icon/widget-stopwatch-reset",
+ STOPWATCH_PAUSE: "assets/Widget/Key Icon/widget-stopwatch-pause",
+ STOPWATCH_RESUME: "assets/Widget/Key Icon/widget-stopwatch-resume",
+ COUNTDOWN_RESET: "assets/Widget/Key Icon/widget-countdown-reset",
+ COUNTDOWN_PAUSE: "assets/Widget/Key Icon/widget-countdown-pause",
+ COUNTDOWN_RESUME: "assets/Widget/Key Icon/widget-countdown-resume",
+ CONFETTIFALL_TRIGGER: "assets/Widget/Key Icon/widget-confettifall-trigger",
+ CONFETTIPOP_TRIGGER: "assets/Widget/Key Icon/widget-confettipop-trigger",
+ SUBATHONTIMER_RESET: "assets/Widget/Key Icon/widget-subathontimer-reset",
+ SUBATHONTIMER_PAUSE: "assets/Widget/Key Icon/widget-subathontimer-pause",
+ SUBATHONTIMER_RESUME: "assets/Widget/Key Icon/widget-subathontimer-resume",
+ SUBATHONTIMER_ADDTIME: "assets/Widget/Key Icon/widget-subathontimer-addtime",
+ COUNTER_INCREMENT: "assets/Widget/Key Icon/widget-counter-increment",
+ COUNTER_DECREMENT: "assets/Widget/Key Icon/widget-counter-decrement",
+ WHEELSPIN_SPIN: "assets/Widget/Key Icon/widget-wheelspin-spin",
+};
+
+/** Dispatches widget stream events (stopwatch, countdown, confetti, etc.). */
+@action({ UUID: "co.meldstudio.streamdeck.widget-control" })
+export class WidgetControl extends MeldAction {
+ protected override migrateSettings(settings: WidgetControlSettings): WidgetControlSettings {
+ const requestedEvent = settings.event ?? DEFAULT_EVENT;
+ const widget =
+ (settings.widget && DEFAULT_EVENT_BY_WIDGET[settings.widget] ? settings.widget : undefined) ??
+ EVENT_WIDGET[requestedEvent] ??
+ DEFAULT_WIDGET;
+ const event = EVENT_WIDGET[requestedEvent] === widget ? requestedEvent : DEFAULT_EVENT_BY_WIDGET[widget];
+ const amount = settings.amount ?? DEFAULT_AMOUNT;
+ if (settings.event === event && settings.widget === widget && settings.amount != null) {
+ return settings;
+ }
+ return { ...settings, widget, event, amount };
+ }
+
+ /** Shows the icon for the configured event. Independent of session state. */
+ protected render({ action, settings }: RenderArgs): Promise {
+ return this.setKeyImage(action, this.#iconFor(settings.event));
+ }
+
+ /** @inheritdoc */
+ override onKeyUp(ev: KeyUpEvent): void {
+ // A migrated setSettings echo can arrive after the user's first press. The
+ // canonical cache is already populated, so use it instead of dispatching the
+ // stale event payload during that window.
+ const settings = this.settingsFor(ev.action.id) ?? ev.payload.settings;
+ const eventType = settings.event || DEFAULT_EVENT;
+ const payload = this.#buildPayload(eventType, settings.amount);
+
+ const meld = this.meld.meld;
+ if (!meld?.sendStreamEvent) {
+ return;
+ }
+ // Events without data are sent with `false`, mirroring the original plugin.
+ meld.sendStreamEvent(eventType, payload ?? false);
+ }
+
+ /** The icon for an event, falling back to the default event's icon. */
+ #iconFor(event: string | undefined): string {
+ const selected = event || DEFAULT_EVENT;
+ return WIDGET_EVENT_ICONS[selected] ?? WIDGET_EVENT_ICONS[DEFAULT_EVENT];
+ }
+
+ /** Builds the optional payload; only the add-time event carries data. */
+ #buildPayload(eventType: string, amountSetting: string | number | undefined): { amount: number } | undefined {
+ if (eventType !== ADD_TIME_EVENT) {
+ return undefined;
+ }
+ const parsed = Number(amountSetting);
+ const amount = Number.isFinite(parsed) ? Math.max(0, Math.round(parsed)) : 0;
+ return { amount };
+ }
+}
diff --git a/src/meld/connection.ts b/src/meld/connection.ts
new file mode 100644
index 0000000..c68e6b2
--- /dev/null
+++ b/src/meld/connection.ts
@@ -0,0 +1,472 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import streamDeck from "@elgato/streamdeck";
+
+import { type MeldEventMap, TypedEmitter } from "./events.js";
+import { QWebChannel as QWebChannelImpl } from "./qwebchannel.js";
+import type { MeldObject, Session } from "./types.js";
+
+/** Constructable view of the vendored (untyped) QWebChannel client. */
+type QWebChannelConstructor = new (
+ transport: WebSocket,
+ initCallback: (channel: { objects: Record }) => void,
+ converters?: unknown,
+) => unknown;
+
+const QWebChannel = QWebChannelImpl as unknown as QWebChannelConstructor;
+
+/** Callbacks produced by one WebChannel connection attempt. */
+export type WebChannelCallbacks = {
+ ready(meld: MeldObject | undefined): void;
+ closed(): void;
+ error(): void;
+};
+
+/** A live WebChannel attempt. Closing it must not call {@link WebChannelCallbacks.closed}. */
+export type WebChannelHandle = {
+ close(): void;
+};
+
+/**
+ * Opens a WebChannel and reports its public `meld` object once initialized.
+ *
+ * Keeping this seam at the initialized-object boundary lets functional tests
+ * supply exactly the API documented by WebChannelAPI.md without reproducing
+ * Qt's private wire protocol.
+ */
+export type OpenWebChannel = (url: string, callbacks: WebChannelCallbacks) => WebChannelHandle;
+
+/** Builds the small adapter from a WebSocket transport to the result-level seam. */
+export function createWebChannelOpener(
+ createSocket: (url: string) => WebSocket,
+ initialize: (socket: WebSocket, ready: (meld: MeldObject | undefined) => void) => void,
+): OpenWebChannel {
+ return (url, callbacks) => {
+ const socket = createSocket(url);
+ socket.onopen = (): void => initialize(socket, callbacks.ready);
+ socket.onclose = callbacks.closed;
+ socket.onerror = callbacks.error;
+ return {
+ close(): void {
+ // A caller-initiated close is teardown, not a second remote-close event.
+ socket.onopen = null;
+ socket.onclose = null;
+ socket.onerror = null;
+ socket.onmessage = null;
+ try {
+ socket.close();
+ } catch {
+ // ignore
+ }
+ },
+ };
+ };
+}
+
+/** Production adapter from WebSocket/QWebChannel to the result-level seam. */
+const openWebChannel = createWebChannelOpener(
+ (url) => new WebSocket(url),
+ (socket, ready) => {
+ // eslint-disable-next-line no-new -- QWebChannel wires itself onto the transport.
+ new QWebChannel(socket, (channel) => ready(channel.objects.meld));
+ },
+);
+
+/** Where Meld Studio's WebChannel server listens. */
+const MELD_ADDRESS = "127.0.0.1";
+const MELD_PORT = 13376;
+
+/** Overrides for {@link MeldConnection}; only tests pass anything but the defaults. */
+export type MeldConnectionOptions = {
+ address?: string;
+ port?: number;
+ /** Shortened by tests so the handshake watchdog can be exercised quickly. */
+ handshakeTimeoutMs?: number;
+ /** Shortened by tests so reconnect cycles do not dominate the run time. */
+ reconnectMinMs?: number;
+ /** Result-level WebChannel adapter; functional tests inject a mock here. */
+ openWebChannel?: OpenWebChannel;
+};
+
+/** Reconnect backoff bounds (milliseconds). */
+const RECONNECT_MIN_MS = 1_000;
+const RECONNECT_MAX_MS = 10_000;
+
+/**
+ * How long a connection attempt may run without becoming ready before it is
+ * abandoned and retried. Without this bound, a peer that accepts the socket but
+ * never answers the init request (a wedged Meld, or a different app squatting
+ * on the port) would leave the plugin permanently not-ready with no reconnect —
+ * `onclose` never fires on a socket nobody closes.
+ */
+const HANDSHAKE_TIMEOUT_MS = 10_000;
+
+/**
+ * Owns the single WebSocket + QWebChannel connection to Meld Studio.
+ *
+ * This is the one place that talks to Meld. It centralizes the behaviour that
+ * used to be scattered across every action and every property inspector:
+ *
+ * - a single socket, reconnected automatically with backoff;
+ * - all Qt signals wired once per connection and surfaced as typed events;
+ * - a registry of active track observers that is transparently re-registered
+ * after a reconnect, so actions never have to re-wire themselves.
+ */
+export class MeldConnection {
+ readonly #emitter = new TypedEmitter((event, err) =>
+ streamDeck.logger.error(`Listener for "${event}" threw`, err),
+ );
+
+ /** Active track observers, keyed by the owning context id → trackId. */
+ readonly #observers = new Map();
+
+ /**
+ * Contexts whose registration Meld is currently known to hold.
+ *
+ * Meld drops every track observer whenever it tears down its audio tracks —
+ * restoring or reopening a session does that without closing the socket, and
+ * track ids are persisted, so the same ids come back. Nothing in the snapshot
+ * distinguishes that from an ordinary edit, so this is cleared on every
+ * session change and the next register call is allowed through.
+ */
+ readonly #registered = new Set();
+
+ /** The Meld WebChannel endpoint this connection targets. */
+ readonly #url: string;
+
+ /** How long a connection attempt may run before it is abandoned and retried. */
+ readonly #handshakeTimeoutMs: number;
+
+ /** Starting reconnect delay, grown on each consecutive failure. */
+ readonly #reconnectMinMs: number;
+
+ /** Opens the production WebChannel, or a result-level test double. */
+ readonly #openWebChannel: OpenWebChannel;
+
+ constructor({
+ address = MELD_ADDRESS,
+ port = MELD_PORT,
+ handshakeTimeoutMs = HANDSHAKE_TIMEOUT_MS,
+ reconnectMinMs = RECONNECT_MIN_MS,
+ openWebChannel: openChannel = openWebChannel,
+ }: MeldConnectionOptions = {}) {
+ this.#url = `ws://${address}:${port}`;
+ this.#handshakeTimeoutMs = handshakeTimeoutMs;
+ this.#reconnectMinMs = reconnectMinMs;
+ this.#openWebChannel = openChannel;
+ this.#reconnectDelay = reconnectMinMs;
+ }
+
+ #channel?: WebChannelHandle;
+ #meld?: MeldObject;
+ #ready = false;
+ #reconnectDelay = RECONNECT_MIN_MS;
+ #reconnectTimer?: ReturnType;
+ #handshakeTimer?: ReturnType;
+ #generation = 0;
+ #disposed = false;
+ #disposeDepth = 0;
+
+ /** The live `meld` object, or `undefined` when disconnected. */
+ get meld(): MeldObject | undefined {
+ return this.#meld;
+ }
+
+ /** Whether the QWebChannel handshake has completed and `meld` is usable. */
+ get ready(): boolean {
+ return this.#ready;
+ }
+
+ /** The latest authoritative session snapshot, or `undefined` when disconnected. */
+ get session(): Session | undefined {
+ return this.#meld?.session;
+ }
+
+ /** Subscribe to a typed Meld event. Returns an unsubscribe function. */
+ on = this.#emitter.on.bind(this.#emitter);
+ /** Remove a previously-registered listener. */
+ off = this.#emitter.off.bind(this.#emitter);
+
+ /** Opens the connection and keeps it alive until {@link dispose} is called. */
+ connect(): void {
+ // `connect` is deliberately idempotent. In a long-lived plugin more than one
+ // startup path may ask for the singleton connection; a second call must not
+ // replace a healthy Meld instance or leave a timer that does so later.
+ if (
+ this.#disposeDepth > 0 ||
+ (!this.#disposed && (this.#channel || this.#reconnectTimer || this.#handshakeTimer || this.#ready))
+ ) {
+ return;
+ }
+ this.#disposed = false;
+ // An explicit start begins a fresh lifecycle. Do not carry a long failure
+ // backoff across dispose/connect cycles (for example, a plugin restart hook).
+ this.#reconnectDelay = this.#reconnectMinMs;
+ this.#open();
+ }
+
+ /** Stops the connection and cancels any pending reconnect. A later connect may restart it. */
+ dispose(): void {
+ this.#disposeDepth++;
+ try {
+ this.#disposed = true;
+ if (this.#reconnectTimer) {
+ clearTimeout(this.#reconnectTimer);
+ this.#reconnectTimer = undefined;
+ }
+ this.#teardownSocket();
+ this.#observers.clear();
+ } finally {
+ this.#disposeDepth--;
+ }
+ }
+
+ /**
+ * Registers a track observer for fast `gainUpdated` callbacks and remembers it
+ * so it can be re-registered automatically after a reconnect.
+ * @param context Owning action context id.
+ * @param trackId Track to observe.
+ */
+ registerTrackObserver(context: string, trackId: string): void {
+ const existing = this.#observers.get(context);
+ if (existing === trackId && this.#registered.has(context)) {
+ return;
+ }
+ // Switching tracks on the same context: drop the previous registration.
+ // Optional-chain the methods too — a Meld build without observer support
+ // must degrade to snapshot-only gain, not throw.
+ if (existing !== undefined && existing !== trackId && this.#ready) {
+ this.#meld?.unregisterTrackObserver?.(context, existing);
+ }
+ this.#observers.set(context, trackId);
+ if (this.#ready) {
+ const register = this.#meld?.registerTrackObserver;
+ register?.(context, trackId);
+ if (register) {
+ this.#registered.add(context);
+ }
+ }
+ }
+
+ /**
+ * Removes a track observer previously added with {@link registerTrackObserver}.
+ * @param context Owning action context id.
+ */
+ unregisterTrackObserver(context: string): void {
+ const trackId = this.#observers.get(context);
+ if (trackId === undefined) {
+ return;
+ }
+ this.#observers.delete(context);
+ this.#registered.delete(context);
+ if (this.#ready) {
+ this.#meld?.unregisterTrackObserver?.(context, trackId);
+ }
+ }
+
+ #open(): void {
+ if (this.#disposed) {
+ return;
+ }
+ this.#teardownSocket();
+
+ const generation = ++this.#generation;
+ this.#handshakeTimer = setTimeout(() => {
+ this.#handshakeTimer = undefined;
+ if (generation !== this.#generation) {
+ return;
+ }
+ streamDeck.logger.warn("Meld handshake timed out; retrying");
+ this.#teardownSocket();
+ this.#scheduleReconnect();
+ }, this.#handshakeTimeoutMs);
+
+ try {
+ const channel = this.#openWebChannel(this.#url, {
+ ready: (meld): void => {
+ if (!this.#disposed && generation === this.#generation) {
+ try {
+ this.#onChannelReady(meld, generation);
+ } catch (err) {
+ // Readiness is normally asynchronous, outside the opener's try/catch.
+ // A malformed API result or failed observer registration must tear down
+ // this generation and retry rather than strand a half-ready connection.
+ if (!this.#disposed && generation === this.#generation) {
+ streamDeck.logger.warn("Failed to initialize Meld channel; retrying", err);
+ this.#teardownSocket();
+ this.#scheduleReconnect();
+ }
+ }
+ }
+ },
+ closed: (): void => {
+ if (generation !== this.#generation) {
+ return;
+ }
+ this.#teardownSocket();
+ this.#scheduleReconnect();
+ },
+ error: (): void => {
+ if (generation === this.#generation) {
+ streamDeck.logger.debug("Meld socket error");
+ }
+ },
+ });
+ // Adapters normally settle asynchronously, but keeping assignment outside
+ // the call also makes a synchronous close/wrong-peer result safe: that
+ // result invalidates the generation, so its returned handle is closed rather
+ // than installed as the current attempt.
+ if (this.#disposed || generation !== this.#generation) {
+ channel.close();
+ } else {
+ this.#channel = channel;
+ }
+ } catch (err) {
+ // A synchronous callback may already have settled and invalidated this
+ // attempt before the adapter throws. Its path owns the retry in that case.
+ if (generation !== this.#generation) {
+ return;
+ }
+ this.#teardownSocket();
+ streamDeck.logger.warn("Failed to open Meld socket", err);
+ this.#scheduleReconnect();
+ }
+ }
+
+ #onChannelReady(meld: MeldObject | undefined, generation: number): void {
+ // A well-behaved adapter resolves once, but treating duplicate callbacks as
+ // no-ops keeps readiness, signal wiring, and observer registration exact-once.
+ if (this.#disposed || generation !== this.#generation || this.#ready || this.#meld) {
+ return;
+ }
+ if (!meld) {
+ // Wrong peer (a QWebChannel server without a `meld` object): retry with
+ // backoff rather than sitting on an open socket to it forever.
+ streamDeck.logger.warn("Meld channel ready but `meld` object is missing; retrying");
+ this.#teardownSocket();
+ this.#scheduleReconnect();
+ return;
+ }
+
+ if (this.#handshakeTimer) {
+ clearTimeout(this.#handshakeTimer);
+ this.#handshakeTimer = undefined;
+ }
+ this.#meld = meld;
+
+ // Wire the signals before declaring the connection ready: if any of these
+ // throws (e.g. a Meld build missing a signal), the dispatch guard closes the
+ // socket and the reconnect path must not find a half-initialized "ready"
+ // connection.
+ meld.sessionChanged.connect(() => {
+ if (generation !== this.#generation || meld !== this.#meld) {
+ return;
+ }
+ // A session republish may be the tail of a restore that destroyed and
+ // rebuilt Meld's audio tracks, taking every observer registration with it
+ // while the socket stayed up. Forget what Meld is holding so the
+ // re-render's register call is sent rather than deduped away.
+ this.#registered.clear();
+ this.#emitter.emit("sessionChanged", meld.session);
+ });
+ meld.isStreamingChanged.connect(() => {
+ if (generation === this.#generation && meld === this.#meld) {
+ this.#emitter.emit("streamingChanged", !!meld.isStreaming);
+ }
+ });
+ meld.isRecordingChanged.connect(() => {
+ if (generation === this.#generation && meld === this.#meld) {
+ this.#emitter.emit("recordingChanged", !!meld.isRecording);
+ }
+ });
+ // Optional: part of the track-observer feature — a build without it must
+ // degrade to snapshot-only gain, not throw during the handshake.
+ meld.gainUpdated?.connect((trackId, gain, muted) => {
+ if (generation === this.#generation && meld === this.#meld) {
+ this.#emitter.emit("gainChanged", { trackId, gain, muted });
+ }
+ });
+
+ // Re-register every active observer against the fresh `meld` object.
+ for (const [context, trackId] of this.#observers) {
+ meld.registerTrackObserver?.(context, trackId);
+ if (meld.registerTrackObserver) {
+ this.#registered.add(context);
+ }
+ }
+
+ // Only a fully completed handshake resets the backoff; a recurring throw
+ // above must keep growing the delay instead of hammering Meld every second.
+ this.#reconnectDelay = this.#reconnectMinMs;
+ this.#ready = true;
+
+ // Announce readiness, then push the initial authoritative state so listeners
+ // hydrate deterministically without racing the first signal.
+ this.#emitter.emit("ready");
+ if (!this.#isCurrentReady(meld, generation)) return;
+ this.#emitter.emit("sessionChanged", meld.session);
+ if (!this.#isCurrentReady(meld, generation)) return;
+ this.#emitter.emit("streamingChanged", !!meld.isStreaming);
+ if (!this.#isCurrentReady(meld, generation)) return;
+ this.#emitter.emit("recordingChanged", !!meld.isRecording);
+ }
+
+ /** Whether an initialization callback still owns the live, ready generation. */
+ #isCurrentReady(meld: MeldObject, generation: number): boolean {
+ return !this.#disposed && this.#ready && generation === this.#generation && meld === this.#meld;
+ }
+
+ #onDisconnected(): void {
+ if (!this.#ready && !this.#meld) {
+ return;
+ }
+ const wasReady = this.#ready;
+ this.#ready = false;
+ this.#meld = undefined;
+ this.#registered.clear();
+ // A failure while validating the API result never announced `ready`, so it
+ // must not manufacture an unmatched `closed` lifecycle event.
+ if (wasReady) {
+ this.#emitter.emit("closed");
+ }
+ }
+
+ #scheduleReconnect(): void {
+ // A `closed` listener is allowed to call connect() synchronously. If it did,
+ // do not leave the old close path's timer armed to replace the fresh attempt.
+ if (this.#disposed || this.#reconnectTimer || this.#channel || this.#handshakeTimer || this.#ready) {
+ return;
+ }
+ const delay = this.#reconnectDelay;
+ this.#reconnectDelay = Math.min(this.#reconnectDelay * 1.5, RECONNECT_MAX_MS);
+ this.#reconnectTimer = setTimeout(() => {
+ this.#reconnectTimer = undefined;
+ this.#open();
+ }, delay);
+ }
+
+ #teardownSocket(): void {
+ // Invalidate first so a close implementation that synchronously releases a
+ // queued callback cannot resurrect the attempt being torn down.
+ this.#generation++;
+ if (this.#handshakeTimer) {
+ clearTimeout(this.#handshakeTimer);
+ this.#handshakeTimer = undefined;
+ }
+ const channel = this.#channel;
+ this.#channel = undefined;
+ if (channel) {
+ try {
+ channel.close();
+ } catch {
+ // ignore
+ }
+ }
+ // Detaching the handlers means `onclose` will never run for this socket, so
+ // the disconnect has to be recorded here or `ready`/`meld` would keep
+ // describing a transport that is gone. Idempotent: a teardown that follows
+ // a real close is a no-op.
+ this.#onDisconnected();
+ }
+}
diff --git a/src/meld/events.ts b/src/meld/events.ts
new file mode 100644
index 0000000..bdb22c4
--- /dev/null
+++ b/src/meld/events.ts
@@ -0,0 +1,96 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import type { Session } from "./types.js";
+
+/** Payload emitted by the fast `gainUpdated` track observer signal. */
+export type GainChange = {
+ trackId: string;
+ gain: number;
+ muted: boolean;
+};
+
+/** The full map of Meld connection events and their argument tuples. */
+export type MeldEventMap = {
+ /** The QWebChannel handshake finished; `meld` is available and a fresh snapshot follows. */
+ ready: [];
+ /** The socket closed; `meld` is no longer usable until the next `ready`. */
+ closed: [];
+ /** The authoritative session snapshot changed. */
+ sessionChanged: [session: Session | undefined];
+ /** A registered track's gain/mute changed (faster than a full session update). */
+ gainChanged: [change: GainChange];
+ /** `isStreaming` changed. */
+ streamingChanged: [streaming: boolean];
+ /** `isRecording` changed. */
+ recordingChanged: [recording: boolean];
+};
+
+type Listener = (...args: Args) => void;
+
+/** Invoked when a listener throws; receives the event name and the error. */
+export type ListenerErrorHandler = (event: string, err: unknown) => void;
+
+/**
+ * A tiny strongly-typed event emitter. Deliberately not Node's `EventEmitter`
+ * so the event names and argument tuples are checked at compile time — the
+ * scattered, stringly-typed `$MS.on(...)` calls were a source of the state bugs
+ * this refactor removes.
+ */
+export class TypedEmitter> {
+ readonly #listeners = new Map>>();
+ readonly #onListenerError: ListenerErrorHandler;
+
+ /**
+ * @param onListenerError Receives errors thrown by listeners (defaults to
+ * `console.error`). Injected so this module stays dependency-free.
+ */
+ constructor(onListenerError: ListenerErrorHandler = (event, err) => console.error(`Listener for "${event}" threw`, err)) {
+ this.#onListenerError = onListenerError;
+ }
+
+ /**
+ * Subscribes to an event.
+ * @param event Event name.
+ * @param listener Callback invoked with the event's arguments.
+ * @returns A function that removes the listener.
+ */
+ on(event: E, listener: Listener): () => void {
+ let set = this.#listeners.get(event);
+ if (!set) {
+ set = new Set();
+ this.#listeners.set(event, set);
+ }
+ set.add(listener as Listener);
+ return () => this.off(event, listener);
+ }
+
+ /**
+ * Removes a previously-registered listener.
+ * @param event Event name.
+ * @param listener The exact callback passed to {@link on}.
+ */
+ off(event: E, listener: Listener): void {
+ this.#listeners.get(event)?.delete(listener as Listener);
+ }
+
+ /**
+ * Emits an event to all listeners. Listener exceptions are isolated so one
+ * bad subscriber cannot break the others.
+ * @param event Event name.
+ * @param args Event arguments.
+ */
+ emit(event: E, ...args: Events[E]): void {
+ const set = this.#listeners.get(event);
+ if (!set) {
+ return;
+ }
+ for (const listener of [...set]) {
+ try {
+ (listener as Listener)(...args);
+ } catch (err) {
+ this.#onListenerError(String(event), err);
+ }
+ }
+ }
+}
diff --git a/src/meld/index.ts b/src/meld/index.ts
new file mode 100644
index 0000000..408a04f
--- /dev/null
+++ b/src/meld/index.ts
@@ -0,0 +1,15 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import { MeldConnection } from "./connection.js";
+
+export { MeldConnection } from "./connection.js";
+export { MeldSession } from "./session.js";
+export type { GainChange, MeldEventMap } from "./events.js";
+export type * from "./types.js";
+
+/**
+ * The single, process-wide Meld Studio connection shared by every action and by
+ * the property-inspector bridge. Import this rather than opening new sockets.
+ */
+export const meld = new MeldConnection();
diff --git a/src/meld/qwebchannel.ts b/src/meld/qwebchannel.ts
new file mode 100644
index 0000000..3d4e1c9
--- /dev/null
+++ b/src/meld/qwebchannel.ts
@@ -0,0 +1,477 @@
+// @ts-nocheck
+/* eslint-disable */
+// Copyright (C) 2016 The Qt Company Ltd.
+// Copyright (C) 2016 Klarälvdalens Datakonsult AB, a KDAB Group company, info@kdab.com, author Milian Wolff
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
+//
+// Vendored from Qt's qwebchannel.js and adapted to an ES module for use inside
+// the Node.js Stream Deck plugin. Functional changes from upstream: the export
+// at the bottom of the file, and hardening of the message dispatch — under Node
+// the transport is an EventTarget, so an exception escaping `onmessage` would
+// kill the whole plugin process rather than being swallowed per-page as in a
+// browser. The protocol implementation is otherwise verbatim so it stays
+// compatible with Meld Studio's QWebChannel server.
+
+import streamDeck from "@elgato/streamdeck";
+
+const QWebChannelMessageTypes = {
+ signal: 1,
+ propertyUpdate: 2,
+ init: 3,
+ idle: 4,
+ debug: 5,
+ invokeMethod: 6,
+ connectToSignal: 7,
+ disconnectFromSignal: 8,
+ setProperty: 9,
+ response: 10,
+};
+
+const QWebChannel = function (transport, initCallback, converters) {
+ if (typeof transport !== "object" || typeof transport.send !== "function") {
+ console.error(
+ "The QWebChannel expects a transport object with a send function and onmessage callback property." +
+ " Given is: transport: " +
+ typeof transport +
+ ", transport.send: " +
+ typeof transport.send,
+ );
+ return;
+ }
+
+ var channel = this;
+ this.transport = transport;
+
+ var converterRegistry = {
+ Date: function (response) {
+ if (
+ typeof response === "string" &&
+ response.match(/^-?\d+-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d*)?([-+−](\d{2}):(\d{2})|Z)?$/)
+ ) {
+ var date = new Date(response);
+ if (!isNaN(date)) return date;
+ }
+ return undefined; // Return undefined if current converter is not applicable
+ },
+ };
+
+ this.usedConverters = [];
+
+ this.addConverter = function (converter) {
+ if (typeof converter === "string") {
+ if (converterRegistry.hasOwnProperty(converter)) this.usedConverters.push(converterRegistry[converter]);
+ else console.error("Converter '" + converter + "' not found");
+ } else if (typeof converter === "function") {
+ this.usedConverters.push(converter);
+ } else {
+ console.error("Invalid converter object type " + typeof converter);
+ }
+ };
+
+ if (Array.isArray(converters)) {
+ for (const converter of converters) this.addConverter(converter);
+ } else if (converters !== undefined) {
+ this.addConverter(converters);
+ }
+
+ this.send = function (data) {
+ if (typeof data !== "string") {
+ data = JSON.stringify(data);
+ }
+ channel.transport.send(data);
+ };
+
+ this.transport.onmessage = function (message) {
+ // Under Node the transport is an EventTarget: an exception escaping this
+ // handler becomes an uncaught exception and kills the whole plugin process,
+ // so every failure (malformed frame, throwing signal subscriber, ...) must
+ // be contained here.
+ try {
+ var data = message.data;
+ if (typeof data === "string") {
+ data = JSON.parse(data);
+ }
+ switch (data.type) {
+ case QWebChannelMessageTypes.signal:
+ channel.handleSignal(data);
+ break;
+ case QWebChannelMessageTypes.response:
+ channel.handleResponse(data);
+ break;
+ case QWebChannelMessageTypes.propertyUpdate:
+ channel.handlePropertyUpdate(data);
+ break;
+ default:
+ console.error("invalid message received:", message.data);
+ break;
+ }
+ } catch (err) {
+ streamDeck.logger.error("QWebChannel message handling failed", err);
+ // If this was the init/handshake, the channel is now half-initialized on
+ // an open socket and nothing would ever retry. Close the transport so the
+ // owner's onclose handling drives a clean reconnect.
+ try {
+ channel.transport.close();
+ } catch {
+ // ignore
+ }
+ }
+ };
+
+ this.execCallbacks = {};
+ this.execId = 0;
+ this.exec = function (data, callback) {
+ if (!callback) {
+ // if no callback is given, send directly
+ channel.send(data);
+ return;
+ }
+ if (channel.execId === Number.MAX_VALUE) {
+ // wrap
+ channel.execId = Number.MIN_VALUE;
+ }
+ if (data.hasOwnProperty("id")) {
+ console.error("Cannot exec message with property id: " + JSON.stringify(data));
+ return;
+ }
+ data.id = channel.execId++;
+ channel.execCallbacks[data.id] = callback;
+ channel.send(data);
+ };
+
+ this.objects = {};
+
+ this.handleSignal = function (message) {
+ var object = channel.objects[message.object];
+ if (object) {
+ object.signalEmitted(message.signal, message.args);
+ } else {
+ console.warn("Unhandled signal: " + message.object + "::" + message.signal);
+ }
+ };
+
+ this.handleResponse = function (message) {
+ if (!message.hasOwnProperty("id")) {
+ console.error("Invalid response message received: ", JSON.stringify(message));
+ return;
+ }
+ var callback = channel.execCallbacks[message.id];
+ if (!callback) {
+ // Duplicate or unknown response id; invoking through the missing entry
+ // would throw a TypeError.
+ streamDeck.logger.error("QWebChannel: unknown response id received", message.id);
+ return;
+ }
+ delete channel.execCallbacks[message.id];
+ callback(message.data);
+ };
+
+ this.handlePropertyUpdate = function (message) {
+ message.data.forEach((data) => {
+ var object = channel.objects[data.object];
+ if (object) {
+ object.propertyUpdate(data.signals, data.properties);
+ } else {
+ console.warn("Unhandled property update: " + data.object + "::" + data.signal);
+ }
+ });
+ channel.exec({ type: QWebChannelMessageTypes.idle });
+ };
+
+ this.debug = function (message) {
+ channel.send({ type: QWebChannelMessageTypes.debug, data: message });
+ };
+
+ channel.exec({ type: QWebChannelMessageTypes.init }, function (data) {
+ for (const objectName of Object.keys(data)) {
+ new QObject(objectName, data[objectName], channel);
+ }
+
+ // now unwrap properties, which might reference other registered objects
+ for (const objectName of Object.keys(channel.objects)) {
+ channel.objects[objectName].unwrapProperties();
+ }
+
+ if (initCallback) {
+ initCallback(channel);
+ }
+ channel.exec({ type: QWebChannelMessageTypes.idle });
+ });
+};
+
+function QObject(name, data, webChannel) {
+ this.__id__ = name;
+ webChannel.objects[name] = this;
+
+ // List of callbacks that get invoked upon signal emission
+ this.__objectSignals__ = {};
+
+ // Cache of all properties, updated when a notify signal is emitted
+ this.__propertyCache__ = {};
+
+ var object = this;
+
+ // ----------------------------------------------------------------------
+
+ this.unwrapQObject = function (response) {
+ for (const converter of webChannel.usedConverters) {
+ var result = converter(response);
+ if (result !== undefined) return result;
+ }
+
+ if (response instanceof Array) {
+ // support list of objects
+ return response.map((qobj) => object.unwrapQObject(qobj));
+ }
+ if (!(response instanceof Object)) return response;
+
+ if (!response["__QObject*__"] || response.id === undefined) {
+ var jObj = {};
+ for (const propName of Object.keys(response)) {
+ jObj[propName] = object.unwrapQObject(response[propName]);
+ }
+ return jObj;
+ }
+
+ var objectId = response.id;
+ if (webChannel.objects[objectId]) return webChannel.objects[objectId];
+
+ if (!response.data) {
+ console.error("Cannot unwrap unknown QObject " + objectId + " without data.");
+ return;
+ }
+
+ var qObject = new QObject(objectId, response.data, webChannel);
+ qObject.destroyed.connect(function () {
+ if (webChannel.objects[objectId] === qObject) {
+ delete webChannel.objects[objectId];
+ // reset the now deleted QObject to an empty {} object
+ // just assigning {} though would not have the desired effect, but the
+ // below also ensures all external references will see the empty map
+ // NOTE: this detour is necessary to workaround QTBUG-40021
+ Object.keys(qObject).forEach((name) => delete qObject[name]);
+ }
+ });
+ // here we are already initialized, and thus must directly unwrap the properties
+ qObject.unwrapProperties();
+ return qObject;
+ };
+
+ this.unwrapProperties = function () {
+ for (const propertyIdx of Object.keys(object.__propertyCache__)) {
+ object.__propertyCache__[propertyIdx] = object.unwrapQObject(object.__propertyCache__[propertyIdx]);
+ }
+ };
+
+ function addSignal(signalData, isPropertyNotifySignal) {
+ var signalName = signalData[0];
+ var signalIndex = signalData[1];
+ object[signalName] = {
+ connect: function (callback) {
+ if (typeof callback !== "function") {
+ console.error("Bad callback given to connect to signal " + signalName);
+ return;
+ }
+
+ object.__objectSignals__[signalIndex] = object.__objectSignals__[signalIndex] || [];
+ object.__objectSignals__[signalIndex].push(callback);
+
+ // only required for "pure" signals, handled separately for properties in propertyUpdate
+ if (isPropertyNotifySignal) return;
+
+ // also note that we always get notified about the destroyed signal
+ if (
+ signalName === "destroyed" ||
+ signalName === "destroyed()" ||
+ signalName === "destroyed(QObject*)"
+ )
+ return;
+
+ // and otherwise we only need to be connected only once
+ if (object.__objectSignals__[signalIndex].length == 1) {
+ webChannel.exec({
+ type: QWebChannelMessageTypes.connectToSignal,
+ object: object.__id__,
+ signal: signalIndex,
+ });
+ }
+ },
+ disconnect: function (callback) {
+ if (typeof callback !== "function") {
+ console.error("Bad callback given to disconnect from signal " + signalName);
+ return;
+ }
+ // This makes a new list. This is important because it won't interfere with
+ // signal processing if a disconnection happens while emittig a signal
+ object.__objectSignals__[signalIndex] = (object.__objectSignals__[signalIndex] || []).filter(function (
+ c,
+ ) {
+ return c != callback;
+ });
+ if (!isPropertyNotifySignal && object.__objectSignals__[signalIndex].length === 0) {
+ // only required for "pure" signals, handled separately for properties in propertyUpdate
+ webChannel.exec({
+ type: QWebChannelMessageTypes.disconnectFromSignal,
+ object: object.__id__,
+ signal: signalIndex,
+ });
+ }
+ },
+ };
+ }
+
+ /**
+ * Invokes all callbacks for the given signalname. Also works for property notify callbacks.
+ */
+ function invokeSignalCallbacks(signalName, signalArgs) {
+ var connections = object.__objectSignals__[signalName];
+ if (connections) {
+ connections.forEach(function (callback) {
+ callback.apply(callback, signalArgs);
+ });
+ }
+ }
+
+ this.propertyUpdate = function (signals, propertyMap) {
+ // update property cache
+ for (const propertyIndex of Object.keys(propertyMap)) {
+ var propertyValue = propertyMap[propertyIndex];
+ object.__propertyCache__[propertyIndex] = this.unwrapQObject(propertyValue);
+ }
+
+ for (const signalName of Object.keys(signals)) {
+ // Invoke all callbacks, as signalEmitted() does not. This ensures the
+ // property cache is updated before the callbacks are invoked.
+ invokeSignalCallbacks(signalName, signals[signalName]);
+ }
+ };
+
+ this.signalEmitted = function (signalName, signalArgs) {
+ invokeSignalCallbacks(signalName, this.unwrapQObject(signalArgs));
+ };
+
+ function addMethod(methodData) {
+ var methodName = methodData[0];
+ var methodIdx = methodData[1];
+
+ // Fully specified methods are invoked by id, others by name for host-side overload resolution
+ var invokedMethod = methodName[methodName.length - 1] === ")" ? methodIdx : methodName;
+
+ object[methodName] = function () {
+ var args = [];
+ var callback;
+ var errCallback;
+ for (var i = 0; i < arguments.length; ++i) {
+ var argument = arguments[i];
+ if (typeof argument === "function") callback = argument;
+ else args.push(argument);
+ }
+
+ var result;
+ // during test, webChannel.exec synchronously calls the callback
+ // therefore, the promise must be constucted before calling
+ // webChannel.exec to ensure the callback is set up
+ if (!callback && typeof Promise === "function") {
+ result = new Promise(function (resolve, reject) {
+ callback = resolve;
+ errCallback = reject;
+ });
+ // Every call site here is fire-and-forget, and a response carrying no
+ // `data` rejects this promise. Marking it handled keeps that from
+ // becoming an unhandled rejection that would take down the plugin
+ // process; callers that do await still observe the rejection.
+ result.catch(function () {});
+ }
+
+ webChannel.exec(
+ {
+ type: QWebChannelMessageTypes.invokeMethod,
+ object: object.__id__,
+ method: invokedMethod,
+ args: args,
+ },
+ function (response) {
+ if (response !== undefined) {
+ var result = object.unwrapQObject(response);
+ if (callback) {
+ callback(result);
+ }
+ } else if (errCallback) {
+ errCallback();
+ }
+ },
+ );
+
+ return result;
+ };
+ }
+
+ function bindGetterSetter(propertyInfo) {
+ var propertyIndex = propertyInfo[0];
+ var propertyName = propertyInfo[1];
+ var notifySignalData = propertyInfo[2];
+ // initialize property cache with current value
+ // NOTE: if this is an object, it is not directly unwrapped as it might
+ // reference other QObject that we do not know yet
+ object.__propertyCache__[propertyIndex] = propertyInfo[3];
+
+ if (notifySignalData) {
+ if (notifySignalData[0] === 1) {
+ // signal name is optimized away, reconstruct the actual name
+ notifySignalData[0] = propertyName + "Changed";
+ }
+ addSignal(notifySignalData, true);
+ }
+
+ Object.defineProperty(object, propertyName, {
+ configurable: true,
+ get: function () {
+ var propertyValue = object.__propertyCache__[propertyIndex];
+ if (propertyValue === undefined) {
+ // This shouldn't happen
+ console.warn(
+ 'Undefined value in property cache for property "' + propertyName + '" in object ' + object.__id__,
+ );
+ }
+
+ return propertyValue;
+ },
+ set: function (value) {
+ if (value === undefined) {
+ console.warn("Property setter for " + propertyName + " called with undefined value!");
+ return;
+ }
+ object.__propertyCache__[propertyIndex] = value;
+ var valueToSend = value;
+ webChannel.exec({
+ type: QWebChannelMessageTypes.setProperty,
+ object: object.__id__,
+ property: propertyIndex,
+ value: valueToSend,
+ });
+ },
+ });
+ }
+
+ // ----------------------------------------------------------------------
+
+ data.methods.forEach(addMethod);
+
+ data.properties.forEach(bindGetterSetter);
+
+ data.signals.forEach(function (signal) {
+ addSignal(signal, false);
+ });
+
+ Object.assign(object, data.enums);
+}
+
+QObject.prototype.toJSON = function () {
+ if (this.__id__ === undefined) return {};
+ return {
+ id: this.__id__,
+ "__QObject*__": true,
+ };
+};
+
+export { QWebChannel };
diff --git a/src/meld/session.ts b/src/meld/session.ts
new file mode 100644
index 0000000..97f9d71
--- /dev/null
+++ b/src/meld/session.ts
@@ -0,0 +1,144 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import type {
+ EffectItem,
+ IdentifiedItem,
+ ItemType,
+ LayerItem,
+ SceneItem,
+ Session,
+ SessionItem,
+ TrackItem,
+} from "./types.js";
+
+/**
+ * An immutable, read-only view over a Meld session snapshot with typed selectors.
+ *
+ * This is the single place that understands the shape of `session.items`, so
+ * actions and the property-inspector bridge no longer each re-implement lookups
+ * against `$MS.meld?.session?.items` (a source of the drift this refactor fixes).
+ * Construct a fresh instance per snapshot; it holds no mutable state.
+ */
+export class MeldSession {
+ readonly #session: Session | undefined;
+
+ constructor(session: Session | undefined) {
+ this.#session = session;
+ }
+
+ /** Whether a session snapshot is present at all. */
+ get isPresent(): boolean {
+ return this.#session?.items !== undefined;
+ }
+
+ /** The raw items map (empty when no snapshot is present). */
+ get items(): Record {
+ return this.#session?.items ?? {};
+ }
+
+ /**
+ * Returns `true` when an item with the given id exists in the snapshot.
+ *
+ * Ids are opaque strings that ultimately come from stored settings, so every
+ * lookup here is an own-property check: `in` (and plain indexing) would let
+ * `constructor` or `toString` resolve through `Object.prototype` and conjure
+ * an item that does not exist.
+ */
+ has(id: string | undefined): boolean {
+ return !!id && Object.hasOwn(this.items, id);
+ }
+
+ /** Returns the item with the given id, paired with its id, or `undefined`. */
+ get(id: string | undefined): IdentifiedItem | undefined {
+ if (!this.has(id)) {
+ return undefined;
+ }
+ const item = this.items[id as string];
+ return item ? ({ ...item, id } as IdentifiedItem) : undefined;
+ }
+
+ /** Returns the scene with the given id, or `undefined` when absent / wrong type. */
+ getScene(id: string | undefined): IdentifiedItem | undefined {
+ return this.#typed(id, "scene");
+ }
+
+ /** Returns the track with the given id, or `undefined` when absent / wrong type. */
+ getTrack(id: string | undefined): IdentifiedItem | undefined {
+ return this.#typed(id, "track");
+ }
+
+ /** Returns the layer with the given id, or `undefined` when absent / wrong type. */
+ getLayer(id: string | undefined): IdentifiedItem | undefined {
+ return this.#typed(id, "layer");
+ }
+
+ /** Returns the effect with the given id, or `undefined` when absent / wrong type. */
+ getEffect(id: string | undefined): IdentifiedItem | undefined {
+ return this.#typed(id, "effect");
+ }
+
+ /** All scenes in the snapshot. */
+ scenes(): IdentifiedItem[] {
+ return this.byType("scene");
+ }
+
+ /** All tracks; optionally restricted to those whose `parent` matches. */
+ tracks(parent?: string): IdentifiedItem[] {
+ return this.byType("track", parent);
+ }
+
+ /** All layers; optionally restricted to a parent scene. */
+ layers(parent?: string): IdentifiedItem[] {
+ return this.byType("layer", parent);
+ }
+
+ /** All effects; optionally restricted to a parent layer. */
+ effects(parent?: string): IdentifiedItem[] {
+ return this.byType("effect", parent);
+ }
+
+ /**
+ * Returns every item of a given type, optionally filtered to a parent id.
+ * Mirrors the property inspector's original `getItemsByType` traversal.
+ * @param type Item kind to collect.
+ * @param parent When provided, only items whose `parent` equals this id.
+ */
+ byType(type: T, parent?: string): IdentifiedItem>[] {
+ const result: IdentifiedItem>[] = [];
+ for (const [id, item] of Object.entries(this.items)) {
+ if (item.type !== type) {
+ continue;
+ }
+ if (parent !== undefined && (item as { parent?: string }).parent !== parent) {
+ continue;
+ }
+ result.push({ ...(item as Extract), id });
+ }
+ return result;
+ }
+
+ /** Finds the first item matching a predicate, paired with its id. */
+ find(predicate: (item: SessionItem, id: string) => boolean): IdentifiedItem | undefined {
+ for (const [id, item] of Object.entries(this.items)) {
+ if (predicate(item, id)) {
+ return { ...item, id } as IdentifiedItem;
+ }
+ }
+ return undefined;
+ }
+
+ #typed(
+ id: string | undefined,
+ type: T,
+ ): IdentifiedItem> | undefined {
+ if (!this.has(id)) {
+ return undefined;
+ }
+ const item = this.items[id as string];
+ if (!item || item.type !== type) {
+ return undefined;
+ }
+ return { ...(item as Extract), id: id as string };
+ }
+}
diff --git a/src/meld/types.ts b/src/meld/types.ts
new file mode 100644
index 0000000..8608cb4
--- /dev/null
+++ b/src/meld/types.ts
@@ -0,0 +1,122 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+//
+// Typed model of the Meld Studio WebChannel session, derived from WebChannelAPI.md.
+// The wire format keys every item by a 32-char hex id inside `session.items`.
+
+/** Discriminator for the four session item kinds. */
+export type ItemType = "scene" | "track" | "layer" | "effect";
+
+/** A scene the user can switch to / stage. */
+export type SceneItem = {
+ type: "scene";
+ index: number;
+ name: string;
+ current: boolean;
+ staged: boolean;
+ /** True for the vertical-canvas twin of a scene; property inspectors filter these out. */
+ vertical?: boolean;
+};
+
+/**
+ * An audio track. `parent` (when present) references the owning scene object.
+ *
+ * Deliberately no `gain`: `StreamDeckMapper::audioTrackToMap` does not publish
+ * it. Gain reaches the plugin only through the `gainUpdated` signal, which
+ * `registerTrackObserver` emits immediately on registration.
+ */
+export type TrackItem = {
+ type: "track";
+ name: string;
+ parent?: string;
+ monitoring: boolean;
+ muted: boolean;
+};
+
+/** A layer within a scene. Media layers additionally expose `mediaSource`/`isPlaying`. */
+export type LayerItem = {
+ type: "layer";
+ parent: string;
+ index: number;
+ name: string;
+ visible: boolean;
+ /** Visibility once ancestor layers are taken into account. */
+ isEffectivelyVisible?: boolean;
+ height?: number;
+ width?: number;
+ x?: number;
+ y?: number;
+ rotation?: number;
+ source?: string;
+ url?: string;
+ text?: string;
+ mediaSource?: string;
+ isPlaying?: boolean;
+};
+
+/** An effect applied to a layer. */
+export type EffectItem = {
+ type: "effect";
+ parent: string;
+ name: string;
+ enabled: boolean;
+};
+
+/** Union of every item kind stored in `session.items`. */
+export type SessionItem = SceneItem | TrackItem | LayerItem | EffectItem;
+
+/** The raw `session` object delivered by the WebChannel. */
+export type Session = {
+ items: Record;
+};
+
+/** An item paired with its id, as returned by the {@link MeldSession} selectors. */
+export type IdentifiedItem = T & { id: string };
+
+/**
+ * The raw `meld` QWebChannel object. Only the members the plugin uses are typed;
+ * see WebChannelAPI.md for the authoritative contract.
+ */
+export type MeldObject = {
+ version?: number;
+ session?: Session;
+ isStreaming?: boolean;
+ isRecording?: boolean;
+
+ // Signals (Qt slots exposed as `{ connect, disconnect }`).
+ sessionChanged: MeldSignal<[]>;
+ isStreamingChanged: MeldSignal<[]>;
+ isRecordingChanged: MeldSignal<[]>;
+ /** Part of the optional track-observer feature; absent on builds without it. */
+ gainUpdated?: MeldSignal<[trackId: string, gain: number, muted: boolean]>;
+
+ // Methods.
+ /** Identifies this client to Meld for telemetry; absent before API version 2. */
+ setClientName?(clientName: string): void;
+ showScene(sceneId: string): void;
+ setStagedScene(sceneId: string): void;
+ showStagedScene(): void;
+ toggleMute(trackId: string): void;
+ toggleMonitor(trackId: string): void;
+ toggleLayer(sceneId: string, layerId: string): void;
+ toggleEffect(sceneId: string, layerId: string, effectId: string): void;
+ toggleRecord(): void;
+ toggleStream(): void;
+ setGain(trackId: string, gain: number): void;
+ setMuted?(trackId: string, muted: boolean): void;
+ setProperty?(objectId: string, propertyName: string, value: unknown): void;
+ registerTrackObserver?(context: string, trackId: string): void;
+ unregisterTrackObserver?(context: string, trackId: string): void;
+ callFunction?(layerId: string, command: string): void;
+ callFunctionWithArgs?(layerId: string, command: string, args: unknown[]): void;
+ sendCommand?(command: string): void;
+ sendCommandWithArgs?(commandName: string, args: unknown[]): void;
+ sendStreamEvent?(type: string, data?: unknown): void;
+ sendEvent?(event: string): void;
+};
+
+/** A Qt signal surfaced by QWebChannel. */
+export type MeldSignal = {
+ connect(callback: (...args: Args) => void): void;
+ disconnect(callback: (...args: Args) => void): void;
+};
diff --git a/src/plugin.ts b/src/plugin.ts
new file mode 100644
index 0000000..a823157
--- /dev/null
+++ b/src/plugin.ts
@@ -0,0 +1,46 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import streamDeck from "@elgato/streamdeck";
+
+import { Clip } from "./actions/clip.js";
+import { Media } from "./actions/media.js";
+import { Replay } from "./actions/replay.js";
+import { Screenshot } from "./actions/screenshot.js";
+import { ShowScene } from "./actions/show-scene.js";
+import { ToggleEffect } from "./actions/toggle-effect.js";
+import { ToggleLayer } from "./actions/toggle-layer.js";
+import { ToggleMonitor } from "./actions/toggle-monitor.js";
+import { ToggleMute } from "./actions/toggle-mute.js";
+import { ToggleRecord } from "./actions/toggle-record.js";
+import { ToggleStream } from "./actions/toggle-stream.js";
+import { TransitionStepper } from "./actions/transition-stepper.js";
+import { VolumeStepper } from "./actions/volume-stepper.js";
+import { WidgetControl } from "./actions/widget-control.js";
+import { meld } from "./meld/index.js";
+import { registerUiBridge } from "./ui-bridge.js";
+
+streamDeck.logger.setLevel("info");
+
+// Register every action. The base class wires each one to the shared Meld
+// connection, so hydration and reconnect handling are automatic.
+streamDeck.actions.registerAction(new ShowScene());
+streamDeck.actions.registerAction(new Screenshot());
+streamDeck.actions.registerAction(new Clip());
+streamDeck.actions.registerAction(new ToggleMute());
+streamDeck.actions.registerAction(new ToggleMonitor());
+streamDeck.actions.registerAction(new VolumeStepper());
+streamDeck.actions.registerAction(new TransitionStepper());
+streamDeck.actions.registerAction(new ToggleLayer());
+streamDeck.actions.registerAction(new ToggleEffect());
+streamDeck.actions.registerAction(new ToggleRecord());
+streamDeck.actions.registerAction(new ToggleStream());
+streamDeck.actions.registerAction(new Replay());
+streamDeck.actions.registerAction(new Media());
+streamDeck.actions.registerAction(new WidgetControl());
+
+// Serve session data to property inspectors from the single Meld connection.
+registerUiBridge();
+
+// Connect to the Stream Deck, then open the single connection to Meld Studio.
+streamDeck.connect().then(() => meld.connect());
diff --git a/src/ui-bridge.ts b/src/ui-bridge.ts
new file mode 100644
index 0000000..0f54430
--- /dev/null
+++ b/src/ui-bridge.ts
@@ -0,0 +1,49 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import streamDeck from "@elgato/streamdeck";
+import type { JsonValue } from "@elgato/utils";
+
+import { meld, type MeldConnection } from "./meld/index.js";
+
+/** Event name property inspectors send to request the current session. */
+const GET_SESSION = "meld.getSession";
+/** Event name the plugin uses to push session snapshots to a property inspector. */
+const SESSION = "meld.session";
+
+/**
+ * Bridges Meld session data to the property inspectors.
+ *
+ * In the old plugin every property inspector opened its own QWebChannel
+ * connection to Meld, which is a primary cause of the state drift this refactor
+ * removes. Here the property inspectors hold no Meld connection at all: they ask
+ * the plugin for the session over Stream Deck's PI↔plugin channel, and the
+ * plugin — the single source of truth — answers and pushes live updates.
+ */
+export function registerUiBridge(connection: MeldConnection = meld): void {
+ const push = (): void => {
+ // The session items are plain JSON at runtime; assert that for the typed API.
+ const payload = {
+ event: SESSION,
+ connected: connection.ready,
+ items: connection.session?.items ?? {},
+ } as unknown as JsonValue;
+ void streamDeck.ui.sendToPropertyInspector(payload);
+ };
+
+ // Answer explicit requests from a property inspector.
+ streamDeck.ui.onSendToPlugin((ev) => {
+ const payload = ev.payload as { event?: string } | undefined;
+ if (payload?.event === GET_SESSION) {
+ push();
+ }
+ });
+
+ // Send a fresh snapshot whenever a property inspector opens...
+ streamDeck.ui.onDidAppear(() => push());
+
+ // ...and whenever the authoritative state changes while one is open.
+ connection.on("ready", push);
+ connection.on("closed", push);
+ connection.on("sessionChanged", push);
+}
diff --git a/test/basic-actions.test.ts b/test/basic-actions.test.ts
new file mode 100644
index 0000000..6ec8537
--- /dev/null
+++ b/test/basic-actions.test.ts
@@ -0,0 +1,257 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import assert from "node:assert/strict";
+import { after, before, describe, test } from "node:test";
+import { setTimeout as sleep } from "node:timers/promises";
+
+import { Clip } from "../src/actions/clip.ts";
+import { Screenshot } from "../src/actions/screenshot.ts";
+import { ToggleMonitor } from "../src/actions/toggle-monitor.ts";
+import { ToggleMute } from "../src/actions/toggle-mute.ts";
+import { ToggleRecord } from "../src/actions/toggle-record.ts";
+import { ToggleStream } from "../src/actions/toggle-stream.ts";
+import { WidgetControl } from "../src/actions/widget-control.ts";
+import { MeldConnection } from "../src/meld/connection.ts";
+import { FakeActionInstance, mount, press, willAppear } from "./helpers/fake-stream-deck.ts";
+import { MockWebChannel, openMockWebChannel } from "./helpers/mock-web-channel.ts";
+import { SessionBuilder } from "./helpers/session-builder.ts";
+
+describe("basic Meld actions", () => {
+ let server: MockWebChannel;
+ let connection: MeldConnection;
+ let trackId: string;
+
+ before(async () => {
+ const builder = new SessionBuilder("basic-actions");
+ builder.scene("Main", { current: true });
+ trackId = builder.track("Microphone", { monitoring: false });
+ server = new MockWebChannel(builder.build("basic-actions", "basic action commands"));
+ const port = await server.listen(0);
+ connection = new MeldConnection({ port, openWebChannel: openMockWebChannel });
+ const ready = new Promise((resolve) => {
+ const off = connection.on("ready", () => {
+ off();
+ resolve();
+ });
+ });
+ connection.connect();
+ await ready;
+ });
+
+ after(async () => {
+ connection.dispose();
+ await server.close();
+ });
+
+ test("clip and screenshot dispatch their documented commands", async () => {
+ const clip = new Clip(connection);
+ const screenshot = new Screenshot(connection);
+ // 42 is deliberately not one of the inspector presets: the combo remains
+ // manually editable and must forward custom values too.
+ const clipSettings = { clipDuration: "42" };
+ const clipKey = new FakeActionInstance("clip", clipSettings);
+ const screenshotKey = new FakeActionInstance("screenshot", {});
+ mount(clip, [clipKey]);
+ mount(screenshot, [screenshotKey]);
+
+ await clip.onWillAppear(willAppear(clipKey, clipSettings) as never);
+ await press(clip, clipKey, clipSettings);
+ await press(screenshot, screenshotKey, {});
+
+ assert.deepEqual(server.methods.sendCommandWithArgs.mock.calls[0].arguments, ["meld.recordClip", [42]]);
+ assert.deepEqual(server.methods.sendCommand.mock.calls.map((call) => call.arguments), [["meld.screenshot"]]);
+ });
+
+ test("clip uses its migrated default before the Stream Deck settings echo", async () => {
+ const clip = new Clip(connection);
+ const clipKey = new FakeActionInstance("clip-default", {});
+ mount(clip, [clipKey]);
+ await clip.onWillAppear(willAppear(clipKey, {}) as never);
+
+ await press(clip, clipKey, {});
+
+ assert.deepEqual(clipKey.settingsWrites, [{ clipDuration: "90" }]);
+ assert.deepEqual(server.methods.sendCommandWithArgs.mock.calls.at(-1)?.arguments, ["meld.recordClip", [90]]);
+ });
+
+ test("clip falls back to the maximum-duration command on older Meld builds", async () => {
+ const clip = new Clip(connection);
+ const settings = { clipDuration: "15" };
+ const clipKey = new FakeActionInstance("clip-old-meld", settings);
+ mount(clip, [clipKey]);
+ const sendCommandWithArgs = server.meld.sendCommandWithArgs;
+ server.meld.sendCommandWithArgs = undefined;
+ try {
+ await press(clip, clipKey, settings);
+ } finally {
+ server.meld.sendCommandWithArgs = sendCommandWithArgs;
+ }
+
+ assert.deepEqual(server.methods.sendCommand.mock.calls.at(-1)?.arguments, ["meld.recordClip"]);
+ });
+
+ test("screenshot falls back to the legacy event on older Meld builds", async () => {
+ const screenshot = new Screenshot(connection);
+ const key = new FakeActionInstance("screenshot-old-meld", {});
+ mount(screenshot, [key]);
+ const sendCommand = server.meld.sendCommand;
+ server.meld.sendCommand = undefined;
+ try {
+ await press(screenshot, key, {});
+ } finally {
+ server.meld.sendCommand = sendCommand;
+ }
+
+ assert.deepEqual(server.methods.sendEvent.mock.calls.at(-1)?.arguments, [
+ "co.meldstudio.events.screenshot",
+ ]);
+ });
+
+ test("recording and streaming keys follow the result signals", async () => {
+ const record = new ToggleRecord(connection);
+ const stream = new ToggleStream(connection);
+ const recordKey = new FakeActionInstance("record", {});
+ const streamKey = new FakeActionInstance("stream", {});
+ mount(record, [recordKey]);
+ mount(stream, [streamKey]);
+ await record.onWillAppear(willAppear(recordKey, {}) as never);
+ await stream.onWillAppear(willAppear(streamKey, {}) as never);
+ assert.equal(recordKey.state, 1);
+ assert.equal(streamKey.state, 1);
+
+ await press(record, recordKey, {});
+ await press(stream, streamKey, {});
+ await sleep(0);
+
+ assert.equal(server.methods.toggleRecord.mock.callCount(), 1);
+ assert.equal(server.methods.toggleStream.mock.callCount(), 1);
+ assert.equal(recordKey.state, 0);
+ assert.equal(streamKey.state, 0);
+ });
+
+ test("monitor and widget settings reach the direct mocked API", async () => {
+ const monitor = new ToggleMonitor(connection);
+ const monitorSettings = { track: trackId, action: "cue" };
+ const monitorKey = new FakeActionInstance("monitor", monitorSettings);
+ mount(monitor, [monitorKey]);
+ await monitor.onWillAppear(willAppear(monitorKey, monitorSettings) as never);
+ await press(monitor, monitorKey, monitorSettings);
+
+ const widget = new WidgetControl(connection);
+ const widgetSettings = {
+ widget: "SUBATHONTIMER",
+ event: "SUBATHONTIMER_ADDTIME",
+ amount: "90",
+ };
+ const widgetKey = new FakeActionInstance("widget", widgetSettings);
+ mount(widget, [widgetKey]);
+ await widget.onWillAppear(willAppear(widgetKey, widgetSettings) as never);
+ await press(widget, widgetKey, widgetSettings);
+
+ assert.deepEqual(server.methods.setProperty.mock.calls.at(-1)?.arguments, [trackId, "monitoring", true]);
+ assert.deepEqual(server.methods.sendStreamEvent.mock.calls.at(-1)?.arguments, [
+ "SUBATHONTIMER_ADDTIME",
+ { amount: 90 },
+ ]);
+ });
+
+ test("mute and monitor implement every selectable press mode", async () => {
+ const mute = new ToggleMute(connection);
+ const muteKey = new FakeActionInstance("mute-modes", { track: trackId, action: "toggle" });
+ mount(mute, [muteKey]);
+ await mute.onWillAppear(willAppear(muteKey, { track: trackId, action: "toggle" }) as never);
+
+ await press(mute, muteKey, { track: trackId, action: "toggle" });
+ await press(mute, muteKey, { track: trackId, action: "mute" });
+ await press(mute, muteKey, { track: trackId, action: "unmute" });
+
+ assert.deepEqual(server.methods.toggleMute.mock.calls.at(-1)?.arguments, [trackId]);
+ assert.deepEqual(
+ server.methods.setMuted.mock.calls.slice(-2).map((call) => call.arguments),
+ [[trackId, true], [trackId, false]],
+ );
+ assert.equal(muteKey.state, 0, "the unmute mode paints the unmuted state");
+
+ const monitor = new ToggleMonitor(connection);
+ const monitorKey = new FakeActionInstance("monitor-modes", { track: trackId, action: "toggle" });
+ mount(monitor, [monitorKey]);
+ await monitor.onWillAppear(willAppear(monitorKey, { track: trackId, action: "toggle" }) as never);
+
+ await press(monitor, monitorKey, { track: trackId, action: "toggle" });
+ await press(monitor, monitorKey, { track: trackId, action: "cue" });
+ await press(monitor, monitorKey, { track: trackId, action: "uncue" });
+
+ assert.deepEqual(server.methods.toggleMonitor.mock.calls.at(-1)?.arguments, [trackId]);
+ assert.deepEqual(
+ server.methods.setProperty.mock.calls.slice(-2).map((call) => call.arguments),
+ [[trackId, "monitoring", true], [trackId, "monitoring", false]],
+ );
+ assert.equal(monitorKey.state, 1, "the uncue mode paints the unmonitored state");
+ });
+
+ test("every widget event dispatches with its matching icon and payload shape", async () => {
+ const cases = [
+ ["STOPWATCH", "STOPWATCH_RESET", "widget-stopwatch-reset"],
+ ["STOPWATCH", "STOPWATCH_PAUSE", "widget-stopwatch-pause"],
+ ["STOPWATCH", "STOPWATCH_RESUME", "widget-stopwatch-resume"],
+ ["COUNTDOWN", "COUNTDOWN_RESET", "widget-countdown-reset"],
+ ["COUNTDOWN", "COUNTDOWN_PAUSE", "widget-countdown-pause"],
+ ["COUNTDOWN", "COUNTDOWN_RESUME", "widget-countdown-resume"],
+ ["CONFETTIFALL", "CONFETTIFALL_TRIGGER", "widget-confettifall-trigger"],
+ ["CONFETTIPOP", "CONFETTIPOP_TRIGGER", "widget-confettipop-trigger"],
+ ["SUBATHONTIMER", "SUBATHONTIMER_RESET", "widget-subathontimer-reset"],
+ ["SUBATHONTIMER", "SUBATHONTIMER_PAUSE", "widget-subathontimer-pause"],
+ ["SUBATHONTIMER", "SUBATHONTIMER_RESUME", "widget-subathontimer-resume"],
+ ["SUBATHONTIMER", "SUBATHONTIMER_ADDTIME", "widget-subathontimer-addtime"],
+ ["WHEELSPIN", "WHEELSPIN_SPIN", "widget-wheelspin-spin"],
+ ["COUNTER", "COUNTER_INCREMENT", "widget-counter-increment"],
+ ["COUNTER", "COUNTER_DECREMENT", "widget-counter-decrement"],
+ ] as const;
+
+ for (const [widget, event, icon] of cases) {
+ const settings = { widget, event, amount: "12.6" };
+ const action = new WidgetControl(connection);
+ const key = new FakeActionInstance(`widget-${event}`, settings);
+ mount(action, [key]);
+ await action.onWillAppear(willAppear(key, settings) as never);
+ await press(action, key, settings);
+
+ assert.ok(key.image?.includes(icon), `${event} should paint ${icon}, got ${key.image}`);
+ assert.deepEqual(server.methods.sendStreamEvent.mock.calls.at(-1)?.arguments, [
+ event,
+ event === "SUBATHONTIMER_ADDTIME" ? { amount: 13 } : false,
+ ]);
+ }
+ });
+
+ test("widget add-time clamps invalid and negative amounts", async () => {
+ const action = new WidgetControl(connection);
+ for (const [id, amount] of [["negative", "-5"], ["invalid", "not-a-number"]] as const) {
+ const settings = { widget: "SUBATHONTIMER", event: "SUBATHONTIMER_ADDTIME", amount };
+ const key = new FakeActionInstance(`widget-${id}`, settings);
+ mount(action, [key]);
+ await action.onWillAppear(willAppear(key, settings) as never);
+ await press(action, key, settings);
+ assert.deepEqual(server.methods.sendStreamEvent.mock.calls.at(-1)?.arguments, [
+ "SUBATHONTIMER_ADDTIME",
+ { amount: 0 },
+ ]);
+ }
+ });
+
+ test("a widget press uses canonical migrated settings before the SDK echo", async () => {
+ const widget = new WidgetControl(connection);
+ const legacySettings = { event: "SUBATHONTIMER_ADDTIME" };
+ const widgetKey = new FakeActionInstance("widget-pre-echo", legacySettings);
+ mount(widget, [widgetKey]);
+ await widget.onWillAppear(willAppear(widgetKey, legacySettings) as never);
+
+ await press(widget, widgetKey, legacySettings);
+
+ assert.deepEqual(server.methods.sendStreamEvent.mock.calls.at(-1)?.arguments, [
+ "SUBATHONTIMER_ADDTIME",
+ { amount: 60 },
+ ]);
+ });
+});
diff --git a/test/connection.test.ts b/test/connection.test.ts
new file mode 100644
index 0000000..9978354
--- /dev/null
+++ b/test/connection.test.ts
@@ -0,0 +1,664 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+//
+// Drives the real connection lifecycle against the initialized `meld` object
+// shape from WebChannelAPI.md, without coupling functional tests to Qt's wire
+// protocol or opening localhost sockets.
+//
+// The plugin is one long-lived process that outlives many Meld instances, so the
+// invariant under test is that every reconnect re-derives state from the NEW
+// instance rather than carrying anything over from the old one.
+//
+// Virtual ports isolate instances without disturbing (or being disturbed by)
+// a Meld Studio or Stream Deck plugin running on the same machine.
+
+import assert from "node:assert/strict";
+import { after, before, describe, mock, test } from "node:test";
+import { setTimeout as sleep } from "node:timers/promises";
+
+import {
+ MeldConnection,
+ type OpenWebChannel,
+ type WebChannelCallbacks,
+ type WebChannelHandle,
+} from "../src/meld/connection.ts";
+import type { MeldObject, MeldSignal } from "../src/meld/types.ts";
+import {
+ MockWebChannel,
+ type MockWebChannelOptions,
+ openMockWebChannel,
+} from "./helpers/mock-web-channel.ts";
+import { type Scenario, SessionBuilder } from "./helpers/session-builder.ts";
+
+/** Builds a one-scene, one-track scenario with ids derived from `tag`. */
+function tinyScenario(tag: string, gain = 0.5): { scenario: Scenario; sceneId: string; trackId: string } {
+ const builder = new SessionBuilder(tag);
+ const sceneId = builder.scene(`Scene ${tag}`, { current: true });
+ const trackId = builder.track(`Track ${tag}`, { gain });
+ return { scenario: builder.build(tag, `test scenario ${tag}`), sceneId, trackId };
+}
+
+/** Waits for a connection event, rejecting if it does not arrive in time. */
+function once(connection: MeldConnection, event: "ready" | "closed", timeoutMs = 5_000): Promise {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ off();
+ reject(new Error(`timed out waiting for "${event}"`));
+ }, timeoutMs);
+ const off = connection.on(event, () => {
+ clearTimeout(timer);
+ off();
+ resolve();
+ });
+ });
+}
+
+type ManualAttempt = WebChannelHandle & {
+ callbacks: WebChannelCallbacks;
+ closeCount: number;
+};
+
+/** Captures lifecycle callbacks so tests can deliver hostile/stale orderings. */
+function manualWebChannels(): { attempts: ManualAttempt[]; openWebChannel: OpenWebChannel } {
+ const attempts: ManualAttempt[] = [];
+ return {
+ attempts,
+ openWebChannel: (_url, callbacks) => {
+ let closed = false;
+ const attempt: ManualAttempt = {
+ callbacks,
+ closeCount: 0,
+ close(): void {
+ if (closed) return;
+ closed = true;
+ attempt.closeCount++;
+ },
+ };
+ attempts.push(attempt);
+ return attempt;
+ },
+ };
+}
+
+function inertSignal(): MeldSignal {
+ return { connect: () => {}, disconnect: () => {} };
+}
+
+/** Minimal documented API result for lifecycle-only tests. */
+function manualMeld(): MeldObject {
+ return {
+ version: 3,
+ session: { items: {} },
+ isStreaming: false,
+ isRecording: false,
+ sessionChanged: inertSignal(),
+ isStreamingChanged: inertSignal(),
+ isRecordingChanged: inertSignal(),
+ showScene: () => {},
+ setStagedScene: () => {},
+ showStagedScene: () => {},
+ toggleMute: () => {},
+ toggleMonitor: () => {},
+ toggleLayer: () => {},
+ toggleEffect: () => {},
+ toggleRecord: () => {},
+ toggleStream: () => {},
+ setGain: () => {},
+ };
+}
+
+describe("MeldConnection", () => {
+ let connection: MeldConnection;
+ let server: MockWebChannel;
+ let port = 0;
+ let firstTrackId = "";
+
+ /** Stops the current instance and waits for the connection to notice. */
+ async function dropInstance(): Promise {
+ const closed = once(connection, "closed");
+ await server.close();
+ await closed;
+ }
+
+ /**
+ * Starts a fresh instance on the same port and waits for the plugin to
+ * hydrate, plus a moment for the traffic it sends afterwards (observer
+ * re-registration) to reach the server.
+ */
+ async function startInstance(scenario: Scenario, options?: MockWebChannelOptions): Promise {
+ server = new MockWebChannel(scenario, options);
+ const ready = once(connection, "ready", 20_000);
+ await server.listen(port);
+ await ready;
+ await sleep(50);
+ }
+
+ before(async () => {
+ // Allocate an isolated virtual endpoint, then point the connection at it.
+ const first = tinyScenario("A");
+ firstTrackId = first.trackId;
+ server = new MockWebChannel(first.scenario);
+ port = await server.listen(0);
+ // Compressed timings so the watchdog is genuinely exercised rather than
+ // waiting out the production 10s bound.
+ connection = new MeldConnection({ port, handshakeTimeoutMs: 300, reconnectMinMs: 100, openWebChannel: openMockWebChannel });
+
+ const ready = once(connection, "ready");
+ connection.connect();
+ await ready;
+ });
+
+ after(async () => {
+ connection.dispose();
+ await server.close();
+ });
+
+ test("exposes the API version and session of the connected instance", () => {
+ assert.equal(connection.ready, true);
+ assert.equal(connection.meld?.version, 3, "the mock advertises the same API version as Meld");
+ assert.ok(connection.session?.items[firstTrackId], "the instance's track should be present");
+ });
+
+ test("connect is idempotent while an instance is already connected", async () => {
+ const accepted = server.acceptedConnections;
+ const meldObject = connection.meld;
+
+ connection.connect();
+ connection.connect();
+ await sleep(250);
+
+ assert.equal(server.acceptedConnections, accepted, "repeated startup calls must not open another channel");
+ assert.equal(connection.meld, meldObject, "the healthy instance must not be torn down and replaced");
+ assert.equal(connection.ready, true);
+ });
+
+ test("session tracks carry no gain, matching StreamDeckMapper", () => {
+ const track = connection.session?.items[firstTrackId];
+ assert.equal(track?.type, "track");
+ assert.equal(
+ (track as { gain?: number }).gain,
+ undefined,
+ "Meld never puts gain in the snapshot; it arrives only via gainUpdated",
+ );
+ });
+
+ test("replaces all state when a different Meld instance takes over", async () => {
+ const oldInstance = server;
+ await dropInstance();
+ assert.equal(connection.ready, false);
+ assert.equal(connection.meld, undefined, "the meld object must not survive a disconnect");
+
+ // A new instance exposes an entirely different set of item ids.
+ const next = tinyScenario("B");
+ await startInstance(next.scenario);
+
+ assert.ok(connection.session?.items[next.trackId], "the new instance's track should be present");
+ assert.equal(
+ connection.session?.items[firstTrackId],
+ undefined,
+ "the previous instance's items must not survive the reconnect",
+ );
+
+ const staleRecordings: boolean[] = [];
+ const off = connection.on("recordingChanged", (recording) => staleRecordings.push(recording));
+ oldInstance.setRecording(true);
+ await sleep(0);
+ off();
+ assert.deepEqual(staleRecordings, [], "signals queued by the stopped instance must be ignored");
+ const recordingOf = (candidate: MeldConnection): boolean | undefined => candidate.meld?.isRecording;
+ assert.equal(recordingOf(connection), false, "stale signals must not mutate the new instance");
+ });
+
+ test("registering an observer yields an immediate gain update", async () => {
+ const { scenario, trackId } = tinyScenario("C", 0.75);
+ await dropInstance();
+ await startInstance(scenario);
+
+ const gains: unknown[] = [];
+ const off = connection.on("gainChanged", (change) => gains.push(change));
+ connection.registerTrackObserver("ctx-1", trackId);
+ await sleep(100);
+ off();
+
+ // StreamDeckMapper::registerTrack emits gainUpdated on registration, which
+ // is how a dial hydrates without waiting for the user to move a fader.
+ assert.deepEqual(gains, [{ trackId, gain: 0.75, muted: false }]);
+ });
+
+ test("re-registers track observers against each new instance", async () => {
+ assert.equal(server.methods.registerTrackObserver.mock.callCount(), 1);
+
+ const previousTrack = server.methods.registerTrackObserver.mock.calls[0].arguments[1];
+ await dropInstance();
+ await startInstance(tinyScenario("D").scenario);
+
+ const registrations = server.methods.registerTrackObserver.mock.calls;
+ assert.equal(registrations.length, 1, "the observer should be re-registered exactly once on the new instance");
+ assert.deepEqual(registrations[0].arguments, ["ctx-1", previousTrack]);
+
+ connection.registerTrackObserver("ctx-1", previousTrack as string);
+ assert.equal(
+ server.methods.registerTrackObserver.mock.callCount(),
+ 1,
+ "a render after reconnect must not duplicate the registration",
+ );
+
+ connection.unregisterTrackObserver("ctx-1");
+ });
+
+ test("delivers live session updates, not just the hydration snapshot", async () => {
+ // Every key and dial repaints off `sessionChanged`. If only the connect-time
+ // hydration emit reached listeners, the plugin would look healthy and then
+ // freeze: scene switches, layer toggles and mutes would never show up.
+ const updates: unknown[] = [];
+ const off = connection.on("sessionChanged", (session) => updates.push(session));
+
+ // Mute a track rather than switching to the already-current scene: the
+ // snapshot must be observed changing, not merely re-asserting what it said.
+ const track = Object.entries(server.session.items).find(([, item]) => item.type === "track")?.[0];
+ assert.ok(track, "the scenario should contain a track");
+ const mutedBefore = (connection.session?.items[track] as { muted: boolean }).muted;
+ assert.equal(mutedBefore, false, "the track starts unmuted");
+
+ server.setMuted(track, true);
+ await sleep(800);
+
+ off();
+ assert.equal(updates.length, 1, "a session change in Meld must reach listeners");
+ assert.equal(
+ (connection.session?.items[track] as { muted: boolean }).muted,
+ true,
+ "the connection's snapshot must carry the new value, not the hydration one",
+ );
+ });
+
+ test("delivers independent session, streaming, and recording results published together", async () => {
+ // The functional boundary receives initialized properties and their signals.
+ // Publishing several results in one turn must not let one handler strand the
+ // others at an older value.
+ const streaming: boolean[] = [];
+ const recording: boolean[] = [];
+ const offStreaming = connection.on("streamingChanged", (value) => streaming.push(value));
+ const offRecording = connection.on("recordingChanged", (value) => recording.push(value));
+
+ // Three result changes in one turn (no debounce, so the session arrives too).
+ const builder = new SessionBuilder("busy");
+ builder.scene("Scene busy", { current: true });
+ const busyTrack = builder.track("Track busy", { gain: 0.5 });
+ server.setSessionDebounce(0);
+ server.loadScenario(builder.build("busy", "busy", { streaming: true, recording: true }));
+ await sleep(400);
+ server.setSessionDebounce(undefined);
+
+ offStreaming();
+ offRecording();
+ assert.ok(streaming.includes(true), `streamingChanged must not be dropped; saw ${JSON.stringify(streaming)}`);
+ assert.ok(recording.includes(true), `recordingChanged must not be dropped; saw ${JSON.stringify(recording)}`);
+ assert.equal(connection.meld?.isStreaming, true);
+ assert.equal(connection.meld?.isRecording, true);
+ assert.ok(connection.session?.items[busyTrack], "and the session itself still arrived");
+ });
+
+ test("delivers recording updates from the current instance", async () => {
+ // Drive a real transition in both directions: asserting `true` while the
+ // value is already true would hold without the plugin reporting anything,
+ // and would never check that the reported boolean matches.
+ server.setRecording(false);
+ await sleep(100);
+ assert.equal(connection.meld?.isRecording, false, "start from a known-false state");
+
+ const recordings: boolean[] = [];
+ const off = connection.on("recordingChanged", (recording) => recordings.push(recording));
+
+ server.setRecording(true);
+ await sleep(100);
+ assert.equal(connection.meld?.isRecording, true);
+
+ server.setRecording(false);
+ await sleep(100);
+ off();
+
+ assert.deepEqual(recordings, [true, false], "each transition must be reported with its own value");
+ assert.equal(connection.meld?.isRecording, false);
+ });
+
+ test("survives repeated instance churn without leaking timers or handles", async () => {
+ // `_getActiveHandles` does not report timers at all, so it cannot see the
+ // leak that matters here; `getActiveResourcesInfo` does.
+ const resources = (): string[] => process.getActiveResourcesInfo().filter((r) => r !== "TTYWrap");
+
+ // Churn once first so any one-off resources exist before the baseline.
+ await dropInstance();
+ await startInstance(tinyScenario("warmup").scenario);
+ const before = resources();
+
+ for (let i = 0; i < 6; i++) {
+ const { scenario, trackId } = tinyScenario(`churn${i}`);
+ await dropInstance();
+ await startInstance(scenario);
+ assert.ok(connection.session?.items[trackId], `cycle ${i} should expose its own session`);
+ }
+
+ const after = resources();
+ assert.ok(
+ after.length <= before.length,
+ `active resources grew from ${before.length} (${before.join(",")}) to ${after.length} (${after.join(",")})`,
+ );
+ });
+
+ test("abandons and retries a peer that never completes the handshake", async () => {
+ await dropInstance();
+
+ // A peer that accepts a channel attempt, never produces an initialized object,
+ // and never closes is the case `closed` cannot rescue: only the watchdog can.
+ // Proving recovery means showing a second attempt reaches the same peer.
+ const stalled = new MockWebChannel(tinyScenario("stalled").scenario, { stallHandshake: true });
+ await stalled.listen(port);
+
+ try {
+ // Long enough for the watchdog (300ms) to fire and a retry to be dialled.
+ await sleep(1_200);
+ assert.equal(connection.ready, false, "a stalled handshake must never report ready");
+ assert.ok(
+ stalled.acceptedConnections >= 2,
+ `the watchdog should have abandoned and redialled; the stalled peer saw ${stalled.acceptedConnections} connection(s)`,
+ );
+ // Abandoning means closing: leaving dead attempts open would pile up a
+ // connection per retry against a peer that never answers.
+ assert.ok(
+ stalled.clientCount <= 1,
+ `abandoned attempts must be closed; ${stalled.clientCount} were still open`,
+ );
+ } finally {
+ // Release the port even when an assertion fails, so one failure does not
+ // cascade into every test that follows.
+ await stalled.close();
+ }
+
+ const { scenario, trackId } = tinyScenario("afterStall");
+ await startInstance(scenario);
+ assert.ok(connection.session?.items[trackId], "the plugin must recover once a real Meld takes the port");
+ });
+
+ test("abandons and retries a peer that exposes no meld object", async () => {
+ await dropInstance();
+
+ // A peer that completes the handshake but exposes no `meld` object must be
+ // rejected by that branch specifically. This connection's watchdog is set
+ // far beyond the test window so it cannot rescue the case and disguise a
+ // missing retry.
+ const wrongPeer = new MockWebChannel(tinyScenario("wrong").scenario, { omitMeldObject: true });
+ const wrongPort = await wrongPeer.listen(0);
+ const probe = new MeldConnection({ port: wrongPort, handshakeTimeoutMs: 60_000, reconnectMinMs: 100, openWebChannel: openMockWebChannel });
+
+ try {
+ probe.connect();
+ await sleep(1_200);
+ assert.equal(probe.ready, false, "a peer without a meld object must never report ready");
+ assert.ok(
+ wrongPeer.acceptedConnections >= 2,
+ `the wrong peer should have been redialled; it saw ${wrongPeer.acceptedConnections} connection(s)`,
+ );
+ } finally {
+ probe.dispose();
+ await wrongPeer.close();
+ }
+
+ await startInstance(tinyScenario("afterWrongPeer").scenario);
+ assert.equal(connection.ready, true);
+ });
+
+ test("dispose leaves no connection reported as ready", async () => {
+ // A disposed connection that still reports ready would hand actions a dead
+ // transport, and would disarm the watchdog on a later connect().
+ const throwaway = new MeldConnection({ port, handshakeTimeoutMs: 300, reconnectMinMs: 100, openWebChannel: openMockWebChannel });
+ const ready = once(throwaway, "ready");
+ throwaway.connect();
+ await ready;
+ assert.equal(throwaway.ready, true);
+
+ throwaway.dispose();
+ assert.equal(throwaway.ready, false, "dispose must clear the ready flag");
+ assert.equal(throwaway.meld, undefined, "dispose must drop the meld object");
+ });
+
+ test("degrades without throwing when the build lacks the observer API", async () => {
+ const { scenario, trackId } = tinyScenario("oldBuild", 0.8);
+ await dropInstance();
+ await startInstance(scenario, { omitObserverApi: true });
+
+ assert.equal(connection.ready, true, "an older build must still connect");
+ assert.equal(connection.meld?.gainUpdated, undefined);
+ // Must not throw despite the methods being absent.
+ connection.registerTrackObserver("ctx-2", trackId);
+ connection.unregisterTrackObserver("ctx-2");
+ assert.equal(connection.session?.items[trackId]?.type, "track");
+ });
+});
+
+describe("MeldConnection attempt lifecycle", () => {
+ test("retries when an asynchronous ready result has a malformed signal", () => {
+ mock.timers.enable({ apis: ["setTimeout"] });
+ try {
+ const manual = manualWebChannels();
+ const connection = new MeldConnection({ reconnectMinMs: 100, openWebChannel: manual.openWebChannel });
+ let readyCount = 0;
+ let closedCount = 0;
+ connection.on("ready", () => readyCount++);
+ connection.on("closed", () => closedCount++);
+
+ connection.connect();
+ const malformed = manualMeld();
+ malformed.sessionChanged = {
+ connect: () => {
+ throw new Error("malformed session signal");
+ },
+ disconnect: () => {},
+ };
+ manual.attempts[0].callbacks.ready(malformed);
+
+ assert.equal(connection.ready, false);
+ assert.equal(connection.meld, undefined);
+ assert.equal(manual.attempts[0].closeCount, 1);
+ assert.equal(readyCount, 0);
+ assert.equal(closedCount, 0, "a rejected handshake must not emit an unmatched closed event");
+ mock.timers.tick(99);
+ assert.equal(manual.attempts.length, 1);
+ mock.timers.tick(1);
+ assert.equal(manual.attempts.length, 2, "the malformed result should be retried");
+ connection.dispose();
+ } finally {
+ mock.timers.reset();
+ }
+ });
+
+ test("retries when observer restoration throws during ready", () => {
+ mock.timers.enable({ apis: ["setTimeout"] });
+ try {
+ const manual = manualWebChannels();
+ const connection = new MeldConnection({ reconnectMinMs: 100, openWebChannel: manual.openWebChannel });
+ connection.registerTrackObserver("ctx", "track");
+ connection.connect();
+ const rejecting = manualMeld();
+ rejecting.registerTrackObserver = () => {
+ throw new Error("observer rejected");
+ };
+
+ manual.attempts[0].callbacks.ready(rejecting);
+ assert.equal(connection.ready, false);
+ assert.equal(connection.meld, undefined);
+ assert.equal(manual.attempts[0].closeCount, 1);
+ mock.timers.tick(100);
+ assert.equal(manual.attempts.length, 2);
+ connection.dispose();
+ } finally {
+ mock.timers.reset();
+ }
+ });
+
+ test("stops old-instance hydration when a ready listener disposes", () => {
+ const manual = manualWebChannels();
+ const connection = new MeldConnection({ openWebChannel: manual.openWebChannel });
+ const events: string[] = [];
+ connection.on("ready", () => {
+ events.push("ready");
+ connection.dispose();
+ });
+ connection.on("closed", () => events.push("closed"));
+ connection.on("sessionChanged", () => events.push("session"));
+ connection.on("streamingChanged", () => events.push("streaming"));
+ connection.on("recordingChanged", () => events.push("recording"));
+
+ connection.connect();
+ manual.attempts[0].callbacks.ready(manualMeld());
+
+ assert.deepEqual(events, ["ready", "closed"]);
+ assert.equal(connection.ready, false);
+ assert.equal(connection.meld, undefined);
+ });
+
+ test("ignores late readiness after a handshake timeout and during reconnect backoff", () => {
+ mock.timers.enable({ apis: ["setTimeout"] });
+ try {
+ const manual = manualWebChannels();
+ const connection = new MeldConnection({
+ handshakeTimeoutMs: 100,
+ reconnectMinMs: 1_000,
+ openWebChannel: manual.openWebChannel,
+ });
+ let readyCount = 0;
+ connection.on("ready", () => readyCount++);
+
+ connection.connect();
+ connection.connect();
+ connection.connect();
+ assert.equal(manual.attempts.length, 1, "connect must be idempotent while the handshake is pending");
+
+ mock.timers.tick(100);
+ assert.equal(manual.attempts[0].closeCount, 1, "the timed-out attempt must be closed once");
+
+ // A result already queued by the abandoned adapter must not revive it.
+ manual.attempts[0].callbacks.ready(manualMeld());
+ assert.equal(connection.ready, false);
+ assert.equal(connection.meld, undefined);
+ assert.equal(readyCount, 0);
+
+ connection.connect();
+ connection.connect();
+ mock.timers.tick(999);
+ assert.equal(manual.attempts.length, 1, "repeated connect must not bypass reconnect backoff");
+ mock.timers.tick(1);
+ assert.equal(manual.attempts.length, 2, "the one scheduled retry should open exactly once");
+
+ connection.connect();
+ assert.equal(manual.attempts.length, 2, "connect must remain idempotent during the retry handshake");
+ connection.dispose();
+ } finally {
+ mock.timers.reset();
+ }
+ });
+
+ test("keeps ready and closed exact-once across reconnect and dispose", () => {
+ mock.timers.enable({ apis: ["setTimeout"] });
+ try {
+ const manual = manualWebChannels();
+ const connection = new MeldConnection({
+ handshakeTimeoutMs: 10_000,
+ reconnectMinMs: 100,
+ openWebChannel: manual.openWebChannel,
+ });
+ let readyCount = 0;
+ let closedCount = 0;
+ let reconnectOnClose = true;
+ connection.on("ready", () => readyCount++);
+ connection.on("closed", () => {
+ closedCount++;
+ if (reconnectOnClose) {
+ // Models another startup owner reacting synchronously to the drop.
+ connection.connect();
+ connection.connect();
+ }
+ });
+
+ connection.connect();
+ manual.attempts[0].callbacks.ready(manualMeld());
+ manual.attempts[0].callbacks.ready(manualMeld());
+ assert.equal(readyCount, 1, "duplicate adapter readiness must be ignored");
+
+ manual.attempts[0].callbacks.closed();
+ manual.attempts[0].callbacks.closed();
+ assert.equal(closedCount, 1, "duplicate close callbacks must be ignored");
+ assert.equal(manual.attempts.length, 2, "the closed listener's repeated connect opens one attempt");
+ mock.timers.tick(1_000);
+ assert.equal(manual.attempts.length, 2, "the old close path must not leave a replacement timer armed");
+
+ manual.attempts[1].callbacks.ready(manualMeld());
+ manual.attempts[1].callbacks.ready(manualMeld());
+ assert.equal(readyCount, 2);
+
+ // dispose emits closed, but connect() from that listener cannot defeat the
+ // in-progress teardown. A later explicit connect is still allowed.
+ connection.dispose();
+ connection.dispose();
+ assert.equal(closedCount, 2);
+ assert.equal(manual.attempts.length, 2);
+ assert.equal(connection.ready, false);
+
+ reconnectOnClose = false;
+ connection.connect();
+ assert.equal(manual.attempts.length, 3);
+ manual.attempts[2].callbacks.ready(manualMeld());
+ assert.equal(readyCount, 3);
+ connection.dispose();
+ assert.equal(closedCount, 3);
+ } finally {
+ mock.timers.reset();
+ }
+ });
+
+ test("ignores a ready callback retained past dispose", () => {
+ const manual = manualWebChannels();
+ const connection = new MeldConnection({ openWebChannel: manual.openWebChannel });
+ let readyCount = 0;
+ connection.on("ready", () => readyCount++);
+
+ connection.connect();
+ connection.dispose();
+ manual.attempts[0].callbacks.ready(manualMeld());
+
+ assert.equal(connection.ready, false);
+ assert.equal(connection.meld, undefined);
+ assert.equal(readyCount, 0);
+ assert.equal(manual.attempts[0].closeCount, 1);
+ });
+
+ test("an explicit dispose-connect cycle resets accumulated reconnect backoff", () => {
+ mock.timers.enable({ apis: ["setTimeout"] });
+ try {
+ const manual = manualWebChannels();
+ const connection = new MeldConnection({
+ handshakeTimeoutMs: 10_000,
+ reconnectMinMs: 100,
+ openWebChannel: manual.openWebChannel,
+ });
+
+ connection.connect();
+ manual.attempts[0].callbacks.closed();
+ mock.timers.tick(100);
+ manual.attempts[1].callbacks.closed();
+ mock.timers.tick(150);
+ manual.attempts[2].callbacks.closed(); // next automatic delay has grown to 225ms
+
+ connection.dispose(); // cancel that retry and begin an explicit lifecycle
+ connection.connect();
+ manual.attempts[3].callbacks.closed();
+ mock.timers.tick(99);
+ assert.equal(manual.attempts.length, 4);
+ mock.timers.tick(1);
+ assert.equal(manual.attempts.length, 5, "the new lifecycle should retry at the 100ms minimum");
+ connection.dispose();
+ } finally {
+ mock.timers.reset();
+ }
+ });
+});
diff --git a/test/events.test.ts b/test/events.test.ts
new file mode 100644
index 0000000..be6df69
--- /dev/null
+++ b/test/events.test.ts
@@ -0,0 +1,49 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { TypedEmitter } from "../src/meld/events.ts";
+
+type Events = { ping: [value: number] };
+
+test("emit delivers arguments to every listener", () => {
+ const emitter = new TypedEmitter();
+ const seen: number[] = [];
+ emitter.on("ping", (value) => seen.push(value));
+ emitter.on("ping", (value) => seen.push(value * 10));
+ emitter.emit("ping", 7);
+ assert.deepEqual(seen, [7, 70]);
+});
+
+test("a throwing listener is isolated from the others and the emitter", () => {
+ const errors: string[] = [];
+ const emitter = new TypedEmitter((event, err) => errors.push(`${event}: ${(err as Error).message}`));
+ const seen: number[] = [];
+ emitter.on("ping", () => {
+ throw new Error("bad subscriber");
+ });
+ emitter.on("ping", (value) => seen.push(value));
+ assert.doesNotThrow(() => emitter.emit("ping", 1));
+ assert.deepEqual(seen, [1]);
+ assert.deepEqual(errors, ["ping: bad subscriber"]);
+});
+
+test("off and the returned unsubscriber both remove listeners", () => {
+ const emitter = new TypedEmitter();
+ const seen: number[] = [];
+ const listener = (value: number): void => {
+ seen.push(value);
+ };
+ const unsubscribe = emitter.on("ping", listener);
+ emitter.emit("ping", 1);
+ emitter.off("ping", listener);
+ emitter.emit("ping", 2);
+ assert.deepEqual(seen, [1]);
+
+ emitter.on("ping", listener);
+ unsubscribe();
+ emitter.emit("ping", 3);
+ assert.deepEqual(seen, [1]);
+});
diff --git a/test/gain.test.ts b/test/gain.test.ts
new file mode 100644
index 0000000..a2d0622
--- /dev/null
+++ b/test/gain.test.ts
@@ -0,0 +1,27 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import { SILENCE_DB, toDb, toGain } from "../src/actions/gain.ts";
+
+test("toDb floors zero gain at silence", () => {
+ assert.equal(toDb(0), SILENCE_DB);
+});
+
+test("toDb of unity gain is 0 dB", () => {
+ assert.equal(toDb(1), 0);
+});
+
+test("toGain clamps to the 0..1 range", () => {
+ assert.equal(toGain(6), 1);
+ assert.equal(toGain(SILENCE_DB - 10), 0);
+ assert.equal(toGain(Number.NaN), 0);
+});
+
+test("dB steps round-trip through gain", () => {
+ for (const dB of [-40, -20, -6, -3, 0]) {
+ assert.ok(Math.abs(toDb(toGain(dB)) - dB) < 1e-9, `round-trip failed for ${dB} dB`);
+ }
+});
diff --git a/test/helpers/fake-dom.ts b/test/helpers/fake-dom.ts
new file mode 100644
index 0000000..8f4d86d
--- /dev/null
+++ b/test/helpers/fake-dom.ts
@@ -0,0 +1,292 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+//
+// Enough of a DOM and of Stream Deck's property-inspector socket to run the real
+// ui/lib/meld-pi.js under Node.
+//
+// The `` semantics are the part that matters. meld-pi.js relies on the
+// browser rule that assigning a value no option carries leaves `selectedIndex`
+// at -1 — that is how it detects a stored id that is no longer valid — and on
+// the first appended option being selected automatically. Both are reproduced
+// here; a looser fake would hide exactly the bugs these tests exist to catch.
+
+import { readFileSync } from "node:fs";
+import { createContext, runInContext } from "node:vm";
+
+/** An ``. */
+export class FakeOption {
+ textContent = "";
+ value = "";
+ selected = false;
+ className = "";
+}
+
+/** Minimal DOMTokenList used to assert state classes applied by the real PI. */
+class FakeClassList {
+ readonly #tokens = new Set();
+
+ toggle(token: string, force?: boolean): boolean {
+ const enabled = force ?? !this.#tokens.has(token);
+ if (enabled) {
+ this.#tokens.add(token);
+ } else {
+ this.#tokens.delete(token);
+ }
+ return enabled;
+ }
+
+ contains(token: string): boolean {
+ return this.#tokens.has(token);
+ }
+}
+
+/** Base element with event listeners and a style object. */
+class FakeElement {
+ /**
+ * Inline style. Elements start visible unless a test seeds otherwise; the
+ * connecting notice in every shipped inspector is markup-hidden, so tests
+ * covering it must start it at `none` — see {@link FakeDiv}.
+ */
+ readonly style: { display: string } = { display: "" };
+ readonly classList = new FakeClassList();
+ disabled = false;
+ readonly #listeners = new Map void)[]>();
+
+ /**
+ * Registers a listener, as the DOM would.
+ * @param type Event name.
+ * @param handler Callback.
+ */
+ addEventListener(type: string, handler: (event: unknown) => void): void {
+ const existing = this.#listeners.get(type) ?? [];
+ existing.push(handler);
+ this.#listeners.set(type, existing);
+ }
+
+ /**
+ * Fires an event at this element, as a user interaction would.
+ * @param type Event name.
+ */
+ dispatch(type: string): void {
+ for (const handler of this.#listeners.get(type) ?? []) {
+ handler({ target: this });
+ }
+ }
+}
+
+/**
+ * A `` with the browser's selection rules.
+ *
+ * Session-driven dropdowns ship `disabled` in the inspector markup, and
+ * `bindSessionSelects` is the only thing that enables them, so that is the
+ * default here: a select seeded enabled could never show that the binding
+ * failed to enable it. Pass `false` for the few markup-enabled selects.
+ */
+export class FakeSelect extends FakeElement {
+ readonly options: FakeOption[] = [];
+ selectedIndex = -1;
+
+ constructor(disabled = true) {
+ super();
+ this.disabled = disabled;
+ }
+
+ /** Clearing `innerHTML` removes every option, as it does in a browser. */
+ set innerHTML(html: string) {
+ if (html === "") {
+ this.options.length = 0;
+ this.selectedIndex = -1;
+ }
+ }
+
+ get innerHTML(): string {
+ return this.options.map((o) => `${o.textContent} `).join("");
+ }
+
+ /**
+ * Appends an option. The first option in an empty select becomes selected
+ * automatically; an option marked `selected` takes the selection.
+ * @param option The option to append.
+ */
+ appendChild(option: FakeOption): FakeOption {
+ this.options.push(option);
+ if (option.selected) {
+ this.selectedIndex = this.options.length - 1;
+ } else if (this.options.length === 1) {
+ this.selectedIndex = 0;
+ }
+ return option;
+ }
+
+ /** The selected option's value, or `""` when nothing is selected. */
+ get value(): string {
+ return this.selectedIndex >= 0 ? (this.options[this.selectedIndex]?.value ?? "") : "";
+ }
+
+ /** Selects the option carrying this value; deselects everything if none does. */
+ set value(next: string) {
+ const index = this.options.findIndex((option) => option.value === next);
+ this.selectedIndex = index;
+ }
+
+ /** The visible text of each option, in order. */
+ get labels(): string[] {
+ return this.options.map((option) => option.textContent);
+ }
+
+ /** The value of each option, in order. */
+ get values(): string[] {
+ return this.options.map((option) => option.value);
+ }
+
+ /** Simulates the user picking an option by its value. */
+ choose(value: string): void {
+ this.value = value;
+ this.dispatch("change");
+ }
+}
+
+/**
+ * A plain ``, used for the "Connecting to Meld Studio…" notice.
+ *
+ * Every shipped inspector declares it as `
`, so it starts hidden. Reproducing that matters: an element seeded
+ * visible would make "the notice is showing" true before the runtime did
+ * anything, and the assertion could not fail.
+ */
+export class FakeDiv extends FakeElement {
+ constructor(display = "none") {
+ super();
+ this.style.display = display;
+ }
+}
+
+/** An `
`: its value is a plain string. */
+export class FakeInput extends FakeElement {
+ value = "";
+
+ /** Simulates the user typing a value and committing it. */
+ enter(value: string): void {
+ this.value = value;
+ this.dispatch("change");
+ }
+}
+
+/** A frame the property inspector sent to Stream Deck. */
+export type SentFrame = { event: string; context?: string; action?: string; payload?: unknown };
+
+/** Stand-in for the property inspector's Stream Deck socket. */
+export class FakePiSocket {
+ static latest: FakePiSocket | undefined;
+
+ readonly sent: SentFrame[] = [];
+ onopen?: () => void;
+ onmessage?: (event: { data: string }) => void;
+
+ constructor() {
+ FakePiSocket.latest = this;
+ }
+
+ /** Records an outbound frame. */
+ send(data: string): void {
+ this.sent.push(JSON.parse(data) as SentFrame);
+ }
+
+ /** Completes the connection, as the app does once the socket is up. */
+ open(): void {
+ this.onopen?.();
+ }
+
+ /**
+ * Delivers an inbound frame.
+ * @param message The message Stream Deck would send.
+ */
+ receive(message: unknown): void {
+ this.onmessage?.({ data: JSON.stringify(message) });
+ }
+
+ /** Every frame sent with the given event name. */
+ framesFor(event: string): SentFrame[] {
+ return this.sent.filter((frame) => frame.event === event);
+ }
+}
+
+/** The property-inspector runtime plus the fakes backing it. */
+export type PropertyInspector = {
+ /** The `MeldPI` object the inspectors call. */
+ MeldPI: Record
unknown> & {
+ bindSessionSelects(ids: string[], keepFilter?: (type: string, item: unknown) => boolean): void;
+ bindField(id: string, fallback?: string): void;
+ bindEditablePreset(
+ id: string,
+ presetId: string,
+ customRowId: string,
+ presets: string[],
+ fallback: string,
+ ): void;
+ watchConnection(loadingId: string): void;
+ getSettings(): Record;
+ patchSettings(patch: Record): void;
+ onSession(fn: (session: unknown) => void): void;
+ };
+ /** The socket the runtime opened. */
+ socket: FakePiSocket;
+ /** Registers an element under an id, as the inspector's markup would. */
+ element(id: string, element: T): T;
+ /** Pushes a session snapshot, as the plugin's ui-bridge does. */
+ pushSession(items: Record, connected?: boolean): void;
+ /** Delivers `didReceiveSettings`, as Stream Deck does after a write. */
+ pushSettings(settings: Record): void;
+};
+
+const MELD_PI_PATH = new URL("../../co.meldstudio.streamdeck.sdPlugin/ui/lib/meld-pi.js", import.meta.url);
+
+/**
+ * Loads the real property-inspector runtime against fake DOM and socket, and
+ * completes its Stream Deck registration handshake.
+ * @param settings Settings the action is configured with.
+ * @param actionUuid The action's manifest UUID.
+ */
+export function loadPropertyInspector(
+ settings: Record = {},
+ actionUuid = "co.meldstudio.streamdeck.toggle-layer",
+): PropertyInspector {
+ const elements = new Map();
+ const window: Record = {};
+ const document = {
+ getElementById: (id: string) => elements.get(id) ?? null,
+ createElement: (tag: string) => (tag === "option" ? new FakeOption() : new FakeSelect()),
+ };
+
+ const context = createContext({ window, document, WebSocket: FakePiSocket, console });
+ runInContext(readFileSync(MELD_PI_PATH, "utf8"), context, { filename: "meld-pi.js" });
+
+ const connect = window.connectElgatoStreamDeckSocket as (
+ port: number,
+ uuid: string,
+ registerEvent: string,
+ info: string,
+ actionInfo: string,
+ ) => void;
+ connect(0, "ctx-1", "registerPropertyInspector", "{}", JSON.stringify({ action: actionUuid, payload: { settings } }));
+
+ const socket = FakePiSocket.latest as FakePiSocket;
+
+ return {
+ MeldPI: window.MeldPI as PropertyInspector["MeldPI"],
+ socket,
+ element(id, element) {
+ elements.set(id, element);
+ return element;
+ },
+ pushSession(items, connected = true) {
+ socket.receive({
+ event: "sendToPropertyInspector",
+ payload: { event: "meld.session", items, connected },
+ });
+ },
+ pushSettings(next) {
+ socket.receive({ event: "didReceiveSettings", payload: { settings: next } });
+ },
+ };
+}
diff --git a/test/helpers/fake-stream-deck.ts b/test/helpers/fake-stream-deck.ts
new file mode 100644
index 0000000..ecb6c89
--- /dev/null
+++ b/test/helpers/fake-stream-deck.ts
@@ -0,0 +1,253 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+//
+// A stand-in for the Stream Deck side of an action: fake key and dial instances
+// that record what the plugin painted, plus a `mount` helper that attaches them
+// to a real action so its render lifecycle can be driven directly.
+//
+// This deliberately fakes only what MeldAction touches. Everything below the
+// action — the Meld connection and session handling — is real code driven by a
+// result-level WebChannel fixture shaped like WebChannelAPI.md.
+
+import type { JsonObject } from "@elgato/utils";
+
+import type { MeldAction } from "../../src/actions/base.ts";
+
+/** Feedback payload passed to a dial's touchscreen. */
+export type Feedback = {
+ icon?: string;
+ title?: string;
+ value?: string;
+ indicator?: { value: number; enabled: boolean; bar_bg_c: string };
+};
+
+/**
+ * A fake visible action instance. Stands in for the SDK's `KeyAction` /
+ * `DialAction`, recording every paint so tests can assert on what the user
+ * would see.
+ */
+export class FakeActionInstance {
+ readonly id: string;
+
+ /** Every state pushed, in order. */
+ readonly states: (0 | 1)[] = [];
+ /** Every image pushed, in order. */
+ readonly images: (string | undefined)[] = [];
+ /** Every dial feedback pushed, in order. */
+ readonly feedbacks: Feedback[] = [];
+ /** Every settings migration persisted through the SDK action instance. */
+ readonly settingsWrites: T[] = [];
+
+ #settings: T;
+
+ constructor(id: string, settings: T) {
+ this.id = id;
+ this.#settings = settings;
+ }
+
+ /** The most recent state pushed, or `undefined` if none. */
+ get state(): 0 | 1 | undefined {
+ return this.states.at(-1);
+ }
+
+ /** The most recent image pushed, or `undefined` if none. */
+ get image(): string | undefined {
+ return this.images.at(-1);
+ }
+
+ /** The most recent dial feedback pushed, or `undefined` if none. */
+ get feedback(): Feedback | undefined {
+ return this.feedbacks.at(-1);
+ }
+
+ /** Replaces the settings the SDK would report for this instance. */
+ setLocalSettings(settings: T): void {
+ this.#settings = settings;
+ }
+
+ // --- The surface MeldAction uses -----------------------------------------
+
+ /** @returns The instance's current settings, as the SDK would. */
+ async getSettings(): Promise {
+ return this.#settings as unknown as U;
+ }
+
+ /** Records and applies a settings write, as Stream Deck would. */
+ async setSettings(settings: U): Promise {
+ this.#settings = settings as unknown as T;
+ this.settingsWrites.push(structuredClone(this.#settings));
+ }
+
+ /** Records a key state push. */
+ async setState(state: 0 | 1): Promise {
+ this.states.push(state);
+ }
+
+ /** Records a key image push. */
+ async setImage(image: string | undefined): Promise {
+ this.images.push(image);
+ }
+
+ /** Records a dial feedback push. */
+ async setFeedback(feedback: Feedback): Promise {
+ this.feedbacks.push(feedback);
+ }
+
+ /** Discards every recorded paint, so a test can assert on what follows. */
+ reset(): void {
+ this.states.length = 0;
+ this.images.length = 0;
+ this.feedbacks.length = 0;
+ }
+}
+
+/** The live visible set behind each mounted action's `actions` getter. */
+const mounted = new WeakMap[]>();
+
+/**
+ * Attaches fake instances to an action, replacing the SDK-backed `actions`
+ * getter that enumerates visible contexts.
+ *
+ * The set is live: {@link disappear} removes an instance from it before the
+ * action's handler runs, which is what the SDK does (it drops the context from
+ * its action store on a prepended willDisappear listener). A frozen set would
+ * let code that asks "is any instance still visible?" always answer yes.
+ * @param action The action under test.
+ * @param instances The instances visible to begin with.
+ */
+export function mount(action: MeldAction, instances: FakeActionInstance[]): void {
+ const live = [...instances] as unknown as FakeActionInstance[];
+ mounted.set(action, live);
+ Object.defineProperty(action, "actions", {
+ configurable: true,
+ get: () => live[Symbol.iterator](),
+ });
+}
+
+/**
+ * Makes an instance visible and delivers `willAppear`, as the app does.
+ * @param action The action under test.
+ * @param instance The appearing instance.
+ * @param settings Settings the SDK reports with the event.
+ * @param controller Whether the instance is a key or an encoder.
+ */
+export async function appear(
+ action: MeldAction,
+ instance: FakeActionInstance,
+ settings: T,
+ controller: "Keypad" | "Encoder" = "Keypad",
+): Promise {
+ const live = mounted.get(action);
+ if (live && !live.includes(instance as never)) {
+ live.push(instance as never);
+ }
+ await action.onWillAppear(willAppear(instance, settings, controller) as never);
+}
+
+/**
+ * Removes an instance from the visible set and delivers `willDisappear`.
+ * @param action The action under test.
+ * @param instance The disappearing instance.
+ */
+export async function disappear(
+ action: MeldAction,
+ instance: FakeActionInstance,
+): Promise {
+ const live = mounted.get(action);
+ const index = live?.indexOf(instance as never) ?? -1;
+ if (index >= 0) {
+ // Removed first: the SDK's store no longer yields the context by the time
+ // the plugin's handler runs.
+ live?.splice(index, 1);
+ }
+ await action.onWillDisappear({ action: instance, payload: { settings: {} } } as never);
+}
+
+/**
+ * Builds a `willAppear` event for a fake instance.
+ * @param instance The appearing instance.
+ * @param settings Settings the SDK reports with the event.
+ * @param controller Whether the instance is a key or an encoder.
+ */
+export function willAppear(
+ instance: FakeActionInstance,
+ settings: T,
+ controller: "Keypad" | "Encoder" = "Keypad",
+): { action: unknown; payload: { settings: T; controller: string } } {
+ instance.setLocalSettings(settings);
+ return { action: instance, payload: { settings, controller } };
+}
+
+/**
+ * Builds a `didReceiveSettings` event for a fake instance.
+ * @param instance The instance whose settings changed.
+ * @param settings The new settings.
+ */
+export function didReceiveSettings(
+ instance: FakeActionInstance,
+ settings: T,
+): { action: unknown; payload: { settings: T } } {
+ instance.setLocalSettings(settings);
+ return { action: instance, payload: { settings } };
+}
+
+/**
+ * Builds a key-press event for a fake instance.
+ * @param instance The pressed instance.
+ * @param settings Settings the SDK reports with the event.
+ */
+export function keyEvent(
+ instance: FakeActionInstance,
+ settings: T,
+): { action: unknown; payload: { settings: T } } {
+ return { action: instance, payload: { settings } };
+}
+
+/**
+ * Reconfigures a visible instance, as the property inspector does when the user
+ * changes a dropdown: Stream Deck persists the settings and delivers
+ * `didReceiveSettings` to the running action.
+ * @param action The action under test.
+ * @param instance The instance being reconfigured.
+ * @param settings The new settings.
+ */
+export async function retarget(
+ action: MeldAction,
+ instance: FakeActionInstance,
+ settings: T,
+): Promise {
+ await action.onDidReceiveSettings(didReceiveSettings(instance, settings) as never);
+}
+
+/**
+ * Presses a key: `keyDown` then `keyUp`, as the app always delivers them.
+ *
+ * Calling `onKeyUp` alone skips the dedupe-cache invalidation that every real
+ * press performs, so a key could appear to repaint correctly in a test while
+ * the shipped plugin suppressed the message.
+ * @param action The action under test.
+ * @param instance The pressed instance.
+ * @param settings Settings the SDK reports with the event.
+ */
+export async function press(
+ action: MeldAction,
+ instance: FakeActionInstance,
+ settings: T,
+): Promise {
+ action.onKeyDown(keyEvent(instance, settings) as never);
+ await action.onKeyUp?.(keyEvent(instance, settings) as never);
+}
+
+/**
+ * Builds a dial-rotate event for a fake instance.
+ * @param instance The rotated instance.
+ * @param settings Settings the SDK reports with the event.
+ * @param ticks Detents turned; negative is counter-clockwise.
+ */
+export function dialRotate(
+ instance: FakeActionInstance,
+ settings: T,
+ ticks: number,
+): { action: unknown; payload: { settings: T; ticks: number } } {
+ return { action: instance, payload: { settings, ticks } };
+}
diff --git a/test/helpers/mock-web-channel.ts b/test/helpers/mock-web-channel.ts
new file mode 100644
index 0000000..5bc6ea8
--- /dev/null
+++ b/test/helpers/mock-web-channel.ts
@@ -0,0 +1,457 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+
+import { mock, type Mock } from "node:test";
+
+import type {
+ OpenWebChannel,
+ WebChannelCallbacks,
+ WebChannelHandle,
+} from "../../src/meld/connection.ts";
+import type { MeldObject, MeldSignal } from "../../src/meld/types.ts";
+import type { FakeLayer, FakeScene, FakeSession, FakeTrack, Scenario } from "./session-builder.ts";
+
+/** Meld's retriggerable session-save delay. */
+export const MELD_SESSION_DEBOUNCE_MS = 600;
+
+export type MockWebChannelOptions = {
+ omitMeldObject?: boolean;
+ stallHandshake?: boolean;
+ omitObserverApi?: boolean;
+ echoDelayMs?: number;
+ sessionDebounceMs?: number;
+};
+
+type RequiredMethod = NonNullable extends (...args: infer A) => infer R
+ ? (...args: A) => R
+ : never;
+
+/** Direct mocks for every Meld method used by the plugin. */
+export type MockMeldMethods = {
+ [K in
+ | "setClientName"
+ | "showScene"
+ | "setStagedScene"
+ | "showStagedScene"
+ | "toggleMute"
+ | "toggleMonitor"
+ | "toggleLayer"
+ | "toggleEffect"
+ | "toggleRecord"
+ | "toggleStream"
+ | "setGain"
+ | "setMuted"
+ | "setProperty"
+ | "registerTrackObserver"
+ | "unregisterTrackObserver"
+ | "callFunction"
+ | "callFunctionWithArgs"
+ | "sendCommand"
+ | "sendCommandWithArgs"
+ | "sendStreamEvent"
+ | "sendEvent"]: Mock>;
+};
+
+class MockSignal implements MeldSignal {
+ readonly #listeners = new Set<(...args: Args) => void>();
+
+ connect(callback: (...args: Args) => void): void {
+ this.#listeners.add(callback);
+ }
+
+ disconnect(callback: (...args: Args) => void): void {
+ this.#listeners.delete(callback);
+ }
+
+ emit(...args: Args): void {
+ for (const callback of [...this.#listeners]) callback(...args);
+ }
+}
+
+type Endpoint = { server?: MockWebChannel };
+const endpoints = new Map();
+let nextPort = 45_000;
+
+class MockAttempt implements WebChannelHandle {
+ readonly #callbacks: WebChannelCallbacks;
+ server?: MockWebChannel;
+ #closed = false;
+ #notifyClosed = false;
+
+ constructor(callbacks: WebChannelCallbacks, server?: MockWebChannel) {
+ this.#callbacks = callbacks;
+ if (server) {
+ this.server = server;
+ server.accept(this);
+ } else {
+ // A refused WebSocket cannot become connected merely because Meld starts
+ // later. It errors, closes, and a later reconnect creates a new attempt.
+ queueMicrotask(() => {
+ if (this.#closed) return;
+ this.#callbacks.error();
+ this.drop();
+ });
+ }
+ }
+
+ ready(meld: MeldObject | undefined): void {
+ queueMicrotask(() => {
+ if (!this.#closed && this.server) this.#callbacks.ready(meld);
+ });
+ }
+
+ drop(): void {
+ if (this.#closed) return;
+ this.#closed = true;
+ this.#notifyClosed = true;
+ this.server?.detach(this);
+ this.server = undefined;
+ queueMicrotask(() => {
+ if (!this.#notifyClosed) return;
+ this.#notifyClosed = false;
+ this.#callbacks.closed();
+ });
+ }
+
+ /** Caller teardown is intentionally silent, matching the production adapter. */
+ close(): void {
+ // Match the browser adapter: caller teardown detaches a remote close that is
+ // already queued but has not yet been delivered.
+ this.#notifyClosed = false;
+ if (this.#closed) return;
+ this.#closed = true;
+ this.server?.detach(this);
+ this.server = undefined;
+ }
+}
+
+/** Result-level opener injected into MeldConnection; no Qt frame is modelled. */
+export const openMockWebChannel: OpenWebChannel = (url, callbacks) => {
+ const endpoint = endpoints.get(Number(new URL(url).port));
+ return new MockAttempt(callbacks, endpoint?.server);
+};
+
+/**
+ * A controllable Meld result object plus a WebSocket-like lifecycle.
+ *
+ * Public methods are `node:test` mocks. Private state is deliberately separate
+ * from `meld.session`: callers see a new snapshot only when sessionChanged is
+ * published, just as they do across the real serialized WebChannel boundary.
+ */
+export class MockWebChannel {
+ readonly methods: MockMeldMethods;
+ readonly #options: MockWebChannelOptions;
+ readonly #attempts = new Set();
+ readonly #observers = new Map>();
+ readonly #pendingEchoes = new Set>();
+ readonly #sessionChanged = new MockSignal<[]>();
+ readonly #streamingChanged = new MockSignal<[]>();
+ readonly #recordingChanged = new MockSignal<[]>();
+ readonly #gainUpdated = new MockSignal<[string, number, boolean]>();
+
+ #state: FakeSession;
+ #gains: Record;
+ #streaming: boolean;
+ #recording: boolean;
+ #accepted = 0;
+ #port?: number;
+ #sessionTimer?: ReturnType;
+ #debounceOverride?: number;
+ readonly #meld: MeldObject;
+
+ constructor(scenario: Scenario, options: MockWebChannelOptions = {}) {
+ this.#state = structuredClone(scenario.session);
+ this.#gains = { ...scenario.gains };
+ this.#streaming = scenario.isStreaming;
+ this.#recording = scenario.isRecording;
+ this.#options = options;
+
+ this.methods = {
+ setClientName: mock.fn((_name: string) => {}),
+ showScene: mock.fn((sceneId: string) => this.showScene(sceneId)),
+ setStagedScene: mock.fn((sceneId: string) => this.#setStagedScene(sceneId)),
+ showStagedScene: mock.fn(() => this.#showStagedScene()),
+ toggleMute: mock.fn((trackId: string) => this.#toggleMute(trackId)),
+ toggleMonitor: mock.fn((trackId: string) => this.#toggleMonitor(trackId)),
+ toggleLayer: mock.fn((sceneId: string, layerId: string) => this.#toggleLayer(sceneId, layerId)),
+ toggleEffect: mock.fn((sceneId: string, layerId: string, effectId: string) =>
+ this.#toggleEffect(sceneId, layerId, effectId)),
+ toggleRecord: mock.fn(() => this.setRecording(!this.#recording)),
+ toggleStream: mock.fn(() => this.setStreaming(!this.#streaming)),
+ setGain: mock.fn((trackId: string, gain: number) => this.setGain(trackId, gain)),
+ setMuted: mock.fn((trackId: string, muted: boolean) => this.setMuted(trackId, muted)),
+ setProperty: mock.fn((objectId: string, propertyName: string, value: unknown) =>
+ this.#setProperty(objectId, propertyName, value)),
+ registerTrackObserver: mock.fn((context: string, trackId: string) =>
+ this.#registerTrackObserver(context, trackId)),
+ unregisterTrackObserver: mock.fn((context: string, trackId: string) =>
+ this.#unregisterTrackObserver(context, trackId)),
+ callFunction: mock.fn((layerId: string, command: string) => this.#callFunction(layerId, command)),
+ callFunctionWithArgs: mock.fn((layerId: string, command: string, _args: unknown[]) =>
+ this.#callFunction(layerId, command)),
+ sendCommand: mock.fn((_command: string) => {}),
+ sendCommandWithArgs: mock.fn((_commandName: string, _args: unknown[]) => {}),
+ sendStreamEvent: mock.fn((_type: string, _data?: unknown) => {}),
+ sendEvent: mock.fn((_event: string) => {}),
+ };
+
+ this.#meld = {
+ version: 3,
+ session: structuredClone(this.#state),
+ isStreaming: this.#streaming,
+ isRecording: this.#recording,
+ sessionChanged: this.#sessionChanged,
+ isStreamingChanged: this.#streamingChanged,
+ isRecordingChanged: this.#recordingChanged,
+ gainUpdated: options.omitObserverApi ? undefined : this.#gainUpdated,
+ ...this.methods,
+ };
+ if (options.omitObserverApi) {
+ this.#meld.registerTrackObserver = undefined;
+ this.#meld.unregisterTrackObserver = undefined;
+ }
+ }
+
+ get meld(): MeldObject {
+ return this.#meld;
+ }
+
+ /** Internal Meld state, useful for asserting the eventual command result. */
+ get session(): FakeSession {
+ return structuredClone(this.#state);
+ }
+
+ get clientCount(): number {
+ return this.#attempts.size;
+ }
+
+ get acceptedConnections(): number {
+ return this.#accepted;
+ }
+
+ get callCount(): number {
+ return Object.values(this.methods).reduce((sum, method) => sum + method.mock.callCount(), 0);
+ }
+
+ resetCalls(): void {
+ for (const method of Object.values(this.methods)) method.mock.resetCalls();
+ }
+
+ async listen(port: number): Promise {
+ const selected = port === 0 ? nextPort++ : port;
+ const endpoint = endpoints.get(selected);
+ if (endpoint?.server && endpoint.server !== this) {
+ throw new Error(`mock WebChannel port ${selected} is already in use`);
+ }
+ if (this.#port !== undefined && this.#port !== selected) {
+ throw new Error("a mock WebChannel instance cannot move between ports");
+ }
+ this.#port = selected;
+ endpoints.set(selected, { server: this });
+ return selected;
+ }
+
+ async close(): Promise {
+ for (const timer of this.#pendingEchoes) clearTimeout(timer);
+ this.#pendingEchoes.clear();
+ if (this.#sessionTimer) clearTimeout(this.#sessionTimer);
+ this.#sessionTimer = undefined;
+ this.#observers.clear();
+ if (this.#port !== undefined && endpoints.get(this.#port)?.server === this) endpoints.delete(this.#port);
+ for (const attempt of [...this.#attempts]) attempt.drop();
+ this.#port = undefined;
+ }
+
+ accept(attempt: MockAttempt): void {
+ this.#accepted++;
+ this.#attempts.add(attempt);
+ if (!this.#options.stallHandshake) attempt.ready(this.#options.omitMeldObject ? undefined : this.#meld);
+ }
+
+ detach(attempt: MockAttempt): void {
+ this.#attempts.delete(attempt);
+ }
+
+ loadScenario(scenario: Scenario): void {
+ this.#state = structuredClone(scenario.session);
+ this.#gains = { ...scenario.gains };
+ this.#streaming = scenario.isStreaming;
+ this.#recording = scenario.isRecording;
+ this.#observers.clear();
+ this.#publishSession();
+ this.#meld.isStreaming = this.#streaming;
+ this.#meld.isRecording = this.#recording;
+ this.#streamingChanged.emit();
+ this.#recordingChanged.emit();
+ }
+
+ setSessionDebounce(delayMs: number | undefined): void {
+ this.#debounceOverride = delayMs;
+ }
+
+ gainOf(trackId: string): number | undefined {
+ return this.#gains[trackId];
+ }
+
+ setStreaming(streaming: boolean): void {
+ this.#streaming = streaming;
+ this.#meld.isStreaming = streaming;
+ this.#streamingChanged.emit();
+ }
+
+ setRecording(recording: boolean): void {
+ this.#recording = recording;
+ this.#meld.isRecording = recording;
+ this.#recordingChanged.emit();
+ }
+
+ setGain(trackId: string, gain: number): void {
+ if (!this.#track(trackId)) return;
+ this.#gains[trackId] = Math.min(1, Math.max(0, gain));
+ this.#emitGain(trackId);
+ }
+
+ emitGainOnly(trackId: string, gain: number, muted: boolean): void {
+ this.#gains[trackId] = gain;
+ this.#gainUpdated.emit(trackId, gain, muted);
+ }
+
+ setMuted(trackId: string, muted: boolean): void {
+ const track = this.#track(trackId);
+ if (!track) return;
+ track.muted = muted;
+ this.#emitGain(trackId);
+ this.#publishSession();
+ }
+
+ showScene(sceneId: string): void {
+ const target = this.#scene(sceneId);
+ if (!target) return;
+ for (const item of Object.values(this.#state.items)) if (item.type === "scene") item.current = false;
+ target.current = true;
+ this.#publishSession();
+ }
+
+ #setStagedScene(sceneId: string): void {
+ const scene = this.#scene(sceneId);
+ if (!scene || scene.vertical || scene.current) return;
+ for (const item of Object.values(this.#state.items)) if (item.type === "scene") item.staged = false;
+ scene.staged = true;
+ this.#publishSessionNow();
+ }
+
+ #showStagedScene(): void {
+ const scenes = Object.values(this.#state.items).filter((item): item is FakeScene => item.type === "scene");
+ const current = scenes.find((scene) => scene.current);
+ const staged = scenes.find((scene) => scene.staged);
+ if (current) { current.current = false; current.staged = true; }
+ if (staged) { staged.staged = false; staged.current = true; }
+ this.#publishSessionNow();
+ }
+
+ #toggleMute(trackId: string): void {
+ const track = this.#track(trackId);
+ if (track) this.setMuted(trackId, !track.muted);
+ }
+
+ #toggleMonitor(trackId: string): void {
+ const track = this.#track(trackId);
+ if (!track) return;
+ track.monitoring = !track.monitoring;
+ this.#publishSession();
+ }
+
+ #toggleLayer(sceneId: string, layerId: string): void {
+ const layer = this.#layerIn(sceneId, layerId);
+ if (!layer) return;
+ layer.visible = !layer.visible;
+ layer.isEffectivelyVisible = layer.visible;
+ this.#publishSession();
+ }
+
+ #toggleEffect(sceneId: string, layerId: string, effectId: string): void {
+ if (!this.#layerIn(sceneId, layerId)) return;
+ const direct = this.#state.items[effectId];
+ const effect = direct?.type === "effect" && direct.parent === layerId ? direct : undefined;
+ if (!effect) return;
+ effect.enabled = !effect.enabled;
+ this.#publishSession();
+ }
+
+ #setProperty(objectId: string, propertyName: string, value: unknown): void {
+ const item = this.#state.items[objectId];
+ if (!item) return;
+ (item as unknown as Record)[propertyName] = value;
+ if (item.type === "track" && propertyName === "muted") this.#emitGain(objectId);
+ this.#publishSession();
+ }
+
+ #registerTrackObserver(context: string, trackId: string): void {
+ if (!this.#track(trackId)) return;
+ const contexts = this.#observers.get(trackId) ?? new Set();
+ contexts.add(context);
+ this.#observers.set(trackId, contexts);
+ this.#emitGain(trackId);
+ }
+
+ #unregisterTrackObserver(context: string, trackId: string): void {
+ const contexts = this.#observers.get(trackId);
+ contexts?.delete(context);
+ if (contexts?.size === 0) this.#observers.delete(trackId);
+ }
+
+ #callFunction(layerId: string, command: string): void {
+ const layer = this.#state.items[layerId];
+ if (layer?.type !== "layer" || layer.mediaSource === undefined) return;
+ if (command === "play") layer.isPlaying = true;
+ else if (command === "pause") layer.isPlaying = false;
+ else if (command !== "seekTo" && command !== "skipBack") return;
+ this.#publishSession();
+ }
+
+ #layerIn(sceneId: string, layerId: string): FakeLayer | undefined {
+ const layer = this.#state.items[layerId];
+ return this.#scene(sceneId) && layer?.type === "layer" && layer.parent === sceneId ? layer : undefined;
+ }
+
+ #track(id: string): FakeTrack | undefined {
+ const item = this.#state.items[id];
+ return item?.type === "track" ? item : undefined;
+ }
+
+ #scene(id: string): FakeScene | undefined {
+ const item = this.#state.items[id];
+ return item?.type === "scene" ? item : undefined;
+ }
+
+ #emitGain(trackId: string): void {
+ if (!this.#observers.has(trackId)) return;
+ const gain = this.#gains[trackId] ?? 0;
+ const muted = this.#track(trackId)?.muted ?? false;
+ const delay = this.#options.echoDelayMs ?? 0;
+ if (delay <= 0) return this.#gainUpdated.emit(trackId, gain, muted);
+ const timer = setTimeout(() => {
+ this.#pendingEchoes.delete(timer);
+ this.#gainUpdated.emit(trackId, gain, muted);
+ }, delay);
+ this.#pendingEchoes.add(timer);
+ }
+
+ #publishSessionNow(): void {
+ if (this.#sessionTimer) clearTimeout(this.#sessionTimer);
+ this.#sessionTimer = undefined;
+ this.#meld.session = structuredClone(this.#state);
+ this.#sessionChanged.emit();
+ }
+
+ #publishSession(): void {
+ const delay = this.#debounceOverride ?? this.#options.sessionDebounceMs ?? MELD_SESSION_DEBOUNCE_MS;
+ if (delay <= 0) return this.#publishSessionNow();
+ if (this.#sessionTimer) clearTimeout(this.#sessionTimer);
+ this.#sessionTimer = setTimeout(() => {
+ this.#sessionTimer = undefined;
+ this.#meld.session = structuredClone(this.#state);
+ this.#sessionChanged.emit();
+ }, delay);
+ }
+}
diff --git a/test/helpers/session-builder.ts b/test/helpers/session-builder.ts
new file mode 100644
index 0000000..9ee5267
--- /dev/null
+++ b/test/helpers/session-builder.ts
@@ -0,0 +1,260 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+//
+// Builders for the session snapshot Meld Studio publishes over the WebChannel.
+//
+// The shapes here mirror Meld/StreamDeckMapper.cpp exactly — `sceneToMap`,
+// `layerToMap`, `effectsToMap` and `audioTrackToMap` — so a scenario built with
+// these helpers is indistinguishable from a real `mapper.filteredMap`. Note in
+// particular that a track carries NO `gain` field: gain reaches the plugin only
+// through the `gainUpdated` signal.
+
+/** The wire snapshot: `{ items: { : item } }`, as `map["items"]` produces. */
+export type FakeSession = { items: Record };
+
+/** Any item that can appear in the snapshot. */
+export type FakeItem = FakeScene | FakeLayer | FakeEffect | FakeTrack;
+
+/** Mirrors `sceneToMap`. */
+export type FakeScene = {
+ type: "scene";
+ name: string;
+ current: boolean;
+ staged: boolean;
+ vertical: boolean;
+ index: number;
+};
+
+/** Mirrors `layerToMap`. Optional fields appear only when the source object has them. */
+export type FakeLayer = {
+ type: "layer";
+ name: string;
+ parent: string;
+ index: number;
+ visible: boolean;
+ isEffectivelyVisible: boolean;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ rotation: number;
+ isPlaying?: boolean;
+ mediaSource?: string;
+ source?: string;
+ url?: string;
+ text?: string;
+};
+
+/** Mirrors `effectsToMap`. */
+export type FakeEffect = {
+ type: "effect";
+ name: string;
+ parent: string;
+ enabled: boolean;
+};
+
+/** Mirrors `audioTrackToMap`; `parent` is present only for scene-owned tracks. */
+export type FakeTrack = {
+ type: "track";
+ name: string;
+ muted: boolean;
+ monitoring: boolean;
+ parent?: string;
+};
+
+/** Gain lives outside the snapshot, exactly as it does in Meld. */
+export type TrackGain = { gain: number };
+
+/**
+ * A complete scenario: the snapshot plus the out-of-band state Meld keeps
+ * separately (per-track gain, and the streaming/recording flags).
+ */
+export type Scenario = {
+ name: string;
+ description: string;
+ session: FakeSession;
+ gains: Record;
+ isStreaming: boolean;
+ isRecording: boolean;
+};
+
+/**
+ * Generates a deterministic 32-char uppercase hex id, the format Meld uses.
+ * Deterministic so scenarios are reproducible between runs — but distinct per
+ * `seed`, so "a different Meld instance" really does mean all-new ids.
+ * @param seed Any string; the same seed always yields the same id.
+ */
+export function itemId(seed: string): string {
+ // FNV-1a, expanded to 128 bits by hashing four salted variants.
+ const block = (salt: string): string => {
+ let hash = 0x811c9dc5;
+ for (const char of `${salt}:${seed}`) {
+ hash ^= char.codePointAt(0) as number;
+ hash = Math.imul(hash, 0x01000193) >>> 0;
+ }
+ return hash.toString(16).padStart(8, "0");
+ };
+ return `${block("a")}${block("b")}${block("c")}${block("d")}`.toUpperCase();
+}
+
+/** Historical settings form accepted only long enough to migrate to a stable effect id. */
+export function indexedEffectId(layerId: string, index: number): string {
+ return `${layerId}-${index}`;
+}
+
+/** Fluent builder that assembles a scenario's snapshot in Meld's own shape. */
+export class SessionBuilder {
+ readonly #items: Record = {};
+ readonly #gains: Record = {};
+ readonly #namespace: string;
+ // Meld walks the horizontal and vertical scene models in separate loops, each
+ // numbering from 0, so a vertical twin can share an index with a horizontal
+ // scene. Two counters reproduce that rather than inventing unique indices.
+ #sceneIndex = 0;
+ #verticalSceneIndex = 0;
+
+ /**
+ * @param namespace Salts every generated id. Two builders with different
+ * namespaces never produce the same id even for identically-named items,
+ * which is what makes one scenario a genuinely different Meld instance from
+ * another rather than the same project reopened.
+ */
+ constructor(namespace: string) {
+ this.#namespace = namespace;
+ }
+
+ /** Namespaced id for an item, so scenarios cannot collide. */
+ #id(seed: string): string {
+ return itemId(`${this.#namespace}/${seed}`);
+ }
+
+ /**
+ * Adds a scene.
+ * @param name Display name; vertical scenes get Meld's " [Vertical]" suffix.
+ * @param options `current`/`staged` flags and whether it is a vertical scene.
+ */
+ scene(name: string, options: { current?: boolean; staged?: boolean; vertical?: boolean } = {}): string {
+ const id = this.#id(`scene:${name}:${options.vertical ? "v" : "h"}`);
+ this.#items[id] = {
+ type: "scene",
+ name: options.vertical ? `${name} [Vertical]` : name,
+ current: options.current ?? false,
+ staged: options.staged ?? false,
+ vertical: options.vertical ?? false,
+ index: options.vertical ? this.#verticalSceneIndex++ : this.#sceneIndex++,
+ };
+ return id;
+ }
+
+ /**
+ * Adds a layer to a scene.
+ * @param parent Owning scene id.
+ * @param name Layer name (Meld prefixes ancestors with "/" for nested layers).
+ * @param options Visibility, ordering, and any media/source extras.
+ */
+ layer(
+ parent: string,
+ name: string,
+ options: {
+ visible?: boolean;
+ index?: number;
+ mediaSource?: string;
+ isPlaying?: boolean;
+ source?: string;
+ url?: string;
+ text?: string;
+ } = {},
+ ): string {
+ const id = this.#id(`layer:${parent}:${name}`);
+ const visible = options.visible ?? true;
+ const layer: FakeLayer = {
+ type: "layer",
+ name,
+ parent,
+ index:
+ options.index ??
+ Object.values(this.#items).filter((i) => i.type === "layer" && i.parent === parent).length,
+ visible,
+ isEffectivelyVisible: visible,
+ x: 0,
+ y: 0,
+ width: 1920,
+ height: 1080,
+ rotation: 0,
+ };
+ // Meld only emits these keys when the underlying object exposes them.
+ if (options.mediaSource !== undefined) {
+ layer.mediaSource = options.mediaSource;
+ layer.isPlaying = options.isPlaying ?? false;
+ }
+ if (options.source !== undefined) {
+ layer.source = options.source;
+ }
+ if (options.url !== undefined) {
+ layer.url = options.url;
+ }
+ if (options.text !== undefined) {
+ layer.text = options.text;
+ }
+ this.#items[id] = layer;
+ return id;
+ }
+
+ /**
+ * Adds an effect to a layer.
+ *
+ * Effects are keyed by the stable id published by
+ * `effectsToMap(effect, map, effect->idString(), parent)`.
+ * @param parent Owning layer id.
+ * @param name Effect display name.
+ * @param options `enabled` state.
+ */
+ effect(parent: string, name: string, options: { enabled?: boolean } = {}): string {
+ const id = this.#id(`effect:${parent}:${name}`);
+ this.#items[id] = { type: "effect", name, parent, enabled: options.enabled ?? true };
+ return id;
+ }
+
+ /**
+ * Adds an audio track.
+ * @param name Track name (the replay clip's track is named "Replay Clip").
+ * @param options Mute/monitor state, owning layer, and starting gain.
+ */
+ track(name: string, options: { muted?: boolean; monitoring?: boolean; parent?: string; gain?: number } = {}): string {
+ const id = this.#id(`track:${name}`);
+ const track: FakeTrack = {
+ type: "track",
+ name,
+ muted: options.muted ?? false,
+ monitoring: options.monitoring ?? false,
+ };
+ if (options.parent !== undefined) {
+ track.parent = options.parent;
+ }
+ this.#items[id] = track;
+ // Gain is deliberately kept out of the snapshot, matching Meld.
+ this.#gains[id] = options.gain ?? 1;
+ return id;
+ }
+
+ /** Produces the finished scenario. */
+ build(name: string, description: string, flags: { streaming?: boolean; recording?: boolean } = {}): Scenario {
+ // Meld builds `items` as a QVariantMap, which Qt serializes with the keys in
+ // lexicographic order of the item id — so the wire order is unrelated to
+ // creation order, and types are interleaved. Anything that walks
+ // `Object.keys(items)` sees that order in production, so the fake must serve
+ // it too; insertion order would flatter code that depends on grouping.
+ const sorted: Record = {};
+ for (const id of Object.keys(this.#items).sort()) {
+ sorted[id] = this.#items[id];
+ }
+ return {
+ name,
+ description,
+ session: { items: structuredClone(sorted) },
+ gains: { ...this.#gains },
+ isStreaming: flags.streaming ?? false,
+ isRecording: flags.recording ?? false,
+ };
+ }
+}
diff --git a/test/media-show-scene.test.ts b/test/media-show-scene.test.ts
new file mode 100644
index 0000000..a2602ae
--- /dev/null
+++ b/test/media-show-scene.test.ts
@@ -0,0 +1,525 @@
+// Copyright (c) 2024 Meld Studio, Inc.
+// Licensed under the MIT license. See LICENSE file in the project root for details.
+//
+// Two actions whose keys must not lie about state they cannot confirm: the media
+// key through a reconnect blip, and the show-scene key when its stored scene
+// belongs to a Meld instance that is no longer running.
+
+import assert from "node:assert/strict";
+import { after, before, beforeEach, describe, test } from "node:test";
+import { setTimeout as sleep } from "node:timers/promises";
+
+import { Media } from "../src/actions/media.ts";
+import { ShowScene } from "../src/actions/show-scene.ts";
+import { MeldConnection } from "../src/meld/connection.ts";
+import { type Scenario, SessionBuilder } from "./helpers/session-builder.ts";
+import { FakeActionInstance, keyEvent, mount, press, retarget, willAppear } from "./helpers/fake-stream-deck.ts";
+import { MockWebChannel, openMockWebChannel } from "./helpers/mock-web-channel.ts";
+
+/** A scene with a playing media layer. */
+function build(): { scenario: Scenario; ids: Record } {
+ const b = new SessionBuilder("ms");
+ const scene = b.scene("Live", { current: true });
+ const other = b.scene("Break");
+ const third = b.scene("Interlude");
+ const clip = b.layer(scene, "Clip", { mediaSource: "clip.mp4", isPlaying: true });
+ const clipB = b.layer(scene, "Clip B", { mediaSource: "other.mp4", isPlaying: true });
+ const track = b.track("Microphone", { gain: 0.6 });
+ return { scenario: b.build("ms", "media"), ids: { scene, other, third, clip, clipB, track } };
+}
+
+describe("Media and ShowScene", () => {
+ let server: MockWebChannel;
+ let connection: MeldConnection;
+ let ids: Record;
+ let port = 0;
+
+ before(async () => {
+ const built = build();
+ ids = built.ids;
+ server = new MockWebChannel(built.scenario);
+ port = await server.listen(0);
+ connection = new MeldConnection({ port, handshakeTimeoutMs: 300, reconnectMinMs: 100, openWebChannel: openMockWebChannel });
+ const ready = new Promise((resolve) => {
+ const off = connection.on("ready", () => {
+ off();
+ resolve();
+ });
+ });
+ connection.connect();
+ await ready;
+ });
+
+ after(async () => {
+ connection.dispose();
+ await server.close();
+ });
+
+ beforeEach(async () => {
+ server.loadScenario(build().scenario);
+ server.resetCalls();
+ await sleep(40);
+ });
+
+ test("the media key shows the playing state", async () => {
+ const action = new Media(connection);
+ const key = new FakeActionInstance("m", { scene: ids.scene, layer: ids.clip, action: "toggle" });
+ mount(action, [key]);
+ await action.onWillAppear(willAppear(key, { scene: ids.scene, layer: ids.clip, action: "toggle" }) as never);
+ await sleep(60);
+
+ assert.ok(key.image?.includes("pause"), `a playing clip shows the pause icon, got ${key.image}`);
+ });
+
+ test("a media press converges on the playback state Meld reports back", async () => {
+ // The press paints optimistically, but the authoritative answer arrives in
+ // the republished snapshot. Both must agree, or the key flips to the wrong
+ // icon shortly after every press.
+ const settings = { scene: ids.scene, layer: ids.clip, action: "toggle" };
+ const action = new Media(connection);
+ const key = new FakeActionInstance("m", settings);
+ mount(action, [key]);
+ await action.onWillAppear(willAppear(key, settings) as never);
+ await sleep(60);
+ assert.ok(key.image?.includes("pause"), "the clip starts playing");
+
+ // Toggle: playing -> paused.
+ await press(action, key, settings);
+ await sleep(150);
+ assert.equal(
+ (server.session.items[ids.clip] as { isPlaying: boolean }).isPlaying,
+ false,
+ "Meld should have paused the clip",
+ );
+ assert.ok(key.image?.includes("play"), `a paused clip shows the play icon, got ${key.image}`);
+
+ // And back again.
+ await press(action, key, settings);
+ await sleep(150);
+ assert.equal((server.session.items[ids.clip] as { isPlaying: boolean }).isPlaying, true);
+ assert.ok(key.image?.includes("pause"), `a playing clip shows the pause icon, got ${key.image}`);
+ });
+
+ test("two toggle presses inside Meld's debounce alternate rather than repeat", async () => {
+ // `isPlaying` only refreshes when Meld republishes the session, behind its
+ // autosave debounce. A second press in that window must act on the pending
+ // intent, not re-read the pre-press snapshot and send the same command.
+ const slow = new MockWebChannel(build().scenario, { sessionDebounceMs: 600 });
+ const slowPort = await slow.listen(0);
+ const slowConnection = new MeldConnection({ port: slowPort, reconnectMinMs: 100, openWebChannel: openMockWebChannel });
+ const slowIds = build().ids;
+
+ try {
+ const ready = new Promise((resolve) => {
+ const off = slowConnection.on("ready", () => {
+ off();
+ resolve();
+ });
+ });
+ slowConnection.connect();
+ await ready;
+
+ const settings = { scene: slowIds.scene, layer: slowIds.clip, action: "toggle" };
+ const action = new Media(slowConnection);
+ const key = new FakeActionInstance("m", settings);
+ mount(action, [key]);
+ await action.onWillAppear(willAppear(key, settings) as never);
+ await sleep(60);
+ slow.resetCalls();
+
+ await press(action, key, settings); // playing -> pause
+ await sleep(120);
+ await press(action, key, settings); // still inside the debounce
+ await sleep(120);
+
+ const members = slow.methods.callFunction.mock.calls.map((call) => call.arguments[1]);
+ assert.deepEqual(members, ["pause", "play"], "the second press must resume, not pause again");
+ assert.ok(key.image?.includes("pause"), "and the key shows the playing state it asked for");
+ } finally {
+ slowConnection.dispose();
+ await slow.close();
+ }
+ });
+
+ test("a pending press is dropped when the key is retargeted to another layer", async () => {
+ // The inspector can retarget a key seconds after a press, while the first
+ // clip's confirmation is still outstanding. Carrying that pending value over
+ // would show the inverse of the new clip's state and send the wrong command.
+ const slow = new MockWebChannel(build().scenario, { sessionDebounceMs: 600 });
+ const slowPort = await slow.listen(0);
+ const slowConnection = new MeldConnection({ port: slowPort, reconnectMinMs: 100, openWebChannel: openMockWebChannel });
+ const slowIds = build().ids;
+
+ try {
+ const ready = new Promise((resolve) => {
+ const off = slowConnection.on("ready", () => {
+ off();
+ resolve();
+ });
+ });
+ slowConnection.connect();
+ await ready;
+
+ const first = { scene: slowIds.scene, layer: slowIds.clip, action: "toggle" };
+ const action = new Media(slowConnection);
+ const key = new FakeActionInstance("m", first);
+ mount(action, [key]);
+ await action.onWillAppear(willAppear(key, first) as never);
+ await sleep(60);
+
+ await press(action, key, first); // clip -> paused (pending, unconfirmed)
+ await sleep(60);
+ slow.resetCalls();
+
+ // Retarget to the second media layer, which is playing.
+ const second = { scene: slowIds.scene, layer: slowIds.clipB, action: "toggle" };
+ await retarget(action, key, second);
+ await sleep(60);
+ assert.ok(key.image?.includes("pause"), `the new clip is playing, got ${key.image}`);
+
+ await press(action, key, second);
+ await sleep(80);
+ assert.deepEqual(
+ slow.methods.callFunction.mock.calls.at(-1)?.arguments,
+ [slowIds.clipB, "pause"],
+ "the press must act on the new layer's real state",
+ );
+ } finally {
+ slowConnection.dispose();
+ await slow.close();
+ }
+ });
+
+ test("a media press holds its icon across a retriggered autosave", async () => {
+ // The optimistic TTL has to outlast Meld's retriggerable debounce, or the
+ // key flips back to the pre-press icon and then forward again.
+ const slow = new MockWebChannel(build().scenario, { sessionDebounceMs: 600 });
+ const slowPort = await slow.listen(0);
+ const slowConnection = new MeldConnection({ port: slowPort, reconnectMinMs: 100, openWebChannel: openMockWebChannel });
+ const slowIds = build().ids;
+
+ try {
+ const ready = new Promise