entangled-worlds-orbiters-shared 4.0.0 → 4.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 +8 -0
- package/dist/clock/Transport.d.ts +8 -2
- package/dist/clock/Transport.js +46 -7
- package/dist/clock/musical.js +13 -8
- package/dist/clock/sync.js +15 -2
- package/dist/loaders/glbLoader.js +17 -4
- package/dist/shortcuts/chord.d.ts +35 -0
- package/dist/shortcuts/chord.js +99 -0
- package/dist/shortcuts/entangledWorlds.d.ts +21 -0
- package/dist/shortcuts/entangledWorlds.js +40 -0
- package/dist/shortcuts/format.d.ts +44 -0
- package/dist/shortcuts/format.js +132 -0
- package/dist/shortcuts/index.d.ts +9 -0
- package/dist/shortcuts/index.js +13 -0
- package/dist/shortcuts/labels.d.ts +10 -0
- package/dist/shortcuts/labels.js +130 -0
- package/dist/shortcuts/match.d.ts +65 -0
- package/dist/shortcuts/match.js +145 -0
- package/dist/shortcuts/orbiters.d.ts +55 -0
- package/dist/shortcuts/orbiters.js +109 -0
- package/dist/shortcuts/overrides.d.ts +30 -0
- package/dist/shortcuts/overrides.js +81 -0
- package/dist/shortcuts/sections.d.ts +23 -0
- package/dist/shortcuts/sections.js +19 -0
- package/dist/shortcuts/site.d.ts +9 -0
- package/dist/shortcuts/site.js +25 -0
- package/package.json +5 -1
- package/src/clock/Transport.ts +37 -6
- package/src/clock/musical.ts +18 -8
- package/src/clock/sync.ts +15 -2
- package/src/loaders/glbLoader.ts +15 -6
- package/src/shortcuts/chord.ts +122 -0
- package/src/shortcuts/entangledWorlds.ts +51 -0
- package/src/shortcuts/format.ts +143 -0
- package/src/shortcuts/index.ts +69 -0
- package/src/shortcuts/labels.ts +139 -0
- package/src/shortcuts/match.ts +195 -0
- package/src/shortcuts/orbiters.ts +132 -0
- package/src/shortcuts/overrides.ts +110 -0
- package/src/shortcuts/sections.ts +36 -0
- package/src/shortcuts/site.ts +33 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// The Orbiters catalogues: every key Orbiters answers, with the keys it has by default.
|
|
2
|
+
//
|
|
3
|
+
// `voice` acts on the focused deck(s), so in a shared session it only reaches a deck this person
|
|
4
|
+
// holds; `client` is about this machine and no deck (MIDI learn, performance mode, the Studio).
|
|
5
|
+
export const ORBITERS_PLAY_SECTION = 'orbiters-play';
|
|
6
|
+
export const ORBITERS_STUDIO_SECTION = 'orbiters-studio';
|
|
7
|
+
const play = (id, label, defaultKeys, { scope = 'voice', group, command } = {}) => Object.freeze({
|
|
8
|
+
id: `orbiters.play.${id}`, section: ORBITERS_PLAY_SECTION, label, defaultKeys: Object.freeze([...defaultKeys]), scope,
|
|
9
|
+
...(group ? { group } : {}), ...(command === false ? { command } : {}),
|
|
10
|
+
});
|
|
11
|
+
const studio = (id, label, defaultKeys) => Object.freeze({ id: `orbiters.studio.${id}`, section: ORBITERS_STUDIO_SECTION, label, defaultKeys: Object.freeze([...defaultKeys]), scope: 'client', group: 'studio' });
|
|
12
|
+
/** How a command menu groups the actions, in its order. */
|
|
13
|
+
export const ORBITERS_COMMAND_GROUPS = Object.freeze([
|
|
14
|
+
{ id: 'transport', label: 'Transport' },
|
|
15
|
+
{ id: 'view', label: 'View' },
|
|
16
|
+
{ id: 'snapshots', label: 'Snapshots' },
|
|
17
|
+
{ id: 'panels', label: 'Panels' },
|
|
18
|
+
{ id: 'orbiter', label: 'Orbiter' },
|
|
19
|
+
{ id: 'studio', label: 'Studio' },
|
|
20
|
+
]);
|
|
21
|
+
/** The snapshot slots, by the number each shows: the number row, 1 to 9, then 0. */
|
|
22
|
+
const SLOT_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0];
|
|
23
|
+
/** Undo and redo, the same keys wherever an edit can be taken back. */
|
|
24
|
+
const UNDO_KEYS = ['Mod+KeyZ'];
|
|
25
|
+
const REDO_KEYS = ['Mod+Shift+KeyZ', 'Ctrl+Shift?+KeyY'];
|
|
26
|
+
/**
|
|
27
|
+
* Playing an Orbiter. Shift is free on most keys (`Shift?`): it is held to select several decks,
|
|
28
|
+
* and a key pressed while selecting means what it means alone.
|
|
29
|
+
*/
|
|
30
|
+
export const ORBITERS_PLAY_SHORTCUTS = Object.freeze([
|
|
31
|
+
// Space takes Ctrl, Option and ⌘ as it comes: only Shift changes what it does.
|
|
32
|
+
play('transport.toggle', 'Play or pause', ['Alt?+Ctrl?+Meta?+Space'], { group: 'transport' }),
|
|
33
|
+
play('transport.restart', 'Play from the beginning', ['Shift+Alt?+Ctrl?+Meta?+Space'], { group: 'transport' }),
|
|
34
|
+
play('rec.hold', 'Record while held', ['KeyR'], { group: 'transport', command: false }),
|
|
35
|
+
play('rec.toggle', 'Rec on or off', ['Shift+KeyR'], { group: 'transport' }),
|
|
36
|
+
play('history.undo', 'Undo', UNDO_KEYS, { group: 'transport' }),
|
|
37
|
+
play('history.redo', 'Redo', REDO_KEYS, { group: 'transport' }),
|
|
38
|
+
play('dimension.1', 'First dimension', ['Shift?+Digit1'], { group: 'view' }),
|
|
39
|
+
play('dimension.2', 'Second dimension', ['Shift?+Digit2'], { group: 'view' }),
|
|
40
|
+
play('dimension.3', 'Third dimension', ['Shift?+Digit3'], { group: 'view' }),
|
|
41
|
+
play('body.world', 'World', ['Shift?+KeyQ'], { group: 'view' }),
|
|
42
|
+
play('body.moon', 'Moon', ['Shift?+KeyW'], { group: 'view' }),
|
|
43
|
+
play('body.star', 'Star', ['Shift?+KeyE'], { group: 'view' }),
|
|
44
|
+
...SLOT_NUMBERS.map((n) => play(`snapshot.recall.${n}`, `Recall snapshot ${n}`, [`Alt+Digit${n}`], { group: 'snapshots' })),
|
|
45
|
+
...SLOT_NUMBERS.map((n) => play(`snapshot.store.${n}`, `Store snapshot ${n}`, [`Alt+Shift+Digit${n}`], { group: 'snapshots' })),
|
|
46
|
+
// The panels, from the bottom of the rail up.
|
|
47
|
+
play('panel.jamming', 'Jamming', ['KeyA'], { group: 'panels' }),
|
|
48
|
+
play('panel.playback', 'Playback', ['KeyS'], { group: 'panels' }),
|
|
49
|
+
play('panel.cosmicLfo', 'Cosmic LFO', ['KeyD'], { group: 'panels' }),
|
|
50
|
+
play('panel.sensors', 'Sensors', ['KeyF'], { group: 'panels' }),
|
|
51
|
+
// The More menu, and this machine's switches.
|
|
52
|
+
play('midiLearn', 'MIDI learn', ['Shift?+KeyM'], { scope: 'client', group: 'orbiter' }),
|
|
53
|
+
play('performanceMode', 'Performance mode', ['Shift?+KeyP'], { scope: 'client', group: 'orbiter' }),
|
|
54
|
+
play('screenCapture', 'Screen capture', [], { scope: 'client', group: 'orbiter' }),
|
|
55
|
+
play('share', 'Share', [], { scope: 'client', group: 'orbiter' }),
|
|
56
|
+
play('fullscreen', 'Fullscreen', [], { scope: 'client', group: 'orbiter' }),
|
|
57
|
+
// `?` on a US keyboard: the key's place, whatever this keyboard prints there.
|
|
58
|
+
play('help', 'Keyboard shortcuts', ['Shift?+Slash'], { scope: 'client', group: 'orbiter', command: false }),
|
|
59
|
+
play('commands', 'Commands', ['Mod+KeyK'], { scope: 'client', group: 'orbiter', command: false }),
|
|
60
|
+
]);
|
|
61
|
+
/**
|
|
62
|
+
* The keys sheet (`?`): what to look up at a glance, in three columns. A row may stand for several
|
|
63
|
+
* actions whose keys differ only in the last key ("Dimension I · II · III — 1 2 3"); the keys
|
|
64
|
+
* shown are always this person's (`mergedKeyParts`).
|
|
65
|
+
*/
|
|
66
|
+
export const ORBITERS_PLAY_SHEET = Object.freeze([
|
|
67
|
+
{ id: 'transport', label: 'Transport', rows: [
|
|
68
|
+
{ id: 'playPause', label: 'Play / pause', actions: ['transport.toggle'] },
|
|
69
|
+
{ id: 'playFromStart', label: 'Play from start', actions: ['transport.restart'] },
|
|
70
|
+
{ id: 'recHeld', label: 'REC while held', actions: ['rec.hold'] },
|
|
71
|
+
{ id: 'recLatch', label: 'REC latch', actions: ['rec.toggle'] },
|
|
72
|
+
{ id: 'undo', label: 'Undo', actions: ['history.undo'] },
|
|
73
|
+
{ id: 'redo', label: 'Redo', actions: ['history.redo'] },
|
|
74
|
+
] },
|
|
75
|
+
{ id: 'view', label: 'View', rows: [
|
|
76
|
+
{ id: 'dimensions', label: 'Dimension I · II · III', actions: ['dimension.1', 'dimension.2', 'dimension.3'] },
|
|
77
|
+
{ id: 'bodies', label: 'World · Moon · Star', actions: ['body.world', 'body.moon', 'body.star'] },
|
|
78
|
+
{ id: 'snapshotRecall', label: 'Snapshot recall', actions: SLOT_NUMBERS.map((n) => `snapshot.recall.${n}`) },
|
|
79
|
+
{ id: 'snapshotStore', label: 'Snapshot store', actions: SLOT_NUMBERS.map((n) => `snapshot.store.${n}`) },
|
|
80
|
+
] },
|
|
81
|
+
{ id: 'panels', label: 'Panels', rows: [
|
|
82
|
+
{ id: 'jamming', label: 'Jamming', actions: ['panel.jamming'] },
|
|
83
|
+
{ id: 'playback', label: 'Playback', actions: ['panel.playback'] },
|
|
84
|
+
{ id: 'cosmicLfo', label: 'Cosmic LFO', actions: ['panel.cosmicLfo'] },
|
|
85
|
+
{ id: 'sensors', label: 'Sensors', actions: ['panel.sensors'] },
|
|
86
|
+
{ id: 'midiLearn', label: 'MIDI learn', actions: ['midiLearn'] },
|
|
87
|
+
{ id: 'performanceMode', label: 'Performance mode', actions: ['performanceMode'] },
|
|
88
|
+
] },
|
|
89
|
+
].map((column) => Object.freeze({
|
|
90
|
+
...column,
|
|
91
|
+
rows: Object.freeze(column.rows.map((row) => Object.freeze({ ...row, actions: Object.freeze(row.actions.map((a) => `orbiters.play.${a}`)) }))),
|
|
92
|
+
})));
|
|
93
|
+
/**
|
|
94
|
+
* The Studio. It is open over play, so the play keys above still work there; where both name a key,
|
|
95
|
+
* the Studio's action is the one.
|
|
96
|
+
*/
|
|
97
|
+
export const ORBITERS_STUDIO_SHORTCUTS = Object.freeze([
|
|
98
|
+
studio('undo', 'Undo', UNDO_KEYS),
|
|
99
|
+
studio('redo', 'Redo', REDO_KEYS),
|
|
100
|
+
studio('map', 'Map', ['Shift+KeyM']),
|
|
101
|
+
]);
|
|
102
|
+
/**
|
|
103
|
+
* The letters the on-screen keyboard plays while it is open (`event.key`, lower-case): the piano row,
|
|
104
|
+
* then Z and X for the octave and C and V for velocity. While it is up they are its notes, and the
|
|
105
|
+
* shortcuts on them wait (`claimKeys`); a settings page says so beside those keys.
|
|
106
|
+
*/
|
|
107
|
+
export const ORBITERS_KEYBOARD_SURFACE_KEYS = Object.freeze([
|
|
108
|
+
'a', 'w', 's', 'e', 'd', 'f', 't', 'g', 'y', 'h', 'u', 'j', 'k', 'o', 'l', 'p', ';', "'", 'z', 'x', 'c', 'v',
|
|
109
|
+
]);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type ShortcutAction, type ShortcutOverrides } from './match.js';
|
|
2
|
+
/** The overrides in a stored value, or `null` when it is not a list of `{ id, keys }`. */
|
|
3
|
+
export declare function parseOverrides(value: unknown): ShortcutOverrides | null;
|
|
4
|
+
/**
|
|
5
|
+
* How the app reads the signed-in person's settings: `null` when no one is signed in (the
|
|
6
|
+
* default keys); `undefined` when the read failed (the overrides in force are kept: a blip must not
|
|
7
|
+
* swap someone's keys for the defaults).
|
|
8
|
+
*/
|
|
9
|
+
export type ReadAccountSettings = (options: {
|
|
10
|
+
fresh: boolean;
|
|
11
|
+
}) => Promise<{
|
|
12
|
+
shortcuts?: unknown;
|
|
13
|
+
} | null | undefined>;
|
|
14
|
+
export interface ShortcutOverridesSource {
|
|
15
|
+
/** The overrides in force. */
|
|
16
|
+
get(): ShortcutOverrides;
|
|
17
|
+
/** A catalogue's actions with their keys now (kept until the overrides change). */
|
|
18
|
+
bindings(catalogue: readonly ShortcutAction[]): Map<string, readonly string[]>;
|
|
19
|
+
/** Called whenever the overrides change; returns the unsubscribe. */
|
|
20
|
+
subscribe(listener: () => void): () => void;
|
|
21
|
+
/** Read again (`fresh`: past the app's own cached read). The newest read wins, whatever order they end in. */
|
|
22
|
+
refresh(options?: {
|
|
23
|
+
fresh?: boolean;
|
|
24
|
+
}): Promise<void>;
|
|
25
|
+
/** Read now, and again whenever the page comes back to the front; once per source. */
|
|
26
|
+
bind(): void;
|
|
27
|
+
/** For tests: set the overrides directly. */
|
|
28
|
+
set(overrides: ShortcutOverrides): void;
|
|
29
|
+
}
|
|
30
|
+
export declare function createShortcutOverridesSource(readAccount: ReadAccountSettings): ShortcutOverridesSource;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// A person's shortcut overrides, read the same way by every app: from their account when signed in
|
|
2
|
+
// (someone signed out has the default keys), and again whenever the page comes back to the front (a key changed
|
|
3
|
+
// on the settings page applies on return, without a reload).
|
|
4
|
+
//
|
|
5
|
+
// Framework-free: the app hands in how it reads the account (its own API client) and gets the
|
|
6
|
+
// overrides and each catalogue's resolved keys back, kept until the overrides change.
|
|
7
|
+
import { resolveBindings } from './match.js';
|
|
8
|
+
/** The overrides in a stored value, or `null` when it is not a list of `{ id, keys }`. */
|
|
9
|
+
export function parseOverrides(value) {
|
|
10
|
+
if (!Array.isArray(value))
|
|
11
|
+
return null;
|
|
12
|
+
return value.filter((entry) => !!entry && typeof entry.id === 'string' && Array.isArray(entry.keys) && entry.keys.every((k) => typeof k === 'string'));
|
|
13
|
+
}
|
|
14
|
+
/** Coming back to the front twice in a row (visible, then focus) asks the account once. */
|
|
15
|
+
const REFRESH_GAP_MS = 2000;
|
|
16
|
+
export function createShortcutOverridesSource(readAccount) {
|
|
17
|
+
let overrides = [];
|
|
18
|
+
let resolved = new WeakMap();
|
|
19
|
+
let latest = 0;
|
|
20
|
+
let applied = 0;
|
|
21
|
+
let lastRefreshAt = -Infinity;
|
|
22
|
+
let bound = false;
|
|
23
|
+
const listeners = new Set();
|
|
24
|
+
const apply = (next) => {
|
|
25
|
+
if (JSON.stringify(next) === JSON.stringify(overrides))
|
|
26
|
+
return;
|
|
27
|
+
overrides = next;
|
|
28
|
+
resolved = new WeakMap();
|
|
29
|
+
listeners.forEach((listener) => listener());
|
|
30
|
+
};
|
|
31
|
+
const refresh = async ({ fresh = false } = {}) => {
|
|
32
|
+
const ticket = ++latest;
|
|
33
|
+
lastRefreshAt = Date.now();
|
|
34
|
+
let settings;
|
|
35
|
+
try {
|
|
36
|
+
settings = await readAccount({ fresh });
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
settings = undefined;
|
|
40
|
+
}
|
|
41
|
+
// The newest read that answered wins: an older one ending after it says nothing new, and a newer
|
|
42
|
+
// one that failed does not throw away an older one's answer.
|
|
43
|
+
if (settings === undefined || ticket < applied)
|
|
44
|
+
return;
|
|
45
|
+
applied = ticket;
|
|
46
|
+
apply(settings === null ? [] : (parseOverrides(settings.shortcuts) ?? []));
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
get: () => overrides,
|
|
50
|
+
bindings(catalogue) {
|
|
51
|
+
let map = resolved.get(catalogue);
|
|
52
|
+
if (!map) {
|
|
53
|
+
map = resolveBindings(catalogue, overrides);
|
|
54
|
+
resolved.set(catalogue, map);
|
|
55
|
+
}
|
|
56
|
+
return map;
|
|
57
|
+
},
|
|
58
|
+
subscribe(listener) {
|
|
59
|
+
listeners.add(listener);
|
|
60
|
+
return () => {
|
|
61
|
+
listeners.delete(listener);
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
refresh,
|
|
65
|
+
bind() {
|
|
66
|
+
const win = globalThis;
|
|
67
|
+
if (bound || typeof win.addEventListener !== 'function' || !win.document?.addEventListener)
|
|
68
|
+
return;
|
|
69
|
+
bound = true;
|
|
70
|
+
void refresh();
|
|
71
|
+
const onFront = () => {
|
|
72
|
+
if (win.document?.visibilityState !== 'visible' || Date.now() - lastRefreshAt < REFRESH_GAP_MS)
|
|
73
|
+
return;
|
|
74
|
+
void refresh({ fresh: true });
|
|
75
|
+
};
|
|
76
|
+
win.document.addEventListener('visibilitychange', onFront);
|
|
77
|
+
win.addEventListener('focus', onFront);
|
|
78
|
+
},
|
|
79
|
+
set: apply,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ShortcutAction } from './match.js';
|
|
2
|
+
/** The apps a person tells apart, in the order a settings page shows them. */
|
|
3
|
+
export declare const SHORTCUT_APPS: readonly [Readonly<{
|
|
4
|
+
id: "plantasia-space";
|
|
5
|
+
label: "Plantasia Space";
|
|
6
|
+
}>, Readonly<{
|
|
7
|
+
id: "orbiters";
|
|
8
|
+
label: "Orbiters";
|
|
9
|
+
}>, Readonly<{
|
|
10
|
+
id: "entangled-worlds";
|
|
11
|
+
label: "Entangled Worlds";
|
|
12
|
+
}>];
|
|
13
|
+
export type ShortcutAppId = (typeof SHORTCUT_APPS)[number]['id'];
|
|
14
|
+
export interface ShortcutSection {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly label: string;
|
|
17
|
+
/** The app it belongs to. */
|
|
18
|
+
readonly app: ShortcutAppId;
|
|
19
|
+
/** Its name inside that app (`Play`, `Studio`); none when the app has only this section. */
|
|
20
|
+
readonly part?: string;
|
|
21
|
+
readonly actions: readonly ShortcutAction[];
|
|
22
|
+
}
|
|
23
|
+
export declare const SHORTCUT_SECTIONS: readonly ShortcutSection[];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Every app's catalogue in one place, in the order a settings page shows them. A section is one
|
|
2
|
+
// surface's keys; two actions may share a key only across sections (the Orbiter Studio lies over
|
|
3
|
+
// play, and takes ⌘Z from it while it is open).
|
|
4
|
+
import { EW_PLAYER_SECTION, EW_PLAYER_SHORTCUTS, EW_STUDIO_SECTION, EW_STUDIO_SHORTCUTS } from './entangledWorlds.js';
|
|
5
|
+
import { ORBITERS_PLAY_SECTION, ORBITERS_PLAY_SHORTCUTS, ORBITERS_STUDIO_SECTION, ORBITERS_STUDIO_SHORTCUTS } from './orbiters.js';
|
|
6
|
+
import { SITE_SECTION, SITE_SHORTCUTS } from './site.js';
|
|
7
|
+
/** The apps a person tells apart, in the order a settings page shows them. */
|
|
8
|
+
export const SHORTCUT_APPS = Object.freeze([
|
|
9
|
+
Object.freeze({ id: 'plantasia-space', label: 'Plantasia Space' }),
|
|
10
|
+
Object.freeze({ id: 'orbiters', label: 'Orbiters' }),
|
|
11
|
+
Object.freeze({ id: 'entangled-worlds', label: 'Entangled Worlds' }),
|
|
12
|
+
]);
|
|
13
|
+
export const SHORTCUT_SECTIONS = Object.freeze([
|
|
14
|
+
Object.freeze({ id: SITE_SECTION, label: 'Plantasia Space', app: 'plantasia-space', actions: SITE_SHORTCUTS }),
|
|
15
|
+
Object.freeze({ id: ORBITERS_PLAY_SECTION, label: 'Orbiters play', app: 'orbiters', part: 'Play', actions: ORBITERS_PLAY_SHORTCUTS }),
|
|
16
|
+
Object.freeze({ id: ORBITERS_STUDIO_SECTION, label: 'Orbiter Studio', app: 'orbiters', part: 'Studio', actions: ORBITERS_STUDIO_SHORTCUTS }),
|
|
17
|
+
Object.freeze({ id: EW_STUDIO_SECTION, label: 'Entangled Worlds studio', app: 'entangled-worlds', part: 'Studio', actions: EW_STUDIO_SHORTCUTS }),
|
|
18
|
+
Object.freeze({ id: EW_PLAYER_SECTION, label: 'Entangled Worlds player', app: 'entangled-worlds', part: 'Player', actions: EW_PLAYER_SHORTCUTS }),
|
|
19
|
+
]);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ShortcutAction } from './match.js';
|
|
2
|
+
export type SiteShortcut = ShortcutAction<'client'>;
|
|
3
|
+
export declare const SITE_SECTION = "site";
|
|
4
|
+
/**
|
|
5
|
+
* Two-key sequences move around the site (G, then H for Home; N, then O for a new Orbiter), as on
|
|
6
|
+
* many sites: a single letter is an Orbiter's on a page that plays one, and the browser holds most
|
|
7
|
+
* ⌘ and ⇧⌘ letters.
|
|
8
|
+
*/
|
|
9
|
+
export declare const SITE_SHORTCUTS: readonly SiteShortcut[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The Plantasia Space site's own keys: the pages around the apps (home, explore, settings). They
|
|
2
|
+
// work on any page of the site, outside an Orbiter or a world.
|
|
3
|
+
export const SITE_SECTION = 'site';
|
|
4
|
+
const site = (id, label, keys) => Object.freeze({ id: `site.${id}`, section: SITE_SECTION, label, defaultKeys: Object.freeze([keys]), scope: 'client' });
|
|
5
|
+
/**
|
|
6
|
+
* Two-key sequences move around the site (G, then H for Home; N, then O for a new Orbiter), as on
|
|
7
|
+
* many sites: a single letter is an Orbiter's on a page that plays one, and the browser holds most
|
|
8
|
+
* ⌘ and ⇧⌘ letters.
|
|
9
|
+
*/
|
|
10
|
+
export const SITE_SHORTCUTS = Object.freeze([
|
|
11
|
+
site('settings', 'Open settings', 'Mod+Shift+Comma'),
|
|
12
|
+
site('commands', 'Commands', 'Mod+KeyK'),
|
|
13
|
+
site('search', 'Search', 'Slash'),
|
|
14
|
+
site('go.home', 'Home', 'KeyG KeyH'),
|
|
15
|
+
site('go.playground', 'Playground', 'KeyG KeyP'),
|
|
16
|
+
site('go.notifications', 'Notifications', 'KeyG KeyN'),
|
|
17
|
+
site('go.audios', 'My audios', 'KeyG KeyA'),
|
|
18
|
+
site('go.orbiters', 'My Orbiters', 'KeyG KeyO'),
|
|
19
|
+
site('go.worlds', 'My Entangled Worlds', 'KeyG KeyW'),
|
|
20
|
+
site('go.collections', 'My collections', 'KeyG KeyC'),
|
|
21
|
+
site('go.stars', 'My stars', 'KeyG KeyS'),
|
|
22
|
+
site('new.orbiter', 'New Orbiter', 'KeyN KeyO'),
|
|
23
|
+
site('new.audio', 'New audio', 'KeyN KeyA'),
|
|
24
|
+
site('new.world', 'New Entangled World', 'KeyN KeyW'),
|
|
25
|
+
]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "entangled-worlds-orbiters-shared",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.2.0",
|
|
4
4
|
"description": "Shared library for entangled worlds orbiters - contains core world rendering, moons system, and UI components",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -86,6 +86,10 @@
|
|
|
86
86
|
"./audio": {
|
|
87
87
|
"types": "./dist/audio/index.d.ts",
|
|
88
88
|
"import": "./dist/audio/index.js"
|
|
89
|
+
},
|
|
90
|
+
"./shortcuts": {
|
|
91
|
+
"types": "./dist/shortcuts/index.d.ts",
|
|
92
|
+
"import": "./dist/shortcuts/index.js"
|
|
89
93
|
}
|
|
90
94
|
},
|
|
91
95
|
"files": [
|
package/src/clock/Transport.ts
CHANGED
|
@@ -77,6 +77,19 @@ export interface TransportOptions {
|
|
|
77
77
|
lookaheadSec?: number;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function isValidRate(rate: number): boolean {
|
|
81
|
+
return Number.isFinite(rate) && rate > 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isValidAnchoredState(s: AnchoredState): boolean {
|
|
85
|
+
if (s.playState !== 'playing' && s.playState !== 'paused') return false;
|
|
86
|
+
if (!Number.isFinite(s.positionSec)) return false;
|
|
87
|
+
if (s.anchorAudioSec != null && !Number.isFinite(s.anchorAudioSec)) return false;
|
|
88
|
+
if (s.rate != null && !isValidRate(s.rate)) return false;
|
|
89
|
+
if (s.loop && !(Number.isFinite(s.loop.startSec) && Number.isFinite(s.loop.endSec))) return false;
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
80
93
|
export class Transport {
|
|
81
94
|
backend: ClockBackend;
|
|
82
95
|
lookaheadSec: number;
|
|
@@ -132,12 +145,13 @@ export class Transport {
|
|
|
132
145
|
this._cancelArmed(); // armed future audio must not sound during pause; re-armed on resume
|
|
133
146
|
}
|
|
134
147
|
seekSec(sec: number): void {
|
|
148
|
+
if (!Number.isFinite(sec)) return; // a NaN seek is ignored, never freezes the transport
|
|
135
149
|
this._reanchor(Math.max(0, sec));
|
|
136
150
|
this._cancelArmed();
|
|
137
151
|
this._reopenEvents(); // a seek MOVES position -> fired events may replay
|
|
138
152
|
}
|
|
139
153
|
setRate(rate: number): void {
|
|
140
|
-
if (rate
|
|
154
|
+
if (!isValidRate(rate)) throw new Error('rate must be a finite number > 0');
|
|
141
155
|
this._reanchor();
|
|
142
156
|
this._rate = rate;
|
|
143
157
|
this._cancelArmed(); // window/audio-time changed -> re-arm pending events (NOT replay fired ones)
|
|
@@ -149,6 +163,7 @@ export class Transport {
|
|
|
149
163
|
return this._playing;
|
|
150
164
|
}
|
|
151
165
|
setLoopSec(loop: LoopSec | null): void {
|
|
166
|
+
if (loop && !(Number.isFinite(loop.startSec) && Number.isFinite(loop.endSec))) return; // ignored
|
|
152
167
|
this._loop = loop ? { startSec: loop.startSec, endSec: loop.endSec } : null;
|
|
153
168
|
this._cancelArmed();
|
|
154
169
|
this._reopenEvents(); // loop topology changed -> events may re-enter/leave the loop window
|
|
@@ -218,6 +233,10 @@ export class Transport {
|
|
|
218
233
|
if (d < -EPS) d += len; // wrapped into the next pass
|
|
219
234
|
return d;
|
|
220
235
|
}
|
|
236
|
+
// At or past the loop end: the transport wraps back to startSec before getting there, so the
|
|
237
|
+
// event never occurs while looping. Without this, its look-ahead distance came out positive just
|
|
238
|
+
// before the wrap and it armed (and sounded) at the wrap instant, at a position never reached.
|
|
239
|
+
if (len > 0 && ev.atSec >= endSec - EPS) return null;
|
|
221
240
|
}
|
|
222
241
|
const d = ev.atSec - pos;
|
|
223
242
|
return d >= -EPS ? d : null; // non-loop: a passed event won't fire again until a seek
|
|
@@ -232,7 +251,13 @@ export class Transport {
|
|
|
232
251
|
}
|
|
233
252
|
|
|
234
253
|
// ---- sync apply (Inc 3) -------------------------------------------------------------
|
|
235
|
-
|
|
254
|
+
/**
|
|
255
|
+
* Apply a peer's anchored state. A malformed one (unknown playState, non-finite position, anchor or
|
|
256
|
+
* loop bounds, or a rate that is not a finite number > 0) is DROPPED whole and `false` is returned: the transport keeps its last
|
|
257
|
+
* good state until the next good one arrives. It never throws — this runs inside `tick()`.
|
|
258
|
+
*/
|
|
259
|
+
applyAnchoredState(s: AnchoredState): boolean {
|
|
260
|
+
if (!isValidAnchoredState(s)) return false;
|
|
236
261
|
this._playing = s.playState === 'playing';
|
|
237
262
|
if (s.rate != null) this._rate = s.rate;
|
|
238
263
|
this._anchorPos = s.positionSec;
|
|
@@ -240,9 +265,13 @@ export class Transport {
|
|
|
240
265
|
if (s.loop !== undefined) this._loop = s.loop ? { ...s.loop } : null;
|
|
241
266
|
this._cancelArmed();
|
|
242
267
|
this._reopenEvents(); // anchored apply repositions the timeline -> events re-evaluate
|
|
268
|
+
return true;
|
|
243
269
|
}
|
|
244
270
|
/** Arm an anchored state (with optional control) to apply when audio time reaches `atAudioSec`. */
|
|
245
|
-
|
|
271
|
+
/** Returns `false` (and arms nothing) for a non-finite `atAudioSec` or a malformed state. */
|
|
272
|
+
scheduleStateChange(atAudioSec: number, state: AnchoredState, meta: ScheduleStateMeta = {}): boolean {
|
|
273
|
+
// checked at intake too: a NaN time would never drain and would block every later change
|
|
274
|
+
if (!Number.isFinite(atAudioSec) || !isValidAnchoredState(state)) return false;
|
|
246
275
|
this._pending.push({
|
|
247
276
|
atAudioSec,
|
|
248
277
|
state,
|
|
@@ -251,6 +280,7 @@ export class Transport {
|
|
|
251
280
|
onApply: meta.onApply ?? null,
|
|
252
281
|
});
|
|
253
282
|
this._pending.sort((a, b) => a.atAudioSec - b.atAudioSec);
|
|
283
|
+
return true;
|
|
254
284
|
}
|
|
255
285
|
/** Drop pending changes older than a freshly-applied (term,seq) — supersession (Codex fix #4). */
|
|
256
286
|
dropPendingOlderThan(term: number, seq: number): void {
|
|
@@ -260,8 +290,9 @@ export class Transport {
|
|
|
260
290
|
const now = this.backend.nowSec();
|
|
261
291
|
while (this._pending.length && this._pending[0].atAudioSec <= now + EPS) {
|
|
262
292
|
const p = this._pending.shift()!;
|
|
263
|
-
this.applyAnchoredState({ ...p.state, anchorAudioSec: p.state.anchorAudioSec ?? now });
|
|
264
|
-
|
|
293
|
+
const applied = this.applyAnchoredState({ ...p.state, anchorAudioSec: p.state.anchorAudioSec ?? now });
|
|
294
|
+
// control rides WITH the state, applied atomically here (fix #5) — a dropped state drops its control too
|
|
295
|
+
if (applied && p.onApply) p.onApply();
|
|
265
296
|
}
|
|
266
297
|
}
|
|
267
298
|
|
|
@@ -312,7 +343,7 @@ export class Transport {
|
|
|
312
343
|
if (!this._loop) return;
|
|
313
344
|
const { startSec, endSec } = this._loop;
|
|
314
345
|
const len = endSec - startSec;
|
|
315
|
-
if (len
|
|
346
|
+
if (!(len > 0)) return; // also catches NaN
|
|
316
347
|
const pos = this.positionSec();
|
|
317
348
|
if (pos < endSec - EPS) return;
|
|
318
349
|
const overshoot = (pos - startSec) % len;
|
package/src/clock/musical.ts
CHANGED
|
@@ -58,9 +58,13 @@ export class MusicalProjection {
|
|
|
58
58
|
constructor(spec: MusicalProjectionSpec = {}) {
|
|
59
59
|
this.ppq = spec.ppq ?? DEFAULT_PPQ;
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
// An entry with bpm <= 0, a NaN bpm or a non-finite start would make beat <-> seconds return
|
|
62
|
+
// Infinity/NaN — skip it. If nothing usable is left, fall back to the same 120 BPM default as an
|
|
63
|
+
// empty map.
|
|
64
|
+
const usable = (spec.tempoMap ?? []).filter(
|
|
65
|
+
(e) => Number.isFinite(e.startSec) && Number.isFinite(e.bpm) && e.bpm > 0,
|
|
66
|
+
);
|
|
67
|
+
const tempo = (usable.length ? usable : [{ startSec: 0, bpm: 120 }]).sort((a, b) => a.startSec - b.startSec);
|
|
64
68
|
// precompute the cumulative beat position at each tempo segment boundary
|
|
65
69
|
this._tempo = tempo.map((s) => ({ ...s, startBeat: 0 }));
|
|
66
70
|
for (let i = 1; i < this._tempo.length; i++) {
|
|
@@ -68,11 +72,17 @@ export class MusicalProjection {
|
|
|
68
72
|
this._tempo[i].startBeat = prev.startBeat + (this._tempo[i].startSec - prev.startSec) * (prev.bpm / 60);
|
|
69
73
|
}
|
|
70
74
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
75
|
+
// Same rule for signatures: a zero/negative/NaN numerator or denominator, or a non-finite start
|
|
76
|
+
// bar, would give Infinity/NaN bars — skip it, and fall back to 4/4 if nothing usable is left.
|
|
77
|
+
const usableSig = (spec.signatureMap ?? []).filter(
|
|
78
|
+
(e) =>
|
|
79
|
+
Number.isFinite(e.startBar) &&
|
|
80
|
+
Number.isFinite(e.numerator) &&
|
|
81
|
+
e.numerator > 0 &&
|
|
82
|
+
Number.isFinite(e.denominator) &&
|
|
83
|
+
e.denominator > 0,
|
|
84
|
+
);
|
|
85
|
+
const sig = (usableSig.length ? usableSig : [{ startBar: 0, numerator: 4, denominator: 4 }]).sort((a, b) => a.startBar - b.startBar);
|
|
76
86
|
// precompute the cumulative beat position at each signature segment's startBar
|
|
77
87
|
this._sig = sig.map((s) => ({ ...s, beatsPerBar: (s.numerator * 4) / s.denominator, startBeat: 0 }));
|
|
78
88
|
for (let i = 1; i < this._sig.length; i++) {
|
package/src/clock/sync.ts
CHANGED
|
@@ -72,6 +72,14 @@ export interface EstimateOffsetOpts {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
const mod = (n: number, m: number): number => ((n % m) + m) % m;
|
|
75
|
+
/** A usable timeline: a finite tempo above zero and finite anchors. Anything else makes every beat
|
|
76
|
+
* <-> time conversion NaN or Infinity, for this peer and for every peer that adopts it. */
|
|
77
|
+
const isValidTimeline = (t: BeatTimelineState | null | undefined): t is BeatTimelineState =>
|
|
78
|
+
!!t &&
|
|
79
|
+
Number.isFinite(t.tempoBpm) &&
|
|
80
|
+
t.tempoBpm > 0 &&
|
|
81
|
+
Number.isFinite(t.anchorBeat) &&
|
|
82
|
+
Number.isFinite(t.anchorSessionMs);
|
|
75
83
|
const newerTs = (a: TimelineStamp, b: TimelineStamp): boolean =>
|
|
76
84
|
a.time > b.time || (a.time === b.time && a.id > b.id);
|
|
77
85
|
|
|
@@ -225,6 +233,8 @@ export class BeatTimeline implements RelayNode {
|
|
|
225
233
|
|
|
226
234
|
// ---- timeline mutations (all leaderless: broadcast + last-writer-wins) ---------------
|
|
227
235
|
private _commit(timeline: BeatTimelineState): void {
|
|
236
|
+
// Validated before it is broadcast: last-writer-wins would hand a broken timeline to every peer.
|
|
237
|
+
if (!isValidTimeline(timeline)) throw new Error('beat timeline needs a finite tempo > 0 and finite anchors');
|
|
228
238
|
this.timeline = { ...timeline };
|
|
229
239
|
this._ts = { time: this.sessionNowMs(), id: this.id };
|
|
230
240
|
if (this.enabled)
|
|
@@ -269,7 +279,9 @@ export class BeatTimeline implements RelayNode {
|
|
|
269
279
|
// ---- start/stop intent (opt-in; never adopted on join) ------------------------------
|
|
270
280
|
setLocalPlaying(playing: boolean): void {
|
|
271
281
|
this.localPlaying = playing;
|
|
272
|
-
|
|
282
|
+
// Solo (`enabled` false) broadcasts nothing, as with the timeline and hello messages.
|
|
283
|
+
if (this.enabled)
|
|
284
|
+
this.relay.send(this.id, { kind: 'beat:transport', id: this.id, playing, atSessionMs: this.sessionNowMs() });
|
|
273
285
|
if (this.onChange) this.onChange();
|
|
274
286
|
}
|
|
275
287
|
|
|
@@ -289,7 +301,8 @@ export class BeatTimeline implements RelayNode {
|
|
|
289
301
|
break;
|
|
290
302
|
case 'beat:timeline':
|
|
291
303
|
if (msg.id) this.peers.add(msg.id);
|
|
292
|
-
|
|
304
|
+
// A malformed timeline (tempo 0, NaN anchor, from a buggy or older peer) is never adopted.
|
|
305
|
+
if (isValidTimeline(msg.timeline) && msg.ts && newerTs(msg.ts, this._ts)) {
|
|
293
306
|
this.timeline = { ...msg.timeline };
|
|
294
307
|
this._ts = msg.ts;
|
|
295
308
|
}
|
package/src/loaders/glbLoader.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as THREE from 'three';
|
|
|
2
2
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
|
3
3
|
import { ensureStandardMaterial, applyFlagsToMaterial, type MaterialFlags } from '../utils/materials.js';
|
|
4
4
|
import { getKTX2LoaderForRenderer } from './ktx2.js';
|
|
5
|
+
import { disposeObject3D } from '../utils/dispose.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* `renderer` is REQUIRED at runtime (throws without one).
|
|
@@ -43,6 +44,9 @@ export async function loadGLB(
|
|
|
43
44
|
|
|
44
45
|
let attempt = 0;
|
|
45
46
|
while (attempt < retries) {
|
|
47
|
+
// An abort during the pause between attempts must stop here: a listener added to an
|
|
48
|
+
// already-aborted signal never fires, so the next attempt would load and resolve anyway.
|
|
49
|
+
if (signal?.aborted) throw new Error('aborted');
|
|
46
50
|
try {
|
|
47
51
|
const loader = new GLTFLoader()
|
|
48
52
|
.setCrossOrigin(withCredentials ? 'use-credentials' : 'anonymous')
|
|
@@ -50,15 +54,20 @@ export async function loadGLB(
|
|
|
50
54
|
.setKTX2Loader(getKTX2LoaderForRenderer(renderer));
|
|
51
55
|
|
|
52
56
|
const root = await new Promise<THREE.Group>((resolve, reject) => {
|
|
57
|
+
const onAbort = () => reject(new Error('aborted'));
|
|
58
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
53
59
|
loader.load(
|
|
54
60
|
url,
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
(gltf) => {
|
|
62
|
+
signal?.removeEventListener('abort', onAbort);
|
|
63
|
+
// Aborted while loading: the promise has already rejected and nobody will own this
|
|
64
|
+
// scene, so free its geometry, materials and textures now instead of leaking them.
|
|
65
|
+
if (signal?.aborted) { disposeObject3D(gltf.scene); return; }
|
|
66
|
+
resolve(gltf.scene);
|
|
67
|
+
},
|
|
68
|
+
onProgress,
|
|
69
|
+
(err) => { signal?.removeEventListener('abort', onAbort); reject(err); }
|
|
58
70
|
);
|
|
59
|
-
if (signal) {
|
|
60
|
-
signal.addEventListener('abort', () => reject(new Error('aborted')));
|
|
61
|
-
}
|
|
62
71
|
});
|
|
63
72
|
|
|
64
73
|
// Post-process materials
|