@m4l-jweb/surface 0.7.0 → 0.9.1
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 +2 -2
- package/src/index.ts +83 -0
- package/src/react.tsx +22 -5
- package/src/store.ts +131 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m4l-jweb/surface",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "m4l-jweb: declare a device's Live parameters as code - the surface Push actually sees - plus a mocked-Live dev harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"peerDependencies": {
|
|
26
26
|
"react": ">=18",
|
|
27
|
-
"@m4l-jweb/bridge": "0.
|
|
27
|
+
"@m4l-jweb/bridge": "0.9.1"
|
|
28
28
|
},
|
|
29
29
|
"peerDependenciesMeta": {
|
|
30
30
|
"react": {
|
package/src/index.ts
CHANGED
|
@@ -142,6 +142,20 @@ export interface WindowSpec {
|
|
|
142
142
|
width: number;
|
|
143
143
|
height: number;
|
|
144
144
|
entry: string;
|
|
145
|
+
/**
|
|
146
|
+
* Keep the window in FRONT of Live, instead of behind it the moment Live is clicked.
|
|
147
|
+
*
|
|
148
|
+
* For a window you read WHILE working in Live - a reference, a cheatsheet - the
|
|
149
|
+
* default behaviour makes it useless: clicking back into the device to type is
|
|
150
|
+
* exactly what sends the window behind the main window. For a window you work IN (an
|
|
151
|
+
* editor), the default is right and this should stay off.
|
|
152
|
+
*
|
|
153
|
+
* It compiles to `[thispatcher]` <- `window flags ... float, window exec`, which is
|
|
154
|
+
* Max's documented route. **The flag list is a REPLACEMENT, not an addition** - that
|
|
155
|
+
* is why the generated message names `grow`, `close` and `title` alongside `float`.
|
|
156
|
+
* Send `float` alone and the window comes up with no close box.
|
|
157
|
+
*/
|
|
158
|
+
alwaysOnTop?: boolean;
|
|
145
159
|
}
|
|
146
160
|
|
|
147
161
|
/**
|
|
@@ -375,6 +389,75 @@ export function defaults<P extends Record<string, ParamSpec>>(surface: Surface<P
|
|
|
375
389
|
return out;
|
|
376
390
|
}
|
|
377
391
|
|
|
392
|
+
/* ------------------------------------------------------------------ *
|
|
393
|
+
* defineWatch - declare what to OBSERVE in Live, once, as code.
|
|
394
|
+
*
|
|
395
|
+
* The twin of defineSurface, and it exists to kill hard rule 4 BY CONSTRUCTION.
|
|
396
|
+
* A LiveAPI observer built during `loadbang` is dead forever; the only safe place
|
|
397
|
+
* to create one is `live.thisdevice`'s bang. That is a trap a hand-written
|
|
398
|
+
* observer falls into silently - the object constructs without error and then
|
|
399
|
+
* notifies nothing. So no device writes the observer at all: it DECLARES the LOM
|
|
400
|
+
* path and property, the build injects the list as data, and the packaged wrapper
|
|
401
|
+
* creates every observer from bang() (see setupWatches in @m4l-jweb/wrapper). The
|
|
402
|
+
* one place LiveAPI is safe is the one place the observers are made.
|
|
403
|
+
*
|
|
404
|
+
* Each change reaches the UI as `watch_<key> <value...>`, exactly as a parameter
|
|
405
|
+
* reaches it as `<id> <value>` - one declaration, one name, both sides. `useWatch`
|
|
406
|
+
* binds it; the app never types the selector.
|
|
407
|
+
*
|
|
408
|
+
* This is READ-ONLY: an observed property flows Live -> UI and nowhere back. A
|
|
409
|
+
* value the app can also WRITE is a parameter (`defineSurface`), not a watch.
|
|
410
|
+
* ------------------------------------------------------------------ */
|
|
411
|
+
|
|
412
|
+
/** One observed Live property. `T` is the value the app sees, so `useWatch` is typed from it. */
|
|
413
|
+
export interface WatchSpec<T = unknown> {
|
|
414
|
+
/** The LOM object path, e.g. `"live_set"` or `"live_set view selected_track"`. */
|
|
415
|
+
path: string;
|
|
416
|
+
/** An OBSERVABLE property on that object, e.g. `"tempo"`, `"scale_name"`, `"is_playing"`. */
|
|
417
|
+
property: string;
|
|
418
|
+
/** What the app shows before Live has replied - the same role a parameter's `default` plays. */
|
|
419
|
+
default: T;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The value type a watch carries. `useWatch` is typed by this. */
|
|
423
|
+
export type WatchValue<S extends WatchSpec> = S extends WatchSpec<infer T> ? T : never;
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Declare one watch. `watch<string>({ path: "live_set", property: "scale_name", default: "C" })`
|
|
427
|
+
* carries the value type through, so `useWatch(w, "scale")` is `string` with nothing to cast.
|
|
428
|
+
*/
|
|
429
|
+
export const watch = <T>(spec: WatchSpec<T>): WatchSpec<T> => ({ ...spec });
|
|
430
|
+
|
|
431
|
+
export interface WatchDef<W extends Record<string, WatchSpec>> {
|
|
432
|
+
watches: W;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export interface Watch<W extends Record<string, WatchSpec> = Record<string, WatchSpec>> extends WatchDef<W> {
|
|
436
|
+
/** Declaration order - the order the build emits WATCH_SPECS and the wrapper attaches observers. */
|
|
437
|
+
readonly keys: readonly Extract<keyof W, string>[];
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Declare the watch surface.
|
|
442
|
+
*
|
|
443
|
+
* Like defineSurface, the checks run HERE, at call time, and throw - the build
|
|
444
|
+
* imports this module to emit the observer list, so a bad declaration fails
|
|
445
|
+
* `pnpm build` and CI. A key becomes the selector suffix `watch_<key>`, so it may
|
|
446
|
+
* not carry whitespace (Max would split the message on it); a path or property
|
|
447
|
+
* left blank would attach an observer to nothing, silently, which is the exact
|
|
448
|
+
* failure this API exists to prevent.
|
|
449
|
+
*/
|
|
450
|
+
export function defineWatch<const W extends Record<string, WatchSpec>>(def: WatchDef<W>): Watch<W> {
|
|
451
|
+
const keys = Object.keys(def.watches) as Extract<keyof W, string>[];
|
|
452
|
+
for (const key of keys) {
|
|
453
|
+
if (/\s/.test(key)) throw new Error(`watch: key "${key}" has whitespace - it becomes the selector watch_${key}, which Max would split`);
|
|
454
|
+
const w = def.watches[key];
|
|
455
|
+
if (!w.path) throw new Error(`watch: "${key}" has no path - an observer with no LOM object attaches to nothing`);
|
|
456
|
+
if (!w.property) throw new Error(`watch: "${key}" has no property - an observer with no property notifies nothing`);
|
|
457
|
+
}
|
|
458
|
+
return { ...def, keys };
|
|
459
|
+
}
|
|
460
|
+
|
|
378
461
|
/** How a value is displayed - the parameter's own `format`, or a sane default. */
|
|
379
462
|
export function formatValue(spec: ParamSpec, value: unknown): string {
|
|
380
463
|
if (spec.kind === "toggle" || spec.kind === "button") return value ? "on" : "off";
|
package/src/react.tsx
CHANGED
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
|
20
20
|
import { outlet } from "@m4l-jweb/bridge";
|
|
21
|
-
import type { ParamSpec, ParamValue, StateSpec, StateValue, Surface, WindowSpec } from "./index";
|
|
21
|
+
import type { ParamSpec, ParamValue, StateSpec, StateValue, Surface, Watch, WatchSpec, WatchValue, WindowSpec } from "./index";
|
|
22
22
|
import { JWEB_VARNAME } from "./index";
|
|
23
|
-
import { paramStore, stateStore } from "./store";
|
|
23
|
+
import { paramStore, stateStore, watchStore } from "./store";
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* A two-way binding to one Live parameter. `[value, setValue]`, like useState -
|
|
@@ -106,9 +106,7 @@ export function useNativeVisibility<P extends Record<string, ParamSpec>>(
|
|
|
106
106
|
* Only `hidden` is used, so this actually works where reflow could not, and no layer
|
|
107
107
|
* is ever visible at the same time as another - so z-order never matters.
|
|
108
108
|
*/
|
|
109
|
-
export function useNativePanel<P extends Record<string, ParamSpec>>(
|
|
110
|
-
surface: Surface<P>,
|
|
111
|
-
): (mode: "web" | "native") => void {
|
|
109
|
+
export function useNativePanel<P extends Record<string, ParamSpec>>(surface: Surface<P>): (mode: "web" | "native") => void {
|
|
112
110
|
return useCallback(
|
|
113
111
|
(mode: "web" | "native") => {
|
|
114
112
|
const native = surface.layout?.native;
|
|
@@ -123,6 +121,25 @@ export function useNativePanel<P extends Record<string, ParamSpec>>(
|
|
|
123
121
|
);
|
|
124
122
|
}
|
|
125
123
|
|
|
124
|
+
/**
|
|
125
|
+
* A one-way binding to an observed Live property, declared with `defineWatch()`.
|
|
126
|
+
*
|
|
127
|
+
* Read-only, so it returns the value alone - no setter. The mirror of `useParam`:
|
|
128
|
+
* a parameter the app both reads and writes; a watch it only reads. Turning the
|
|
129
|
+
* tempo, changing the scale, selecting a track in Live moves this React state, and
|
|
130
|
+
* the wrapper attaches every observer from `bang()` - the one place LiveAPI is
|
|
131
|
+
* safe - so the trap of a dead loadbang observer is not one a device can fall into.
|
|
132
|
+
*
|
|
133
|
+
* The selector is DERIVED (`watch_<key>`), exactly as `useParam`'s is, so a key
|
|
134
|
+
* that is not declared is a build error at the call site, not a value that never
|
|
135
|
+
* arrives.
|
|
136
|
+
*/
|
|
137
|
+
export function useWatch<W extends Record<string, WatchSpec>, K extends Extract<keyof W, string>>(watch: Watch<W>, key: K): WatchValue<W[K]> {
|
|
138
|
+
const store = useMemo(() => watchStore(watch), [watch]);
|
|
139
|
+
const values = useSyncExternalStore(store.subscribe, store.get, store.get);
|
|
140
|
+
return values[key] as WatchValue<W[K]>;
|
|
141
|
+
}
|
|
142
|
+
|
|
126
143
|
/**
|
|
127
144
|
* A two-way binding to a JSON state slot, persisted in the Live SET.
|
|
128
145
|
*
|
package/src/store.ts
CHANGED
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
* the user's hand wins, and the next value after the window lands normally.
|
|
31
31
|
*/
|
|
32
32
|
import { bindInlet, outlet } from "@m4l-jweb/bridge";
|
|
33
|
-
import { defaults, type ParamSpec, type StateSpec, type Surface } from "./index";
|
|
33
|
+
import { defaults, type ParamSpec, type StateSpec, type Surface, type WatchSpec } from "./index";
|
|
34
34
|
|
|
35
35
|
/** How long the user's hand beats an inbound value: long enough to cover the gaps between drag events, short enough to be imperceptible. */
|
|
36
36
|
export const GUARD_MS = 120;
|
|
@@ -155,8 +155,70 @@ export function paramStore<P extends Record<string, ParamSpec>>(surface: Surface
|
|
|
155
155
|
* OUT: `get_state <id>`, `sync_state <id> <json>` - one handler each, in the wrapper.
|
|
156
156
|
* IN: `state_<id> <json>` - one binding per slot, so the
|
|
157
157
|
* bridge can dispatch it without the app unpacking an id.
|
|
158
|
+
*
|
|
159
|
+
* ------------------------------------------------------------------------------
|
|
160
|
+
* EVERY VALUE TRAVELS INSIDE AN ENVELOPE, because a Max [dict] IS a key/value map
|
|
161
|
+
* and cannot hold anything else.
|
|
162
|
+
*
|
|
163
|
+
* The wrapper stores a slot by handing the JSON to `Dict.parse()`. A dict has KEYS;
|
|
164
|
+
* that is what a dict is. So an OBJECT round-tripped fine and nothing else did:
|
|
165
|
+
* `state<string>` sent `"c1 e1"` and `state<FxParam[]>` sent `["cutoff"]`, and
|
|
166
|
+
* `parse()` had nowhere to put either. The dict stayed empty, `stringify()` gave back
|
|
167
|
+
* `{}`, and the app read its own default forever.
|
|
168
|
+
*
|
|
169
|
+
* That one bug wore two disguises, and cost real debugging as both: a drum map (an
|
|
170
|
+
* object) persisted while the pattern text (a string) silently did not - which looks
|
|
171
|
+
* exactly like Live losing your work; and the fx device's `named` slot (an array) came
|
|
172
|
+
* back `{}` on every load, which was written off as the state-DEFAULT seeding gap. That
|
|
173
|
+
* gap is real, but it is not this: `named` had never persisted at all.
|
|
174
|
+
*
|
|
175
|
+
* So the wire format is `{"__value": <whatever>}`, always. The dict gets its key, and
|
|
176
|
+
* `state<T>` means what it says for every T.
|
|
158
177
|
* ------------------------------------------------------------------ */
|
|
159
178
|
|
|
179
|
+
/** The one key a [dict] carries, so that a scalar has somewhere to live. */
|
|
180
|
+
const ENVELOPE = "__value";
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The envelope, as JSON, with every literal space escaped - so it crosses as ONE atom.
|
|
184
|
+
*
|
|
185
|
+
* [jweb] hands each argument to Max, and Max SPLITS A SYMBOL ON WHITESPACE. The
|
|
186
|
+
* wrapper has always papered over this by rejoining the pieces with a single space,
|
|
187
|
+
* which works for exactly as long as the payload never contains meaningful whitespace.
|
|
188
|
+
* `JSON.stringify` of an object is compact, so a drum map arrived in one piece and the
|
|
189
|
+
* seam held.
|
|
190
|
+
*
|
|
191
|
+
* A PATTERN IS NOTHING BUT WHITESPACE. `"c1 e1"` would come back `"c1 e1"` - the run
|
|
192
|
+
* of spaces rejoined as one, the user's text quietly reformatted - and a multi-line
|
|
193
|
+
* pattern (which is the whole point of the Studio window) is worse.
|
|
194
|
+
*
|
|
195
|
+
* ` ` is JSON's own escape for a space, so the payload contains no literal spaces
|
|
196
|
+
* at all: Max cannot split what is not there, the rejoin becomes a no-op, and the dict's
|
|
197
|
+
* JSON parser turns the escapes back into the spaces the user typed. Newlines and tabs
|
|
198
|
+
* need no help - `JSON.stringify` already escapes those as `\n` and `\t`.
|
|
199
|
+
*/
|
|
200
|
+
function stateToWire(value: unknown): string {
|
|
201
|
+
return JSON.stringify({ [ENVELOPE]: value }).replace(/ /g, "\\u0020");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Unwrap what came out of the dict.
|
|
206
|
+
*
|
|
207
|
+
* A missing envelope is not an error, and there are two innocent ways to get one: a
|
|
208
|
+
* slot Live has never saved (a fresh, empty `{}`), or a value written by a build from
|
|
209
|
+
* before the envelope - which could only ever have been an object, since nothing else
|
|
210
|
+
* could be stored. Both are answered without blanking the app, so opening an old set
|
|
211
|
+
* keeps its drum map.
|
|
212
|
+
*/
|
|
213
|
+
function unwrap(parsed: unknown, fallback: unknown): unknown {
|
|
214
|
+
if (parsed && typeof parsed === "object" && ENVELOPE in (parsed as Record<string, unknown>)) {
|
|
215
|
+
return (parsed as Record<string, unknown>)[ENVELOPE];
|
|
216
|
+
}
|
|
217
|
+
// An empty dict means "nothing saved yet" - the declared default is the honest answer.
|
|
218
|
+
if (parsed && typeof parsed === "object" && Object.keys(parsed as object).length === 0) return fallback;
|
|
219
|
+
return parsed ?? fallback;
|
|
220
|
+
}
|
|
221
|
+
|
|
160
222
|
export interface StateStore {
|
|
161
223
|
get(): Values;
|
|
162
224
|
subscribe(fn: () => void): () => void;
|
|
@@ -190,7 +252,10 @@ export function stateStore(surface: { state?: Record<string, StateSpec> }): Stat
|
|
|
190
252
|
for (const id of ids) {
|
|
191
253
|
bindInlet(`state_${id}`, (raw) => {
|
|
192
254
|
try {
|
|
193
|
-
|
|
255
|
+
// The dict hands back `{"__value": ...}` - see the envelope note above. An
|
|
256
|
+
// empty dict and a pre-envelope value both resolve to something sane rather
|
|
257
|
+
// than to nothing.
|
|
258
|
+
values = { ...values, [id]: unwrap(JSON.parse(String(raw)), surface.state![id].default) };
|
|
194
259
|
notify();
|
|
195
260
|
} catch {
|
|
196
261
|
// An empty dict stringifies to "{}" and parses fine; anything that does
|
|
@@ -212,10 +277,73 @@ export function stateStore(surface: { state?: Record<string, StateSpec> }): Stat
|
|
|
212
277
|
write(id, value) {
|
|
213
278
|
values = { ...values, [id]: value };
|
|
214
279
|
notify();
|
|
215
|
-
|
|
280
|
+
// Enveloped, ALWAYS - a bare string or array has nowhere to live in a [dict] -
|
|
281
|
+
// and space-escaped, so Max cannot split the payload on the way.
|
|
282
|
+
outlet("sync_state", id, stateToWire(value));
|
|
216
283
|
},
|
|
217
284
|
};
|
|
218
285
|
|
|
219
286
|
stateStores.set(surface, store);
|
|
220
287
|
return store;
|
|
221
288
|
}
|
|
289
|
+
|
|
290
|
+
/* ------------------------------------------------------------------ *
|
|
291
|
+
* The watch store - the read-only Live values behind useWatch().
|
|
292
|
+
*
|
|
293
|
+
* A watch is the mirror image of a parameter: it flows Live -> UI only, so this
|
|
294
|
+
* store has a `get`/`subscribe` and NO `write`. Each declared watch binds
|
|
295
|
+
* `watch_<key>` exactly once - same one-binding-per-selector reason paramStore
|
|
296
|
+
* exists - and fans out to subscribers. The wrapper attaches the observer (from
|
|
297
|
+
* bang(), the only safe place) and resends the current value on ui_ready, so a
|
|
298
|
+
* page that loaded after the last change still gets it; the app just calls
|
|
299
|
+
* uiReady() as it already does for tempo.
|
|
300
|
+
* ------------------------------------------------------------------ */
|
|
301
|
+
|
|
302
|
+
export interface WatchStore {
|
|
303
|
+
get(): Values;
|
|
304
|
+
subscribe(fn: () => void): () => void;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const watchStores = new WeakMap<object, WatchStore>();
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* One store per watch declaration, created once and never torn down - the bridge's
|
|
311
|
+
* bindings are process-wide, so tearing one down would unbind a watch another
|
|
312
|
+
* component still reads.
|
|
313
|
+
*/
|
|
314
|
+
export function watchStore(watch: { watches?: Record<string, WatchSpec>; keys?: readonly string[] }): WatchStore {
|
|
315
|
+
const existing = watchStores.get(watch);
|
|
316
|
+
if (existing) return existing;
|
|
317
|
+
|
|
318
|
+
const keys = watch.keys ?? (watch.watches ? Object.keys(watch.watches) : []);
|
|
319
|
+
|
|
320
|
+
// What the app shows before Live has replied: the declared defaults.
|
|
321
|
+
let values: Values = {};
|
|
322
|
+
for (const key of keys) values[key] = watch.watches?.[key].default;
|
|
323
|
+
|
|
324
|
+
const listeners = new Set<() => void>();
|
|
325
|
+
const notify = () => {
|
|
326
|
+
for (const fn of listeners) fn();
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
for (const key of keys) {
|
|
330
|
+
// `watch_<key> <value...>` - out of the wrapper's observeProperty, forwarded on
|
|
331
|
+
// change and once on ui_ready. A scalar arrives as one atom; a property that
|
|
332
|
+
// yields several (a colour, a pair) arrives spread, so keep the list intact.
|
|
333
|
+
bindInlet(`watch_${key}`, (...args) => {
|
|
334
|
+
values = { ...values, [key]: args.length <= 1 ? args[0] : args };
|
|
335
|
+
notify();
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const store: WatchStore = {
|
|
340
|
+
get: () => values,
|
|
341
|
+
subscribe(fn) {
|
|
342
|
+
listeners.add(fn);
|
|
343
|
+
return () => listeners.delete(fn);
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
watchStores.set(watch, store);
|
|
348
|
+
return store;
|
|
349
|
+
}
|