@m4l-jweb/wrapper 1.3.0 → 1.6.0
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.
- package/package.json +1 -1
- package/sources.mjs +1 -0
- package/src/controls.ts +715 -0
- package/src/core.ts +52 -4
- package/src/max.d.ts +38 -0
package/package.json
CHANGED
package/sources.mjs
CHANGED
|
@@ -25,4 +25,5 @@ export const sources = [
|
|
|
25
25
|
src("core.ts"), // build stamp, lifecycle, the anything() guard, payload extraction
|
|
26
26
|
src("liveapi.ts"), // transport poll, tempo observer, clip I/O
|
|
27
27
|
src("watch.ts"), // defineWatch() observers - after liveapi, it calls observeProperty()
|
|
28
|
+
src("controls.ts"), // defineControls() - the Push takeover: discovery, the grab, the frame diff
|
|
28
29
|
];
|
package/src/controls.ts
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* controls.ts - the CONTROL SURFACE half of the wrapper: discovery, the grab, the
|
|
3
|
+
* focus policy, and the frame buffer that decides what the hardware is told.
|
|
4
|
+
*
|
|
5
|
+
* Concatenated after liveapi.ts. What to claim is injected by the build as
|
|
6
|
+
* CONTROLS_SPEC, from the device's `defineControls()` - this file is generic and a
|
|
7
|
+
* device that declared no controls ships an undefined spec and does nothing.
|
|
8
|
+
*
|
|
9
|
+
* ------------------------------------------------------------------------------
|
|
10
|
+
* EVERY RULE BELOW IS A MEASUREMENT. doc/MAX-FACTS.md, "Grabbing a Push control",
|
|
11
|
+
* on a Push 3 in Live 12. Four of them contradict the obvious guess and all four
|
|
12
|
+
* fail SILENTLY, which is why they are restated at the code that depends on them:
|
|
13
|
+
*
|
|
14
|
+
* - A control is grabbed BY NAME. A bare id is rejected outright; the two-atom
|
|
15
|
+
* `id <n>` works and buys nothing. The id is wanted only to point the chain's
|
|
16
|
+
* `[live.observer]` at the control.
|
|
17
|
+
* - `LiveAPI.call` DOES NOT THROW when Live refuses. It posts a console line and
|
|
18
|
+
* returns normally, so `try/catch` catches nothing and there is no success to
|
|
19
|
+
* branch on. Nothing here may claim it verified a grab.
|
|
20
|
+
* - Y COUNTS FROM THE TOP in `send_value` and in the `value` payload. The flip to
|
|
21
|
+
* the API's bottom-up orientation lives in the PAGE (pads.ts), in one place;
|
|
22
|
+
* this file is entirely in hardware coordinates and never flips anything.
|
|
23
|
+
* - THE FIRST FRAME AFTER A GRAB IS LOST. Live's own surface script repaints the
|
|
24
|
+
* matrix just after handing it over, so the first paint is deferred and until it
|
|
25
|
+
* runs the hardware's state is treated as unknown.
|
|
26
|
+
*
|
|
27
|
+
* ...and one that makes the rest shippable: giving a control back is safe by three
|
|
28
|
+
* routes - an explicit release, deleting the device without one, and reinstalling
|
|
29
|
+
* the .amxd while an instance is loaded. A device cannot strand the hardware, so it
|
|
30
|
+
* only has to release when it stops WANTING the grid.
|
|
31
|
+
* ------------------------------------------------------------------------------
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** The control-surface classes this library knows how to talk to. */
|
|
35
|
+
var CONTROLS_SURFACE_TYPES = ["Push", "Push2", "Push3", "Move"];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* How long to wait before the first paint after a grab, in ms.
|
|
39
|
+
*
|
|
40
|
+
* Measured: a paint issued in the grab's own message turn does not appear, and the
|
|
41
|
+
* same paint 400 ms later does. It is Live's surface script redrawing the matrix as
|
|
42
|
+
* it hands it over; there is nothing to wait ON, so this is a delay and not a
|
|
43
|
+
* handshake.
|
|
44
|
+
*/
|
|
45
|
+
var CONTROLS_FIRST_FRAME_MS = 400;
|
|
46
|
+
|
|
47
|
+
/** The Push (or Move) we resolved, and what Live calls its class. */
|
|
48
|
+
var controlsSurface: LiveAPI | null = null;
|
|
49
|
+
var controlsSurfaceType = "";
|
|
50
|
+
/** `live_app`, observing `control_surfaces` - a Push can be plugged in mid-set. */
|
|
51
|
+
var controlsApp: LiveAPI | null = null;
|
|
52
|
+
|
|
53
|
+
/** Per declared key: the resolved control, its LOM id, and the name that resolved. */
|
|
54
|
+
var controlsObjects: { [key: string]: LiveAPI | null } = {};
|
|
55
|
+
var controlsIds: { [key: string]: number } = {};
|
|
56
|
+
var controlsNames: { [key: string]: string } = {};
|
|
57
|
+
|
|
58
|
+
/** Per key: the last frame the PAGE asked for, and the last frame the HARDWARE was told. */
|
|
59
|
+
var controlsWanted: { [key: string]: number[] } = {};
|
|
60
|
+
var controlsShown: { [key: string]: number[] | null } = {};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Has setupControls() finished?
|
|
64
|
+
*
|
|
65
|
+
* Two things fire before it does and neither may act on a half-built picture: an
|
|
66
|
+
* observer calls back once while it is being CREATED, and Live restores this device's
|
|
67
|
+
* saved parameters during patcher load - which is a context where LiveAPI objects are
|
|
68
|
+
* born dead (hard rule 4), so a grab decided there would be decided against nothing.
|
|
69
|
+
*/
|
|
70
|
+
var controlsStarted = false;
|
|
71
|
+
|
|
72
|
+
/** Has the `focus` PARAMETER told us its value? If so it outranks the declared default. */
|
|
73
|
+
var controlsFocusSeen = false;
|
|
74
|
+
|
|
75
|
+
/** Are we holding the declared controls right now? */
|
|
76
|
+
var controlsHeld = false;
|
|
77
|
+
/** The `takeover` parameter, and the `focus` menu index. */
|
|
78
|
+
var controlsEnabled = false;
|
|
79
|
+
var controlsFocus = 0;
|
|
80
|
+
/** FOCUS_OPTIONS in @m4l-jweb/surface: the menu's value is its index. */
|
|
81
|
+
var CONTROLS_FOCUS_DEVICE = 0;
|
|
82
|
+
var CONTROLS_FOCUS_TRACK = 1;
|
|
83
|
+
var CONTROLS_FOCUS_ALWAYS = 2;
|
|
84
|
+
|
|
85
|
+
/** This device and its track, for the focus comparison. */
|
|
86
|
+
var controlsThisDeviceId = 0;
|
|
87
|
+
var controlsThisTrackId = 0;
|
|
88
|
+
/** What Live currently has selected. */
|
|
89
|
+
var controlsSelectedTrackId = 0;
|
|
90
|
+
var controlsSelectedDeviceId = 0;
|
|
91
|
+
/** The focus observers, held so they stay alive. Dropping the reference kills the observer. */
|
|
92
|
+
var controlsTrackObs: LiveAPI | null = null;
|
|
93
|
+
var controlsDeviceObs: LiveAPI | null = null;
|
|
94
|
+
/** The deferred first frame, and the deferred rebuild of the device observer. */
|
|
95
|
+
var controlsFirstFrameTask: Task | null = null;
|
|
96
|
+
var controlsDeviceObsTask: Task | null = null;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The retry that exists because resolving on load is a RACE.
|
|
100
|
+
*
|
|
101
|
+
* A device loads when Live loads the set, and Live's own Push script initialises on its
|
|
102
|
+
* own schedule. Ask too early and `control_surfaces` is empty, or the surface is there and
|
|
103
|
+
* `get_control` answers 0 for every name - so every role comes back unresolved and the
|
|
104
|
+
* device never grabs anything. Re-adding the device by hand fixes it, because the second
|
|
105
|
+
* load happens after Push is ready. That is exactly the shape of the bug reported against
|
|
106
|
+
* this: intermittent, unrelated to any other device, and cured by a re-drag.
|
|
107
|
+
*
|
|
108
|
+
* The `control_surfaces` observer does not cover it. It fires on CHANGE, and a Push that
|
|
109
|
+
* was plugged in all along never changes - it just was not ready yet.
|
|
110
|
+
*
|
|
111
|
+
* So: if nothing resolved, ask again, a few times, and stop as soon as something does.
|
|
112
|
+
*/
|
|
113
|
+
var controlsRetryTask: Task | null = null;
|
|
114
|
+
var controlsRetriesLeft = 0;
|
|
115
|
+
var CONTROLS_RETRY_MS = 1000;
|
|
116
|
+
var CONTROLS_RETRIES = 12;
|
|
117
|
+
|
|
118
|
+
/** Is this build a device that declared controls at all? */
|
|
119
|
+
function controlsDeclared(): boolean {
|
|
120
|
+
return typeof CONTROLS_SPEC !== "undefined" && !!CONTROLS_SPEC && !!CONTROLS_SPEC.controls.length;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Attach everything. Called from bang(), never loadbang - a LiveAPI object created
|
|
125
|
+
* in a patcher-loading context is DEAD, and recreating unconditionally (no
|
|
126
|
+
* `if (obs) return` guard) is what stops that bug being permanent.
|
|
127
|
+
*/
|
|
128
|
+
function setupControls(): void {
|
|
129
|
+
if (!controlsDeclared()) return;
|
|
130
|
+
|
|
131
|
+
// Nothing an ATTACHING observer fires may act. Every LiveAPI observer here calls
|
|
132
|
+
// back once, immediately, while it is being created - so `control_surfaces` was
|
|
133
|
+
// resolving the roles before this function had worked out which track the device is
|
|
134
|
+
// on, and announcing "not_focused - this track 0 vs selected 0" on the way past. The
|
|
135
|
+
// callbacks still RECORD what they are handed; they just do not decide anything
|
|
136
|
+
// until the picture is whole.
|
|
137
|
+
controlsStarted = false;
|
|
138
|
+
|
|
139
|
+
controlsObjects = {};
|
|
140
|
+
controlsIds = {};
|
|
141
|
+
controlsNames = {};
|
|
142
|
+
controlsShown = {};
|
|
143
|
+
controlsHeld = false;
|
|
144
|
+
controlsSurface = null;
|
|
145
|
+
controlsSurfaceType = "";
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Seed `focus` from the DECLARATION - but only if the PARAMETER has not already
|
|
149
|
+
* spoken.
|
|
150
|
+
*
|
|
151
|
+
* Live restores a saved parameter during patcher load, so `controls_focus` arrives
|
|
152
|
+
* before this runs, carrying what the user actually chose. Overwriting it with the
|
|
153
|
+
* declared default is how a set saved with `Always` comes back as `Track` - the
|
|
154
|
+
* device works, differently, and nothing says why. The seed is for the case the
|
|
155
|
+
* parameter never announces itself at all, which is the one it exists for.
|
|
156
|
+
*/
|
|
157
|
+
if (!controlsFocusSeen) controlsFocus = CONTROLS_SPEC!.focus;
|
|
158
|
+
|
|
159
|
+
// The device's own identity FIRST, before anything can observe and ask about it.
|
|
160
|
+
controlsIdentify();
|
|
161
|
+
controlsWatchSurfaces();
|
|
162
|
+
controlsWatchFocus();
|
|
163
|
+
controlsResolve();
|
|
164
|
+
|
|
165
|
+
// One line saying what was actually found, every load. Everything downstream of
|
|
166
|
+
// here is silent when it fails - Live refuses a grab by posting and returning
|
|
167
|
+
// normally - so this is the only place a person can check that the surface was
|
|
168
|
+
// seen at all, and that this instance knows which track it is on.
|
|
169
|
+
post(
|
|
170
|
+
"m4l-jweb: controls on " +
|
|
171
|
+
(controlsSurfaceType || "NO SURFACE") +
|
|
172
|
+
", track " +
|
|
173
|
+
controlsThisTrackId +
|
|
174
|
+
", device " +
|
|
175
|
+
controlsThisDeviceId +
|
|
176
|
+
", focus " +
|
|
177
|
+
controlsFocus +
|
|
178
|
+
"\n",
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// Announce unconditionally on setup: `controlsApply` only speaks when the reason
|
|
182
|
+
// CHANGES, and after a reload the reason is usually the same one as before.
|
|
183
|
+
controlsStarted = true;
|
|
184
|
+
controlsReason = "";
|
|
185
|
+
controlsApply();
|
|
186
|
+
|
|
187
|
+
// ...and if the answer was "nothing is there", it may simply be too early. See
|
|
188
|
+
// controlsRetryTask.
|
|
189
|
+
controlsScheduleRetry(CONTROLS_RETRIES);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Re-send what a freshly loaded page could not have heard.
|
|
194
|
+
*
|
|
195
|
+
* Called from ui_ready(). The page loads asynchronously and long after bang()
|
|
196
|
+
* resolved the roles, so every one of these messages was sent to nobody.
|
|
197
|
+
*/
|
|
198
|
+
function resendControls(): void {
|
|
199
|
+
if (!controlsDeclared()) return;
|
|
200
|
+
var specs = CONTROLS_SPEC!.controls;
|
|
201
|
+
for (var i = 0; i < specs.length; i++) {
|
|
202
|
+
outlet(0, "controls_role", specs[i].key, controlsIds[specs[i].key] ? 1 : 0);
|
|
203
|
+
}
|
|
204
|
+
outlet(0, "controls_held", controlsHeld ? 1 : 0, controlsReason || controlsHoldReason());
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Watch for a Push being plugged in or unplugged mid-set.
|
|
209
|
+
*
|
|
210
|
+
* `control_surfaces` is a flat list of `id N` pairs WITH EMPTY SLOTS (`id 0`) for
|
|
211
|
+
* every unconfigured control-surface slot in Live's preferences, so most of it is
|
|
212
|
+
* nothing. The callback only re-resolves; it does no LOM work of its own, because a
|
|
213
|
+
* notification is not a safe place to change the set.
|
|
214
|
+
*/
|
|
215
|
+
function controlsWatchSurfaces(): void {
|
|
216
|
+
controlsApp = null;
|
|
217
|
+
try {
|
|
218
|
+
controlsApp = new LiveAPI(function (a: unknown[]) {
|
|
219
|
+
// The attach callback is not news - setupControls resolves right after this.
|
|
220
|
+
if (!controlsStarted) return;
|
|
221
|
+
if (a && a[0] == "control_surfaces") {
|
|
222
|
+
post("m4l-jweb: control surfaces changed - re-resolving\n");
|
|
223
|
+
controlsResolve();
|
|
224
|
+
controlsApply();
|
|
225
|
+
// A Push just arrived, or just left. Either way the retry's question is answered.
|
|
226
|
+
controlsScheduleRetry(CONTROLS_RETRIES);
|
|
227
|
+
}
|
|
228
|
+
}, "live_app");
|
|
229
|
+
controlsApp.property = "control_surfaces";
|
|
230
|
+
} catch (e) {
|
|
231
|
+
post("m4l-jweb: cannot observe control_surfaces - " + (e as Error).message + "\n");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The first Push or Move in Live's control-surface slots. */
|
|
236
|
+
function controlsFindSurface(): void {
|
|
237
|
+
controlsSurface = null;
|
|
238
|
+
controlsSurfaceType = "";
|
|
239
|
+
var app = new LiveAPI("live_app");
|
|
240
|
+
if (!app.id) return;
|
|
241
|
+
var flat = controlsAtoms(app.get("control_surfaces"));
|
|
242
|
+
for (var i = 0; i < flat.length; i++) {
|
|
243
|
+
if (String(flat[i]) === "id") continue; // the list alternates "id" and the number
|
|
244
|
+
var id = Number(flat[i]);
|
|
245
|
+
if (!id) continue; // an empty preferences slot
|
|
246
|
+
var cs = new LiveAPI("id " + id);
|
|
247
|
+
if (!cs.id) continue;
|
|
248
|
+
var type = String(cs.type);
|
|
249
|
+
for (var t = 0; t < CONTROLS_SURFACE_TYPES.length; t++) {
|
|
250
|
+
if (type === CONTROLS_SURFACE_TYPES[t]) {
|
|
251
|
+
controlsSurface = cs;
|
|
252
|
+
controlsSurfaceType = type;
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Resolve every declared role against the hardware that is actually plugged in.
|
|
261
|
+
*
|
|
262
|
+
* A Push 3 answers `get_control_names` with 176 names and they are NOT the Push 2
|
|
263
|
+
* set, so a role is a CANDIDATE LIST and the first candidate the hardware admits to
|
|
264
|
+
* having is the one used. Checking the list first is what turns "Live rejects the
|
|
265
|
+
* call and says nothing" into a `controls_role <key> 0` the page can show: a name
|
|
266
|
+
* that is not there is never called.
|
|
267
|
+
*/
|
|
268
|
+
function controlsResolve(): void {
|
|
269
|
+
var specs = CONTROLS_SPEC!.controls;
|
|
270
|
+
controlsFindSurface();
|
|
271
|
+
|
|
272
|
+
if (!controlsSurface) {
|
|
273
|
+
for (var n = 0; n < specs.length; n++) {
|
|
274
|
+
controlsObjects[specs[n].key] = null;
|
|
275
|
+
controlsIds[specs[n].key] = 0;
|
|
276
|
+
outlet(0, "controls_role", specs[n].key, 0);
|
|
277
|
+
outlet(1, "tk_" + specs[n].key, "id", 0); // id 0 = no object; the observer goes quiet
|
|
278
|
+
}
|
|
279
|
+
post("m4l-jweb: no Push or Move connected - the declared controls resolve to nothing\n");
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
var available = controlsControlNames();
|
|
284
|
+
for (var i = 0; i < specs.length; i++) {
|
|
285
|
+
var spec = specs[i];
|
|
286
|
+
var name = "";
|
|
287
|
+
for (var c = 0; c < spec.names.length; c++) {
|
|
288
|
+
if (controlsHasName(available, spec.names[c])) {
|
|
289
|
+
name = spec.names[c];
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
var id = 0;
|
|
295
|
+
if (name) id = controlsIdOf(controlsSurface.call("get_control", name));
|
|
296
|
+
|
|
297
|
+
controlsNames[spec.key] = name;
|
|
298
|
+
controlsIds[spec.key] = id;
|
|
299
|
+
controlsObjects[spec.key] = id ? new LiveAPI("id " + id) : null;
|
|
300
|
+
// The observer lives in the PATCHER (the `takeover` chain), so a press reaches
|
|
301
|
+
// the page without passing through [js]. All it needs from here is the id, and
|
|
302
|
+
// `[route tk_<key>]` strips the selector before it reaches the right inlet.
|
|
303
|
+
outlet(1, "tk_" + spec.key, "id", id);
|
|
304
|
+
outlet(0, "controls_role", spec.key, id ? 1 : 0);
|
|
305
|
+
|
|
306
|
+
if (!id) {
|
|
307
|
+
post(
|
|
308
|
+
'm4l-jweb: role "' +
|
|
309
|
+
spec.role +
|
|
310
|
+
'" (' +
|
|
311
|
+
spec.key +
|
|
312
|
+
") is not on this " +
|
|
313
|
+
(controlsSurfaceType || "surface") +
|
|
314
|
+
" - tried " +
|
|
315
|
+
spec.names.join(", ") +
|
|
316
|
+
"\n",
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* The connected surface's control vocabulary, as a lowercase lookup.
|
|
324
|
+
*
|
|
325
|
+
* The reply is MAX-FORMATTED: the selector, the count, then `control <name>` pairs,
|
|
326
|
+
* then `done`. Nothing here depends on that layout - every atom goes in the set and
|
|
327
|
+
* a name is looked up by value, so `control` and `done` are simply names no role
|
|
328
|
+
* asks for.
|
|
329
|
+
*/
|
|
330
|
+
function controlsControlNames(): { [name: string]: boolean } {
|
|
331
|
+
var out: { [name: string]: boolean } = {};
|
|
332
|
+
try {
|
|
333
|
+
var atoms = controlsAtoms(controlsSurface!.call("get_control_names"));
|
|
334
|
+
for (var i = 0; i < atoms.length; i++) out[String(atoms[i]).toLowerCase()] = true;
|
|
335
|
+
} catch (e) {
|
|
336
|
+
post("m4l-jweb: get_control_names failed - " + (e as Error).message + "\n");
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function controlsHasName(available: { [name: string]: boolean }, name: string): boolean {
|
|
342
|
+
return available[String(name).toLowerCase()] === true;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** A LOM reply as a plain array, whatever shape Max handed over. */
|
|
346
|
+
function controlsAtoms(v: unknown): unknown[] {
|
|
347
|
+
if (v === null || typeof v === "undefined") return [];
|
|
348
|
+
if (typeof v === "string") return [v];
|
|
349
|
+
var list = v as { length?: number; [i: number]: unknown };
|
|
350
|
+
if (typeof list.length !== "number") return [v];
|
|
351
|
+
var out: unknown[] = [];
|
|
352
|
+
for (var i = 0; i < list.length; i++) out.push(list[i]);
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** An id, however the LOM chose to hand it over: a number, "id 5", or ["id", 5]. */
|
|
357
|
+
function controlsIdOf(v: unknown): number {
|
|
358
|
+
if (v === null || typeof v === "undefined") return 0;
|
|
359
|
+
if (typeof v === "number") return v;
|
|
360
|
+
if (typeof v === "string") {
|
|
361
|
+
var parts = v.split(" ");
|
|
362
|
+
return Number(parts[parts.length - 1]);
|
|
363
|
+
}
|
|
364
|
+
var list = v as { length?: number; [i: number]: unknown };
|
|
365
|
+
if (typeof list.length === "number" && list.length > 0) return Number(list[list.length - 1]);
|
|
366
|
+
return 0;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/* ------------------------------------------------------------------ *
|
|
370
|
+
* The focus policy
|
|
371
|
+
* ------------------------------------------------------------------ */
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* The id an OBSERVER callback carries, from the atoms after the property name.
|
|
375
|
+
*
|
|
376
|
+
* A property observer is handed `[<property>, ...value]`, and an OBJECT-valued
|
|
377
|
+
* property's value is the TWO atoms `id <n>` - so the callback is
|
|
378
|
+
* `["selected_track", "id", 5]` and `a[1]` is the symbol `id`, not a number.
|
|
379
|
+
*
|
|
380
|
+
* THAT IS THE BUG THIS EXISTS TO NAME. Reading `a[1]` gives `Number("id")` = NaN,
|
|
381
|
+
* which is not equal to anything, including itself - so the focus test could never
|
|
382
|
+
* be true, `takeover` looked switched on and nothing was ever grabbed, and there was
|
|
383
|
+
* no error anywhere because NaN is a perfectly ordinary number to compare. Take the
|
|
384
|
+
* LAST atom, which is the id in both the `id <n>` and the bare-number shapes.
|
|
385
|
+
*/
|
|
386
|
+
function controlsIdFromCallback(a: unknown[]): number {
|
|
387
|
+
if (!a || a.length < 2) return 0;
|
|
388
|
+
return controlsIdOf(a[a.length - 1]);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Observe what Live has selected, so `focus` can decide whether THIS instance holds
|
|
393
|
+
* the hardware.
|
|
394
|
+
*
|
|
395
|
+
* Two of these devices in one set is the normal case, not the edge, and `Always`
|
|
396
|
+
* means the last one loaded wins the grid forever - which reads as the first one
|
|
397
|
+
* being broken. The selected TRACK is one observer; the selected DEVICE is an
|
|
398
|
+
* observer on the selected track's view, so it has to be rebuilt whenever the track
|
|
399
|
+
* changes.
|
|
400
|
+
*/
|
|
401
|
+
function controlsIdentify(): void {
|
|
402
|
+
var me = new LiveAPI("this_device");
|
|
403
|
+
controlsThisDeviceId = me.id ? Number(me.id) : 0;
|
|
404
|
+
// ownTrack() (liveapi.ts) CLIMBS to the Track. `this_device canonical_parent` is
|
|
405
|
+
// the track only when the device sits directly on one; inside a Rack it is the
|
|
406
|
+
// CHAIN, whose id equals no selected_track ever - so a device in a rack would never
|
|
407
|
+
// hold the grid under Track focus, silently.
|
|
408
|
+
var track = ownTrack();
|
|
409
|
+
controlsThisTrackId = track && track.id ? Number(track.id) : 0;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function controlsWatchFocus(): void {
|
|
413
|
+
controlsTrackObs = null;
|
|
414
|
+
try {
|
|
415
|
+
controlsTrackObs = new LiveAPI(function (a: unknown[]) {
|
|
416
|
+
if (!a || a[0] != "selected_track") return;
|
|
417
|
+
controlsSelectedTrackId = controlsIdFromCallback(a);
|
|
418
|
+
// Rebuilding an observer from inside a notification is not something to do in
|
|
419
|
+
// the notification's own turn - the Live API is explicit that the set must not
|
|
420
|
+
// be modified there. A Task puts it in the next one.
|
|
421
|
+
if (controlsDeviceObsTask) controlsDeviceObsTask.cancel();
|
|
422
|
+
controlsDeviceObsTask = new Task(controlsWatchSelectedDevice, this);
|
|
423
|
+
controlsDeviceObsTask.schedule(0);
|
|
424
|
+
// The id above is RECORDED either way - it is the value this observer exists
|
|
425
|
+
// for, and the attach callback is the only place the current one arrives.
|
|
426
|
+
if (controlsStarted) controlsApply();
|
|
427
|
+
}, "live_set view");
|
|
428
|
+
controlsTrackObs.property = "selected_track";
|
|
429
|
+
} catch (e) {
|
|
430
|
+
post("m4l-jweb: cannot observe the selected track - " + (e as Error).message + "\n");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
controlsWatchSelectedDevice();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** ...and the selected DEVICE, on whichever track is selected now. */
|
|
437
|
+
function controlsWatchSelectedDevice(): void {
|
|
438
|
+
controlsDeviceObs = null;
|
|
439
|
+
try {
|
|
440
|
+
controlsDeviceObs = new LiveAPI(function (a: unknown[]) {
|
|
441
|
+
if (!a || a[0] != "selected_device") return;
|
|
442
|
+
controlsSelectedDeviceId = controlsIdFromCallback(a);
|
|
443
|
+
if (controlsStarted) controlsApply();
|
|
444
|
+
}, "live_set view selected_track view");
|
|
445
|
+
controlsDeviceObs.property = "selected_device";
|
|
446
|
+
} catch (e) {
|
|
447
|
+
// A device-focused takeover then behaves like a track-focused one. Say so: the
|
|
448
|
+
// alternative is a device that never grabs and never explains why.
|
|
449
|
+
post('m4l-jweb: cannot observe the selected device - focus "Device" will behave like "Track" - ' + (e as Error).message + "\n");
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Should this instance be holding the hardware - and if not, WHY NOT?
|
|
455
|
+
*
|
|
456
|
+
* It answers a reason rather than a boolean because "the pads did not light up" has
|
|
457
|
+
* four completely different causes that look identical on a dark Push, and a rejected
|
|
458
|
+
* LiveAPI call reports nothing at all. `held` means take it; anything else is the
|
|
459
|
+
* reason, and it goes to the Max console and to the device view.
|
|
460
|
+
*
|
|
461
|
+
* off `takeover` is off. The default, and the commonest answer.
|
|
462
|
+
* no_surface no Push or Move in Live's control-surface slots.
|
|
463
|
+
* unresolved a surface is there, but no declared role resolved on it.
|
|
464
|
+
* not_focused `focus` says another track or device has it.
|
|
465
|
+
*/
|
|
466
|
+
function controlsHoldReason(): string {
|
|
467
|
+
if (!controlsEnabled) return "off";
|
|
468
|
+
if (!controlsSurface) return "no_surface";
|
|
469
|
+
if (!controlsAnyResolved()) return "unresolved";
|
|
470
|
+
if (controlsFocus === CONTROLS_FOCUS_ALWAYS) return "held";
|
|
471
|
+
|
|
472
|
+
var byTrack = controlsThisTrackId !== 0 && controlsThisTrackId === controlsSelectedTrackId;
|
|
473
|
+
if (controlsFocus === CONTROLS_FOCUS_DEVICE) {
|
|
474
|
+
// With no reachable selected-device observer this degenerates to the track test,
|
|
475
|
+
// which is the conservative answer: it can be too generous, never too silent.
|
|
476
|
+
if (!controlsDeviceObs) return byTrack ? "held" : "not_focused";
|
|
477
|
+
return controlsThisDeviceId !== 0 && controlsThisDeviceId === controlsSelectedDeviceId ? "held" : "not_focused";
|
|
478
|
+
}
|
|
479
|
+
return byTrack ? "held" : "not_focused";
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Ask again in a second, up to `tries` times, unless something has already resolved.
|
|
484
|
+
*
|
|
485
|
+
* Cheap: `get_control_names` on a surface that is not ready costs a rejected call and a
|
|
486
|
+
* console line, and twelve of them over twelve seconds is nothing next to a device that
|
|
487
|
+
* silently never works until the user re-drags it.
|
|
488
|
+
*/
|
|
489
|
+
function controlsScheduleRetry(tries: number): void {
|
|
490
|
+
if (controlsRetryTask) controlsRetryTask.cancel();
|
|
491
|
+
controlsRetryTask = null;
|
|
492
|
+
if (tries <= 0) return;
|
|
493
|
+
if (controlsAnyResolved()) return;
|
|
494
|
+
|
|
495
|
+
controlsRetriesLeft = tries;
|
|
496
|
+
controlsRetryTask = new Task(controlsRetry, this);
|
|
497
|
+
controlsRetryTask.interval = CONTROLS_RETRY_MS;
|
|
498
|
+
controlsRetryTask.repeat(tries);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function controlsRetry(): void {
|
|
502
|
+
controlsRetriesLeft--;
|
|
503
|
+
if (controlsAnyResolved()) {
|
|
504
|
+
if (controlsRetryTask) controlsRetryTask.cancel();
|
|
505
|
+
controlsRetryTask = null;
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
controlsResolve();
|
|
509
|
+
controlsApply();
|
|
510
|
+
if (controlsAnyResolved()) {
|
|
511
|
+
post("m4l-jweb: controls resolved on retry - Live's surface was not ready at device load\n");
|
|
512
|
+
if (controlsRetryTask) controlsRetryTask.cancel();
|
|
513
|
+
controlsRetryTask = null;
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (controlsRetriesLeft <= 0) {
|
|
517
|
+
post(
|
|
518
|
+
"m4l-jweb: controls still unresolved after " +
|
|
519
|
+
CONTROLS_RETRIES +
|
|
520
|
+
" tries - plug a Push in, or delete and re-drag the device\n",
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Did ANY declared role resolve? A surface with none of them is not a surface we can use. */
|
|
526
|
+
function controlsAnyResolved(): boolean {
|
|
527
|
+
var specs = CONTROLS_SPEC!.controls;
|
|
528
|
+
for (var i = 0; i < specs.length; i++) if (controlsIds[specs[i].key]) return true;
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/* ------------------------------------------------------------------ *
|
|
533
|
+
* The grab
|
|
534
|
+
* ------------------------------------------------------------------ */
|
|
535
|
+
|
|
536
|
+
/** The last reason announced, so a re-check that changed nothing says nothing. */
|
|
537
|
+
var controlsReason = "";
|
|
538
|
+
|
|
539
|
+
/** Take or give back the declared controls, so that reality matches the policy. */
|
|
540
|
+
function controlsApply(): void {
|
|
541
|
+
if (!controlsDeclared() || !controlsStarted) return;
|
|
542
|
+
var reason = controlsHoldReason();
|
|
543
|
+
var want = reason === "held";
|
|
544
|
+
if (want !== controlsHeld) {
|
|
545
|
+
if (want) controlsGrab();
|
|
546
|
+
else controlsRelease();
|
|
547
|
+
}
|
|
548
|
+
if (reason === controlsReason) return;
|
|
549
|
+
controlsReason = reason;
|
|
550
|
+
// The one line that answers "I turned Takeovr on and nothing happened". There is no
|
|
551
|
+
// other channel: Live refuses a grab by posting and returning normally, so this is
|
|
552
|
+
// the wrapper saying what it DECIDED, which is the half it does know.
|
|
553
|
+
post("m4l-jweb: controls " + (want ? "HELD" : "not held") + " (" + reason + ")" + controlsFocusDetail(reason) + "\n");
|
|
554
|
+
outlet(0, "controls_held", want ? 1 : 0, reason);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** The ids behind a `not_focused`, because that is the one whose cause is invisible. */
|
|
558
|
+
function controlsFocusDetail(reason: string): string {
|
|
559
|
+
if (reason !== "not_focused") return "";
|
|
560
|
+
return (
|
|
561
|
+
" - this track " +
|
|
562
|
+
controlsThisTrackId +
|
|
563
|
+
" vs selected " +
|
|
564
|
+
controlsSelectedTrackId +
|
|
565
|
+
", this device " +
|
|
566
|
+
controlsThisDeviceId +
|
|
567
|
+
" vs selected " +
|
|
568
|
+
controlsSelectedDeviceId
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function controlsGrab(): void {
|
|
573
|
+
var specs = CONTROLS_SPEC!.controls;
|
|
574
|
+
for (var i = 0; i < specs.length; i++) {
|
|
575
|
+
var name = controlsNames[specs[i].key];
|
|
576
|
+
if (!name || !controlsIds[specs[i].key]) continue;
|
|
577
|
+
// BY NAME, on the SURFACE. A bare id is rejected and the two-atom `id <n>` form
|
|
578
|
+
// buys nothing over this. There is no return value worth reading: a rejected
|
|
579
|
+
// call posts to the Max console and returns normally.
|
|
580
|
+
controlsSurface!.call("grab_control", name);
|
|
581
|
+
// What is lit is now unknown - Live repaints the matrix as it hands it over.
|
|
582
|
+
controlsShown[specs[i].key] = null;
|
|
583
|
+
}
|
|
584
|
+
controlsHeld = true;
|
|
585
|
+
// The announcement is controlsApply's, not this function's: it is the one that
|
|
586
|
+
// knows WHY, and a device view that only ever heard "held 0" could not tell the
|
|
587
|
+
// four reasons apart.
|
|
588
|
+
|
|
589
|
+
// THE FIRST FRAME AFTER A GRAB IS LOST, measured. Deferred, not handshaked: there
|
|
590
|
+
// is nothing to wait on. A device that painted once here and then only on change
|
|
591
|
+
// would come up blank and stay blank.
|
|
592
|
+
if (controlsFirstFrameTask) controlsFirstFrameTask.cancel();
|
|
593
|
+
controlsFirstFrameTask = new Task(controlsPaintAll, this);
|
|
594
|
+
controlsFirstFrameTask.schedule(CONTROLS_FIRST_FRAME_MS);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function controlsRelease(): void {
|
|
598
|
+
if (controlsFirstFrameTask) controlsFirstFrameTask.cancel();
|
|
599
|
+
var specs = CONTROLS_SPEC!.controls;
|
|
600
|
+
for (var i = 0; i < specs.length; i++) {
|
|
601
|
+
var name = controlsNames[specs[i].key];
|
|
602
|
+
// The surface can be GONE by now - a Push unplugged mid-set re-resolves to null,
|
|
603
|
+
// and a release is exactly what we then want to do and cannot. Nothing is stranded
|
|
604
|
+
// by skipping it: a grab belongs to the device context, and Live drops it.
|
|
605
|
+
if (!name || !controlsIds[specs[i].key] || !controlsSurface) continue;
|
|
606
|
+
controlsSurface.call("release_control", name);
|
|
607
|
+
controlsShown[specs[i].key] = null;
|
|
608
|
+
}
|
|
609
|
+
controlsHeld = false;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** The deferred first frame: everything the page has asked for, with nothing assumed lit. */
|
|
613
|
+
function controlsPaintAll(): void {
|
|
614
|
+
if (!controlsHeld) return;
|
|
615
|
+
var specs = CONTROLS_SPEC!.controls;
|
|
616
|
+
for (var i = 0; i < specs.length; i++) {
|
|
617
|
+
var key = specs[i].key;
|
|
618
|
+
controlsShown[key] = null;
|
|
619
|
+
controlsPaint(key, controlsWanted[key] || controlsBlank(specs[i]));
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** A frame of nothing, so a control the page has not painted yet is dark rather than whatever Live left. */
|
|
624
|
+
function controlsBlank(spec: { rows: number; cols: number }): number[] {
|
|
625
|
+
var cells: number[] = [];
|
|
626
|
+
for (var i = 0; i < spec.rows * spec.cols; i++) cells.push(0);
|
|
627
|
+
return cells;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* THE FRAME DIFF, and the only place `send_value` is called.
|
|
632
|
+
*
|
|
633
|
+
* The page sends the WHOLE grid; this decides what the hardware is actually told.
|
|
634
|
+
* It is not the same diff the page does. The page's question is "did the device
|
|
635
|
+
* change its mind"; this one is "does the pad already show this" - and after a grab
|
|
636
|
+
* the answer is unknown for every cell, so `controlsShown` is null and everything
|
|
637
|
+
* goes out.
|
|
638
|
+
*
|
|
639
|
+
* The budget is not a constraint: 64 `send_value` calls measured at ~2.6 ms, flat
|
|
640
|
+
* across fifty consecutive full-grid frames, with no stutter in Live's UI. The diff
|
|
641
|
+
* is here for the messages it does not send, not because the hardware could not keep
|
|
642
|
+
* up.
|
|
643
|
+
*/
|
|
644
|
+
function controlsPaint(key: string, cells: number[]): void {
|
|
645
|
+
var obj = controlsObjects[key];
|
|
646
|
+
if (!obj || !controlsHeld) return;
|
|
647
|
+
var spec = controlsSpecFor(key);
|
|
648
|
+
if (!spec) return;
|
|
649
|
+
|
|
650
|
+
var shown = controlsShown[key];
|
|
651
|
+
var next: number[] = [];
|
|
652
|
+
for (var i = 0; i < cells.length; i++) {
|
|
653
|
+
var colour = Number(cells[i]) || 0;
|
|
654
|
+
next.push(colour);
|
|
655
|
+
if (shown && shown[i] === colour) continue;
|
|
656
|
+
// Hardware coordinates throughout: row 0 is the TOP, which is the order the page
|
|
657
|
+
// packed the frame in. Nothing in this file flips y.
|
|
658
|
+
obj.call("send_value", i % spec.cols, Math.floor(i / spec.cols), colour);
|
|
659
|
+
}
|
|
660
|
+
controlsShown[key] = next;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function controlsSpecFor(key: string): { key: string; rows: number; cols: number } | null {
|
|
664
|
+
var specs = CONTROLS_SPEC!.controls;
|
|
665
|
+
for (var i = 0; i < specs.length; i++) if (specs[i].key === key) return specs[i];
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/* ------------------------------------------------------------------ *
|
|
670
|
+
* The message handlers
|
|
671
|
+
* ------------------------------------------------------------------ */
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* The `takeover` parameter, tapped straight off the `live.toggle` by the chain.
|
|
675
|
+
*
|
|
676
|
+
* It does NOT come from the page, deliberately: a device whose grid dies because its
|
|
677
|
+
* Chromium view was closed would fail exactly when the user is looking at the Push
|
|
678
|
+
* instead of the screen.
|
|
679
|
+
*/
|
|
680
|
+
function controls_takeover(v: unknown): void {
|
|
681
|
+
controlsEnabled = Number(v) >= 0.5;
|
|
682
|
+
controlsApply();
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** The `focus` menu, as its index. See FOCUS_OPTIONS in @m4l-jweb/surface. */
|
|
686
|
+
function controls_focus(v: unknown): void {
|
|
687
|
+
controlsFocus = Math.round(Number(v));
|
|
688
|
+
controlsFocusSeen = true;
|
|
689
|
+
controlsApply();
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* `controls_frame <key> <c0> <c1> ...` - the whole grid, as palette indices, in
|
|
694
|
+
* hardware order.
|
|
695
|
+
*
|
|
696
|
+
* Variadic, so it reads `arguments` rather than a fixed signature. It is stored even
|
|
697
|
+
* when nothing is held: that is what makes the deferred first frame after a grab
|
|
698
|
+
* show the picture the device already wanted rather than a blank grid.
|
|
699
|
+
*/
|
|
700
|
+
function controls_frame(): void {
|
|
701
|
+
if (!controlsDeclared()) return;
|
|
702
|
+
var args = arrayfromargs(arguments);
|
|
703
|
+
if (!args.length) return;
|
|
704
|
+
var key = String(args[0]);
|
|
705
|
+
var cells: number[] = [];
|
|
706
|
+
for (var i = 1; i < args.length; i++) cells.push(Number(args[i]) || 0);
|
|
707
|
+
controlsWanted[key] = cells;
|
|
708
|
+
controlsPaint(key, cells);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** `controls_refresh` - forget what we believe is lit and repaint everything. */
|
|
712
|
+
function controls_refresh(): void {
|
|
713
|
+
if (!controlsDeclared()) return;
|
|
714
|
+
controlsPaintAll();
|
|
715
|
+
}
|
package/src/core.ts
CHANGED
|
@@ -51,10 +51,17 @@ function bang(): void {
|
|
|
51
51
|
setupTempoObserver(); // liveapi.ts
|
|
52
52
|
startTickPoll(); // liveapi.ts
|
|
53
53
|
setupWatches(); // watch.ts - the device's declared defineWatch() observers
|
|
54
|
+
setupControls(); // controls.ts - the device's declared defineControls() takeover
|
|
54
55
|
followWindowSizes(); // a resized window resizes its page
|
|
55
56
|
// A device's own wrapper/device.ts hooks in here: this is the ONLY safe place
|
|
56
57
|
// to create LiveAPI objects (see the loadbang trap above).
|
|
57
58
|
if (typeof onDeviceReady === "function") onDeviceReady();
|
|
59
|
+
// ...and a HEADLESS device's own logic hooks in HERE, under a name of its own.
|
|
60
|
+
// `wrapper/device.ts` is repo-wide and already owns `onDeviceReady`; everything is
|
|
61
|
+
// concatenated into one [js] script, so a headless device defining the same function
|
|
62
|
+
// would not extend that hook, it would REPLACE it - silently, and for every device in
|
|
63
|
+
// the repo that shares the script. Two hooks, two names, no collision.
|
|
64
|
+
if (typeof onHeadlessReady === "function") onHeadlessReady();
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
/** Patcher loaded. File work is safe here; LiveAPI is NOT. */
|
|
@@ -71,6 +78,7 @@ function reload(): void {
|
|
|
71
78
|
setupTempoObserver();
|
|
72
79
|
startTickPoll();
|
|
73
80
|
setupWatches(); // watch.ts
|
|
81
|
+
setupControls(); // controls.ts
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
/**
|
|
@@ -126,11 +134,33 @@ function ui_ready(): void {
|
|
|
126
134
|
resendWatches(); // watch.ts - the current value of every declared watch, for a late page
|
|
127
135
|
sendDeviceFolder(); // where this device's files land, for a device that declares any
|
|
128
136
|
sendTrackKind(); // liveapi.ts - audio | midi | none, which decides what clips it can make
|
|
137
|
+
resendControls(); // controls.ts - which declared roles resolved, and do we hold them
|
|
129
138
|
// The device resends its own state here. The page loads asynchronously, so
|
|
130
139
|
// anything sent before it was listening is simply gone.
|
|
131
140
|
if (typeof onUiReady === "function") onUiReady();
|
|
132
141
|
}
|
|
133
142
|
|
|
143
|
+
/**
|
|
144
|
+
* `open_url <url>` - hand a URL to the user's default web browser.
|
|
145
|
+
*
|
|
146
|
+
* `; max launchbrowser <url>` is the only door Max offers, and it is measured to reach the
|
|
147
|
+
* shell (doc/MAX-FACTS.md). What it cannot do is REVEAL A FOLDER - the same message with a
|
|
148
|
+
* correct `file://` path opens nothing at all - so this is deliberately for `http` and
|
|
149
|
+
* `https`, which is what it does work for. Anything else is refused rather than sent into
|
|
150
|
+
* a mechanism known not to answer.
|
|
151
|
+
*
|
|
152
|
+
* `messnamed` is a Max HOST function: fixed arity only, never `.apply` (it crashes Live).
|
|
153
|
+
*/
|
|
154
|
+
function open_url(url: string): void {
|
|
155
|
+
var target = String(url);
|
|
156
|
+
if (target.indexOf("http://") !== 0 && target.indexOf("https://") !== 0) {
|
|
157
|
+
post("m4l-jweb: open_url refused " + target + " - only http and https are opened, and launchbrowser cannot reveal a folder\n");
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
post("m4l-jweb: opening " + target + "\n");
|
|
161
|
+
messnamed("max", "launchbrowser", target);
|
|
162
|
+
}
|
|
163
|
+
|
|
134
164
|
/**
|
|
135
165
|
* One page hands a message to another page's window.
|
|
136
166
|
*
|
|
@@ -351,9 +381,7 @@ function param_label(id: string, ...rest: unknown[]): void {
|
|
|
351
381
|
// The device view is told as well: its own controls should show what the code
|
|
352
382
|
// called this, not the declared short name.
|
|
353
383
|
outlet(0, "param_desc", id, label);
|
|
354
|
-
post(
|
|
355
|
-
"m4l-jweb: param_label " + id + " '" + before + "' -> '" + after + "'" + (String(after) === label ? "" : " (did NOT take)") + "\n",
|
|
356
|
-
);
|
|
384
|
+
post("m4l-jweb: param_label " + id + " '" + before + "' -> '" + after + "'" + (String(after) === label ? "" : " (did NOT take)") + "\n");
|
|
357
385
|
}
|
|
358
386
|
|
|
359
387
|
function param_unit(id: string, ...rest: unknown[]): void {
|
|
@@ -544,7 +572,23 @@ function window(id: string): void {
|
|
|
544
572
|
* real file next to the .amxd on first load and point jweb at that file:// URL.
|
|
545
573
|
* ------------------------------------------------------------------ */
|
|
546
574
|
|
|
575
|
+
/**
|
|
576
|
+
* Is this a HEADLESS device - no [jweb], no page, no payload?
|
|
577
|
+
*
|
|
578
|
+
* The build injects `HEADLESS` for a device whose manifest says `target: "headless"`.
|
|
579
|
+
* Everything else about the wrapper is unchanged; what changes is that there is
|
|
580
|
+
* nothing on outlet 0 that wants a URL, and nothing on disk to extract for it.
|
|
581
|
+
*/
|
|
582
|
+
function headless(): boolean {
|
|
583
|
+
return typeof HEADLESS !== "undefined" && !!HEADLESS;
|
|
584
|
+
}
|
|
585
|
+
|
|
547
586
|
function loadWebview(): void {
|
|
587
|
+
// Nothing to point at a page that is not in the patcher. Outlet 0 still carries the
|
|
588
|
+
// app's messages under this target - it is [js] talking to the routes the chains and
|
|
589
|
+
// the Surface put there - so sending a `url` down it would be a message the device's
|
|
590
|
+
// own logic has to learn to ignore, for a browser it does not have.
|
|
591
|
+
if (headless()) return;
|
|
548
592
|
try {
|
|
549
593
|
var url = resolveUiUrl();
|
|
550
594
|
if (!url) return;
|
|
@@ -592,7 +636,10 @@ function loadWindows(): void {
|
|
|
592
636
|
// is invisible without saying it: nothing else in Max reports a page that
|
|
593
637
|
// did not load.
|
|
594
638
|
post(
|
|
595
|
-
"m4l-jweb: window '" +
|
|
639
|
+
"m4l-jweb: window '" +
|
|
640
|
+
siteId +
|
|
641
|
+
"' is missing its sidecar folder - expected " +
|
|
642
|
+
target +
|
|
596
643
|
". Install the whole '<device>-site' folder NEXT TO the .amxd.\n",
|
|
597
644
|
);
|
|
598
645
|
}
|
|
@@ -645,6 +692,7 @@ var resizeTask: Task | null = null;
|
|
|
645
692
|
var appliedSize: { [id: string]: string } = {};
|
|
646
693
|
|
|
647
694
|
function followWindowSizes(): void {
|
|
695
|
+
if (headless()) return; // no windows without a browser to put one in
|
|
648
696
|
var ids = listWindowIds();
|
|
649
697
|
if (!ids.length) return;
|
|
650
698
|
if (resizeTask) resizeTask.cancel();
|
package/src/max.d.ts
CHANGED
|
@@ -211,6 +211,44 @@ declare const WATCH_SPECS: { key: string; path: string; property: string }[] | u
|
|
|
211
211
|
*/
|
|
212
212
|
declare const FILES_SPEC: { saves: boolean; fetches: boolean; tellPage: boolean } | undefined;
|
|
213
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Injected by @m4l-jweb/build for a device whose manifest says `target: "headless"`.
|
|
216
|
+
*
|
|
217
|
+
* There is no [jweb] in the patcher, no HTML payload in the .amxd and no page to hand
|
|
218
|
+
* a URL to: the device's own logic is this script, appended after the wrapper from
|
|
219
|
+
* `src/app/<device>/headless.ts`. Undefined for every other device.
|
|
220
|
+
*/
|
|
221
|
+
declare const HEADLESS: number | undefined;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* A HEADLESS device's own entry point, from `src/app/<device>/headless.ts`.
|
|
225
|
+
*
|
|
226
|
+
* Called from `bang()` - live.thisdevice, the one moment a LiveAPI object is not born
|
|
227
|
+
* dead. It is a separate name from `onDeviceReady` on purpose: that one belongs to the
|
|
228
|
+
* repo-wide `wrapper/device.ts`, everything is concatenated into ONE [js] script, and
|
|
229
|
+
* a second definition of a function does not extend it, it replaces it.
|
|
230
|
+
*/
|
|
231
|
+
declare function onHeadlessReady(): void;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Injected by @m4l-jweb/build from the device's `defineControls()`: what this
|
|
235
|
+
* device claims on the control surface.
|
|
236
|
+
*
|
|
237
|
+
* `names` is the CANDIDATE list for the role, in order - a Push 3 answers
|
|
238
|
+
* `get_control_names` with 176 names and they are not the Push 2 set, so the
|
|
239
|
+
* wrapper resolves against that answer rather than calling a name it hopes exists.
|
|
240
|
+
* Undefined for a device that declares no controls, which is also a device with no
|
|
241
|
+
* `takeover` chain and therefore no observers.
|
|
242
|
+
*/
|
|
243
|
+
declare const CONTROLS_SPEC:
|
|
244
|
+
| {
|
|
245
|
+
surface: string;
|
|
246
|
+
/** The declared `focus` default, as the menu index Max stores it as. */
|
|
247
|
+
focus: number;
|
|
248
|
+
controls: { key: string; kind: string; role: string; rows: number; cols: number; names: string[] }[];
|
|
249
|
+
}
|
|
250
|
+
| undefined;
|
|
251
|
+
|
|
214
252
|
/**
|
|
215
253
|
* Injected by @m4l-jweb/build for every window declared with `site:` - window id
|
|
216
254
|
* -> the path of its index.html RELATIVE to the device folder.
|