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