moontide-mcp 2.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +44 -6
  2. package/dist/ask.js +30 -8
  3. package/dist/mcp.js +60 -14
  4. package/dist/protocol.js +89 -2
  5. package/dist/reference.js +22 -0
  6. package/dist/session.js +53 -0
  7. package/package.json +1 -1
  8. package/page/ask-view.js +21 -7
  9. package/page/ask.js +292 -34
  10. package/page/change-timing.js +25 -4
  11. package/page/harmony.js +13 -0
  12. package/page/index.html +4 -3
  13. package/page/jam-model.js +11 -2
  14. package/page/jam-view.js +17 -0
  15. package/page/lights.js +278 -0
  16. package/page/main.js +1723 -140
  17. package/page/midi-clock.js +1 -1
  18. package/page/midi-out.js +12 -0
  19. package/page/mixer-groups.js +114 -0
  20. package/page/mixer-model.js +172 -29
  21. package/page/mixer-rewrite.js +30 -1
  22. package/page/mixer-view.js +282 -13
  23. package/page/note-edit.js +267 -0
  24. package/page/note-locate.js +346 -0
  25. package/page/note-panel.js +381 -0
  26. package/page/note-tag.js +126 -0
  27. package/page/page-rewrite.js +184 -0
  28. package/page/song-edit.js +230 -0
  29. package/page/song-fit.js +250 -0
  30. package/page/song-grid-model.js +491 -0
  31. package/page/song-grid-view.js +710 -0
  32. package/page/song-prefs.js +100 -0
  33. package/page/structure-check.js +216 -0
  34. package/page/structure-view.js +38 -0
  35. package/page/structure.js +760 -0
  36. package/page/style.css +879 -84
  37. package/page/takeover.js +20 -5
  38. package/page/timeline-edit-view.js +1956 -0
  39. package/page/timeline-gesture.js +279 -0
  40. package/page/timeline.js +196 -3
  41. package/page/tracks.js +37 -8
  42. package/page/ui-state.js +10 -5
  43. package/page/views.js +25 -8
  44. package/reference/functions.md +1 -0
  45. package/reference/midi.md +2 -0
  46. package/reference/structure.md +131 -0
