@m4l-jweb/wrapper 0.6.0 → 0.6.5

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 (3) hide show
  1. package/package.json +1 -1
  2. package/src/core.ts +119 -16
  3. package/src/max.d.ts +9 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m4l-jweb/wrapper",
3
- "version": "0.6.0",
3
+ "version": "0.6.5",
4
4
  "description": "m4l-jweb: the Max for Live glue layer connecting a device to LiveAPI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/core.ts CHANGED
@@ -76,15 +76,48 @@ function reload(): void {
76
76
  */
77
77
  function anything(): void {}
78
78
 
79
+ /* ------------------------------------------------------------------ *
80
+ * Who to answer
81
+ *
82
+ * A reply normally goes out outlet 0, to the device's OWN [jweb]. But a floating
83
+ * window's [jweb] lives inside a subpatcher, so there is no cord to it - the
84
+ * wrapper reaches it BY NAME, through its [r window-read-<id>] (see loadWindows).
85
+ *
86
+ * So a handler must not hard-code outlet(0): when it is answering a WINDOW it has
87
+ * to route to that window's receiver instead. `window()` below sets replyWindow
88
+ * for the duration of a window message's dispatch, and reply() honours it - so the
89
+ * same get_state()/ui_ready() serve the device view AND any window, unchanged.
90
+ * ------------------------------------------------------------------ */
91
+
92
+ /** The window a reply should go to, or null for the device's own [jweb] (outlet 0). */
93
+ var replyWindow: string | null = null;
94
+
95
+ /**
96
+ * outlet(0, selector, value) that follows replyWindow - to a window's
97
+ * [r window-read-<id>] when one is set, to the device's own [jweb] otherwise.
98
+ *
99
+ * It takes a FIXED (selector, value) rather than a rest arg, and that is
100
+ * deliberate: `outlet` and `messnamed` are Max HOST functions, and calling
101
+ * `.apply` on them is not reliable across Max builds - when it fails it fails
102
+ * SILENTLY, and an exception here takes the whole ui_ready handshake (mode, build,
103
+ * the state resend) down with it, with no symptom but a device that never fills in
104
+ * its header. Every reply the wrapper sends is one selector and one value, so this
105
+ * is all it ever needs.
106
+ */
107
+ function reply(selector: string, value: unknown): void {
108
+ if (replyWindow !== null) messnamed("window-read-" + replyWindow, selector, value);
109
+ else outlet(0, selector, value);
110
+ }
111
+
79
112
  /**
80
113
  * The UI announces it finished loading. The page loads asynchronously, so never
81
114
  * assume it was listening when state last changed - resend all of it.
82
115
  */
83
116
  function ui_ready(): void {
84
- outlet(0, "mode", MODE);
117
+ reply("mode", MODE);
85
118
  // The UI shows this next to its own baked-in version: a mismatch means a
86
119
  // mixed install (stale .amxd instance vs newer extracted UI, or vice versa).
87
- outlet(0, "build", buildStamp());
120
+ reply("build", buildStamp());
88
121
  sendCurrentTempo(); // liveapi.ts
89
122
  // The device resends its own state here. The page loads asynchronously, so
90
123
  // anything sent before it was listening is simply gone.
@@ -119,7 +152,7 @@ function get_state(id: string): void {
119
152
  // hands back is what came out of the set. An empty "{}" here after a reopen means
120
153
  // the [pattr] did not save - which is a failure with no other symptom.
121
154
  post("m4l-jweb: get_state " + id + " -> " + json + "\n");
122
- outlet(0, "state_" + id, json);
155
+ reply("state_" + id, json);
123
156
  } catch (e) {
124
157
  post("m4l-jweb: get_state error for " + id + " - " + (e as Error).message + "\n");
125
158
  }
@@ -146,11 +179,66 @@ function sync_state(id: string): void {
146
179
  // bound to nothing), both look exactly like a successful write from here. What the
147
180
  // dict says it holds is the only evidence.
148
181
  post("m4l-jweb: sync_state " + id + " <- " + json + " (dict now " + d.stringify() + ")\n");
182
+ // BROADCAST the new value to every OTHER view - the device UI and any other
183
+ // window - so a live edit in one page appears in the rest at once. Without this
184
+ // a second page only sees a slot when it next asks for it (on load), which is
185
+ // why an edit in the floating window never reached the device view. The WRITER
186
+ // is skipped: it already applied the value optimistically, and echoing it back
187
+ // could revert the control mid-typing (stateStore has no echo guard).
188
+ broadcastState(id, d.stringify());
149
189
  } catch (e) {
150
190
  post("m4l-jweb: sync_state error for " + id + " - " + (e as Error).message + "\n");
151
191
  }
152
192
  }
153
193
 
194
+ /**
195
+ * Push `state_<id> <json>` to every view except the one that wrote it (replyWindow:
196
+ * a window id, or null for the device view). A window is reached by name, the
197
+ * device view by outlet 0 - the same two paths reply() chooses between.
198
+ */
199
+ function broadcastState(id: string, json: string): void {
200
+ if (replyWindow !== null) outlet(0, "state_" + id, json); // device view, unless it wrote
201
+ var ids = listWindowIds();
202
+ for (var i = 0; i < ids.length; i++) {
203
+ if (ids[i] !== replyWindow) messnamed("window-read-" + ids[i], "state_" + id, json);
204
+ }
205
+ }
206
+
207
+ /* ------------------------------------------------------------------ *
208
+ * Floating-window messages
209
+ *
210
+ * A window's page uses the ORDINARY bridge (`outlet(sel, ...)`), so it emits bare
211
+ * selectors just like the device view. They cannot arrive here bare, though: this
212
+ * [js] already has a get_state/ui_ready/etc, and a window's must not be mistaken
213
+ * for the device view's. So the subpatcher TAGS them - `[prepend window <id>]` on
214
+ * the window's [jweb] outlet (see applyWindows in surface.mjs) - and they land
215
+ * here as `window <id> <selector> <args...>`, dispatched on the first word.
216
+ *
217
+ * We then dispatch the INNER selector through the very same handlers, with
218
+ * replyWindow set so any reply routes back to the window and not the device view.
219
+ * That is what gives a window access to the device's state: `get_state`/
220
+ * `sync_state` reach the shared [dict], and `state_<id>` comes back to the window.
221
+ * Anything the library does not know goes to the device's own onWindowMessage().
222
+ * ------------------------------------------------------------------ */
223
+
224
+ function window(id: string): void {
225
+ var selector = String(arguments[1]);
226
+ var args: unknown[] = [];
227
+ for (var i = 2; i < arguments.length; i++) args.push(arguments[i]);
228
+
229
+ var prev = replyWindow;
230
+ replyWindow = id;
231
+ try {
232
+ if (selector === "ui_ready") ui_ready();
233
+ else if (selector === "get_state") get_state(String(args[0]));
234
+ else if (selector === "sync_state") (sync_state as any).apply(null, args);
235
+ else if (typeof onWindowMessage === "function") (onWindowMessage as any).apply(null, [id, selector].concat(args as any[]));
236
+ else post("m4l-jweb: window " + id + " sent unhandled '" + selector + "'\n");
237
+ } finally {
238
+ replyWindow = prev;
239
+ }
240
+ }
241
+
154
242
  /* ------------------------------------------------------------------ *
155
243
  * The self-extracting UI payload
156
244
  *
@@ -179,29 +267,44 @@ function loadWebview(): void {
179
267
  *
180
268
  * A window's [jweb] lives inside a subpatcher, so there is no cord from this
181
269
  * [js] to it. `messnamed` reaches the [r window-read-<id>] the build put next to
182
- * it - naming the receiver instead of the cord.
183
- *
184
- * The window payloads are the extra payloads whose name is `<device>_<id>.html`
185
- * (packageDevices names them; the device's own UI is UI_PAYLOAD and is not in
186
- * this list). Strip that prefix EXPLICITLY: a regex like /^.*_/ is greedy, so a
187
- * window id with an underscore in it - `edit_grid` - would arrive as `grid`, and
188
- * the page would then load into a receiver nobody is listening on.
270
+ * it - naming the receiver instead of the cord. The ids come from listWindowIds().
189
271
  */
190
272
  function loadWindows(): void {
191
- if (typeof EXTRA_PAYLOAD_NAMES === "undefined") return;
192
273
  var folder = deviceFolder();
193
274
  if (!folder) return;
194
275
 
195
- var prefix = typeof UI_PAYLOAD_NAME !== "undefined" ? UI_PAYLOAD_NAME.replace(/\.html$/, "") + "_" : "";
276
+ var ids = listWindowIds();
277
+ var prefix = windowPrefix();
278
+ for (var i = 0; i < ids.length; i++) {
279
+ var winId = ids[i];
280
+ var winUrl = encodeURI("file:///" + folder + "/" + prefix + winId + ".html") + "?v=" + encodeURIComponent(buildStamp());
281
+ messnamed("window-read-" + winId, "url", winUrl);
282
+ post("m4l-jweb: window " + winId + " -> " + winUrl + "\n");
283
+ }
284
+ }
285
+
286
+ /** The `<device>_` prefix a window payload's name carries, so the device's own UI is not one. */
287
+ function windowPrefix(): string {
288
+ return typeof UI_PAYLOAD_NAME !== "undefined" ? UI_PAYLOAD_NAME.replace(/\.html$/, "") + "_" : "";
289
+ }
290
+
291
+ /**
292
+ * The id of every floating window this device has, from its extracted payloads.
293
+ *
294
+ * Strip the `<device>_` prefix EXPLICITLY: a regex like /^.*_/ is greedy, so a
295
+ * window id with an underscore in it - `edit_grid` - would come back as `grid`.
296
+ */
297
+ function listWindowIds(): string[] {
298
+ var ids: string[] = [];
299
+ if (typeof EXTRA_PAYLOAD_NAMES === "undefined") return ids;
300
+ var prefix = windowPrefix();
196
301
  for (var i = 0; i < EXTRA_PAYLOAD_NAMES.length; i++) {
197
302
  var name = EXTRA_PAYLOAD_NAMES[i];
198
303
  if (name.slice(-5) !== ".html") continue; // a sample, a preset, ...: not a window
199
304
  if (prefix && name.slice(0, prefix.length) !== prefix) continue;
200
- var winId = name.slice(prefix.length, name.length - 5);
201
- var winUrl = encodeURI("file:///" + folder + "/" + name) + "?v=" + encodeURIComponent(buildStamp());
202
- messnamed("window-read-" + winId, "url", winUrl);
203
- post("m4l-jweb: window " + winId + " -> " + winUrl + "\n");
305
+ ids.push(name.slice(prefix.length, name.length - 5));
204
306
  }
307
+ return ids;
205
308
  }
206
309
 
207
310
  function resolveUiUrl(): string | null {
package/src/max.d.ts CHANGED
@@ -187,3 +187,12 @@ declare function onMaxurlReply(responseDictName: string): boolean;
187
187
 
188
188
  /** Live's tempo changed (and once on attach). */
189
189
  declare function onTempoChange(bpm: number): void;
190
+
191
+ /**
192
+ * A floating window's page sent a selector the library does not handle itself
193
+ * (i.e. not ui_ready/get_state/sync_state). `windowId` is which window; reply with
194
+ * `outlet(0, ...)` and it routes back to that window automatically (see reply() in
195
+ * core.ts). This is how a window editor - a drum map, a browser - talks to the
196
+ * device beyond shared state.
197
+ */
198
+ declare function onWindowMessage(windowId: string, selector: string, ...args: unknown[]): void;