@fcannizzaro/streamdeck-react 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +190 -21
  2. package/README.md +3 -1
  3. package/dist/action.d.ts +2 -2
  4. package/dist/action.js +2 -2
  5. package/dist/bundler-shared.d.ts +11 -0
  6. package/dist/bundler-shared.js +11 -0
  7. package/dist/context/event-bus.d.ts +1 -1
  8. package/dist/context/event-bus.js +1 -1
  9. package/dist/context/touchstrip-context.d.ts +2 -0
  10. package/dist/context/touchstrip-context.js +5 -0
  11. package/dist/devtools/bridge.d.ts +35 -7
  12. package/dist/devtools/bridge.js +153 -46
  13. package/dist/devtools/highlight.d.ts +6 -0
  14. package/dist/devtools/highlight.js +106 -57
  15. package/dist/devtools/index.js +6 -0
  16. package/dist/devtools/observers/lifecycle.d.ts +4 -4
  17. package/dist/devtools/server.d.ts +6 -1
  18. package/dist/devtools/server.js +6 -1
  19. package/dist/devtools/types.d.ts +50 -6
  20. package/dist/font-inline.d.ts +5 -1
  21. package/dist/font-inline.js +8 -3
  22. package/dist/hooks/animation.d.ts +154 -0
  23. package/dist/hooks/animation.js +381 -0
  24. package/dist/hooks/events.js +1 -5
  25. package/dist/hooks/touchstrip.d.ts +6 -0
  26. package/dist/hooks/touchstrip.js +37 -0
  27. package/dist/index.d.ts +7 -2
  28. package/dist/index.js +3 -2
  29. package/dist/manifest-codegen.d.ts +38 -0
  30. package/dist/manifest-codegen.js +110 -0
  31. package/dist/node_modules/.bun/xxhash-wasm@1.1.0/node_modules/xxhash-wasm/esm/xxhash-wasm.js +3157 -0
  32. package/dist/plugin.js +20 -9
  33. package/dist/reconciler/host-config.js +19 -1
  34. package/dist/reconciler/vnode.d.ts +26 -0
  35. package/dist/reconciler/vnode.js +41 -10
  36. package/dist/render/buffer-pool.d.ts +19 -0
  37. package/dist/render/buffer-pool.js +51 -0
  38. package/dist/render/cache.d.ts +41 -0
  39. package/dist/render/cache.js +159 -5
  40. package/dist/render/image-cache.d.ts +53 -0
  41. package/dist/render/image-cache.js +128 -0
  42. package/dist/render/metrics.d.ts +58 -0
  43. package/dist/render/metrics.js +101 -0
  44. package/dist/render/pipeline.d.ts +46 -1
  45. package/dist/render/pipeline.js +370 -36
  46. package/dist/render/png.d.ts +10 -1
  47. package/dist/render/png.js +31 -13
  48. package/dist/render/render-pool.d.ts +26 -0
  49. package/dist/render/render-pool.js +141 -0
  50. package/dist/render/svg.d.ts +7 -0
  51. package/dist/render/svg.js +139 -0
  52. package/dist/render/worker.d.ts +1 -0
  53. package/dist/rollup.d.ts +23 -9
  54. package/dist/rollup.js +24 -9
  55. package/dist/roots/flush-coordinator.d.ts +18 -0
  56. package/dist/roots/flush-coordinator.js +38 -0
  57. package/dist/roots/registry.d.ts +6 -4
  58. package/dist/roots/registry.js +47 -33
  59. package/dist/roots/root.d.ts +32 -2
  60. package/dist/roots/root.js +104 -14
  61. package/dist/roots/settings-equality.d.ts +5 -0
  62. package/dist/roots/settings-equality.js +24 -0
  63. package/dist/roots/touchstrip-root.d.ts +93 -0
  64. package/dist/roots/touchstrip-root.js +383 -0
  65. package/dist/types.d.ts +62 -16
  66. package/dist/vite.d.ts +22 -8
  67. package/dist/vite.js +24 -8
  68. package/package.json +5 -4
  69. package/dist/context/touchbar-context.d.ts +0 -2
  70. package/dist/context/touchbar-context.js +0 -5
  71. package/dist/hooks/touchbar.d.ts +0 -6
  72. package/dist/hooks/touchbar.js +0 -37
  73. package/dist/roots/touchbar-root.d.ts +0 -45
  74. package/dist/roots/touchbar-root.js +0 -175
@@ -1,5 +1,7 @@
1
+ import { shallowEqualSettings } from "./settings-equality.js";
1
2
  import { ReactRoot } from "./root.js";