@@ -14,14 +14,19 @@ import { BUS_IDS, GAIN_STEPS, MOVE_OVER_MAX, MOVE_OVER_MIN, PARAM_STEPS, SENDS,
14
14
  * MIDI fader while it is dragged and the last value at its release, with no evaluation.
15
15
  * `onStripSelect()` (optional) is called after the user selects or clears a track strip (a click on its name, or a
16
16
  * primary-button pointer-down inside it); a render never calls it. The caller reads `selectedStrip()` then.
17
+ * `onGroupFold(key)` is called when a group's fold button is pressed; the caller changes the fold state both views share
18
+ * and draws the Mixer again (the view itself never folds anything).
17
19
  */
18
- export function createMixerView({ document, gesture, midiPreview, onStripSelect = null }) {
20
+ export function createMixerView({ document, gesture, midiPreview, onStripSelect = null, onGroupFold }) {
19
21
  if (!midiPreview || typeof midiPreview.move !== 'function' || typeof midiPreview.flush !== 'function') {
20
22
  throw new Error('Moontide: the Mixer view needs a midiPreview with move and flush.');
21
23
  }
22
24
  if (onStripSelect !== null && typeof onStripSelect !== 'function') {
23
25
  throw new Error('Moontide: the Mixer view needs onStripSelect to be a function.');
24
26
  }
27
+ if (typeof onGroupFold !== 'function') {
28
+ throw new Error('Moontide: the Mixer view needs onGroupFold to be a function.');
29
+ }
25
30
  const mixerView = document.getElementById('mixer-view');
26
31
  const mixerNotice = document.getElementById('mixer-notice');
27
32
  const mixerMessage = document.getElementById('mixer-message');
@@ -37,6 +42,10 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
37
42
  const refs = new Map();
38
43
  // One entry per bus strip, keyed `${orbit}:${bus}`: { el, titleEl, chipsEl, fedByEl, warningEl, chips, current }.
39
44
  const busRefs = new Map();
45
+ // One entry per group frame, keyed by the group's key: { el, foldEl, chevronEl, nameEl, countEl, muteEl, soloEl, noteEl, bodyEl, current, stale }.
46
+ const groupRefs = new Map();
47
+ // Counts the bodies built, so each group body has an id of its own for the fold button's aria-controls.
48
+ let groupBodies = 0;
40
49
 
41
50
  // The inspector's own state: the selected chip's key, the shape of the rows drawn, the rows by
42
51
  // parameter id, the model it shows, whether More parameters is open, and whether a control kept its
@@ -72,6 +81,14 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
72
81
  control.disabled = !writable || !ref.current.editable || control.dataset.controlDisabled === 'true';
73
82
  }
74
83
  }
84
+ // A group head's own buttons (never the frame's querySelectorAll: a frame holds its strips' controls too). They are off
85
+ // while a notice is up and when the group has no track they can reach; the fold button is never one of them.
86
+ // A frame the model no longer has (kept only because a held strip is inside) is off too: its tracks are out of date.
87
+ for (const groupRef of groupRefs.values()) {
88
+ for (const control of [groupRef.muteEl, groupRef.soloEl]) {
89
+ control.disabled = !writable || groupRef.stale === true || control.dataset.controlDisabled === 'true';
90
+ }
91
+ }
75
92
  // The inspector's writing controls follow the same rule; a bypassed effect is read-only.
76
93
  const inspectorEditable = insp.data !== null && insp.data.editable;
77
94
  for (const control of mixerInspector.querySelectorAll('[data-writes="true"]')) {
@@ -159,6 +176,19 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
159
176
  gesture.discrete({ identity, type: kind, on }, mixerHistoryLabel({ kind, name, on }));
160
177
  }
161
178
 
179
+ /**
180
+ * A press of a group's M or S: one discrete change for every track the button reaches, applied and recorded at once, one
181
+ * history version. The direction is the one the model drew: a group with none or some of its tracks pressed presses them
182
+ * all, a group with every track pressed lets them all go.
183
+ */
184
+ function pressGroup(ref, kind) {
185
+ // The buttons of a frame the model no longer has are disabled; this keeps a press that still arrives from writing old names.
186
+ if (ref.stale === true) return;
187
+ const { name } = ref.current;
188
+ const { on, identities } = ref.current[kind];
189
+ gesture.discrete({ type: 'group', kind, on, identities, group: name }, mixerHistoryLabel({ kind: kind === 'mute' ? 'group-mute' : 'group-solo', group: name, on }));
190
+ }
191
+
162
192
  /** Scrolls the strips area sideways, instantly, by the smallest amount that puts `stripEl` wholly in
163
193
  * view (keeping the area's own left padding as a margin). Only the strips area moves. */
164
194
  function revealStrip(stripEl) {
@@ -502,6 +532,148 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
502
532
  });
503
533
  }
504
534
 
