moontide-mcp 2.0.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -6
- package/dist/ask.js +76 -15
- package/dist/mcp.js +198 -26
- package/dist/protocol.js +130 -2
- package/dist/reference.js +22 -0
- package/dist/request-status.js +221 -0
- package/dist/session.js +256 -7
- package/package.json +1 -1
- package/page/ask-view.js +64 -57
- package/page/ask.js +363 -188
- package/page/bar-model.js +43 -2
- package/page/change-timing.js +60 -4
- package/page/harmony.js +13 -0
- package/page/index.html +7 -4
- package/page/jam-analysis.js +68 -23
- package/page/jam-model.js +196 -31
- package/page/jam-resolve.js +271 -0
- package/page/jam-view.js +182 -10
- package/page/lights.js +300 -0
- package/page/main.js +2171 -188
- package/page/midi-clock.js +23 -4
- package/page/midi-out.js +17 -2
- package/page/mixer-groups.js +114 -0
- package/page/mixer-model.js +172 -29
- package/page/mixer-rewrite.js +30 -1
- package/page/mixer-view.js +289 -14
- package/page/note-edit.js +267 -0
- package/page/note-locate.js +346 -0
- package/page/note-panel.js +402 -0
- package/page/note-tag.js +141 -0
- package/page/page-rewrite.js +184 -0
- package/page/query-check.js +21 -2
- package/page/request-status-view.js +419 -0
- package/page/request-status.js +686 -0
- package/page/song-edit.js +230 -0
- package/page/song-fit.js +250 -0
- package/page/song-grid-model.js +506 -0
- package/page/song-grid-view.js +710 -0
- package/page/song-prefs.js +100 -0
- package/page/structure-check.js +216 -0
- package/page/structure-view.js +38 -0
- package/page/structure.js +761 -0
- package/page/style.css +1218 -65
- package/page/takeover.js +20 -5
- package/page/tempo-glide.js +330 -0
- package/page/tempo-guard.js +62 -10
- package/page/timeline-edit-view.js +1956 -0
- package/page/timeline-gesture.js +279 -0
- package/page/timeline.js +197 -3
- package/page/top-bar-view.js +18 -13
- package/page/tracks.js +37 -8
- package/page/ui-state.js +10 -5
- package/page/views.js +39 -9
- package/reference/functions.md +1 -0
- package/reference/midi.md +2 -0
- package/reference/structure.md +131 -0
- package/reference/transitions.md +13 -1
package/page/mixer-rewrite.js
CHANGED
|
@@ -721,6 +721,10 @@ function inspectTrack(code, entry, scope) {
|
|
|
721
721
|
const sharedRoot =
|
|
722
722
|
(root.type === 'Identifier' && declared.has(root.name)) ||
|
|
723
723
|
(root.type === 'CallExpression' && root.callee.type === 'Identifier' && declared.has(root.callee.name));
|
|
724
|
+
// A track that starts with play(...) may be built by the song's own helper, whose lines are not shared code the user needs
|
|
725
|
+
// to hear about. The model drops the "built from shared code" line only when the song reading says that helper is the
|
|
726
|
+
// canonical one; this read just says the track starts with a call named play.
|
|
727
|
+
const playRoot = root.type === 'CallExpression' && root.callee.type === 'Identifier' && root.callee.name === 'play';
|
|
724
728
|
for (const call of spine.calls) {
|
|
725
729
|
if (!MIXER_CALLS.has(call.name) && effectOfMethod(call.name) === null) continue;
|
|
726
730
|
const args = call.node.arguments;
|
|
@@ -731,6 +735,7 @@ function inspectTrack(code, entry, scope) {
|
|
|
731
735
|
return {
|
|
732
736
|
spine,
|
|
733
737
|
sharedRoot,
|
|
738
|
+
playRoot,
|
|
734
739
|
...readReach(spine, entry.expression, scope),
|
|
735
740
|
gain: readControl(spine.calls, 'gain'),
|
|
736
741
|
postgain: readControl(spine.calls, 'postgain'),
|
|
@@ -774,7 +779,7 @@ function publicBus({ orbit, sends, busParams }) {
|
|
|
774
779
|
/**
|
|
775
780
|
* Parses `code` once and reads every track: `{ tracks, reads }`, `tracks` from extractTracks and
|
|
776
781
|
* `reads` a Map from track identity to `{ ok: true, gain: { kind, value }, postgain: { kind,
|
|
777
|
-
* value }, effects, bus, label, sharedRoot, playsMidi, innerPostgain }` (`sharedRoot` is true when the track's root is a declared name or a call of one; `playsMidi` is true when a `.midi(` call sits anywhere the track's code reaches, `innerPostgain` is true when a `.postgain(` call sits in that code off the spine; `busHidden` is true when the track writes an orbit, a send or a bus value anywhere but on its own chain (inside another call, in shared code it names, through `.set({ ... })` or a bare call), so its bus facts are not trusted; see readReach; kind 'none', 'fixed' or 'pattern'; `bus` is `{ orbit, sends, busParams }`
|
|
782
|
+
* value }, effects, bus, label, sharedRoot, playRoot, playsMidi, innerPostgain }` (`sharedRoot` is true when the track's root is a declared name or a call of one; `playRoot` is true when the track's own statement starts with a `play(...)` call; `playsMidi` is true when a `.midi(` call sits anywhere the track's code reaches, `innerPostgain` is true when a `.postgain(` call sits in that code off the spine; `busHidden` is true when the track writes an orbit, a send or a bus value anywhere but on its own chain (inside another call, in shared code it names, through `.set({ ... })` or a bare call), so its bus facts are not trusted; see readReach; kind 'none', 'fixed' or 'pattern'; `bus` is `{ orbit, sends, busParams }`
|
|
778
783
|
* of readBusFacts without call positions (kind 'unwritten', 'fixed' or 'pattern'); `effects` is a Map from effect id
|
|
779
784
|
* to `{ calls, params, written, bypassable, bypassWhy }`, see readEffects; `label` is null for a track without one,
|
|
780
785
|
* else `{ raw, ...parseLabel(raw) }`) or `{ ok: false, reason }`. One bad track never throws; an
|
|
@@ -798,6 +803,7 @@ export function readProgram(code, { parse }) {
|
|
|
798
803
|
bus: publicBus(read.bus),
|
|
799
804
|
label: raw === null ? null : { raw, ...parseLabel(raw) },
|
|
800
805
|
sharedRoot: read.sharedRoot,
|
|
806
|
+
playRoot: read.playRoot,
|
|
801
807
|
playsMidi: read.playsMidi,
|
|
802
808
|
innerPostgain: read.innerPostgain,
|
|
803
809
|
busHidden: read.busHidden,
|
|
@@ -1561,3 +1567,26 @@ export function writeTrack(code, identity, change, { parse }) {
|
|
|
1561
1567
|
}
|
|
1562
1568
|
return { code: applyEdits(code, edits), edits };
|
|
1563
1569
|
}
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* Writes one mute or solo change on many tracks at once and returns `{ code, edits }`, like writeTrack. `change` is
|
|
1573
|
+
* `{ type: 'mute' | 'solo', on }` and `identities` the tracks to write, each read and written against the SAME base
|
|
1574
|
+
* text. All their edits are joined into one list and applied once, by position, so the result is the same whatever
|
|
1575
|
+
* order the tracks are listed in, no track is written twice (a repeated identity counts once) and two edits that
|
|
1576
|
+
* overlap throw. The first track that cannot be written throws its MixerRewriteError and nothing is returned, so a
|
|
1577
|
+
* refused track never leaves the others half written. A track already in the asked state adds no edit.
|
|
1578
|
+
*/
|
|
1579
|
+
export function writeTracks(code, identities, change, { parse }) {
|
|
1580
|
+
if (!Array.isArray(identities) || identities.length === 0 || identities.some((identity) => typeof identity !== 'string')) {
|
|
1581
|
+
throw new MixerRewriteError('unsupported-change', 'a group change needs at least one track.');
|
|
1582
|
+
}
|
|
1583
|
+
const type = change ? change.type : undefined;
|
|
1584
|
+
if (type !== 'mute' && type !== 'solo') {
|
|
1585
|
+
throw new MixerRewriteError('unsupported-change', `a group can only be muted or soloed, not '${String(type)}'.`);
|
|
1586
|
+
}
|
|
1587
|
+
const edits = [];
|
|
1588
|
+
for (const identity of new Set(identities)) {
|
|
1589
|
+
edits.push(...writeTrack(code, identity, { identity, type, on: change.on }, { parse }).edits);
|
|
1590
|
+
}
|
|
1591
|
+
return { code: applyEdits(code, edits), edits };
|
|
1592
|
+
}
|
package/page/mixer-view.js
CHANGED
|
@@ -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(
|
|
761
|
+
el.append(nameRowEl, tagEl, rootNoteEl, midiChipsEl, midiNoteEl, section, sharedEl, msEl, noteEl);
|
|
585
762
|
} else {
|
|
586
|
-
el.append(
|
|
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,11 +2043,12 @@ 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();
|
|
1869
2050
|
refreshNotice();
|
|
1870
|
-
return;
|
|
2051
|
+
return { created: 0 };
|
|
1871
2052
|
}
|
|
1872
2053
|
mixerMessage.hidden = true;
|
|
1873
2054
|
mixerBody.hidden = false;
|
|
@@ -1877,8 +2058,16 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
|
|
|
1877
2058
|
if (noNotesText === null) mixerNoNotes.removeAttribute('title');
|
|
1878
2059
|
else mixerNoNotes.title = MIXER_COPY.noNotesTitle;
|
|
1879
2060
|
mixerNoNotes.hidden = noNotesText === null;
|
|
2061
|
+
// How many strips and group frames this render built new. A new element starts dark, so the caller makes the lights write
|
|
2062
|
+
// their lit values again when it is more than 0.
|
|
2063
|
+
let created = 0;
|
|
1880
2064
|
const seen = new Set();
|
|
1881
|
-
|
|
2065
|
+
// The strips placed in the DOM whose fader the hand holds: they are not moved or removed until it lets go.
|
|
2066
|
+
const held = new Set();
|
|
2067
|
+
for (const ref of refs.values()) {
|
|
2068
|
+
if (ref.el.parentNode !== null && isHeld(ref)) held.add(ref.el);
|
|
2069
|
+
}
|
|
2070
|
+
model.strips.forEach((strip) => {
|
|
1882
2071
|
seen.add(strip.identity);
|
|
1883
2072
|
let ref = refs.get(strip.identity);
|
|
1884
2073
|
if (ref && ref.midi !== (strip.midi !== null)) {
|
|
@@ -1895,16 +2084,14 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
|
|
|
1895
2084
|
if (!ref) {
|
|
1896
2085
|
ref = createStrip(strip);
|
|
1897
2086
|
refs.set(strip.identity, ref);
|
|
2087
|
+
created += 1;
|
|
1898
2088
|
}
|
|
1899
2089
|
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
2090
|
});
|
|
1905
2091
|
// The bus strips come after the tracks, in the model's order (orbit, then reverb before delay).
|
|
1906
2092
|
const seenBuses = new Set();
|
|
1907
|
-
|
|
2093
|
+
const busEls = [];
|
|
2094
|
+
model.buses.forEach((entry) => {
|
|
1908
2095
|
seenBuses.add(entry.key);
|
|
1909
2096
|
let busRef = busRefs.get(entry.key);
|
|
1910
2097
|
if (!busRef) {
|
|
@@ -1912,9 +2099,60 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
|
|
|
1912
2099
|
busRefs.set(entry.key, busRef);
|
|
1913
2100
|
}
|
|
1914
2101
|
updateBusStrip(busRef, entry);
|
|
1915
|
-
|
|
1916
|
-
if (mixerStrips.children[position] !== busRef.el) mixerStrips.insertBefore(busRef.el, mixerStrips.children[position] || null);
|
|
2102
|
+
busEls.push(busRef.el);
|
|
1917
2103
|
});
|
|
2104
|
+
// Where every element goes: a flat Mixer is its strips then its buses; a structured piece nests the strips in group
|
|
2105
|
+
// frames (the frames are walked with a stack, never by recursion). Each parent gets the order it should have.
|
|
2106
|
+
const topEls = [];
|
|
2107
|
+
const plans = [{ parent: mixerStrips, desired: topEls }];
|
|
2108
|
+
const seenGroups = new Set();
|
|
2109
|
+
if (model.layout === null || model.layout === undefined) {
|
|
2110
|
+
for (const strip of model.strips) {
|
|
2111
|
+
const ref = refs.get(strip.identity);
|
|
2112
|
+
if (ref !== undefined) topEls.push(ref.el);
|
|
2113
|
+
}
|
|
2114
|
+
} else {
|
|
2115
|
+
const work = [{ items: model.layout.items, desired: topEls }];
|
|
2116
|
+
while (work.length > 0) {
|
|
2117
|
+
const { items, desired } = work.pop();
|
|
2118
|
+
for (const item of items) {
|
|
2119
|
+
if (item.kind === 'strip') {
|
|
2120
|
+
const ref = refs.get(item.identity);
|
|
2121
|
+
if (ref !== undefined) desired.push(ref.el);
|
|
2122
|
+
continue;
|
|
2123
|
+
}
|
|
2124
|
+
// Two frames with one key would share one element and hide strips, so a repeated key stops the render.
|
|
2125
|
+
if (seenGroups.has(item.key)) throw new Error(`Moontide: the Mixer got the group ${item.key} twice.`);
|
|
2126
|
+
seenGroups.add(item.key);
|
|
2127
|
+
let groupRef = groupRefs.get(item.key);
|
|
2128
|
+
if (groupRef === undefined) {
|
|
2129
|
+
groupRef = createGroupFrame(item);
|
|
2130
|
+
groupRefs.set(item.key, groupRef);
|
|
2131
|
+
created += 1;
|
|
2132
|
+
}
|
|
2133
|
+
updateGroupFrame(groupRef, item);
|
|
2134
|
+
desired.push(groupRef.el);
|
|
2135
|
+
const inner = [];
|
|
2136
|
+
plans.push({ parent: groupRef.bodyEl, desired: inner });
|
|
2137
|
+
work.push({ items: item.items, desired: inner });
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
for (const el of busEls) topEls.push(el);
|
|
2142
|
+
const misplaced = new Set();
|
|
2143
|
+
for (const { parent, desired } of plans) place(parent, desired, held, misplaced);
|
|
2144
|
+
// A frame the model no longer has goes, with the frames nested in it. One that still holds a held strip stays until the
|
|
2145
|
+
// strip is let go, marked stale: its buttons are off and hasStale asks for a draw after the release.
|
|
2146
|
+
for (const [key, groupRef] of [...groupRefs]) {
|
|
2147
|
+
if (seenGroups.has(key)) continue;
|
|
2148
|
+
if (groupRef.el.parentNode !== null && [...held].some((heldEl) => groupRef.el.contains(heldEl))) {
|
|
2149
|
+
groupRef.stale = true;
|
|
2150
|
+
continue;
|
|
2151
|
+
}
|
|
2152
|
+
groupRef.el.remove();
|
|
2153
|
+
groupRefs.delete(key);
|
|
2154
|
+
}
|
|
2155
|
+
for (const ref of refs.values()) ref.misplaced = misplaced.has(ref.el);
|
|
1918
2156
|
for (const [key, busRef] of [...busRefs]) {
|
|
1919
2157
|
if (seenBuses.has(key)) continue;
|
|
1920
2158
|
busRef.el.remove();
|
|
@@ -1936,6 +2174,7 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
|
|
|
1936
2174
|
refs.delete(identity);
|
|
1937
2175
|
}
|
|
1938
2176
|
refreshDisabled();
|
|
2177
|
+
return { created };
|
|
1939
2178
|
}
|
|
1940
2179
|
|
|
1941
2180
|
return {
|
|
@@ -1959,6 +2198,15 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
|
|
|
1959
2198
|
const busRef = busRefs.get(`${busEl.dataset.orbit}:${busEl.dataset.bus}`);
|
|
1960
2199
|
return busRef === undefined ? null : busStripTarget(element, busRef);
|
|
1961
2200
|
}
|
|
2201
|
+
// A group's head or frame (a strip inside it was answered above): the group, with its parent group when it is nested.
|
|
2202
|
+
// The names come from the model's entry for the frame, never from the shown (cut) text.
|
|
2203
|
+
const groupEl = element.closest('.mx-group[data-group]');
|
|
2204
|
+
if (groupEl !== null) {
|
|
2205
|
+
const groupRef = groupRefs.get(groupEl.dataset.group);
|
|
2206
|
+
if (groupRef === undefined) return null;
|
|
2207
|
+
const { name, path } = groupRef.current;
|
|
2208
|
+
return path.length > 1 ? { kind: 'group', name, parent: path[path.length - 2] } : { kind: 'group', name };
|
|
2209
|
+
}
|
|
1962
2210
|
return null;
|
|
1963
2211
|
},
|
|
1964
2212
|
/** The identity of the selected track strip, or null: the editor lights that track's block. */
|
|
@@ -1979,11 +2227,38 @@ export function createMixerView({ document, gesture, midiPreview, onStripSelect
|
|
|
1979
2227
|
/** True when a fader kept its own value while held and is free to take the model's now. */
|
|
1980
2228
|
hasStale() {
|
|
1981
2229
|
for (const ref of refs.values()) {
|
|
1982
|
-
if (ref.stale && !isHeld(ref)) return true;
|
|
2230
|
+
if ((ref.stale || ref.misplaced === true) && !isHeld(ref)) return true;
|
|
2231
|
+
}
|
|
2232
|
+
// A frame kept for a strip that has been let go since: the next draw removes it.
|
|
2233
|
+
for (const groupRef of groupRefs.values()) {
|
|
2234
|
+
if (groupRef.stale === true && !gesture.hasPending() && ![...refs.values()].some((ref) => ref.pointerDown && groupRef.el.contains(ref.el))) return true;
|
|
1983
2235
|
}
|
|
1984
2236
|
// An inspector control kept its own value while it was held: free to take the model's now.
|
|
1985
2237
|
return insp.stale && !gesture.hasPending() && ![...insp.rows.values()].some((r) => r.pointerDown);
|
|
1986
2238
|
},
|
|
2239
|
+
/**
|
|
2240
|
+
* Draws the lights that changed (page/lights.js frame): `[{ kind: 'track' | 'group', id, value }]`, `value` from 0 to 1.
|
|
2241
|
+
* With motion 'on' the fill's own opacity is the brightness; with motion 'off' a light is only lit or unlit, which the
|
|
2242
|
+
* stylesheet shows through `data-lit`, and no opacity is ever written. A light this view does not hold is skipped.
|
|
2243
|
+
*/
|
|
2244
|
+
setLights(changes, motion) {
|
|
2245
|
+
if (motion !== 'on' && motion !== 'off') {
|
|
2246
|
+
throw new Error(`Moontide: the lights need motion 'on' or 'off', got ${String(motion)}.`);
|
|
2247
|
+
}
|
|
2248
|
+
for (const change of changes) {
|
|
2249
|
+
const ref = change.kind === 'group' ? groupRefs.get(change.id) : refs.get(change.id);
|
|
2250
|
+
if (ref === undefined) continue;
|
|
2251
|
+
if (motion === 'on') ref.lightFillEl.style.opacity = String(change.value);
|
|
2252
|
+
else ref.lightEl.dataset.lit = String(change.value > 0);
|
|
2253
|
+
}
|
|
2254
|
+
},
|
|
2255
|
+
/** Takes every light back to unlit: no inline opacity and no lit mark, in both forms. */
|
|
2256
|
+
clearLights() {
|
|
2257
|
+
for (const ref of [...refs.values(), ...groupRefs.values()]) {
|
|
2258
|
+
if (ref.lightFillEl.style.opacity !== '') ref.lightFillEl.style.opacity = '';
|
|
2259
|
+
if (ref.lightEl.dataset.lit !== undefined) delete ref.lightEl.dataset.lit;
|
|
2260
|
+
}
|
|
2261
|
+
},
|
|
1987
2262
|
/** 'connected' | 'connecting' | 'disconnected' (the same states the History view gets). */
|
|
1988
2263
|
setConnected(state) {
|
|
1989
2264
|
connection = state;
|