2
- import { TouchBarRoot } from "./touchbar-root.js";
3
+ import { TouchStripRoot } from "./touchstrip-root.js";
4
+ import { FlushCoordinator } from "./flush-coordinator.js";
3
5
  //#region src/roots/registry.ts
4
6
  var SEGMENT_WIDTH = 200;
5
7
  var KEY_SIZES = {
@@ -77,16 +79,17 @@ function getCanvasInfo(deviceType, surfaceType) {
77
79
  type: "key"
78
80
  };
79
81
  }
80
- var RootRegistry = class {
82
+ var RootRegistry = class RootRegistry {
81
83
  roots = /* @__PURE__ */ new Map();
82
- touchBarRoots = /* @__PURE__ */ new Map();
83
- touchBarActions = /* @__PURE__ */ new Map();
84
+ touchStripRoots = /* @__PURE__ */ new Map();
85
+ touchStripActions = /* @__PURE__ */ new Map();
84
86
  renderConfig;
85
87
  renderDebounceMs;
86
88
  sdkInstance;
87
89
  globalSettings = {};
88
90
  onGlobalSettingsChange;
89
91
  wrapper;
92
+ flushCoordinator = new FlushCoordinator();
90
93
  /** DevTools observer. Set externally by startDevtoolsServer(). null when devtools is off. */
91
94
  observer = null;
92
95
  constructor(renderConfig, renderDebounceMs, sdkInstance, onGlobalSettingsChange, wrapper) {
@@ -97,18 +100,19 @@ var RootRegistry = class {
97
100
  this.wrapper = wrapper;
98
101
  }
99
102
  setGlobalSettings(settings) {
103
+ if (shallowEqualSettings(this.globalSettings, settings)) return;
100
104
  this.globalSettings = settings;
101
105
  for (const root of this.roots.values()) root.updateGlobalSettings(settings);
102
- for (const tbRoot of this.touchBarRoots.values()) tbRoot.updateGlobalSettings(settings);
106
+ for (const tbRoot of this.touchStripRoots.values()) tbRoot.updateGlobalSettings(settings);
103
107
  }
104
108
  create(ev, component, definition) {
105
109
  const contextId = ev.action.id;
106
- if (this.roots.has(contextId) || this.touchBarActions.has(contextId)) return;
110
+ if (this.roots.has(contextId) || this.touchStripActions.has(contextId)) return;
107
111
  const device = ev.action.device;
108
112
  const controller = ev.action.controllerType;
109
113
  const isEncoder = controller === "Encoder";
110
- if (isEncoder && definition.touchBar) {
111
- this.registerTouchBarColumn(ev, definition);
114
+ if (isEncoder && definition.touchStrip) {
115
+ this.registerTouchStripColumn(ev, definition);
112
116
  return;
113
117
  }
114
118
  let surfaceType = "key";
@@ -129,7 +133,7 @@ var RootRegistry = class {
129
133
  const canvas = getCanvasInfo(device.type, surfaceType);
130
134
  const root = new ReactRoot(component, actionInfo, deviceInfo, canvas, ev.payload.settings, this.globalSettings, ev.action, this.sdkInstance, this.renderConfig, this.renderDebounceMs, async (settings) => {
131
135
  await ev.action.setSettings(settings);
132
- }, this.onGlobalSettingsChange, this.wrapper, definition.wrapper, definition.dialLayout);
136
+ }, this.onGlobalSettingsChange, this.flushCoordinator, this.wrapper, definition.wrapper, definition.dialLayout);
133
137
  root.eventBus.emitSticky("willAppear", {
134
138
  settings: ev.payload.settings,
135
139
  controller,
@@ -147,16 +151,16 @@ var RootRegistry = class {
147
151
  } : void 0
148
152
  });
149
153
  }
150
- registerTouchBarColumn(ev, definition) {
154
+ registerTouchStripColumn(ev, definition) {
151
155
  const actionId = ev.action.id;
152
156
  const device = ev.action.device;
153
157
  const deviceId = device.id;
154
158
  const column = this.getEncoderColumn(ev);
155
159
  if (column === void 0) {
156
- console.warn("[@fcannizzaro/streamdeck-react] Cannot determine encoder column for touchbar action:", actionId);
160
+ console.warn("[@fcannizzaro/streamdeck-react] Cannot determine encoder column for touchstrip action:", actionId);
157
161
  return;
158
162
  }
159
- let tbRoot = this.touchBarRoots.get(deviceId);
163
+ let tbRoot = this.touchStripRoots.get(deviceId);
160
164
  if (!tbRoot) {
161
165
  const deviceInfo = {
162
166
  id: deviceId,
@@ -164,31 +168,31 @@ var RootRegistry = class {
164
168
  size: device.size,
165
169
  name: device.name
166
170
  };
167
- tbRoot = new TouchBarRoot(definition.touchBar, deviceInfo, this.globalSettings, this.renderConfig, this.renderDebounceMs, this.onGlobalSettingsChange, this.wrapper, definition.touchBarFPS);
168
- this.touchBarRoots.set(deviceId, tbRoot);
169
- this.observer?.onTouchBarCreated(deviceId, tbRoot, deviceInfo);
171
+ tbRoot = new TouchStripRoot(definition.touchStrip, deviceInfo, this.globalSettings, this.renderConfig, this.renderDebounceMs, this.onGlobalSettingsChange, this.wrapper, definition.touchStripFPS, this.flushCoordinator);
172
+ this.touchStripRoots.set(deviceId, tbRoot);
173
+ this.observer?.onTouchStripCreated(deviceId, tbRoot, deviceInfo);
170
174
  }
171
175
  tbRoot.addColumn(column, actionId, ev.action);
172
- this.observer?.onTouchBarColumnChanged(deviceId, [...tbRoot.columnNumbers], tbRoot.columnActionMap);
173
- this.touchBarActions.set(actionId, deviceId);
176
+ this.observer?.onTouchStripColumnChanged(deviceId, [...tbRoot.columnNumbers], tbRoot.columnActionMap);
177
+ this.touchStripActions.set(actionId, deviceId);
174
178
  }
175
179
  getEncoderColumn(ev) {
176
180
  return ev.action.coordinates?.column;
177
181
  }
178
182
  destroy(contextId) {
179
- const deviceId = this.touchBarActions.get(contextId);
183
+ const deviceId = this.touchStripActions.get(contextId);
180
184
  if (deviceId) {
181
- const tbRoot = this.touchBarRoots.get(deviceId);
185
+ const tbRoot = this.touchStripRoots.get(deviceId);
182
186
  if (tbRoot) {
183
187
  const column = tbRoot.findColumnByActionId(contextId);
184
188
  if (column !== void 0) tbRoot.removeColumn(column);
185
189
  if (tbRoot.isEmpty) {
186
- this.observer?.onTouchBarDestroyed(deviceId);
190
+ this.observer?.onTouchStripDestroyed(deviceId);
187
191
  tbRoot.unmount();
188
- this.touchBarRoots.delete(deviceId);
192
+ this.touchStripRoots.delete(deviceId);
189
193
  }
190
194
  }
191
- this.touchBarActions.delete(contextId);
195
+ this.touchStripActions.delete(contextId);
192
196
  return;
193
197
  }
194
198
  const root = this.roots.get(contextId);
@@ -198,29 +202,39 @@ var RootRegistry = class {
198
202
  this.roots.delete(contextId);
199
203
  }
200
204
  }
205
+ static INTERACTION_EVENTS = new Set([
206
+ "keyDown",
207
+ "keyUp",
208
+ "dialRotate",
209
+ "dialDown",
210
+ "dialUp",
211
+ "touchTap"
212
+ ]);
201
213
  dispatch(contextId, event, payload) {
202
214
  const root = this.roots.get(contextId);
203
215
  if (root) {
216
+ if (RootRegistry.INTERACTION_EVENTS.has(event)) root.markInteraction();
204
217
  root.eventBus.emit(event, payload);
205
218
  this.observer?.onDispatch(contextId, event, payload);
206
219
  return;
207
220
  }
208
- const deviceId = this.touchBarActions.get(contextId);
221
+ const deviceId = this.touchStripActions.get(contextId);
209
222
  if (deviceId) {
210
- const tbRoot = this.touchBarRoots.get(deviceId);
223
+ const tbRoot = this.touchStripRoots.get(deviceId);
211
224
  if (tbRoot) {
212
- this.dispatchToTouchBar(tbRoot, contextId, event, payload);
225
+ if (RootRegistry.INTERACTION_EVENTS.has(event)) tbRoot.markInteraction();
226
+ this.dispatchToTouchStrip(tbRoot, contextId, event, payload);
213
227
  this.observer?.onDispatch(contextId, event, payload);
214
228
  }
215
229
  }
216
230
  }
217
- dispatchToTouchBar(tbRoot, actionId, event, payload) {
231
+ dispatchToTouchStrip(tbRoot, actionId, event, payload) {
218
232
  const column = tbRoot.findColumnByActionId(actionId);
219
233
  if (column === void 0) return;
220
234
  switch (event) {
221
235
  case "touchTap": {
222
236
  const tp = payload;
223
- tbRoot.eventBus.emit("touchBarTap", {
237
+ tbRoot.eventBus.emit("touchStripTap", {
224
238
  tapPos: [column * SEGMENT_WIDTH + tp.tapPos[0], tp.tapPos[1]],
225
239
  hold: tp.hold,
226
240
  column
@@ -229,7 +243,7 @@ var RootRegistry = class {
229
243
  }
230
244
  case "dialRotate": {
231
245
  const dr = payload;
232
- tbRoot.eventBus.emit("touchBarDialRotate", {
246
+ tbRoot.eventBus.emit("touchStripDialRotate", {
233
247
  column,
234
248
  ticks: dr.ticks,
235
249
  pressed: dr.pressed
@@ -237,10 +251,10 @@ var RootRegistry = class {
237
251
  break;
238
252
  }
239
253
  case "dialDown":
240
- tbRoot.eventBus.emit("touchBarDialDown", { column });
254
+ tbRoot.eventBus.emit("touchStripDialDown", { column });
241
255
  break;
242
256
  case "dialUp":
243
- tbRoot.eventBus.emit("touchBarDialUp", { column });
257
+ tbRoot.eventBus.emit("touchStripDialUp", { column });
244
258
  break;
245
259
  }
246
260
  }
@@ -251,9 +265,9 @@ var RootRegistry = class {
251
265
  destroyAll() {
252
266
  for (const [_, root] of this.roots) root.unmount();
253
267
  this.roots.clear();
254
- for (const [_, tbRoot] of this.touchBarRoots) tbRoot.unmount();
255
- this.touchBarRoots.clear();
256
- this.touchBarActions.clear();
268
+ for (const [_, tbRoot] of this.touchStripRoots) tbRoot.unmount();
269
+ this.touchStripRoots.clear();
270
+ this.touchStripActions.clear();
257
271
  }
258
272
  };
259
273
  //#endregion
@@ -3,12 +3,14 @@ import { VContainer } from '../reconciler/vnode';
3
3
  import { RenderConfig } from '../render/pipeline';
4
4
  import { EventBus } from '../context/event-bus';
5
5
  import { ActionInfo, CanvasInfo, DeviceInfo, EncoderLayout, StreamDeckAccess, WrapperComponent } from '../types';
6
+ import { FlushCoordinator, FlushableRoot } from './flush-coordinator';
6
7
  import { JsonObject } from '@elgato/utils';
7
8
  import { Action, DialAction, KeyAction } from '@elgato/streamdeck';
8
- export declare class ReactRoot {
9
+ export declare class ReactRoot implements FlushableRoot {
9
10
  private component;
10
11
  private actionInfo;
11
12
  private deviceInfo;
13
+ private flushCoordinator?;
12
14
  private pluginWrapper?;
13
15
  private actionWrapper?;
14
16
  readonly eventBus: EventBus;
@@ -25,6 +27,20 @@ export declare class ReactRoot {
25
27
  private sdkAction;
26
28
  private sdkInstance;
27
29
  private disposed;
30
+ private _renderCount;
31
+ private _lastRenderReport;
32
+ private static readonly RENDER_WARN_THRESHOLD;
33
+ private _rendering;
34
+ private _pendingFlush;
35
+ private _recentRenders;
36
+ private _lastInteraction;
37
+ private static readonly ANIMATION_WINDOW_MS;
38
+ private static readonly ANIMATION_THRESHOLD;
39
+ private static readonly INTERACTION_COOLDOWN_MS;
40
+ private static readonly IDLE_THRESHOLD_MS;
41
+ private _lastFlushTime;
42
+ /** Current render priority (lower = higher priority). Used by flush coordinator for ordering. */
43
+ get priority(): number;
28
44
  /** Last data URI successfully sent to hardware. Used by devtools snapshots. */
29
45
  lastDataUri: string | null;
30
46
  /**
@@ -41,11 +57,25 @@ export declare class ReactRoot {
41
57
  private streamDeckValue;
42
58
  private settingsValue;
43
59
  private globalSettingsValue;
44
- constructor(component: ComponentType, actionInfo: ActionInfo, deviceInfo: DeviceInfo, canvas: CanvasInfo, initialSettings: JsonObject, initialGlobalSettings: JsonObject, sdkAction: Action | DialAction | KeyAction, sdkInstance: StreamDeckAccess["sdk"], renderConfig: RenderConfig, renderDebounceMs: number, onSettingsChange: (settings: JsonObject) => Promise<void>, onGlobalSettingsChange: (settings: JsonObject) => Promise<void>, pluginWrapper?: WrapperComponent | undefined, actionWrapper?: WrapperComponent | undefined, dialLayout?: EncoderLayout);
60
+ constructor(component: ComponentType, actionInfo: ActionInfo, deviceInfo: DeviceInfo, canvas: CanvasInfo, initialSettings: JsonObject, initialGlobalSettings: JsonObject, sdkAction: Action | DialAction | KeyAction, sdkInstance: StreamDeckAccess["sdk"], renderConfig: RenderConfig, renderDebounceMs: number, onSettingsChange: (settings: JsonObject) => Promise<void>, onGlobalSettingsChange: (settings: JsonObject) => Promise<void>, flushCoordinator?: FlushCoordinator | undefined, pluginWrapper?: WrapperComponent | undefined, actionWrapper?: WrapperComponent | undefined, dialLayout?: EncoderLayout);
45
61
  private render;
46
62
  private buildTree;
47
63
  private scheduleRerender;
64
+ /** Record a user interaction (keyDown, dialRotate, etc.) for adaptive debounce. */
65
+ markInteraction(): void;
66
+ /** Compute effective debounce based on recent render frequency and interaction state. */
67
+ private get effectiveDebounceMs();
48
68
  private flush;
69
+ /**
70
+ * Submit this root for flushing. Routes through the coordinator
71
+ * (priority-ordered) when available, or flushes directly.
72
+ */
73
+ private submitFlush;
74
+ /**
75
+ * Execute the flush. Called by FlushCoordinator in priority order,
76
+ * or directly when no coordinator is present.
77
+ */
78
+ executeFlush(): Promise<void>;
49
79
  private doFlush;
50
80
  updateSettings(settings: JsonObject): void;
51
81
  updateGlobalSettings(settings: JsonObject): void;
@@ -3,6 +3,7 @@ import { reconciler } from "../reconciler/renderer.js";
3
3
  import { renderToDataUri } from "../render/pipeline.js";
4
4
  import { EventBus } from "../context/event-bus.js";
5
5
  import { ActionContext, CanvasContext, DeviceContext, EventBusContext, GlobalSettingsContext, SettingsContext, StreamDeckContext } from "../context/providers.js";
6
+ import { partialHasChanges, shallowEqualSettings } from "./settings-equality.js";
6
7
  import { createElement } from "react";
7
8
  //#region src/roots/root.ts
8
9
  var DEFAULT_DIAL_LAYOUT = {
@@ -18,7 +19,7 @@ var DEFAULT_DIAL_LAYOUT = {
18
19
  ]
19
20
  }]
20
21
  };
21
- var ReactRoot = class {
22
+ var ReactRoot = class ReactRoot {
22
23
  eventBus = new EventBus();
23
24
  container;
24
25
  fiberRoot;
@@ -33,6 +34,28 @@ var ReactRoot = class {
33
34
  sdkAction;
34
35
  sdkInstance;
35
36
  disposed = false;
37
+ _renderCount = 0;
38
+ _lastRenderReport = 0;
39
+ static RENDER_WARN_THRESHOLD = 30;
40
+ _rendering = false;
41
+ _pendingFlush = false;
42
+ _recentRenders = [];
43
+ _lastInteraction = 0;
44
+ static ANIMATION_WINDOW_MS = 100;
45
+ static ANIMATION_THRESHOLD = 2;
46
+ static INTERACTION_COOLDOWN_MS = 500;
47
+ static IDLE_THRESHOLD_MS = 2e3;
48
+ _lastFlushTime = 0;
49
+ /** Current render priority (lower = higher priority). Used by flush coordinator for ordering. */
50
+ get priority() {
51
+ const now = Date.now();
52
+ const cutoff = now - ReactRoot.ANIMATION_WINDOW_MS;
53
+ while (this._recentRenders.length > 0 && this._recentRenders[0] < cutoff) this._recentRenders.shift();
54
+ if (this._recentRenders.length > ReactRoot.ANIMATION_THRESHOLD) return 0;
55
+ if (now - this._lastInteraction < ReactRoot.INTERACTION_COOLDOWN_MS) return 1;
56
+ if (this._lastFlushTime > 0 && now - this._lastFlushTime > ReactRoot.IDLE_THRESHOLD_MS) return 3;
57
+ return 2;
58
+ }
36
59
  /** Last data URI successfully sent to hardware. Used by devtools snapshots. */
37
60
  lastDataUri = null;
38
61
  /**
@@ -63,10 +86,11 @@ var ReactRoot = class {
63
86
  streamDeckValue;
64
87
  settingsValue;
65
88
  globalSettingsValue;
66
- constructor(component, actionInfo, deviceInfo, canvas, initialSettings, initialGlobalSettings, sdkAction, sdkInstance, renderConfig, renderDebounceMs, onSettingsChange, onGlobalSettingsChange, pluginWrapper, actionWrapper, dialLayout) {
89
+ constructor(component, actionInfo, deviceInfo, canvas, initialSettings, initialGlobalSettings, sdkAction, sdkInstance, renderConfig, renderDebounceMs, onSettingsChange, onGlobalSettingsChange, flushCoordinator, pluginWrapper, actionWrapper, dialLayout) {
67
90
  this.component = component;
68
91
  this.actionInfo = actionInfo;
69
92
  this.deviceInfo = deviceInfo;
93
+ this.flushCoordinator = flushCoordinator;
70
94
  this.pluginWrapper = pluginWrapper;
71
95
  this.actionWrapper = actionWrapper;
72
96
  this.canvas = canvas;
@@ -78,27 +102,33 @@ var ReactRoot = class {
78
102
  this.renderDebounceMs = renderDebounceMs;
79
103
  this.resolvedDialLayout = resolveDialLayout(dialLayout);
80
104
  this.setSettingsFn = (partial) => {
81
- this.settings = {
105
+ const hasChanges = partialHasChanges(this.settings, partial);
106
+ const nextSettings = hasChanges ? {
82
107
  ...this.settings,
83
108
  ...partial
84
- };
109
+ } : this.settings;
110
+ onSettingsChange(nextSettings);
111
+ if (!hasChanges) return;
112
+ this.settings = nextSettings;
85
113
  this.settingsValue = {
86
114
  settings: this.settings,
87
115
  setSettings: this.setSettingsFn
88
116
  };
89
- onSettingsChange(this.settings);
90
117
  this.scheduleRerender();
91
118
  };
92
119
  this.setGlobalSettingsFn = (partial) => {
93
- this.globalSettings = {
120
+ const hasChanges = partialHasChanges(this.globalSettings, partial);
121
+ const nextSettings = hasChanges ? {
94
122
  ...this.globalSettings,
95
123
  ...partial
96
- };
124
+ } : this.globalSettings;
125
+ onGlobalSettingsChange(nextSettings);
126
+ if (!hasChanges) return;
127
+ this.globalSettings = nextSettings;
97
128
  this.globalSettingsValue = {
98
129
  settings: this.globalSettings,
99
130
  setSettings: this.setGlobalSettingsFn
100
131
  };
101
- onGlobalSettingsChange(this.globalSettings);
102
132
  this.scheduleRerender();
103
133
  };
104
134
  this.streamDeckValue = {
@@ -144,27 +174,86 @@ var ReactRoot = class {
144
174
  if (this.disposed) return;
145
175
  this.render();
146
176
  }
177
+ /** Record a user interaction (keyDown, dialRotate, etc.) for adaptive debounce. */
178
+ markInteraction() {
179
+ this._lastInteraction = Date.now();
180
+ }
181
+ /** Compute effective debounce based on recent render frequency and interaction state. */
182
+ get effectiveDebounceMs() {
183
+ const now = Date.now();
184
+ const cutoff = now - ReactRoot.ANIMATION_WINDOW_MS;
185
+ while (this._recentRenders.length > 0 && this._recentRenders[0] < cutoff) this._recentRenders.shift();
186
+ if (this._recentRenders.length > ReactRoot.ANIMATION_THRESHOLD) return 0;
187
+ if (now - this._lastInteraction < ReactRoot.INTERACTION_COOLDOWN_MS) return Math.min(this.renderDebounceMs, 16);
188
+ return this.renderDebounceMs;
189
+ }
147
190
  async flush() {
148
191
  if (this.disposed) return;
149
- if (this.renderDebounceMs > 0 && this.container.renderTimer !== null) clearTimeout(this.container.renderTimer);
150
- if (this.renderDebounceMs > 0) this.container.renderTimer = setTimeout(async () => {
192
+ if (this._rendering) {
193
+ this._pendingFlush = true;
194
+ return;
195
+ }
196
+ this._recentRenders.push(Date.now());
197
+ const debounce = this.effectiveDebounceMs;
198
+ if (debounce > 0 && this.container.renderTimer !== null) clearTimeout(this.container.renderTimer);
199
+ if (debounce > 0) this.container.renderTimer = setTimeout(() => {
151
200
  this.container.renderTimer = null;
152
- await this.doFlush();
153
- }, this.renderDebounceMs);
154
- else await this.doFlush();
201
+ this.submitFlush();
202
+ }, debounce);
203
+ else this.submitFlush();
204
+ }
205
+ /**
206
+ * Submit this root for flushing. Routes through the coordinator
207
+ * (priority-ordered) when available, or flushes directly.
208
+ */
209
+ submitFlush() {
210
+ if (this.disposed) return;
211
+ if (this.flushCoordinator) this.flushCoordinator.requestFlush(this);
212
+ else this.doFlush();
213
+ }
214
+ /**
215
+ * Execute the flush. Called by FlushCoordinator in priority order,
216
+ * or directly when no coordinator is present.
217
+ */
218
+ async executeFlush() {
219
+ await this.doFlush();
155
220
  }
156
221
  async doFlush() {
157
222
  if (this.disposed) return;
223
+ this._rendering = true;
224
+ this._pendingFlush = false;
225
+ this._lastFlushTime = Date.now();
226
+ if (this.renderConfig.debug) {
227
+ this._renderCount++;
228
+ const now = Date.now();
229
+ if (now - this._lastRenderReport > 1e3) {
230
+ if (this._renderCount > ReactRoot.RENDER_WARN_THRESHOLD) console.warn(`[@fcannizzaro/streamdeck-react] Action ${this.actionInfo.id} rendered ${this._renderCount}x in 1s`);
231
+ this._renderCount = 0;
232
+ this._lastRenderReport = now;
233
+ }
234
+ }
158
235
  try {
159
236
  const dataUri = await renderToDataUri(this.container, this.canvas.width, this.canvas.height, this.renderConfig);
160
237
  if (dataUri === null || this.disposed) return;
161
238
  this.lastDataUri = dataUri;
162
- if (!this.suppressHardwarePush) await this.pushImage(dataUri);
239
+ if (!this.suppressHardwarePush) this.pushImage(dataUri).catch((err) => {
240
+ console.error("[@fcannizzaro/streamdeck-react] Hardware push error:", err);
241
+ });
163
242
  } catch (err) {
164
243
  console.error("[@fcannizzaro/streamdeck-react] Render error:", err);
244
+ } finally {
245
+ this._rendering = false;
246
+ if (this._pendingFlush && !this.disposed) {
247
+ this._pendingFlush = false;
248
+ await this.doFlush();
249
+ }
165
250
  }
166
251
  }
167
252
  updateSettings(settings) {
253
+ if (shallowEqualSettings(this.settings, settings)) {
254
+ this.eventBus.emit("settingsChanged", settings);
255
+ return;
256
+ }
168
257
  this.settings = { ...settings };
169
258
  this.settingsValue = {
170
259
  settings: this.settings,
@@ -174,6 +263,7 @@ var ReactRoot = class {
174
263
  this.scheduleRerender();
175
264
  }
176
265
  updateGlobalSettings(settings) {
266
+ if (shallowEqualSettings(this.globalSettings, settings)) return;
177
267
  this.globalSettings = { ...settings };
178
268
  this.globalSettingsValue = {
179
269
  settings: this.globalSettings,
@@ -0,0 +1,5 @@
1
+ import { JsonObject } from '@elgato/utils';
2
+ /** Returns true when applying `partial` would change at least one key in `current`. */
3
+ export declare function partialHasChanges(current: JsonObject, partial: JsonObject): boolean;
4
+ /** Shallow equality for full settings objects. */
5
+ export declare function shallowEqualSettings(a: JsonObject, b: JsonObject): boolean;
@@ -0,0 +1,24 @@
1
+ //#region src/roots/settings-equality.ts
2
+ /** Returns true when applying `partial` would change at least one key in `current`. */
3
+ function partialHasChanges(current, partial) {
4
+ const keys = Object.keys(partial);
5
+ for (let i = 0; i < keys.length; i++) {
6
+ const key = keys[i];
7
+ if (!Object.is(current[key], partial[key])) return true;
8
+ }
9
+ return false;
10
+ }
11
+ /** Shallow equality for full settings objects. */
12
+ function shallowEqualSettings(a, b) {
13
+ const aKeys = Object.keys(a);
14
+ const bKeys = Object.keys(b);
15
+ if (aKeys.length !== bKeys.length) return false;
16
+ for (let i = 0; i < aKeys.length; i++) {
17
+ const key = aKeys[i];
18
+ if (!Object.prototype.hasOwnProperty.call(b, key)) return false;
19
+ if (!Object.is(a[key], b[key])) return false;
20
+ }
21
+ return true;
22
+ }
23
+ //#endregion
24
+ export { partialHasChanges, shallowEqualSettings };
@@ -0,0 +1,93 @@
1
+ import { ComponentType } from 'react';
2
+ import { VContainer } from '../reconciler/vnode';
3
+ import { RenderConfig } from '../render/pipeline';
4
+ import { EventBus } from '../context/event-bus';
5
+ import { DeviceInfo, WrapperComponent } from '../types';
6
+ import { FlushCoordinator, FlushableRoot } from './flush-coordinator';
7
+ import { DialAction } from '@elgato/streamdeck';
8
+ import { JsonObject } from '@elgato/utils';
9
+ export declare class TouchStripRoot implements FlushableRoot {
10
+ private component;
11
+ private flushCoordinator?;
12
+ readonly eventBus: EventBus;
13
+ private container;
14
+ private fiberRoot;
15
+ private columns;
16
+ private globalSettings;
17
+ private setGlobalSettingsFn;
18
+ private renderDebounceMs;
19
+ private renderConfig;
20
+ private deviceInfo;
21
+ private disposed;
22
+ private fps;
23
+ private pluginWrapper?;
24
+ private _renderCount;
25
+ private _lastRenderReport;
26
+ private static readonly RENDER_WARN_THRESHOLD;
27
+ private _rendering;
28
+ private _pendingFlush;
29
+ private _recentRenders;
30
+ private _lastInteraction;
31
+ private static readonly ANIMATION_WINDOW_MS;
32
+ private static readonly ANIMATION_THRESHOLD;
33
+ private static readonly INTERACTION_COOLDOWN_MS;
34
+ private static readonly IDLE_THRESHOLD_MS;
35
+ private _lastFlushTime;
36
+ /** Current render priority (lower = higher priority). Used by flush coordinator. */
37
+ get priority(): number;
38
+ /** Last rendered per-column data URIs. Used by devtools snapshots. */
39
+ lastSegmentUris: Map<number, string>;
40
+ /**
41
+ * When true, doFlush skips pushing rendered segments to hardware.
42
+ * Set by the devtools bridge while a highlight overlay is active so
43
+ * that rapid re-renders don't overwrite the highlight on the device.
44
+ * The highlight path calls pushSegmentImages() directly (bypassing
45
+ * this flag).
46
+ *
47
+ * Mirrors ReactRoot.suppressHardwarePush — same pattern, different
48
+ * granularity (per-segment instead of single image).
49
+ */
50
+ suppressHardwarePush: boolean;
51
+ /**
52
+ * Push per-column data URIs to the physical Stream Deck touch strip.
53
+ * Used by the devtools highlight overlay to bypass suppressHardwarePush.
54
+ *
55
+ * @param uris Map of column → data URI to push to hardware.
56
+ * Columns not present in the map are left unchanged.
57
+ */
58
+ pushSegmentImages(uris: Map<number, string>): Promise<void>;
59
+ /** Exposes the VContainer for devtools inspection. */
60
+ get vcontainer(): VContainer;
61
+ /** Sorted column numbers for devtools observer. */
62
+ get columnNumbers(): number[];
63
+ /** Column → actionId map for devtools observer. */
64
+ get columnActionMap(): Map<number, string>;
65
+ private globalSettingsValue;
66
+ private touchStripValue;
67
+ constructor(component: ComponentType, deviceInfo: DeviceInfo, initialGlobalSettings: JsonObject, renderConfig: RenderConfig, renderDebounceMs: number, onGlobalSettingsChange: (settings: JsonObject) => Promise<void>, pluginWrapper?: WrapperComponent, touchStripFPS?: number, flushCoordinator?: FlushCoordinator | undefined);
68
+ addColumn(column: number, actionId: string, sdkAction: DialAction): void;
69
+ removeColumn(column: number): void;
70
+ get isEmpty(): boolean;
71
+ findColumnByActionId(actionId: string): number | undefined;
72
+ private updateTouchStripInfo;
73
+ private render;
74
+ private buildTree;
75
+ private scheduleRerender;
76
+ /** Record a user interaction for adaptive debounce. */
77
+ markInteraction(): void;
78
+ private get effectiveDebounceMs();
79
+ private flush;
80
+ /**
81
+ * Submit this root for flushing. Routes through the coordinator
82
+ * (priority-ordered) when available, or flushes directly.
83
+ */
84
+ private submitFlush;
85
+ /**
86
+ * Execute the flush. Called by FlushCoordinator in priority order,
87
+ * or directly when no coordinator is present.
88
+ */
89
+ executeFlush(): Promise<void>;
90
+ private doFlush;
91
+ updateGlobalSettings(settings: JsonObject): void;
92
+ unmount(): void;
93
+ }