dsh-realtime-audio-ws 0.2.7 → 0.2.9
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/lib/client/bundle.js +565 -110
- package/lib/client/index.js +565 -110
- package/lib/index.d.ts +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +9 -1
- package/lib/index.js.map +1 -1
- package/lib/injection.d.ts +12 -5
- package/lib/injection.d.ts.map +1 -1
- package/lib/injection.js.map +1 -1
- package/lib/strip.d.ts +55 -20
- package/lib/strip.d.ts.map +1 -1
- package/lib/strip.js +355 -29
- package/lib/strip.js.map +1 -1
- package/package.json +3 -3
- package/src/client/index.ts +615 -108
- package/src/index.ts +10 -1
- package/src/injection.ts +10 -5
- package/src/strip.ts +357 -29
package/lib/client/index.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* actually been read, rather than shipping a capture path that a policy might silently kill.
|
|
25
25
|
*/
|
|
26
26
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
-
exports.STRIP_ELEMENT_ID = exports.INJECTED_KEY = exports.GLOBAL_KEY = exports.MAX_BACKLOG_SECONDS = exports.CAPTURE_BUFFER = exports.SAMPLE_RATE = exports.DEFAULT_PATH = exports.inject = exports.name = void 0;
|
|
27
|
+
exports.METER_SEGMENTS = exports.STRIP_MODEL = exports.STRIP_WORDMARK = exports.STRIP_ELEMENT_ID = exports.INJECTED_KEY = exports.GLOBAL_KEY = exports.MAX_BACKLOG_SECONDS = exports.CAPTURE_BUFFER = exports.SAMPLE_RATE = exports.DEFAULT_PATH = exports.inject = exports.name = void 0;
|
|
28
28
|
exports.socketUrl = socketUrl;
|
|
29
29
|
exports.pageAuthority = pageAuthority;
|
|
30
30
|
exports.withToken = withToken;
|
|
@@ -33,6 +33,7 @@ exports.float32FromPcm16 = float32FromPcm16;
|
|
|
33
33
|
exports.defaultDeps = defaultDeps;
|
|
34
34
|
exports.createAudioClient = createAudioClient;
|
|
35
35
|
exports.stripRoot = stripRoot;
|
|
36
|
+
exports.waitKeys = waitKeys;
|
|
36
37
|
exports.renderStrip = renderStrip;
|
|
37
38
|
exports.mountStrip = mountStrip;
|
|
38
39
|
exports.apply = apply;
|
|
@@ -351,6 +352,26 @@ function createAudioClient(deps) {
|
|
|
351
352
|
// ---- the strip: the panel this plugin puts in the app ---------------------------------------------------
|
|
352
353
|
/** The element the injected markup provides. Duplicated in the host half, which mints the row. */
|
|
353
354
|
exports.STRIP_ELEMENT_ID = 'dsh-realtime-strip';
|
|
355
|
+
/** The plate's wordmark and model, engraved. The host half styles the plate; the deck is minted here. */
|
|
356
|
+
exports.STRIP_WORDMARK = 'REALTIME';
|
|
357
|
+
exports.STRIP_MODEL = 'DV-1 · DESK VOICE UNIT';
|
|
358
|
+
/** The segments in the input meter. Its own scale, not a level — see {@link meterMarkup}. */
|
|
359
|
+
exports.METER_SEGMENTS = 14;
|
|
360
|
+
/** The lamp's own word, engraved beside it. */
|
|
361
|
+
const LAMP_WORDS = { live: 'LIVE', connecting: 'LINK', closed: 'CLOSED' };
|
|
362
|
+
/**
|
|
363
|
+
* Short legends for the controls that sit on the plate.
|
|
364
|
+
*
|
|
365
|
+
* A description is not a label: schema prose written to explain a field to a developer is too long to scan
|
|
366
|
+
* beside a control, and two such sentences about different fields read as one field described twice. The
|
|
367
|
+
* full prose is one drill level down, where the raw registry rows are.
|
|
368
|
+
*/
|
|
369
|
+
const SHORT_LABELS = {
|
|
370
|
+
speakMilestones: 'SPEAK STEPS',
|
|
371
|
+
maxPromptChars: 'PROMPT',
|
|
372
|
+
maxSpokenMilestones: 'MILESTONES',
|
|
373
|
+
sessionId: 'SESSION',
|
|
374
|
+
};
|
|
354
375
|
/**
|
|
355
376
|
* Where the injected markup puts the panel, or `null` when this page does not carry it.
|
|
356
377
|
* @param scope - the page scope, injectable so both cases are testable without a DOM.
|
|
@@ -386,113 +407,470 @@ function show(value) {
|
|
|
386
407
|
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
387
408
|
}
|
|
388
409
|
/**
|
|
389
|
-
*
|
|
410
|
+
* A value as a number, for the controls that are numeric.
|
|
390
411
|
*
|
|
391
|
-
*
|
|
392
|
-
* is
|
|
393
|
-
*
|
|
394
|
-
* @
|
|
412
|
+
* `0` rather than a refusal for anything that is not a number: a number control for a setting that reported
|
|
413
|
+
* nothing is a control for a setting that will refuse the write, and the registry's own reply is what says
|
|
414
|
+
* so. Inventing a second refusal here would put two answers behind one refusal.
|
|
415
|
+
* @param value - a setting's value as `status` reported it.
|
|
416
|
+
* @returns the value, or `0`.
|
|
395
417
|
*/
|
|
396
|
-
function
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
418
|
+
function asNumber(value) {
|
|
419
|
+
return typeof value === 'number' ? value : 0;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* A value as a list of strings, when it is one.
|
|
423
|
+
* @param value - a setting's value as `status` reported it.
|
|
424
|
+
* @returns the strings, or `undefined` when the value is not an array.
|
|
425
|
+
*/
|
|
426
|
+
function asList(value) {
|
|
427
|
+
if (!Array.isArray(value))
|
|
428
|
+
return undefined;
|
|
429
|
+
return value.filter((item) => typeof item === 'string');
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* One live setting of a given kind, by field.
|
|
433
|
+
*
|
|
434
|
+
* By `field` and not by `<owner>.<field>` key: which plugin owns a budget is not the panel's business, and
|
|
435
|
+
* a panel keyed on an owner's name breaks the day the owner is renamed. The same resolution the control
|
|
436
|
+
* channel's `steer` verb makes, for the same reason.
|
|
437
|
+
* @param settings - every setting the status reported.
|
|
438
|
+
* @param field - the setting's own name.
|
|
439
|
+
* @param kind - the kind the control can drive.
|
|
440
|
+
* @returns the setting, or `undefined`.
|
|
441
|
+
*/
|
|
442
|
+
function liveField(settings, field, kind) {
|
|
443
|
+
return settings.find(entry => entry.field === field && entry.kind === kind && entry.scope === 'live');
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* The two budgets that each bound how long the voice waits, in the order the panel writes them.
|
|
447
|
+
*
|
|
448
|
+
* Measured rather than assumed, and the panel therefore rests on facts: the agent's `delegationTimeoutMs`
|
|
449
|
+
* (how long the voice model waits for a responder) and the responder's `answerTimeoutMs` (how long one turn
|
|
450
|
+
* waits for the agent). Both are `live`, so one control that writes both is an affordance the protocol can
|
|
451
|
+
* honour — and **the smaller bound is what is heard**, in the responder's own words, so `min` is the truth
|
|
452
|
+
* about what the pair does.
|
|
453
|
+
*
|
|
454
|
+
* Zero or one key is a real answer: in a profile with no agent row, or no responder row, the other budget
|
|
455
|
+
* does not exist to be written.
|
|
456
|
+
* @param settings - every setting the status reported.
|
|
457
|
+
* @returns the keys, in the order they are written.
|
|
458
|
+
*/
|
|
459
|
+
function waitKeys(settings) {
|
|
460
|
+
const keys = [];
|
|
461
|
+
for (const field of ['delegationTimeoutMs', 'answerTimeoutMs']) {
|
|
462
|
+
const entry = liveField(settings, field, 'number');
|
|
463
|
+
if (entry !== undefined)
|
|
464
|
+
keys.push(entry.key);
|
|
408
465
|
}
|
|
409
|
-
|
|
410
|
-
// Restart-bound settings are omitted rather than shown read-only: the gate says no control at all, and a
|
|
411
|
-
// row that cannot be changed is a row a reader will try to change.
|
|
412
|
-
const settings = (reply?.settings ?? []).filter(entry => entry.scope !== 'restart');
|
|
413
|
-
lines.push(settings.length === 0
|
|
414
|
-
? '<p data-dsh-strip-empty>no settings to show</p>'
|
|
415
|
-
: `<ul>${settings.map(entry => settingRow(entry, state.notes)).join('')}</ul>`);
|
|
416
|
-
return lines.join('');
|
|
466
|
+
return keys;
|
|
417
467
|
}
|
|
418
468
|
/**
|
|
419
|
-
*
|
|
420
|
-
*
|
|
469
|
+
* What the wait control says it wrote, from the pair the registry now reports.
|
|
470
|
+
*
|
|
471
|
+
* Never the value that was asked for: a setter may clamp what it was handed, so the answer to "what does
|
|
472
|
+
* this now do?" is the pair the plugin holds. Reported as a **pair** — a half-applied write has to be
|
|
473
|
+
* visible as one, and averaging it would hide exactly the state a reader needs to see.
|
|
474
|
+
* @param settings - every setting the status reported, re-read after the write.
|
|
475
|
+
* @returns one line for the note.
|
|
476
|
+
*/
|
|
477
|
+
function waitSummary(settings) {
|
|
478
|
+
const values = waitKeys(settings).map(key => asNumber(settings.find(entry => entry.key === key)?.value));
|
|
479
|
+
if (values.length === 0)
|
|
480
|
+
return 'no wait budget is registered';
|
|
481
|
+
return `waits up to ${String(Math.round(Math.min(...values) / 1000))} s — ${values.map(value => String(value)).join(' / ')} ms`;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* The pilot lamp's state.
|
|
485
|
+
*
|
|
486
|
+
* Green live, amber connecting, dark closed — one lamp rather than three words, and the amber is real
|
|
487
|
+
* rather than decorative: a connect is a promise that settles, and the state it is in while it settles is
|
|
488
|
+
* the only thing on this plate that changes without a repaint from the status.
|
|
489
|
+
* @param state - what the panel is showing.
|
|
490
|
+
* @returns which of the three the lamp reports.
|
|
491
|
+
*/
|
|
492
|
+
function lampState(state) {
|
|
493
|
+
if (state.connecting)
|
|
494
|
+
return 'connecting';
|
|
495
|
+
return state.reply?.voice?.open === true ? 'live' : 'closed';
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* The input meter.
|
|
499
|
+
*
|
|
500
|
+
* **Not a level.** A real level needs a repaint loop, and repainting this element's `innerHTML` on a timer
|
|
501
|
+
* would destroy whatever is half-typed in it — the list editor is the case that matters. So the bar encodes
|
|
502
|
+
* the capture path's *state*, which is the one thing about the microphone that changes without a repaint:
|
|
503
|
+
* dark when the device is not open, filled when it is, and a fault shown at the top of the scale where the
|
|
504
|
+
* eye already looks for "something is wrong with the input". The state is named on the element for anyone
|
|
505
|
+
* who cannot read the colour, and the title says what the bar is so it cannot be mistaken for a peak meter.
|
|
421
506
|
* @param audio - what the microphone is doing.
|
|
422
|
-
* @returns
|
|
507
|
+
* @returns the meter's markup.
|
|
423
508
|
*/
|
|
424
|
-
function
|
|
425
|
-
const
|
|
509
|
+
function meterMarkup(audio) {
|
|
510
|
+
const live = audio.kind === 'live';
|
|
511
|
+
const fault = audio.kind === 'failed';
|
|
512
|
+
const segments = Array.from({ length: exports.METER_SEGMENTS }, (_unused, index) => {
|
|
513
|
+
if (fault)
|
|
514
|
+
return index >= exports.METER_SEGMENTS - 2 ? '<i class="dvr-seg on hot"></i>' : '<i class="dvr-seg"></i>';
|
|
515
|
+
if (!live || index >= 11)
|
|
516
|
+
return '<i class="dvr-seg"></i>';
|
|
517
|
+
return `<i class="dvr-seg on${index >= 9 ? ' warm' : ''}"></i>`;
|
|
518
|
+
}).join('');
|
|
519
|
+
return `<span class="dvr-meter" data-meter="${audio.kind}" role="img"`
|
|
520
|
+
+ ` aria-label="microphone ${audio.kind}" title="capture state — not a level">`
|
|
521
|
+
+ '<span class="dvr-cap"><span>INPUT</span><span>−12 −6 0</span></span>'
|
|
522
|
+
+ `<span class="dvr-scale">${segments}</span>`
|
|
523
|
+
+ '</span>';
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Where the voice is pointed.
|
|
527
|
+
*
|
|
528
|
+
* Three separate states, kept separate: nothing has answered yet, the profile has no agent to answer, and a
|
|
529
|
+
* session that exists and knows where it points. Collapsing the first two is the mistake this line exists to
|
|
530
|
+
* avoid — "no agent is mounted" behind a control that has simply not been asked yet sends a reader to the
|
|
531
|
+
* wrong file.
|
|
532
|
+
* @param reply - the last status, if any.
|
|
533
|
+
* @returns one engraved line.
|
|
534
|
+
*/
|
|
535
|
+
function whereText(reply) {
|
|
426
536
|
if (reply === null)
|
|
427
|
-
return
|
|
537
|
+
return 'no status yet';
|
|
428
538
|
const voice = reply.voice;
|
|
429
|
-
// `null` is "nobody answered the question", which is a composition without an agent row — deliberately
|
|
430
|
-
// not the same statement as a session that is merely closed.
|
|
431
539
|
if (voice === null || voice === undefined)
|
|
432
|
-
return
|
|
433
|
-
const
|
|
434
|
-
|
|
435
|
-
return `voice: ${voice.open ? 'open' : 'closed'}${where}${which} · ${microphone}`;
|
|
540
|
+
return 'no agent mounted';
|
|
541
|
+
const model = `${voice.provider ?? '?'}/${voice.model ?? '?'}`;
|
|
542
|
+
return voice.sessionId === undefined ? model : `${model} · ${voice.sessionId}`;
|
|
436
543
|
}
|
|
437
544
|
/**
|
|
438
|
-
* The
|
|
439
|
-
*
|
|
440
|
-
*
|
|
545
|
+
* The knob's pointer position, as one of eleven buckets.
|
|
546
|
+
*
|
|
547
|
+
* A readout, not a control: the pointer encodes where the value sits on the knob's own scale, and the field
|
|
548
|
+
* beside it is what writes. The scale is the panel's, because the registry declares a kind and not a range —
|
|
549
|
+
* which is exactly why a draggable knob would be a lie about what the panel knows.
|
|
550
|
+
* @param value - the value in force.
|
|
551
|
+
* @param span - the panel's own scale for this knob.
|
|
552
|
+
* @returns a bucket from 0 to 10.
|
|
441
553
|
*/
|
|
442
|
-
function
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
`${String(reply.audio?.clients ?? 0)} socket(s)`,
|
|
447
|
-
`journal ${String(reply.journal?.size ?? 0)}`,
|
|
448
|
-
...last === undefined ? [] : [`last ${last}`],
|
|
449
|
-
].join(' · ');
|
|
554
|
+
function knobBucket(value, span) {
|
|
555
|
+
const [low, high] = span;
|
|
556
|
+
const bucket = Math.round(((value - low) / (high - low)) * 10);
|
|
557
|
+
return Math.min(10, Math.max(0, bucket));
|
|
450
558
|
}
|
|
451
559
|
/**
|
|
452
|
-
*
|
|
560
|
+
* One knob: the dial, the legend, the value, and the field that writes it.
|
|
561
|
+
* @param knob - what to draw.
|
|
562
|
+
* @returns the knob's markup.
|
|
563
|
+
*/
|
|
564
|
+
function knobMarkup(knob) {
|
|
565
|
+
const key = escapeHtml(knob.key);
|
|
566
|
+
const caption = knob.caption === undefined ? '' : `<span class="dvr-knob-caption">${escapeHtml(knob.caption)}</span>`;
|
|
567
|
+
return '<div class="dvr-knob-unit">'
|
|
568
|
+
+ `<span class="dvr-knob dvr-knob--${String(knobBucket(knob.value, knob.span))}"`
|
|
569
|
+
+ ' title="readout — the field below is the control"></span>'
|
|
570
|
+
+ `<span class="dvr-knob-label">${escapeHtml(knob.label)}</span>`
|
|
571
|
+
+ `<span class="dvr-knob-value">${escapeHtml(knob.shown)}</span>`
|
|
572
|
+
+ caption
|
|
573
|
+
+ '<span class="dvr-knob-set">'
|
|
574
|
+
+ `<input class="dvr-input" type="number" data-input="${key}" value="${escapeHtml(String(knob.value))}">`
|
|
575
|
+
+ `<button class="dvr-btn" data-action="${knob.action}" data-key="${key}">Set</button>`
|
|
576
|
+
+ '</span></div>';
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* The few controls worth a physical affordance.
|
|
453
580
|
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
* @param
|
|
457
|
-
* @returns the markup.
|
|
581
|
+
* One primary for the wait budget rather than the two indistinguishable rows the screenshot showed, and the
|
|
582
|
+
* mechanism one drill level down where it is reachable without being in the way.
|
|
583
|
+
* @param settings - every setting the status reported.
|
|
584
|
+
* @returns the knobs' markup, empty when none of them are registered.
|
|
458
585
|
*/
|
|
459
|
-
function
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
586
|
+
function knobsMarkup(settings) {
|
|
587
|
+
const knobs = [];
|
|
588
|
+
const waits = waitKeys(settings);
|
|
589
|
+
if (waits.length > 0) {
|
|
590
|
+
const values = waits.map(key => asNumber(settings.find(entry => entry.key === key)?.value));
|
|
591
|
+
const binding = Math.min(...values);
|
|
592
|
+
// The cap is named only when the two actually diverge, because that is the only case in which one of
|
|
593
|
+
// them is inert — and an inert control that accepts input and does nothing is the defect this design
|
|
594
|
+
// exists to remove.
|
|
595
|
+
const caption = values.length < 2 || values[0] === values[1]
|
|
596
|
+
? undefined
|
|
597
|
+
: `capped by the ${values[0] < values[1] ? 'reply' : 'turn'} budget`;
|
|
598
|
+
knobs.push(knobMarkup({
|
|
599
|
+
label: 'WAIT',
|
|
600
|
+
action: waits.length === 2 ? 'wait' : 'set',
|
|
601
|
+
key: waits[0],
|
|
602
|
+
value: binding,
|
|
603
|
+
shown: `${String(Math.round(binding / 1000))} s`,
|
|
604
|
+
span: [1000, 120_000],
|
|
605
|
+
...caption === undefined ? {} : { caption },
|
|
606
|
+
}));
|
|
607
|
+
}
|
|
608
|
+
const prompt = liveField(settings, 'maxPromptChars', 'number');
|
|
609
|
+
if (prompt !== undefined) {
|
|
610
|
+
knobs.push(knobMarkup({
|
|
611
|
+
label: SHORT_LABELS.maxPromptChars,
|
|
612
|
+
action: 'set',
|
|
613
|
+
key: prompt.key,
|
|
614
|
+
value: asNumber(prompt.value),
|
|
615
|
+
shown: `${String(asNumber(prompt.value))} ch`,
|
|
616
|
+
span: [0, 8000],
|
|
617
|
+
}));
|
|
618
|
+
}
|
|
619
|
+
const milestones = liveField(settings, 'maxSpokenMilestones', 'number');
|
|
620
|
+
if (milestones !== undefined) {
|
|
621
|
+
knobs.push(knobMarkup({
|
|
622
|
+
label: SHORT_LABELS.maxSpokenMilestones,
|
|
623
|
+
action: 'set',
|
|
624
|
+
key: milestones.key,
|
|
625
|
+
value: asNumber(milestones.value),
|
|
626
|
+
shown: String(asNumber(milestones.value)),
|
|
627
|
+
span: [0, 6],
|
|
628
|
+
}));
|
|
629
|
+
}
|
|
630
|
+
return knobs.join('');
|
|
467
631
|
}
|
|
468
632
|
/**
|
|
469
|
-
*
|
|
633
|
+
* A switch that is not a setting: it drives a verb.
|
|
634
|
+
* @param action - the control verb.
|
|
635
|
+
* @param on - whether the thing it drives is on.
|
|
636
|
+
* @param label - its legend.
|
|
637
|
+
* @param colour - the LED's colour when lit.
|
|
638
|
+
* @returns the switch's markup.
|
|
639
|
+
*/
|
|
640
|
+
function lampSwitch(action, on, label, colour) {
|
|
641
|
+
const lit = on ? ` on ${colour}` : '';
|
|
642
|
+
return `<button class="dvr-switch" data-action="${action}" aria-pressed="${String(on)}">`
|
|
643
|
+
+ `<i class="dvr-led${lit}"></i><span>${label}</span></button>`;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* A boolean setting as a toggle.
|
|
470
647
|
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
474
|
-
* @param
|
|
475
|
-
* @returns the markup
|
|
648
|
+
* A toggle and not a text box holding `true`: typing `false` into a field is a coercion the user has to get
|
|
649
|
+
* right, and a toggle cannot be got wrong. The value it writes is carried on the button, so the flip is
|
|
650
|
+
* decided against the value the panel was rendered with rather than a second read of the page.
|
|
651
|
+
* @param entry - the setting.
|
|
652
|
+
* @returns the toggle's markup.
|
|
476
653
|
*/
|
|
477
|
-
function
|
|
654
|
+
function booleanSwitch(entry) {
|
|
478
655
|
const key = escapeHtml(entry.key);
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
? ''
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
656
|
+
const on = entry.value === true;
|
|
657
|
+
return `<button class="dvr-switch" data-action="toggle" data-key="${key}"`
|
|
658
|
+
+ ` data-value="${on ? 'false' : 'true'}" aria-pressed="${String(on)}">`
|
|
659
|
+
+ `<i class="dvr-led${on ? ' on' : ''}"></i>`
|
|
660
|
+
+ `<span>${escapeHtml(SHORT_LABELS[entry.field] ?? entry.field)}</span>`
|
|
661
|
+
+ `<b class="dvr-switch-state">${on ? 'ON' : 'OFF'}</b></button>`;
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* The switches row: the microphone, the session, and every live boolean.
|
|
665
|
+
*
|
|
666
|
+
* The microphone and the session switches are controls in their own right, so they appear at both levels.
|
|
667
|
+
* A *setting* boolean does not: the deepest level gives every setting its own control, so carrying the
|
|
668
|
+
* switch there as well would put one setting behind two toggles on one screen — the same defect as two rows
|
|
669
|
+
* that read identically, in miniature.
|
|
670
|
+
* @param state - what the panel is showing.
|
|
671
|
+
* @param settings - every setting the status reported.
|
|
672
|
+
* @returns the row's markup.
|
|
673
|
+
*/
|
|
674
|
+
function switchesMarkup(state, settings) {
|
|
675
|
+
const voice = state.reply?.voice;
|
|
676
|
+
const open = voice !== null && voice !== undefined && voice.open;
|
|
677
|
+
const boolean = state.level === 'controls' ? liveField(settings, 'speakMilestones', 'boolean') : undefined;
|
|
678
|
+
return '<div class="dvr-switches">'
|
|
679
|
+
+ lampSwitch(state.audio.kind === 'live' ? 'disconnect' : 'connect', state.audio.kind === 'live', 'MIC', 'green')
|
|
680
|
+
+ lampSwitch(open ? 'voice-stop' : 'voice-start', open, 'VOICE', 'green')
|
|
681
|
+
+ (boolean === undefined ? '' : booleanSwitch(boolean))
|
|
682
|
+
+ '</div>';
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* The tabs that switch between the two drill levels inside the panel.
|
|
686
|
+
* @param level - the level showing.
|
|
687
|
+
* @returns the tabs' markup.
|
|
688
|
+
*/
|
|
689
|
+
function tabsMarkup(level) {
|
|
690
|
+
const tab = (target, label) => `<button class="dvr-tab${level === target ? ' active' : ''}" data-action="level" data-level="${target}">${label}</button>`;
|
|
691
|
+
return `<div class="dvr-tabs">${tab('controls', 'CONTROLS')}${tab('all', 'ALL SETTINGS')}</div>`;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* A list as a real editor: every entry visible, each removable, and one field to add.
|
|
695
|
+
*
|
|
696
|
+
* Not a comma-separated string in a text box — that is a serialisation format leaked into the UI, and one
|
|
697
|
+
* stray space silently changes the value. The wire format is JSON and only JSON (a list of secrets cannot be
|
|
698
|
+
* split on a comma), so the editor builds the array and sends it whole.
|
|
699
|
+
*
|
|
700
|
+
* When the plugin **does not report** the value — a write-only list — the note says so, because an editor
|
|
701
|
+
* showing an empty list would otherwise read as "the list is empty" rather than "the list is not readable".
|
|
702
|
+
* @param entry - the setting.
|
|
703
|
+
* @param state - what the panel is showing, for the entries the editor has built.
|
|
704
|
+
* @returns the editor's markup.
|
|
705
|
+
*/
|
|
706
|
+
function listEditor(entry, state) {
|
|
707
|
+
const key = escapeHtml(entry.key);
|
|
708
|
+
const reported = asList(entry.value);
|
|
709
|
+
const entries = reported ?? state.drafts[entry.key] ?? [];
|
|
710
|
+
const rows = entries.map((item, index) => `<li class="dvr-entry"><span class="dvr-entry-value">${escapeHtml(item)}</span>`
|
|
711
|
+
+ `<button class="dvr-remove" data-action="list-remove" data-key="${key}"`
|
|
712
|
+
+ ` data-index="${String(index)}">✕</button></li>`).join('');
|
|
713
|
+
return `<ul class="dvr-list">${rows}</ul>`
|
|
714
|
+
+ (reported === undefined
|
|
715
|
+
? '<em class="dvr-write-only">the plugin does not report this value — this list replaces whatever is set</em>'
|
|
716
|
+
: '')
|
|
717
|
+
+ `<input class="dvr-input dvr-input--wide" data-input="${key}" value="">`
|
|
718
|
+
+ `<button class="dvr-btn" data-action="list-add" data-key="${key}">Add</button>`;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* The control a live setting gets, which is decided by its kind rather than by its description.
|
|
722
|
+
* @param entry - the setting.
|
|
723
|
+
* @param state - what the panel is showing, for a list editor's own entries.
|
|
724
|
+
* @returns the control's markup.
|
|
725
|
+
*/
|
|
726
|
+
function liveControl(entry, state) {
|
|
727
|
+
const key = escapeHtml(entry.key);
|
|
728
|
+
if (entry.kind === 'boolean')
|
|
729
|
+
return booleanSwitch(entry);
|
|
730
|
+
if (entry.kind === 'string-list')
|
|
731
|
+
return listEditor(entry, state);
|
|
732
|
+
// The channel has a verb for exactly one purpose — steering at a session — and this is the field it means.
|
|
733
|
+
// Everything else goes through `set <key>=<value>`.
|
|
489
734
|
const action = entry.field === 'sessionId' && entry.kind === 'string' ? 'steer' : 'set';
|
|
490
735
|
const control = entry.choices !== undefined && entry.choices.length > 0
|
|
491
|
-
? `<select data-input="${key}">${choiceOptions(entry, entry.choices)}</select>`
|
|
492
|
-
: `<input data-input="${key}" value="${escapeHtml(show(entry.value))}">`;
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
736
|
+
? `<select class="dvr-select" data-input="${key}">${choiceOptions(entry, entry.choices)}</select>`
|
|
737
|
+
: `<input class="dvr-input dvr-input--wide" data-input="${key}" value="${escapeHtml(show(entry.value))}">`;
|
|
738
|
+
return control + `<button class="dvr-btn" data-action="${action}" data-key="${key}">${action === 'steer' ? 'Steer' : 'Set'}</button>`;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* One registry row, as the deepest drill level shows it: the key, what the owner says it does, and the
|
|
742
|
+
* control its kind asks for.
|
|
743
|
+
* @param entry - the setting.
|
|
744
|
+
* @param state - what the panel is showing.
|
|
745
|
+
* @returns the row's markup.
|
|
746
|
+
*/
|
|
747
|
+
function settingRow(entry, state) {
|
|
748
|
+
const key = escapeHtml(entry.key);
|
|
749
|
+
const head = `<span class="dvr-skey">${key}</span>`;
|
|
750
|
+
const describe = entry.describe === undefined ? '' : `<span class="dvr-sdesc">${escapeHtml(entry.describe)}</span>`;
|
|
751
|
+
const noted = state.notes[entry.key];
|
|
752
|
+
const note = noted === undefined ? '' : `<em class="dvr-note" data-note="${key}">${escapeHtml(noted)}</em>`;
|
|
753
|
+
if (entry.scope !== 'live') {
|
|
754
|
+
// Session-bound: the change exists, it just costs a reconnect, and saying so is the gate's second class.
|
|
755
|
+
return `<div class="dvr-srow" data-key="${key}" data-scope="session">${head}${describe}`
|
|
756
|
+
+ `<b>${escapeHtml(show(entry.value))}</b>`
|
|
757
|
+
+ '<em class="dvr-fixed">fixed when the session opens — reconnect to apply</em>'
|
|
758
|
+
+ note
|
|
759
|
+
+ '</div>';
|
|
760
|
+
}
|
|
761
|
+
return `<div class="dvr-srow" data-key="${key}" data-scope="live">${head}${describe}`
|
|
762
|
+
+ liveControl(entry, state) + note + '</div>';
|
|
763
|
+
}
|
|
764
|
+
/**
|
|
765
|
+
* The raw registry rows.
|
|
766
|
+
*
|
|
767
|
+
* Developer detail the panel used to carry — the journal's size, the socket count, the last event name — is
|
|
768
|
+
* **not** here. It belongs to `voice_journal` and `voice_diagnose`, which already expose it; a surface
|
|
769
|
+
* carrying it reads as a log excerpt with buttons under it, which is what "maximally expanded with every
|
|
770
|
+
* field visible" actually described. The test for what belongs: which of these would a reader *act* on?
|
|
771
|
+
* @param state - what the panel is showing.
|
|
772
|
+
* @param settings - every setting the status reported.
|
|
773
|
+
* @returns the rows' markup.
|
|
774
|
+
*/
|
|
775
|
+
function rowsMarkup(state, settings) {
|
|
776
|
+
// Restart-bound settings get no row at all. The control-plane gate says no affordance the protocol cannot
|
|
777
|
+
// honour, and a row that cannot be changed is a row a reader will try to change.
|
|
778
|
+
const rows = settings.filter(entry => entry.scope !== 'restart');
|
|
779
|
+
if (rows.length === 0)
|
|
780
|
+
return '<p class="dvr-empty">no settings to show</p>';
|
|
781
|
+
return rows.map(entry => settingRow(entry, state)).join('');
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* The panel: the drill-through's contents, one level at a time.
|
|
785
|
+
*
|
|
786
|
+
* The knobs and the raw rows are never on screen together. That is not layout taste — both name their inputs
|
|
787
|
+
* by the setting's key, and two inputs under one name would make the panel read whichever came first in the
|
|
788
|
+
* document, so a control would write a value nobody typed.
|
|
789
|
+
* @param state - what the panel is showing.
|
|
790
|
+
* @param settings - every setting the status reported.
|
|
791
|
+
* @returns the panel's markup.
|
|
792
|
+
*/
|
|
793
|
+
function panelMarkup(state, settings) {
|
|
794
|
+
const knobs = state.level === 'controls' ? knobsMarkup(settings) : '';
|
|
795
|
+
return '<div class="dvr-panel">'
|
|
796
|
+
+ (knobs === '' ? '' : `<div class="dvr-knobs">${knobs}</div>`)
|
|
797
|
+
+ switchesMarkup(state, settings)
|
|
798
|
+
+ tabsMarkup(state.level)
|
|
799
|
+
+ (state.level === 'all' ? rowsMarkup(state, settings) : '')
|
|
800
|
+
+ '</div>';
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* The 1U plate: state, not settings.
|
|
804
|
+
* @param state - what the panel is showing.
|
|
805
|
+
* @returns the deck's markup.
|
|
806
|
+
*/
|
|
807
|
+
function deckMarkup(state) {
|
|
808
|
+
const lamp = lampState(state);
|
|
809
|
+
const collapsed = state.level === 'collapsed';
|
|
810
|
+
const micOn = state.audio.kind === 'live';
|
|
811
|
+
return '<div class="dvr-row">'
|
|
812
|
+
+ '<span class="dvr-screw"></span>'
|
|
813
|
+
+ '<span class="dvr-brand">'
|
|
814
|
+
+ `<b class="dvr-name">${exports.STRIP_WORDMARK}</b><i class="dvr-model">${exports.STRIP_MODEL}</i>`
|
|
815
|
+
+ '</span>'
|
|
816
|
+
+ '<span class="dvr-rule"></span>'
|
|
817
|
+
+ `<span class="dvr-jewel" data-lamp="${lamp}" title="session: ${lamp}">`
|
|
818
|
+
+ `<i class="dvr-lamp"></i><i class="dvr-jewel-label">${LAMP_WORDS[lamp]}</i></span>`
|
|
819
|
+
+ meterMarkup(state.audio)
|
|
820
|
+
+ `<span class="dvr-where">${escapeHtml(whereText(state.reply))}</span>`
|
|
821
|
+
+ '<span class="dvr-spacer"></span>'
|
|
822
|
+
+ `<button class="dvr-btn${micOn ? ' on' : ''}" data-action="${micOn ? 'disconnect' : 'connect'}">MIC</button>`
|
|
823
|
+
+ '<button class="dvr-btn" data-action="level"'
|
|
824
|
+
+ ` data-level="${collapsed ? 'controls' : 'collapsed'}">SETTINGS ${collapsed ? '⌃' : '⌄'}</button>`
|
|
825
|
+
+ '<span class="dvr-screw"></span>'
|
|
826
|
+
+ '</div>';
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* The microphone's own failure, said in words rather than implied by a lamp.
|
|
830
|
+
* @param audio - what the microphone is doing.
|
|
831
|
+
* @returns the line's markup, empty when there is nothing wrong.
|
|
832
|
+
*/
|
|
833
|
+
function faultMarkup(audio) {
|
|
834
|
+
return audio.kind === 'failed' ? `<p class="dvr-fault">microphone: ${escapeHtml(audio.reason)}</p>` : '';
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* The notice, and the page it points at when it points anywhere.
|
|
838
|
+
* @param state - what the panel is showing.
|
|
839
|
+
* @returns the line's markup, empty when there is nothing to say.
|
|
840
|
+
*/
|
|
841
|
+
function noticeMarkup(state) {
|
|
842
|
+
if (state.notice === '')
|
|
843
|
+
return '';
|
|
844
|
+
const link = state.noticeLink ?? '';
|
|
845
|
+
const anchor = link === ''
|
|
846
|
+
? ''
|
|
847
|
+
: ` <a href="${escapeHtml(link)}" target="_blank" rel="noreferrer noopener">open ↗</a>`;
|
|
848
|
+
return `<p class="dvr-notice" data-dsh-strip-notice>${escapeHtml(state.notice)}${anchor}</p>`;
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Render the whole panel.
|
|
852
|
+
*
|
|
853
|
+
* A pure function of the state, because that is what makes the panel testable at all: the injected script
|
|
854
|
+
* is three lines and the DOM is one assignment, so everything worth checking lives here.
|
|
855
|
+
*
|
|
856
|
+
* The order is the panel's own geometry: the drill-through grows **upward** from the plate, so it comes
|
|
857
|
+
* first in the document and the deck stays pinned to the bottom of the page.
|
|
858
|
+
* @param state - what the panel is showing.
|
|
859
|
+
* @returns the markup for the panel's element.
|
|
860
|
+
*/
|
|
861
|
+
function renderStrip(state) {
|
|
862
|
+
const settings = state.reply?.settings ?? [];
|
|
863
|
+
const parts = [];
|
|
864
|
+
if (state.level !== 'collapsed')
|
|
865
|
+
parts.push(panelMarkup(state, settings));
|
|
866
|
+
parts.push(deckMarkup(state));
|
|
867
|
+
const fault = faultMarkup(state.audio);
|
|
868
|
+
if (fault !== '')
|
|
869
|
+
parts.push(fault);
|
|
870
|
+
const notice = noticeMarkup(state);
|
|
871
|
+
if (notice !== '')
|
|
872
|
+
parts.push(notice);
|
|
873
|
+
return parts.join('');
|
|
496
874
|
}
|
|
497
875
|
/**
|
|
498
876
|
* A picker's options, with the value in force always among them.
|
|
@@ -510,6 +888,16 @@ function choiceOptions(entry, candidates) {
|
|
|
510
888
|
const all = candidates.includes(current) ? candidates : [current, ...candidates];
|
|
511
889
|
return all.map(choice => `<option value="${escapeHtml(choice)}"${choice === current ? ' selected' : ''}>${escapeHtml(choice)}</option>`).join('');
|
|
512
890
|
}
|
|
891
|
+
/**
|
|
892
|
+
* Read a click's level, refusing anything that is not one.
|
|
893
|
+
* @param raw - the `data-level` the button carried.
|
|
894
|
+
* @returns the level it names.
|
|
895
|
+
*/
|
|
896
|
+
function asLevel(raw) {
|
|
897
|
+
if (raw === 'controls' || raw === 'all')
|
|
898
|
+
return raw;
|
|
899
|
+
return 'collapsed';
|
|
900
|
+
}
|
|
513
901
|
/**
|
|
514
902
|
* Mount the panel on its element.
|
|
515
903
|
*
|
|
@@ -522,11 +910,17 @@ function choiceOptions(entry, candidates) {
|
|
|
522
910
|
function mountStrip(deps) {
|
|
523
911
|
let reply = null;
|
|
524
912
|
const notes = {};
|
|
913
|
+
const drafts = {};
|
|
525
914
|
let notice = '';
|
|
526
915
|
/** The page the current notice points at, or empty. Set only where a refusal carried one. */
|
|
527
916
|
let noticeLink = '';
|
|
917
|
+
let level = 'collapsed';
|
|
918
|
+
let connecting = false;
|
|
919
|
+
const settings = () => reply?.settings ?? [];
|
|
528
920
|
const paint = () => {
|
|
529
|
-
deps.root.innerHTML = renderStrip({
|
|
921
|
+
deps.root.innerHTML = renderStrip({
|
|
922
|
+
reply, notes, drafts, notice, noticeLink, audio: deps.audio(), level, connecting,
|
|
923
|
+
});
|
|
530
924
|
};
|
|
531
925
|
const refresh = async () => {
|
|
532
926
|
try {
|
|
@@ -539,52 +933,69 @@ function mountStrip(deps) {
|
|
|
539
933
|
}
|
|
540
934
|
paint();
|
|
541
935
|
};
|
|
936
|
+
/** One list as the editor holds it: what the plugin reported, else what the editor has built. */
|
|
937
|
+
const listAt = (key) => asList(settings().find(entry => entry.key === key)?.value) ?? drafts[key] ?? [];
|
|
542
938
|
/**
|
|
543
|
-
* Send one
|
|
544
|
-
*
|
|
939
|
+
* Send one intent and report what came back, then re-render.
|
|
940
|
+
*
|
|
941
|
+
* An intent can be more than one frame: the wait control writes two budgets, and the pair is reported as
|
|
942
|
+
* a pair. The first refusal stops the sequence — a half-applied pair is a state to *see*, not one to keep
|
|
943
|
+
* writing past.
|
|
944
|
+
* @param frames - the control frames, in order.
|
|
545
945
|
* @param fieldKey - the setting the change was about, when it was about one.
|
|
546
|
-
* @param done - what to say when
|
|
946
|
+
* @param done - what to say when every frame was applied.
|
|
947
|
+
* @returns the refusal it stopped on, or `undefined` when every frame was applied. Returned rather than
|
|
948
|
+
* only recorded because one caller — the wait pair — has something better to say than "applied", and a
|
|
949
|
+
* line written from a re-read must not overwrite a refusal.
|
|
547
950
|
*/
|
|
548
|
-
const send = async (
|
|
549
|
-
let
|
|
951
|
+
const send = async (frames, fieldKey, done) => {
|
|
952
|
+
let refusal;
|
|
550
953
|
try {
|
|
551
|
-
const
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
954
|
+
for (const frame of frames) {
|
|
955
|
+
const answer = await deps.request(frame);
|
|
956
|
+
if (!answer.ok) {
|
|
957
|
+
// A refusal carries the reason written to be relayed verbatim, so it is relayed rather than
|
|
958
|
+
// translated — including for a field frozen by its class. A **session** refusal nests its own
|
|
959
|
+
// classified code, remedy and page and leaves the control-level `reason` unset, so reading only
|
|
960
|
+
// that answered the failure a user is most likely to meet with "refused: no reason given" — a
|
|
961
|
+
// panel saying nothing at the exact moment it knows what to do.
|
|
962
|
+
refusal = answer.refusal === undefined
|
|
963
|
+
? `${answer.code ?? 'refused'}: ${answer.reason ?? 'no reason given'}`
|
|
964
|
+
: `${answer.refusal.code ?? answer.refusal.class ?? 'refused'}: ${answer.refusal.remedy ?? 'no reason given'}`;
|
|
965
|
+
noticeLink = answer.refusal?.link ?? '';
|
|
966
|
+
break;
|
|
967
|
+
}
|
|
563
968
|
noticeLink = '';
|
|
564
|
-
line = answer.ok ? done : `${answer.code ?? 'refused'}: ${answer.reason ?? 'no reason given'}`;
|
|
565
969
|
}
|
|
566
970
|
}
|
|
567
971
|
catch (error) {
|
|
568
|
-
|
|
972
|
+
refusal = error instanceof Error ? error.message : String(error);
|
|
569
973
|
}
|
|
974
|
+
const line = refusal ?? done;
|
|
570
975
|
if (fieldKey === undefined)
|
|
571
976
|
notice = line;
|
|
572
977
|
else
|
|
573
978
|
notes[fieldKey] = line;
|
|
574
979
|
await refresh();
|
|
980
|
+
return refusal;
|
|
575
981
|
};
|
|
576
982
|
const onClick = (event) => {
|
|
577
983
|
const action = event.target?.dataset?.action;
|
|
578
984
|
if (action === undefined)
|
|
579
985
|
return;
|
|
580
986
|
const key = event.target?.dataset?.key;
|
|
581
|
-
const value =
|
|
987
|
+
const value = event.target?.dataset?.value
|
|
988
|
+
?? (key === undefined ? undefined : deps.root.querySelector(`[data-input="${key}"]`)?.value);
|
|
582
989
|
const act = async () => {
|
|
583
990
|
// Cleared per action: a notice from the last one is about the last one, and leaving it up would make
|
|
584
991
|
// a successful reconnect read as a failed one.
|
|
585
992
|
notice = '';
|
|
586
993
|
noticeLink = '';
|
|
587
994
|
switch (action) {
|
|
995
|
+
case 'level':
|
|
996
|
+
level = asLevel(event.target?.dataset?.level);
|
|
997
|
+
paint();
|
|
998
|
+
return;
|
|
588
999
|
case 'refresh':
|
|
589
1000
|
await refresh();
|
|
590
1001
|
return;
|
|
@@ -593,28 +1004,72 @@ function mountStrip(deps) {
|
|
|
593
1004
|
await refresh();
|
|
594
1005
|
return;
|
|
595
1006
|
case 'connect': {
|
|
1007
|
+
// The lamp goes amber for the length of the connect, which is the only thing on this plate that
|
|
1008
|
+
// moves without the status having moved.
|
|
1009
|
+
connecting = true;
|
|
1010
|
+
paint();
|
|
596
1011
|
const reached = await deps.connect();
|
|
1012
|
+
connecting = false;
|
|
597
1013
|
if (reached.kind === 'failed')
|
|
598
1014
|
notice = reached.reason;
|
|
599
1015
|
await refresh();
|
|
600
1016
|
return;
|
|
601
1017
|
}
|
|
602
1018
|
case 'voice-start':
|
|
603
|
-
await send('start', undefined, 'voice started');
|
|
1019
|
+
await send(['start'], undefined, 'voice started');
|
|
604
1020
|
return;
|
|
605
1021
|
case 'voice-stop':
|
|
606
|
-
await send('stop', undefined, 'voice stopped');
|
|
1022
|
+
await send(['stop'], undefined, 'voice stopped');
|
|
1023
|
+
return;
|
|
1024
|
+
case 'toggle':
|
|
1025
|
+
await send([`set ${key ?? ''}=${value ?? ''}`], key, `${value ?? ''}`);
|
|
607
1026
|
return;
|
|
1027
|
+
case 'wait': {
|
|
1028
|
+
const keys = waitKeys(settings());
|
|
1029
|
+
if (keys.length === 0)
|
|
1030
|
+
return;
|
|
1031
|
+
const stopped = await send(keys.map(name => `set ${name}=${value ?? ''}`), keys[0], 'applied');
|
|
1032
|
+
// The note is what the registry now says, not what was asked for: a setter may clamp, and a pair
|
|
1033
|
+
// that half-applied has to be visible as a pair. Only when the write went through, because a
|
|
1034
|
+
// refusal is a better answer than any value.
|
|
1035
|
+
if (stopped === undefined) {
|
|
1036
|
+
notes[keys[0]] = waitSummary(settings());
|
|
1037
|
+
paint();
|
|
1038
|
+
}
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
case 'list-add': {
|
|
1042
|
+
if (key === undefined)
|
|
1043
|
+
return;
|
|
1044
|
+
const next = [...listAt(key), value ?? ''];
|
|
1045
|
+
drafts[key] = next;
|
|
1046
|
+
await send([`set ${key}=${JSON.stringify(next)}`], key, `${String(next.length)} in the list`);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
case 'list-remove': {
|
|
1050
|
+
if (key === undefined)
|
|
1051
|
+
return;
|
|
1052
|
+
// A click that named no index is a click from something the panel did not render. It removes
|
|
1053
|
+
// nothing rather than defaulting to the first entry, which is what `Number(undefined)` would do.
|
|
1054
|
+
const index = event.target?.dataset?.index;
|
|
1055
|
+
const next = listAt(key).filter((_item, at) => at !== (index === undefined ? -1 : Number(index)));
|
|
1056
|
+
drafts[key] = next;
|
|
1057
|
+
await send([`set ${key}=${JSON.stringify(next)}`], key, `${String(next.length)} in the list`);
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
608
1060
|
case 'steer':
|
|
609
|
-
await send(`steer ${value ?? ''}
|
|
1061
|
+
await send([`steer ${value ?? ''}`], key, 'steered');
|
|
610
1062
|
return;
|
|
611
|
-
case 'set': await send(`set ${key ?? ''}=${value ?? ''}
|
|
1063
|
+
case 'set': await send([`set ${key ?? ''}=${value ?? ''}`], key, 'applied');
|
|
612
1064
|
}
|
|
613
1065
|
};
|
|
614
1066
|
void act();
|
|
615
1067
|
};
|
|
616
1068
|
deps.root.addEventListener('click', onClick);
|
|
617
|
-
return {
|
|
1069
|
+
return {
|
|
1070
|
+
refresh,
|
|
1071
|
+
state: () => ({ reply, notes, drafts, notice, noticeLink, audio: deps.audio(), level, connecting }),
|
|
1072
|
+
};
|
|
618
1073
|
}
|
|
619
1074
|
/**
|
|
620
1075
|
* The client plugin. Publishes the client and the strip on a global, and releases them with the plugin.
|