moontide-mcp 2.0.2 → 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.
- package/README.md +41 -5
- package/dist/ask.js +30 -8
- package/dist/mcp.js +60 -14
- package/dist/protocol.js +89 -2
- package/dist/reference.js +22 -0
- package/dist/session.js +53 -0
- package/package.json +1 -1
- package/page/ask-view.js +21 -7
- package/page/ask.js +292 -34
- package/page/change-timing.js +25 -4
- package/page/harmony.js +13 -0
- package/page/index.html +4 -3
- package/page/jam-model.js +11 -2
- package/page/jam-view.js +17 -0
- package/page/lights.js +278 -0
- package/page/main.js +1723 -140
- package/page/midi-clock.js +1 -1
- package/page/midi-out.js +12 -0
- 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 +282 -13
- package/page/note-edit.js +267 -0
- package/page/note-locate.js +346 -0
- package/page/note-panel.js +381 -0
- package/page/note-tag.js +126 -0
- package/page/page-rewrite.js +184 -0
- package/page/song-edit.js +230 -0
- package/page/song-fit.js +250 -0
- package/page/song-grid-model.js +491 -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 +760 -0
- package/page/style.css +879 -84
- package/page/takeover.js +20 -5
- package/page/timeline-edit-view.js +1956 -0
- package/page/timeline-gesture.js +279 -0
- package/page/timeline.js +196 -3
- package/page/tracks.js +37 -8
- package/page/ui-state.js +10 -5
- package/page/views.js +25 -8
- package/reference/functions.md +1 -0
- package/reference/midi.md +2 -0
- package/reference/structure.md +131 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
// Pure module for Moontide's browser page: what the Song grid shows for a song the page has read. It turns the reading
|
|
2
|
+
// of page/structure.js into columns (one per section entry, in play order), rows (groups with their fold state, tracks
|
|
3
|
+
// with one cell per section, statements outside the song plan), the live state of each section header, the playing
|
|
4
|
+
// section and the keyboard moves between the controls. It has no DOM access at import time and no state, so
|
|
5
|
+
// node:test and page/main.js import it without a build step; page/song-grid-view.js draws what it returns.
|
|
6
|
+
|
|
7
|
+
import { readHarmonyText } from './harmony.js';
|
|
8
|
+
import { arrangementPosition } from './parts.js';
|
|
9
|
+
import {
|
|
10
|
+
SONG_COPY,
|
|
11
|
+
SONG_REFUSALS,
|
|
12
|
+
countText,
|
|
13
|
+
displayHarmony,
|
|
14
|
+
displayName,
|
|
15
|
+
cleanText,
|
|
16
|
+
fillSong,
|
|
17
|
+
harmonyParts,
|
|
18
|
+
quoted,
|
|
19
|
+
summaryText,
|
|
20
|
+
} from './structure.js';
|
|
21
|
+
|
|
22
|
+
/** From this nesting level on (the top level is 1), a group's indent stops growing, so a deep tree keeps room for names. */
|
|
23
|
+
export const SONG_GRID_DEEP_FROM = 5;
|
|
24
|
+
|
|
25
|
+
/** The launch choices of the toolbar, by value. */
|
|
26
|
+
const IDLE_TITLES = { section: SONG_COPY.idleTitleSection, phrase: SONG_COPY.idleTitlePhrase, bar: SONG_COPY.idleTitleBar };
|
|
27
|
+
|
|
28
|
+
/** The three launch choices of the toolbar in order: `{ value, label, name }`, the visible words and the accessible name. */
|
|
29
|
+
export function launchOptions() {
|
|
30
|
+
return [
|
|
31
|
+
{ value: 'section', label: SONG_COPY.launchSection, name: SONG_COPY.launchSectionName },
|
|
32
|
+
{ value: 'phrase', label: SONG_COPY.launchPhrase, name: SONG_COPY.launchPhraseName },
|
|
33
|
+
{ value: 'bar', label: SONG_COPY.launchBar, name: SONG_COPY.launchBarName },
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The key of a group: the names from the root down, as JSON text. */
|
|
38
|
+
export function groupKey(path) {
|
|
39
|
+
return JSON.stringify(path);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The words a cell shows while its change waits for the line it takes over on, by the timing the person set. */
|
|
43
|
+
const WAIT_TEXTS = { bar: SONG_COPY.waitBar, phrase: SONG_COPY.waitPhrase };
|
|
44
|
+
|
|
45
|
+
/** The cell of one track in one section as the grid shows it: its text, its title and whether its section is shared. */
|
|
46
|
+
function cellModel(track, cell, section, shared, fit = null) {
|
|
47
|
+
const values = { track: quoted(track.name), section: quoted(section.name) };
|
|
48
|
+
// What the fit query found for a fixed variation in this section, once it has answered.
|
|
49
|
+
const found = cell.kind === 'fixed' && fit !== null ? fit(track.name, cell.section, cell.key) : null;
|
|
50
|
+
let title;
|
|
51
|
+
if (cell.kind === 'rest') title = fillSong(SONG_COPY.cellTitleRest, values);
|
|
52
|
+
else if (cell.kind === 'degrees') title = fillSong(SONG_COPY.cellTitleDegrees, { ...values, variation: quoted(cell.key) });
|
|
53
|
+
else if (cell.kind === 'fixed') {
|
|
54
|
+
title = found !== null ? found.title : fillSong(SONG_COPY.cellTitleFixed, { ...values, variation: quoted(cell.key), harmony: displayHarmony(cell.fixedHarmony) });
|
|
55
|
+
} else title = fillSong(SONG_COPY.cellTitle, { ...values, variation: quoted(cell.key) });
|
|
56
|
+
return {
|
|
57
|
+
section: cell.section,
|
|
58
|
+
key: cell.key,
|
|
59
|
+
kind: cell.kind,
|
|
60
|
+
text: cell.kind === 'rest' ? SONG_COPY.cellRest : displayName(cell.key),
|
|
61
|
+
title,
|
|
62
|
+
shared,
|
|
63
|
+
wait: null,
|
|
64
|
+
fit: found === null ? null : { state: found.state, line: found.line, outside: found.outsideText },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The cell as it is while the person's own change to it waits for its line: the name that was chosen, with the words that
|
|
70
|
+
* say it is not playing yet. `choice` is `{ key }` or `{ rest: true }`.
|
|
71
|
+
*/
|
|
72
|
+
function waitingCellModel(track, cell, section, shared, waiting) {
|
|
73
|
+
let chosen;
|
|
74
|
+
if (waiting.choice.rest === true) chosen = { section: cell.section, key: null, kind: 'rest', fixedHarmony: null };
|
|
75
|
+
else {
|
|
76
|
+
const variation = track.variations.find((candidate) => candidate.key === waiting.choice.key);
|
|
77
|
+
if (variation === undefined) throw new Error(`Moontide: the waiting change names variation '${String(waiting.choice.key)}', which track '${track.name}' does not have.`);
|
|
78
|
+
chosen = { section: cell.section, key: variation.key, kind: variation.kind, fixedHarmony: variation.fixedHarmony };
|
|
79
|
+
}
|
|
80
|
+
return { ...cellModel(track, chosen, section, shared), wait: WAIT_TEXTS[waiting.at] };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The title of a shown name when the shown text is not the whole of it, else null. */
|
|
84
|
+
function wholeName(name) {
|
|
85
|
+
const whole = cleanText(name);
|
|
86
|
+
return displayName(name) === whole ? null : whole;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** One column per section entry: its name, length, harmony and how many entries share its name. */
|
|
90
|
+
function buildColumns(reading) {
|
|
91
|
+
const columnsOf = new Map();
|
|
92
|
+
reading.sections.forEach((section, index) => {
|
|
93
|
+
const list = columnsOf.get(section.name) ?? [];
|
|
94
|
+
list.push(index);
|
|
95
|
+
columnsOf.set(section.name, list);
|
|
96
|
+
});
|
|
97
|
+
return reading.sections.map((section, index) => {
|
|
98
|
+
const harmony = harmonyParts(section.harmony);
|
|
99
|
+
const useColumns = columnsOf.get(section.name);
|
|
100
|
+
const uses = useColumns.length;
|
|
101
|
+
return {
|
|
102
|
+
index,
|
|
103
|
+
name: section.name,
|
|
104
|
+
length: section.length,
|
|
105
|
+
text: displayName(section.name),
|
|
106
|
+
title: wholeName(section.name),
|
|
107
|
+
lengthText: countText(section.length, SONG_COPY.cycleOne, SONG_COPY.cycleMany),
|
|
108
|
+
harmonyText: harmony.text,
|
|
109
|
+
harmonyTitle: harmony.whole ?? harmony.text,
|
|
110
|
+
uses,
|
|
111
|
+
useColumns,
|
|
112
|
+
usedText: uses > 1 ? fillSong(SONG_COPY.usedTimes, { n: uses }) : null,
|
|
113
|
+
usedTitle: uses > 1 ? fillSong(SONG_COPY.usedTitle, { n: uses, columns: useColumns.map((i) => i + 1).join(', ') }) : null,
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The groups and the tracks inside them in display order, walked without recursion: each entry has its depth and `hidden`
|
|
120
|
+
* (true below a folded group, whatever its own fold state), and `counts` holds, by group key, every track below the group
|
|
121
|
+
* at any depth.
|
|
122
|
+
*/
|
|
123
|
+
function walkGroups(reading, folds) {
|
|
124
|
+
const list = [];
|
|
125
|
+
const counts = new Map();
|
|
126
|
+
const stack = [{ items: reading.groups.map((group) => ({ group })), index: 0, path: [], key: null, hidden: false }];
|
|
127
|
+
while (stack.length > 0) {
|
|
128
|
+
const frame = stack[stack.length - 1];
|
|
129
|
+
if (frame.index >= frame.items.length) {
|
|
130
|
+
stack.pop();
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const item = frame.items[frame.index];
|
|
134
|
+
frame.index += 1;
|
|
135
|
+
if (item.track !== undefined) {
|
|
136
|
+
list.push({ kind: 'track', name: item.track, depth: frame.path.length, hidden: frame.hidden });
|
|
137
|
+
// Every group on the way up has one more track below it.
|
|
138
|
+
for (let i = 1; i < stack.length; i++) counts.set(stack[i].key, counts.get(stack[i].key) + 1);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const path = [...frame.path, item.group.name];
|
|
142
|
+
const key = groupKey(path);
|
|
143
|
+
const open = !folds.has(key);
|
|
144
|
+
list.push({ kind: 'group', name: item.group.name, path, key, open, depth: frame.path.length, hidden: frame.hidden });
|
|
145
|
+
counts.set(key, 0);
|
|
146
|
+
stack.push({ items: item.group.items, index: 0, path, key, hidden: frame.hidden || !open });
|
|
147
|
+
}
|
|
148
|
+
return { list, counts };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The grid for a read song. `folds` is a Set of group keys that are folded, `launchChoice` one of 'section', 'phrase',
|
|
153
|
+
* 'bar'. `fit` is null while the fit query has not answered (a fixed variation then shows no fit words), else the lookup of
|
|
154
|
+
* page/song-fit.js, `(track, sectionIndex, key) => fit or null`. Returns `{ title, corner, columns, rows, sections,
|
|
155
|
+
* launchChoice, listFor }`:
|
|
156
|
+
* - `columns[i]`: `{ index, name, length, text, title, lengthText, harmonyText, harmonyTitle, uses, useColumns, usedText,
|
|
157
|
+
* usedTitle }` for the i-th section entry in code order;
|
|
158
|
+
* - a fixed cell is `{ section, key, kind, text, title, shared, wait, fit }` where `fit` is null until the answer is in, else
|
|
159
|
+
* `{ state, line, outside }` with `state` 'fits', 'does-not-fit' or 'cannot-check', the words of the second line and the
|
|
160
|
+
* outside notes joined by ', ' (null unless they do not fit); the cell's title then holds the whole fit text;
|
|
161
|
+
* - `cellWaiting` is `{ track, section, choice, at }` for a change of the person's own to a cell that waits for its line, or
|
|
162
|
+
* null: `section` is the section's name, so every column of that name shows the chosen name and the waiting words, and `at`
|
|
163
|
+
* is 'bar' or 'phrase';
|
|
164
|
+
* - `rows`, in display order: group rows `{ kind: 'group', key, path, name, text, title, count, countText, depth, level, open,
|
|
165
|
+
* hidden }`, track rows `{ kind: 'track', key, name, text, title, trackIndex, depth, level, hidden, cells }` and rows of
|
|
166
|
+
* statements the plan does not build `{ kind: 'outside', key, name, text, title, note, depth, level, hidden }`. A row
|
|
167
|
+
* below a folded group is hidden whatever its own fold state; the keys are stable ('g:' + group key, 't:' + track name,
|
|
168
|
+
* 'o:' + statement index).
|
|
169
|
+
*/
|
|
170
|
+
export function buildSongGrid({ reading, folds, launchChoice, cellWaiting = null, fit = null }) {
|
|
171
|
+
if (reading === null || typeof reading !== 'object' || reading.state !== 'read') {
|
|
172
|
+
throw new Error('Moontide: buildSongGrid needs a read song.');
|
|
173
|
+
}
|
|
174
|
+
if (!Object.hasOwn(IDLE_TITLES, launchChoice)) throw new Error(`Moontide: unknown launch choice '${String(launchChoice)}'.`);
|
|
175
|
+
if (cellWaiting !== null && !Object.hasOwn(WAIT_TEXTS, cellWaiting.at)) {
|
|
176
|
+
throw new Error(`Moontide: a change waits on 'bar' or 'phrase', not '${String(cellWaiting.at)}'.`);
|
|
177
|
+
}
|
|
178
|
+
const columns = buildColumns(reading);
|
|
179
|
+
const byName = new Map(reading.tracks.map((track, trackIndex) => [track.name, { track, trackIndex }]));
|
|
180
|
+
const trackRow = (name, depth, hidden) => {
|
|
181
|
+
const { track, trackIndex } = byName.get(name);
|
|
182
|
+
return {
|
|
183
|
+
kind: 'track',
|
|
184
|
+
key: `t:${name}`,
|
|
185
|
+
name,
|
|
186
|
+
text: displayName(name),
|
|
187
|
+
title: wholeName(name),
|
|
188
|
+
trackIndex,
|
|
189
|
+
depth,
|
|
190
|
+
level: depth + 1,
|
|
191
|
+
hidden,
|
|
192
|
+
cells: track.cells.map((cell) => {
|
|
193
|
+
const section = reading.sections[cell.section];
|
|
194
|
+
const shared = columns[cell.section].uses > 1;
|
|
195
|
+
const waits = cellWaiting !== null && cellWaiting.track === name && cellWaiting.section === section.name;
|
|
196
|
+
return waits ? waitingCellModel(track, cell, section, shared, cellWaiting) : cellModel(track, cell, section, shared, fit);
|
|
197
|
+
}),
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const { list, counts } = walkGroups(reading, folds);
|
|
202
|
+
const rows = [];
|
|
203
|
+
for (const entry of list) {
|
|
204
|
+
if (entry.kind === 'track') {
|
|
205
|
+
rows.push(trackRow(entry.name, entry.depth, entry.hidden));
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const count = counts.get(entry.key);
|
|
209
|
+
rows.push({
|
|
210
|
+
kind: 'group',
|
|
211
|
+
key: `g:${entry.key}`,
|
|
212
|
+
groupKey: entry.key,
|
|
213
|
+
path: entry.path,
|
|
214
|
+
name: entry.name,
|
|
215
|
+
text: displayName(entry.name),
|
|
216
|
+
title: wholeName(entry.name),
|
|
217
|
+
foldTitle: fillSong(entry.open ? SONG_COPY.foldTitle : SONG_COPY.openTitle, { group: quoted(entry.name) }),
|
|
218
|
+
count,
|
|
219
|
+
countText: countText(count, SONG_COPY.trackOne, SONG_COPY.trackMany),
|
|
220
|
+
depth: entry.depth,
|
|
221
|
+
level: entry.depth + 1,
|
|
222
|
+
open: entry.open,
|
|
223
|
+
hidden: entry.hidden,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
for (const entry of reading.ungrouped) {
|
|
227
|
+
if (entry.structured) rows.push(trackRow(entry.name, 0, false));
|
|
228
|
+
else {
|
|
229
|
+
rows.push({
|
|
230
|
+
kind: 'outside',
|
|
231
|
+
key: `o:${entry.index}`,
|
|
232
|
+
name: entry.name,
|
|
233
|
+
text: displayName(entry.name),
|
|
234
|
+
title: wholeName(entry.name),
|
|
235
|
+
note: SONG_COPY.outside,
|
|
236
|
+
depth: 0,
|
|
237
|
+
level: 1,
|
|
238
|
+
hidden: false,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
title: SONG_COPY.viewTitle,
|
|
244
|
+
corner: summaryText(reading),
|
|
245
|
+
columns,
|
|
246
|
+
rows,
|
|
247
|
+
sections: reading.sections,
|
|
248
|
+
launchChoice,
|
|
249
|
+
// The list that opens on a cell, built when it is asked for and from the very reading this grid was drawn from.
|
|
250
|
+
listFor: (trackName, sectionIndex) => cellList({ reading, trackName, sectionIndex, fit }),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The muted words after a variation's name in the list: `follows the harmony` for a degree variation, for a fixed one the
|
|
256
|
+
* fit words for the section being edited (nothing while the fit query has not answered), nothing for a plain one.
|
|
257
|
+
*/
|
|
258
|
+
function optionNote(variation, trackName, sectionIndex, fit) {
|
|
259
|
+
if (variation.kind === 'degrees') return SONG_COPY.cellFollows;
|
|
260
|
+
if (variation.kind !== 'fixed' || fit === null) return null;
|
|
261
|
+
const found = fit(trackName, sectionIndex, variation.key);
|
|
262
|
+
return found === null ? null : found.line;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* What the list that opens on a cell shows: `{ label, note, currentText, options }`. `label` is the accessible name, `note`
|
|
267
|
+
* the line that says the change applies to every use of a section that is used more than once (else null), and `options` the
|
|
268
|
+
* track's variations in code order, used or not, then `rest`: `{ id, key, rest, text, title, note, current }` with `key` null
|
|
269
|
+
* for the rest, `note` the muted words after the name (`follows the harmony` for a degree variation, the fit words for the
|
|
270
|
+
* section being edited for a fixed one once `fit` has answered) and `current` true for what the cell holds. The reading must have the track and the section.
|
|
271
|
+
*/
|
|
272
|
+
export function cellList({ reading, trackName, sectionIndex, fit = null }) {
|
|
273
|
+
const track = reading.tracks.find((candidate) => candidate.name === trackName);
|
|
274
|
+
const section = reading.sections[sectionIndex];
|
|
275
|
+
const cell = track === undefined ? undefined : track.cells.find((candidate) => candidate.section === sectionIndex);
|
|
276
|
+
if (track === undefined || section === undefined || cell === undefined) {
|
|
277
|
+
throw new Error(`Moontide: the song has no cell for track '${String(trackName)}' in section ${String(sectionIndex)}.`);
|
|
278
|
+
}
|
|
279
|
+
const options = track.variations.map((variation, i) => ({
|
|
280
|
+
id: `song-option-${i}`,
|
|
281
|
+
key: variation.key,
|
|
282
|
+
rest: false,
|
|
283
|
+
text: displayName(variation.key),
|
|
284
|
+
title: wholeName(variation.key),
|
|
285
|
+
note: optionNote(variation, trackName, sectionIndex, fit),
|
|
286
|
+
current: cell.key === variation.key,
|
|
287
|
+
}));
|
|
288
|
+
options.push({ id: `song-option-${options.length}`, key: null, rest: true, text: SONG_COPY.cellRest, title: null, note: null, current: cell.kind === 'rest' });
|
|
289
|
+
const uses = reading.sections.filter((candidate) => candidate.name === section.name).length;
|
|
290
|
+
return {
|
|
291
|
+
label: fillSong(SONG_COPY.listLabel, { track: quoted(trackName), section: quoted(section.name) }),
|
|
292
|
+
note: uses > 1 ? fillSong(SONG_COPY.listShared, { section: quoted(section.name), n: uses }) : null,
|
|
293
|
+
currentText: SONG_COPY.listCurrent,
|
|
294
|
+
options,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Where the list that opens on a cell goes, in the pixels of the viewport: `cell` is the cell's `{ left, top, bottom }`,
|
|
300
|
+
* `size` the list's `{ width, height }` as it would be drawn, `viewport` its `{ width, height }`. The list opens below the
|
|
301
|
+
* cell, above it when the room below is too short for it and the room above is not, and when neither holds it all it takes
|
|
302
|
+
* the larger side with a `maxHeight` of that room, so it scrolls inside itself. It never leaves the viewport. Returns
|
|
303
|
+
* `{ left, top, maxHeight, above }` (`maxHeight` null when the list keeps its own height).
|
|
304
|
+
*/
|
|
305
|
+
export function listPlacement({ cell, size, viewport }) {
|
|
306
|
+
const below = viewport.height - cell.bottom;
|
|
307
|
+
const above = cell.top;
|
|
308
|
+
let top;
|
|
309
|
+
let maxHeight = null;
|
|
310
|
+
let isAbove = false;
|
|
311
|
+
if (size.height <= below) top = cell.bottom;
|
|
312
|
+
else if (size.height <= above) {
|
|
313
|
+
top = cell.top - size.height;
|
|
314
|
+
isAbove = true;
|
|
315
|
+
} else if (below >= above) {
|
|
316
|
+
maxHeight = Math.max(0, below);
|
|
317
|
+
top = cell.bottom;
|
|
318
|
+
} else {
|
|
319
|
+
maxHeight = Math.max(0, above);
|
|
320
|
+
top = cell.top - maxHeight;
|
|
321
|
+
isAbove = true;
|
|
322
|
+
}
|
|
323
|
+
const height = maxHeight === null ? size.height : maxHeight;
|
|
324
|
+
top = Math.max(0, Math.min(top, viewport.height - height));
|
|
325
|
+
const left = Math.max(0, Math.min(cell.left, viewport.width - size.width));
|
|
326
|
+
return { left, top, maxHeight, above: isAbove };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** The history label of a cell change: `changed bass in verse: vb`, or `: rest`. `choice` is `{ key }` or `{ rest: true }`. */
|
|
330
|
+
export function cellRecordLabel({ track, section, choice }) {
|
|
331
|
+
const chosen = choice.rest === true ? SONG_COPY.cellRest : displayName(choice.key);
|
|
332
|
+
return fillSong(SONG_COPY.cellRecord, { track: displayName(track), section: displayName(section), choice: chosen });
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The section that plays at `cycle`: `{ index, cycleIn, progress }`. The position is the cycle within the structure
|
|
337
|
+
* (arrangementPosition with the shift and the total); each section covers [start, start + length) of the unshifted
|
|
338
|
+
* structure, so on a section line the section that starts there plays. `cycleIn` counts from 1; `progress` is the
|
|
339
|
+
* fraction of the section behind.
|
|
340
|
+
*/
|
|
341
|
+
export function playingSection(reading, cycle) {
|
|
342
|
+
const position = arrangementPosition(cycle, reading.shift, reading.total);
|
|
343
|
+
let start = 0;
|
|
344
|
+
for (let index = 0; index < reading.sections.length; index++) {
|
|
345
|
+
const { length } = reading.sections[index];
|
|
346
|
+
if (position >= start && position < start + length) {
|
|
347
|
+
return { index, cycleIn: Math.floor(position - start) + 1, progress: (position - start) / length };
|
|
348
|
+
}
|
|
349
|
+
start += length;
|
|
350
|
+
}
|
|
351
|
+
// Floating-point rounding at the very end of the structure: the last section plays.
|
|
352
|
+
const last = reading.sections.length - 1;
|
|
353
|
+
return { index: last, cycleIn: reading.sections[last].length, progress: 1 };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* The harmony the Jam view's Declared line shows while `section` (an entry of a read song) plays, in the shape of a
|
|
358
|
+
* reading's `declared`: the section's own text or the piece's, `{ state: 'none' }` when neither is declared, and for a
|
|
359
|
+
* piece harmony the page cannot read `pieceDeclared`, the reading of the piece's harmony, which holds the reason. A
|
|
360
|
+
* harmony text the page cannot read throws (the song reader has refused such text already).
|
|
361
|
+
*/
|
|
362
|
+
export function sectionDeclared(section, pieceDeclared) {
|
|
363
|
+
const { harmony } = section;
|
|
364
|
+
if (harmony.from === 'none') return { state: 'none' };
|
|
365
|
+
if (harmony.from === 'piece-unreadable') return pieceDeclared;
|
|
366
|
+
const read = readHarmonyText(harmony.text);
|
|
367
|
+
if (read === null) throw new Error(`Moontide: the harmony of section '${String(section.name)}' cannot be read for the Jam view.`);
|
|
368
|
+
return { state: 'declared', ...read, text: harmony.text };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* What one section header shows: `{ state, stateText, action, title, progress }`. `playing` is the playingSection result
|
|
373
|
+
* (or null); `stopped` is true while the page makes no sound, and then every header is idle. `motion` 'off' steps the
|
|
374
|
+
* progress to whole cycles. `action` is the visible word after the state ('' when the section plays). `queued` is
|
|
375
|
+
* `{ index, countdownText, line }` for the section a launch waits for, or null: that header says `Queued — {countdown}`
|
|
376
|
+
* with the word Cancel, whatever else it was (a launch of the section that plays is queued too), and carries the `line`
|
|
377
|
+
* the launch takes over on.
|
|
378
|
+
*/
|
|
379
|
+
export function headerLive({ column, playing, stopped, launchChoice, motion, queued = null }) {
|
|
380
|
+
if (!Object.hasOwn(IDLE_TITLES, launchChoice)) throw new Error(`Moontide: unknown launch choice '${String(launchChoice)}'.`);
|
|
381
|
+
if (queued !== null && queued.index === column.index) {
|
|
382
|
+
return {
|
|
383
|
+
state: 'queued',
|
|
384
|
+
stateText: fillSong(SONG_COPY.queuedState, { countdown: queued.countdownText }),
|
|
385
|
+
action: SONG_COPY.cancelWord,
|
|
386
|
+
title: SONG_COPY.queuedTitle,
|
|
387
|
+
progress: 0,
|
|
388
|
+
line: queued.line,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
if (!stopped && playing !== null && playing !== undefined && playing.index === column.index) {
|
|
392
|
+
const values = { n: playing.cycleIn, length: column.length };
|
|
393
|
+
const raw = motion === 'off' ? Math.floor(playing.progress * column.length) / column.length : playing.progress;
|
|
394
|
+
return {
|
|
395
|
+
state: 'playing',
|
|
396
|
+
stateText: fillSong(SONG_COPY.playingState, values),
|
|
397
|
+
action: '',
|
|
398
|
+
title: fillSong(SONG_COPY.playingTitle, values),
|
|
399
|
+
progress: Math.max(0, Math.min(1, raw)),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
state: 'idle',
|
|
404
|
+
stateText: column.lengthText,
|
|
405
|
+
action: SONG_COPY.launchWord,
|
|
406
|
+
title: stopped ? SONG_COPY.stoppedTitle : IDLE_TITLES[launchChoice],
|
|
407
|
+
progress: 0,
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** The sentence under the toolbar while a section plays: `Playing section 'y', cycle 3 of 8.`, or null when none does. */
|
|
412
|
+
export function nowText(reading, playing) {
|
|
413
|
+
if (playing === null || playing === undefined) return null;
|
|
414
|
+
const section = reading.sections[playing.index];
|
|
415
|
+
return fillSong(SONG_COPY.now, { section: quoted(section.name), n: playing.cycleIn, length: section.length });
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* The one sentence under the toolbar, as `{ kind, text }`, or null when there is nothing to say. The first of these that
|
|
420
|
+
* applies is shown, in this order: `disconnected` (the socket is closed), `queued` (a launch of the person's own waits,
|
|
421
|
+
* `launch` is `{ kind: 'queued', section }`), `already` (a launch that needs no code change, `launch` is
|
|
422
|
+
* `{ kind: 'already', section }`), `waiting` (another change waits for its line), `dirty` (the editor holds text that was
|
|
423
|
+
* never evaluated) and `refused` (a gesture was refused, `refused` is the reason id of SONG_COPY.refusals). A reason id
|
|
424
|
+
* the copy does not know throws, whatever else applies.
|
|
425
|
+
*/
|
|
426
|
+
export function songLine({ connected, launch = null, waiting = false, dirty = false, refused = null }) {
|
|
427
|
+
if (refused !== null && refused !== undefined && !SONG_REFUSALS.includes(refused)) {
|
|
428
|
+
throw new Error(`Moontide: unknown song refusal '${String(refused)}' (expected one of ${SONG_REFUSALS.join(', ')}).`);
|
|
429
|
+
}
|
|
430
|
+
if (launch !== null && launch !== undefined && launch.kind !== 'queued' && launch.kind !== 'already') {
|
|
431
|
+
throw new Error(`Moontide: unknown launch line '${String(launch.kind)}' (expected queued or already).`);
|
|
432
|
+
}
|
|
433
|
+
if (!connected) return { kind: 'disconnected', text: SONG_COPY.lineDisconnected };
|
|
434
|
+
if (launch !== null && launch !== undefined) {
|
|
435
|
+
const template = launch.kind === 'queued' ? SONG_COPY.lineQueued : SONG_COPY.lineAlready;
|
|
436
|
+
return { kind: launch.kind, text: fillSong(template, { section: quoted(launch.section) }) };
|
|
437
|
+
}
|
|
438
|
+
if (waiting) return { kind: 'waiting', text: SONG_COPY.lineWaiting };
|
|
439
|
+
if (dirty) return { kind: 'dirty', text: SONG_COPY.lineDirty };
|
|
440
|
+
if (refused !== null && refused !== undefined) return { kind: 'refused', text: SONG_COPY.refusals[refused] };
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Which controls the line switches off: `{ headers, cells }`, true meaning aria-disabled. Off: `disconnected`, `waiting` and
|
|
446
|
+
* `dirty` switch off both; `queued` switches off the cells only (the headers stay usable to cancel or to replace the launch);
|
|
447
|
+
* `already` and `refused` switch off nothing. No line (null) switches off nothing; any other kind throws.
|
|
448
|
+
*/
|
|
449
|
+
export function controlsDisabled(kind) {
|
|
450
|
+
if (kind === null || kind === undefined) return { headers: false, cells: false };
|
|
451
|
+
const off = { disconnected: [true, true], queued: [false, true], already: [false, false], waiting: [true, true], dirty: [true, true], refused: [false, false] };
|
|
452
|
+
if (!Object.hasOwn(off, kind)) throw new Error(`Moontide: unknown song line kind '${String(kind)}'.`);
|
|
453
|
+
return { headers: off[kind][0], cells: off[kind][1] };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* The control a key moves to: `{ row, column }` or null. `layout` lists the visible rows, each the sorted columns that
|
|
458
|
+
* hold a control (column 0 is the name column, 1..N the sections; a row without a control is an empty list and is
|
|
459
|
+
* skipped); `from` is the `{ row, column }` of the focused control. Left and Right stay in the row, Up and Down go to the
|
|
460
|
+
* next row with a control, at the same column or the nearest one (a row is entered at its first control from the name
|
|
461
|
+
* column), Home and End go to the first and last control of the row, Ctrl+Home and Ctrl+End to those of the grid. An
|
|
462
|
+
* edge gives null; any other key gives null.
|
|
463
|
+
*/
|
|
464
|
+
export function gridMove(layout, from, key, { ctrl = false } = {}) {
|
|
465
|
+
const columns = layout[from.row] ?? [];
|
|
466
|
+
const at = columns.indexOf(from.column);
|
|
467
|
+
if (at < 0) return null;
|
|
468
|
+
const firstRow = layout.findIndex((list) => list.length > 0);
|
|
469
|
+
const lastRow = layout.findLastIndex((list) => list.length > 0);
|
|
470
|
+
if (key === 'ArrowLeft') return at > 0 ? { row: from.row, column: columns[at - 1] } : null;
|
|
471
|
+
if (key === 'ArrowRight') return at < columns.length - 1 ? { row: from.row, column: columns[at + 1] } : null;
|
|
472
|
+
if (key === 'Home' && ctrl) return firstRow < 0 ? null : { row: firstRow, column: layout[firstRow][0] };
|
|
473
|
+
if (key === 'End' && ctrl) return lastRow < 0 ? null : { row: lastRow, column: layout[lastRow].at(-1) };
|
|
474
|
+
if (key === 'Home') return { row: from.row, column: columns[0] };
|
|
475
|
+
if (key === 'End') return { row: from.row, column: columns.at(-1) };
|
|
476
|
+
if (key === 'ArrowUp' || key === 'ArrowDown') {
|
|
477
|
+
const step = key === 'ArrowUp' ? -1 : 1;
|
|
478
|
+
for (let row = from.row + step; row >= 0 && row < layout.length; row += step) {
|
|
479
|
+
const candidates = layout[row];
|
|
480
|
+
if (candidates.length === 0) continue;
|
|
481
|
+
if (candidates.includes(from.column)) return { row, column: from.column };
|
|
482
|
+
let best = candidates[0];
|
|
483
|
+
for (const column of candidates) {
|
|
484
|
+
if (Math.abs(column - from.column) < Math.abs(best - from.column)) best = column;
|
|
485
|
+
}
|
|
486
|
+
return { row, column: best };
|
|
487
|
+
}
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
return null;
|
|
491
|
+
}
|