535
+ /** Sets an element's text only when it changed, so drawing the same model again writes nothing. */
536
+ function setText(el, text) {
537
+ if (el.textContent !== text) el.textContent = text;
538
+ }
539
+
540
+ /** Sets an attribute only when its value changed. */
541
+ function setAttr(el, name, value) {
542
+ if (el.getAttribute(name) !== value) el.setAttribute(name, value);
543
+ }
544
+
545
+ /**
546
+ * A light: a ring with a fill inside it. The fill is the part that changes (page/lights.js says how bright it is); the
547
+ * whole light is decoration for the eye and is never announced, since a note would otherwise speak every time it plays.
548
+ */
549
+ function createLight() {
550
+ const el = document.createElement('span');
551
+ el.className = 'mx-light';
552
+ el.setAttribute('aria-hidden', 'true');
553
+ const fillEl = document.createElement('span');
554
+ fillEl.className = 'mx-light-fill';
555
+ el.append(fillEl);
556
+ return { el, fillEl };
557
+ }
558
+
559
+ /**
560
+ * The frame of one group: a head (the fold button with its chevron and name, then the count line) and a body that holds
561
+ * the group's strips and sub-groups. Names reach the page only through textContent, title and setAttribute.
562
+ */
563
+ function createGroupFrame(item) {
564
+ const el = document.createElement('div');
565
+ el.className = 'mx-group';
566
+ el.setAttribute('role', 'group');
567
+ const head = document.createElement('div');
568
+ head.className = 'mx-group-head';
569
+ const titleRow = document.createElement('div');
570
+ titleRow.className = 'mx-group-title';
571
+ const foldEl = document.createElement('button');
572
+ foldEl.type = 'button';
573
+ foldEl.className = 'mx-fold';
574
+ const chevronEl = document.createElement('span');
575
+ chevronEl.className = 'mx-fold-chevron';
576
+ chevronEl.setAttribute('aria-hidden', 'true');
577
+ const nameEl = document.createElement('span');
578
+ nameEl.className = 'mx-fold-name';
579
+ foldEl.append(chevronEl, nameEl);
580
+ const light = createLight();
581
+ titleRow.append(light.el, foldEl);
582
+ const line = document.createElement('div');
583
+ line.className = 'mx-group-line';
584
+ const countEl = document.createElement('span');
585
+ countEl.className = 'mx-group-count';
586
+ const muteEl = createToggle('mx-mute', 'M', MIXER_COPY.mute);
587
+ const soloEl = createToggle('mx-solo', 'S', MIXER_COPY.solo);
588
+ line.append(countEl, muteEl, soloEl);
589
+ // The one muted line that says why a button reaches fewer tracks, or that the group has none.
590
+ const noteEl = document.createElement('p');
591
+ noteEl.className = 'mx-group-note';
592
+ noteEl.toggleAttribute('hidden', true);
593
+ head.append(titleRow, line, noteEl);
594
+ const bodyEl = document.createElement('div');
595
+ bodyEl.className = 'mx-group-body';
596
+ groupBodies += 1;
597
+ bodyEl.id = `mx-group-body-${groupBodies}`;
598
+ foldEl.setAttribute('aria-controls', bodyEl.id);
599
+ el.append(head, bodyEl);
600
+ const ref = { el, foldEl, chevronEl, nameEl, lightEl: light.el, lightFillEl: light.fillEl, countEl, muteEl, soloEl, noteEl, bodyEl, current: item };
601
+ // Only the caller changes the fold state: this view just says which group was pressed.
602
+ // A frame the model no longer has (ref.stale) folds nothing: its group is not in the code any more.
603
+ foldEl.addEventListener('click', () => {
604
+ if (ref.stale !== true) onGroupFold(ref.current.key);
605
+ });
606
+ muteEl.addEventListener('click', () => pressGroup(ref, 'mute'));
607
+ soloEl.addEventListener('click', () => pressGroup(ref, 'solo'));
608
+ return ref;
609
+ }
610
+
611
+ function updateGroupFrame(ref, item) {
612
+ ref.current = item;
613
+ ref.stale = false;
614
+ const { el } = ref;
615
+ setAttr(el, 'aria-label', item.label);
616
+ setAttr(el, 'data-group', item.key);
617
+ setAttr(el, 'data-folded', String(!item.open));
618
+ if (item.deep) setAttr(el, 'data-deep', 'true');
619
+ else if (el.hasAttribute('data-deep')) el.removeAttribute('data-deep');
620
+ setText(ref.chevronEl, item.open ? '\u25be' : '\u25b8');
621
+ setText(ref.nameEl, item.text);
622
+ setAttr(ref.nameEl, 'title', item.title);
623
+ setAttr(ref.foldEl, 'aria-expanded', String(item.open));
624
+ setAttr(ref.foldEl, 'title', item.foldTitle);
625
+ // A function as the replacement keeps a `$` in a name from being read as a pattern.
626
+ setAttr(ref.lightEl, 'title', MIXER_COPY.lightGroupTitle.replace('{group}', () => item.text));
627
+ setText(ref.countEl, item.countText);
628
+ setAttr(ref.countEl, 'title', item.countTitle);
629
+ for (const [button, kind] of [[ref.muteEl, 'mute'], [ref.soloEl, 'solo']]) {
630
+ const control = item[kind];
631
+ setAttr(button, 'aria-label', control.name);
632
+ setAttr(button, 'aria-pressed', String(control.pressed));
633
+ setAttr(button, 'title', control.title);
634
+ setAttr(button, 'data-control-disabled', String(control.disabled));
635
+ }
636
+ const noteText = item.noteText;
637
+ setText(ref.noteEl, noteText ?? '');
638
+ if (noteText === null) {
639
+ if (ref.noteEl.hasAttribute('title')) ref.noteEl.removeAttribute('title');
640
+ } else {
641
+ setAttr(ref.noteEl, 'title', noteText);
642
+ }
643
+ if (ref.noteEl.hasAttribute('hidden') !== (noteText === null)) ref.noteEl.toggleAttribute('hidden', noteText === null);
644
+ if (ref.bodyEl.hasAttribute('hidden') === item.open) ref.bodyEl.toggleAttribute('hidden', !item.open);
645
+ }
646
+
647
+ /**
648
+ * Puts `desired` (elements) as the children of `parent`, in that order, moving only what is not already in place.
649
+ * `held` holds the elements that must not be moved or removed (a strip whose fader the hand holds): they stay where
650
+ * they are, and `misplaced` collects those that are not where the model wants them, so a later draw puts them right.
651
+ */
652
+ function place(parent, desired, held, misplaced) {
653
+ const wanted = new Set(desired);
654
+ for (const child of [...parent.children]) {
655
+ if (wanted.has(child) || held.has(child)) continue;
656
+ // A frame that still holds a held strip stays until the strip is let go.
657
+ if (held.size > 0 && [...held].some((heldEl) => child.contains(heldEl))) continue;
658
+ child.remove();
659
+ }
660
+ let next = null;
661
+ for (let i = desired.length - 1; i >= 0; i--) {
662
+ const el = desired[i];
663
+ if (el.parentNode === parent && el.nextSibling === next) {
664
+ next = el;
665
+ continue;
666
+ }
667
+ if (held.has(el) && el.parentNode !== null) {
668
+ misplaced.add(el);
669
+ if (el.parentNode === parent) next = el;
670
+ continue;
671
+ }
672
+ parent.insertBefore(el, next);
673
+ next = el;
674
+ }
675
+ }
676
+
505
677
  function createStrip(strip) {
506
678
  const el = document.createElement('div');
507
679
  el.className = 'mx-strip';
@@ -510,6 +682,11 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
510
682
  nameEl.type = 'button';
511
683
  nameEl.className = 'mx-name';
512
684
  nameEl.setAttribute('aria-pressed', 'false');
685
+ // The light sits before the name; the row keeps both on one line. A strip with no label has no light (updateStrip hides it).
686
+ const nameRowEl = document.createElement('div');
687
+ nameRowEl.className = 'mx-name-row';
688
+ const light = createLight();
689
+ nameRowEl.append(light.el, nameEl);
513
690
  // Present only for a strip the mixer cannot edit: the one muted line that says why.
514
691
  const reasonEl = document.createElement('div');
515
692
  reasonEl.className = 'mx-reason';
@@ -581,11 +758,11 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
581
758
  sharedEl.toggleAttribute('hidden', true);
582
759
  if (midi) {
583
760
  el.dataset.midi = 'true';
584
- el.append(nameEl, tagEl, rootNoteEl, midiChipsEl, midiNoteEl, section, sharedEl, msEl, noteEl);
761
+ el.append(nameRowEl, tagEl, rootNoteEl, midiChipsEl, midiNoteEl, section, sharedEl, msEl, noteEl);
585
762
  } else {
586
- el.append(nameEl, rootNoteEl, chainEl, sendsEl, section, msEl, noteEl);
763
+ el.append(nameRowEl, rootNoteEl, chainEl, sendsEl, section, msEl, noteEl);
587
764
  }
588
- const ref = { midi, el, nameEl, rootNoteEl, sharedEl, tagEl, tagNameEl, tagChannelEl, reasonEl, faderEl, readoutEl, lineEl, section, muteEl, soloEl, noteEl, chainEl, addEl, addListEl, sendsEl, sendRows: [], chips: new Map(), midiChipsEl, midiNoteEl, midiChips: new Map(), addOpen: false, addKey: null, current: strip, pointerDown: false };
765
+ const ref = { midi, el, nameEl, lightEl: light.el, lightFillEl: light.fillEl, rootNoteEl, sharedEl, tagEl, tagNameEl, tagChannelEl, reasonEl, faderEl, readoutEl, lineEl, section, muteEl, soloEl, noteEl, chainEl, addEl, addListEl, sendsEl, sendRows: [], chips: new Map(), midiChipsEl, midiNoteEl, midiChips: new Map(), addOpen: false, addKey: null, current: strip, pointerDown: false };
589
766
  for (const bus of BUS_IDS) {
590
767
  const row = createSendRow(ref, bus);
591
768
  ref.sendRows.push(row);
@@ -647,6 +824,9 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
647
824
  ref.el.dataset.color = String(strip.color);
648
825
  ref.nameEl.textContent = strip.name;
649
826
  ref.nameEl.title = strip.name;
827
+ const hasLight = strip.lightKey !== null && strip.lightKey !== undefined;
828
+ if (ref.lightEl.hasAttribute('hidden') === hasLight) ref.lightEl.toggleAttribute('hidden', !hasLight);
829
+ setAttr(ref.lightEl, 'title', MIXER_COPY.lightTrackTitle.replace('{name}', () => strip.name));
650
830
  const tag = strip.midi === null ? null : strip.midi.tag;
651
831
  ref.tagEl.toggleAttribute('hidden', tag === null);
652
832
  ref.tagNameEl.textContent = tag === null ? '' : tag.name;
@@ -1863,6 +2043,7 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
1863
2043
  mixerNoNotes.textContent = '';
1864
2044
  refs.clear();
1865
2045
  busRefs.clear();
2046
+ groupRefs.clear();
1866
2047
  selectedStrip = null;
1867
2048
  mixerStrips.replaceChildren();
1868
2049
  renderInspector();
@@ -1878,7 +2059,12 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
1878
2059
  else mixerNoNotes.title = MIXER_COPY.noNotesTitle;
1879
2060
  mixerNoNotes.hidden = noNotesText === null;
1880
2061
  const seen = new Set();
1881
- model.strips.forEach((strip, index) => {
2062
+ // The strips placed in the DOM whose fader the hand holds: they are not moved or removed until it lets go.
2063
+ const held = new Set();
2064
+ for (const ref of refs.values()) {
2065
+ if (ref.el.parentNode !== null && isHeld(ref)) held.add(ref.el);
2066
+ }
2067
+ model.strips.forEach((strip) => {
1882
2068
  seen.add(strip.identity);
1883
2069
  let ref = refs.get(strip.identity);
1884
2070
  if (ref && ref.midi !== (strip.midi !== null)) {
@@ -1897,14 +2083,11 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
1897
2083
  refs.set(strip.identity, ref);
1898
2084
  }
1899
2085
  updateStrip(ref, strip);
1900
- // Move nodes only when the order changed, so a node the user is dragging is never re-attached.
1901
- if (mixerStrips.children[index] !== ref.el) {
1902
- mixerStrips.insertBefore(ref.el, mixerStrips.children[index] || null);
1903
- }
1904
2086
  });
1905
2087
  // The bus strips come after the tracks, in the model's order (orbit, then reverb before delay).
1906
2088
  const seenBuses = new Set();
1907
- model.buses.forEach((entry, index) => {
2089
+ const busEls = [];
2090
+ model.buses.forEach((entry) => {
1908
2091
  seenBuses.add(entry.key);
1909
2092
  let busRef = busRefs.get(entry.key);
1910
2093
  if (!busRef) {
@@ -1912,9 +2095,59 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
1912
2095
  busRefs.set(entry.key, busRef);
1913
2096
  }
1914
2097
  updateBusStrip(busRef, entry);
1915
- const position = model.strips.length + index;
1916
- if (mixerStrips.children[position] !== busRef.el) mixerStrips.insertBefore(busRef.el, mixerStrips.children[position] || null);
2098
+ busEls.push(busRef.el);
1917
2099
  });
2100
+ // Where every element goes: a flat Mixer is its strips then its buses; a structured piece nests the strips in group
2101
+ // frames (the frames are walked with a stack, never by recursion). Each parent gets the order it should have.
2102
+ const topEls = [];
2103
+ const plans = [{ parent: mixerStrips, desired: topEls }];
2104
+ const seenGroups = new Set();
2105
+ if (model.layout === null || model.layout === undefined) {
2106
+ for (const strip of model.strips) {
2107
+ const ref = refs.get(strip.identity);
2108
+ if (ref !== undefined) topEls.push(ref.el);
2109
+ }
2110
+ } else {
2111
+ const work = [{ items: model.layout.items, desired: topEls }];
2112
+ while (work.length > 0) {
2113
+ const { items, desired } = work.pop();
2114
+ for (const item of items) {
2115
+ if (item.kind === 'strip') {
2116
+ const ref = refs.get(item.identity);
2117
+ if (ref !== undefined) desired.push(ref.el);
2118
+ continue;
2119
+ }
2120
+ // Two frames with one key would share one element and hide strips, so a repeated key stops the render.
2121
+ if (seenGroups.has(item.key)) throw new Error(`Moontide: the Mixer got the group ${item.key} twice.`);
2122
+ seenGroups.add(item.key);
2123
+ let groupRef = groupRefs.get(item.key);
2124
+ if (groupRef === undefined) {
2125
+ groupRef = createGroupFrame(item);
2126
+ groupRefs.set(item.key, groupRef);
2127
+ }
2128
+ updateGroupFrame(groupRef, item);
2129
+ desired.push(groupRef.el);
2130
+ const inner = [];
2131
+ plans.push({ parent: groupRef.bodyEl, desired: inner });
2132
+ work.push({ items: item.items, desired: inner });
2133
+ }
2134
+ }
2135
+ }
2136
+ for (const el of busEls) topEls.push(el);
2137
+ const misplaced = new Set();
2138
+ for (const { parent, desired } of plans) place(parent, desired, held, misplaced);
2139
+ // A frame the model no longer has goes, with the frames nested in it. One that still holds a held strip stays until the
2140
+ // strip is let go, marked stale: its buttons are off and hasStale asks for a draw after the release.
2141
+ for (const [key, groupRef] of [...groupRefs]) {
2142
+ if (seenGroups.has(key)) continue;
2143
+ if (groupRef.el.parentNode !== null && [...held].some((heldEl) => groupRef.el.contains(heldEl))) {
2144
+ groupRef.stale = true;
2145
+ continue;
2146
+ }
2147
+ groupRef.el.remove();
2148
+ groupRefs.delete(key);
2149
+ }
2150
+ for (const ref of refs.values()) ref.misplaced = misplaced.has(ref.el);
1918
2151
  for (const [key, busRef] of [...busRefs]) {
1919
2152
  if (seenBuses.has(key)) continue;
1920
2153
  busRef.el.remove();
@@ -1959,6 +2192,15 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
1959
2192
  const busRef = busRefs.get(`${busEl.dataset.orbit}:${busEl.dataset.bus}`);
1960
2193
  return busRef === undefined ? null : busStripTarget(element, busRef);
1961
2194
  }
2195
+ // A group's head or frame (a strip inside it was answered above): the group, with its parent group when it is nested.
2196
+ // The names come from the model's entry for the frame, never from the shown (cut) text.
2197
+ const groupEl = element.closest('.mx-group[data-group]');
2198
+ if (groupEl !== null) {
2199
+ const groupRef = groupRefs.get(groupEl.dataset.group);
2200
+ if (groupRef === undefined) return null;
2201
+ const { name, path } = groupRef.current;
2202
+ return path.length > 1 ? { kind: 'group', name, parent: path[path.length - 2] } : { kind: 'group', name };
2203
+ }
1962
2204
  return null;
1963
2205
  },
1964
2206
  /** The identity of the selected track strip, or null: the editor lights that track's block. */
@@ -1979,11 +2221,38 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
1979
2221
  /** True when a fader kept its own value while held and is free to take the model's now. */
1980
2222
  hasStale() {
1981
2223
  for (const ref of refs.values()) {
1982
- if (ref.stale && !isHeld(ref)) return true;
2224
+ if ((ref.stale || ref.misplaced === true) && !isHeld(ref)) return true;
2225
+ }
2226
+ // A frame kept for a strip that has been let go since: the next draw removes it.
2227
+ for (const groupRef of groupRefs.values()) {
2228
+ if (groupRef.stale === true && !gesture.hasPending() && ![...refs.values()].some((ref) => ref.pointerDown && groupRef.el.contains(ref.el))) return true;
1983
2229
  }
1984
2230
  // An inspector control kept its own value while it was held: free to take the model's now.
1985
2231
  return insp.stale && !gesture.hasPending() && ![...insp.rows.values()].some((r) => r.pointerDown);
1986
2232
  },
2233
+ /**
2234
+ * Draws the lights that changed (page/lights.js frame): `[{ kind: 'track' | 'group', id, value }]`, `value` from 0 to 1.
2235
+ * With motion 'on' the fill's own opacity is the brightness; with motion 'off' a light is only lit or unlit, which the
2236
+ * stylesheet shows through `data-lit`, and no opacity is ever written. A light this view does not hold is skipped.
2237
+ */
2238
+ setLights(changes, motion) {
2239
+ if (motion !== 'on' && motion !== 'off') {
2240
+ throw new Error(`Moontide: the lights need motion 'on' or 'off', got ${String(motion)}.`);
2241
+ }
2242
+ for (const change of changes) {
2243
+ const ref = change.kind === 'group' ? groupRefs.get(change.id) : refs.get(change.id);
2244
+ if (ref === undefined) continue;
2245
+ if (motion === 'on') ref.lightFillEl.style.opacity = String(change.value);
2246
+ else ref.lightEl.dataset.lit = String(change.value > 0);
2247
+ }
2248
+ },
2249
+ /** Takes every light back to unlit: no inline opacity and no lit mark, in both forms. */
2250
+ clearLights() {
2251
+ for (const ref of [...refs.values(), ...groupRefs.values()]) {
2252
+ if (ref.lightFillEl.style.opacity !== '') ref.lightFillEl.style.opacity = '';
2253
+ if (ref.lightEl.dataset.lit !== undefined) delete ref.lightEl.dataset.lit;
2254
+ }
2255
+ },
1987
2256
  /** 'connected' | 'connecting' | 'disconnected' (the same states the History view gets). */
1988
2257
  setConnected(state) {
1989
2258
  connection = state;