@xiboplayer/sync 0.6.12 → 0.7.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 -4
- package/examples/test-multi-display.js +205 -0
- package/package.json +2 -2
- package/src/choreography.js +168 -0
- package/src/choreography.test.js +248 -0
- package/src/index.d.ts +61 -1
- package/src/sync-manager.js +17 -1
- package/src/sync-manager.test.js +116 -0
- package/src/ws-transport.js +27 -3
- package/src/ws-transport.test.js +261 -0
package/README.md
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
# @xiboplayer/sync
|
|
2
2
|
|
|
3
|
-
**Multi-display synchronization for Xibo video walls — same-machine and cross-device.**
|
|
3
|
+
**Multi-display synchronization for Xibo video walls — same-machine and cross-device. New in v0.7.0.**
|
|
4
4
|
|
|
5
5
|
## Overview
|
|
6
6
|
|
|
7
|
-
Coordinates layout transitions and video playback across multiple displays:
|
|
7
|
+
Coordinates layout transitions and video playback across multiple displays with <8ms precision:
|
|
8
8
|
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
9
|
+
- **Cross-device sync** — WebSocket relay for LAN video walls where each screen is a separate device
|
|
10
|
+
- **Same-machine sync** — BroadcastChannel for multi-tab/multi-window setups on a single device
|
|
11
11
|
|
|
12
12
|
Both modes share the same sync protocol — only the transport layer differs.
|
|
13
13
|
|
|
14
14
|
### Capabilities
|
|
15
15
|
|
|
16
16
|
- **Synchronized layout transitions** — lead signals followers to change layout, waits for all to be ready, then sends a simultaneous "show" signal
|
|
17
|
+
- **12 choreography effects** — diagonal cascade, wave sweep, center-out, and more for dramatic transition patterns
|
|
17
18
|
- **Coordinated video start** — video playback begins at the same moment on all displays
|
|
18
19
|
- **Stats/logs delegation** — followers delegate proof-of-play stats and log submission through the lead, avoiding duplicate CMS traffic
|
|
20
|
+
- **Token authentication** — shared CMS key secures the WebSocket relay
|
|
21
|
+
- **Sync group isolation** — multiple sync groups can share the same relay via `syncGroupId`
|
|
22
|
+
- **Offline LAN sync** — persisted config enables sync without CMS connectivity
|
|
19
23
|
- **Automatic follower discovery** — heartbeats every 5s, stale detection after 15s
|
|
20
24
|
- **Graceful degradation** — if a follower is unresponsive, the lead proceeds after a 10s timeout
|
|
21
25
|
- **Auto-reconnect** — WebSocket transport reconnects with exponential backoff (1s → 30s)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Multi-Display Sync Demo — test all sync features locally
|
|
4
|
+
*
|
|
5
|
+
* Starts a relay + simulates 4 displays in a 2×2 grid with choreography.
|
|
6
|
+
* Demonstrates: group isolation, wall mode (layoutMap), topology,
|
|
7
|
+
* auto-detected totalDisplays, and transition choreography.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node packages/sync/examples/test-multi-display.js
|
|
11
|
+
*
|
|
12
|
+
* What it does:
|
|
13
|
+
* 1. Starts a standalone relay on port 9590
|
|
14
|
+
* 2. Creates 4 SyncManager instances (1 lead + 3 followers)
|
|
15
|
+
* 3. All join group "lobby" with 2×2 topology
|
|
16
|
+
* 4. Lead requests layout changes — followers sync
|
|
17
|
+
* 5. Choreography staggers are computed and logged
|
|
18
|
+
* 6. Wall mode maps lead's layout to position-specific layouts
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
import { attachSyncRelay } from '../../proxy/src/sync-relay.js';
|
|
23
|
+
import { SyncManager } from '../src/sync-manager.js';
|
|
24
|
+
import { WebSocketTransport } from '../src/ws-transport.js';
|
|
25
|
+
import { computeStagger } from '../src/choreography.js';
|
|
26
|
+
|
|
27
|
+
const PORT = 9590;
|
|
28
|
+
const RELAY_URL = `ws://127.0.0.1:${PORT}/sync`;
|
|
29
|
+
|
|
30
|
+
// ── Display configs ──────────────────────────────────────────────
|
|
31
|
+
const displays = [
|
|
32
|
+
{
|
|
33
|
+
id: 'display-lead',
|
|
34
|
+
isLead: true,
|
|
35
|
+
topology: { x: 0, y: 0, orientation: 0 },
|
|
36
|
+
layoutMap: null, // lead plays the original layout
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'display-top-right',
|
|
40
|
+
isLead: false,
|
|
41
|
+
topology: { x: 1, y: 0, orientation: 0 },
|
|
42
|
+
layoutMap: { '100': 201 }, // wall mode: lead's 100 → this display's 201
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'display-bottom-left',
|
|
46
|
+
isLead: false,
|
|
47
|
+
topology: { x: 0, y: 1, orientation: 90 }, // portrait totem
|
|
48
|
+
layoutMap: { '100': 202 },
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: 'display-bottom-right',
|
|
52
|
+
isLead: false,
|
|
53
|
+
topology: { x: 1, y: 1, orientation: 0 },
|
|
54
|
+
layoutMap: { '100': 203 },
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const CHOREOGRAPHY = 'diagonal-tl';
|
|
59
|
+
const STAGGER_MS = 200;
|
|
60
|
+
const GRID_COLS = 2;
|
|
61
|
+
const GRID_ROWS = 2;
|
|
62
|
+
|
|
63
|
+
// ── Start relay ──────────────────────────────────────────────────
|
|
64
|
+
const server = http.createServer((_req, res) => {
|
|
65
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
66
|
+
res.end(JSON.stringify({ service: 'test-relay', status: 'ok' }));
|
|
67
|
+
});
|
|
68
|
+
attachSyncRelay(server);
|
|
69
|
+
|
|
70
|
+
server.listen(PORT, async () => {
|
|
71
|
+
console.log(`\n🔄 Relay running on ws://127.0.0.1:${PORT}/sync\n`);
|
|
72
|
+
|
|
73
|
+
// Wait for server to be ready
|
|
74
|
+
await sleep(200);
|
|
75
|
+
|
|
76
|
+
// ── Create SyncManagers ──────────────────────────────────────
|
|
77
|
+
const managers = [];
|
|
78
|
+
|
|
79
|
+
for (const display of displays) {
|
|
80
|
+
const syncConfig = {
|
|
81
|
+
syncGroup: 'lobby',
|
|
82
|
+
syncPublisherPort: PORT,
|
|
83
|
+
syncSwitchDelay: 500,
|
|
84
|
+
syncVideoPauseDelay: 100,
|
|
85
|
+
isLead: display.isLead,
|
|
86
|
+
relayUrl: RELAY_URL,
|
|
87
|
+
topology: display.topology,
|
|
88
|
+
layoutMap: display.layoutMap,
|
|
89
|
+
choreography: CHOREOGRAPHY,
|
|
90
|
+
staggerMs: STAGGER_MS,
|
|
91
|
+
gridCols: GRID_COLS,
|
|
92
|
+
gridRows: GRID_ROWS,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const transport = new WebSocketTransport(RELAY_URL, {
|
|
96
|
+
syncGroup: 'lobby',
|
|
97
|
+
displayId: display.id,
|
|
98
|
+
topology: display.topology,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const manager = new SyncManager({
|
|
102
|
+
displayId: display.id,
|
|
103
|
+
syncConfig,
|
|
104
|
+
transport,
|
|
105
|
+
onLayoutChange: (layoutId) => {
|
|
106
|
+
// Wall mode: map lead's layout to position-specific
|
|
107
|
+
const mapped = display.layoutMap?.[layoutId] ?? layoutId;
|
|
108
|
+
const tag = mapped !== layoutId ? ` (wall: ${layoutId}→${mapped})` : '';
|
|
109
|
+
console.log(` 📺 ${display.id}: Loading layout ${mapped}${tag}`);
|
|
110
|
+
|
|
111
|
+
// Simulate load time
|
|
112
|
+
setTimeout(() => {
|
|
113
|
+
manager.reportReady(layoutId);
|
|
114
|
+
console.log(` ✅ ${display.id}: Ready`);
|
|
115
|
+
}, 100 + Math.random() * 200);
|
|
116
|
+
},
|
|
117
|
+
onLayoutShow: (layoutId) => {
|
|
118
|
+
const stagger = computeStagger({
|
|
119
|
+
choreography: CHOREOGRAPHY,
|
|
120
|
+
topology: display.topology,
|
|
121
|
+
gridCols: GRID_COLS,
|
|
122
|
+
gridRows: GRID_ROWS,
|
|
123
|
+
staggerMs: STAGGER_MS,
|
|
124
|
+
});
|
|
125
|
+
console.log(` 🎬 ${display.id}: Show layout ${layoutId} (delay: ${stagger}ms, ${CHOREOGRAPHY})`);
|
|
126
|
+
},
|
|
127
|
+
onVideoStart: (layoutId, regionId) => {
|
|
128
|
+
console.log(` ▶️ ${display.id}: Video start in layout ${layoutId} region ${regionId}`);
|
|
129
|
+
},
|
|
130
|
+
onGroupUpdate: (totalDisplays, topology) => {
|
|
131
|
+
console.log(` 📡 ${display.id}: Group update — ${totalDisplays} displays, topology:`, JSON.stringify(topology));
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
manager.start();
|
|
136
|
+
managers.push({ display, manager });
|
|
137
|
+
console.log(`${display.isLead ? '👑' : '👤'} ${display.id} joined (${display.topology.x},${display.topology.y}) orientation=${display.topology.orientation ?? 0}°`);
|
|
138
|
+
await sleep(300);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
console.log('\n--- All displays connected ---\n');
|
|
142
|
+
await sleep(500);
|
|
143
|
+
|
|
144
|
+
// ── Show choreography stagger table ────────────────────────
|
|
145
|
+
console.log(`\n📊 Choreography: ${CHOREOGRAPHY} (${STAGGER_MS}ms stagger)`);
|
|
146
|
+
console.log('┌─────────────────────────┬──────────┬──────────┐');
|
|
147
|
+
console.log('│ Display │ Position │ Delay │');
|
|
148
|
+
console.log('├─────────────────────────┼──────────┼──────────┤');
|
|
149
|
+
for (const { display } of managers) {
|
|
150
|
+
const stagger = computeStagger({
|
|
151
|
+
choreography: CHOREOGRAPHY,
|
|
152
|
+
topology: display.topology,
|
|
153
|
+
gridCols: GRID_COLS,
|
|
154
|
+
gridRows: GRID_ROWS,
|
|
155
|
+
staggerMs: STAGGER_MS,
|
|
156
|
+
});
|
|
157
|
+
console.log(`│ ${display.id.padEnd(23)} │ (${display.topology.x},${display.topology.y}) │ ${String(stagger).padStart(4)}ms │`);
|
|
158
|
+
}
|
|
159
|
+
console.log('└─────────────────────────┴──────────┴──────────┘\n');
|
|
160
|
+
|
|
161
|
+
// ── Lead requests layout change ────────────────────────────
|
|
162
|
+
const lead = managers.find(m => m.display.isLead);
|
|
163
|
+
|
|
164
|
+
console.log('🚀 Lead requesting layout 100 (wall mode: each display maps to its own layout)...\n');
|
|
165
|
+
await lead.manager.requestLayoutChange(100);
|
|
166
|
+
console.log('\n✅ Layout 100 shown across all displays!\n');
|
|
167
|
+
|
|
168
|
+
await sleep(1000);
|
|
169
|
+
|
|
170
|
+
console.log('🚀 Lead requesting layout 200 (mirror mode: all show same layout)...\n');
|
|
171
|
+
await lead.manager.requestLayoutChange(200);
|
|
172
|
+
console.log('\n✅ Layout 200 shown across all displays!\n');
|
|
173
|
+
|
|
174
|
+
await sleep(500);
|
|
175
|
+
|
|
176
|
+
// ── Show other choreography patterns ──────────────────────
|
|
177
|
+
console.log('\n📊 All choreography patterns for this 2×2 grid:\n');
|
|
178
|
+
const patterns = ['simultaneous', 'wave-right', 'wave-left', 'wave-down', 'wave-up',
|
|
179
|
+
'diagonal-tl', 'diagonal-tr', 'diagonal-bl', 'diagonal-br',
|
|
180
|
+
'center-out', 'outside-in'];
|
|
181
|
+
|
|
182
|
+
for (const pattern of patterns) {
|
|
183
|
+
const delays = displays.map(d => computeStagger({
|
|
184
|
+
choreography: pattern,
|
|
185
|
+
topology: d.topology,
|
|
186
|
+
gridCols: GRID_COLS,
|
|
187
|
+
gridRows: GRID_ROWS,
|
|
188
|
+
staggerMs: STAGGER_MS,
|
|
189
|
+
}));
|
|
190
|
+
const grid = `(0,0)=${delays[0]}ms (1,0)=${delays[1]}ms | (0,1)=${delays[2]}ms (1,1)=${delays[3]}ms`;
|
|
191
|
+
console.log(` ${pattern.padEnd(14)} → ${grid}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Cleanup ────────────────────────────────────────────────
|
|
195
|
+
console.log('\n🧹 Cleaning up...');
|
|
196
|
+
for (const { manager } of managers) {
|
|
197
|
+
manager.stop();
|
|
198
|
+
}
|
|
199
|
+
server.close();
|
|
200
|
+
console.log('Done.\n');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
function sleep(ms) {
|
|
204
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
205
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Multi-display synchronization for Xibo Player",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/sync-manager.js",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
}
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@xiboplayer/utils": "0.
|
|
15
|
+
"@xiboplayer/utils": "0.7.0"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"vitest": "^2.0.0"
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2024-2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* Transition choreography — compute stagger delays for cascading
|
|
5
|
+
* layout transitions across multiple displays.
|
|
6
|
+
*
|
|
7
|
+
* Supports two modes:
|
|
8
|
+
*
|
|
9
|
+
* 1D mode (position-based):
|
|
10
|
+
* Displays are numbered 0..N-1 in a row. Simple linear choreographies.
|
|
11
|
+
* Config: { position, totalDisplays }
|
|
12
|
+
*
|
|
13
|
+
* 2D mode (topology-based):
|
|
14
|
+
* Each display has (x, y) coordinates and an orientation vector.
|
|
15
|
+
* Enables directional sweeps, diagonal cascades, and radial effects.
|
|
16
|
+
* Config: { topology: { x, y, orientation? }, gridCols, gridRows }
|
|
17
|
+
*
|
|
18
|
+
* Topology format:
|
|
19
|
+
* { x: 1, y: 0, orientation: 0 }
|
|
20
|
+
* - x, y: grid coordinates (0-indexed)
|
|
21
|
+
* - orientation: degrees clockwise from upright (0=landscape, 90=portrait-right,
|
|
22
|
+
* 180=inverted, 270=portrait-left). Defaults to 0.
|
|
23
|
+
*
|
|
24
|
+
* Choreographies:
|
|
25
|
+
* simultaneous — all at once (default, no delay)
|
|
26
|
+
* wave-right — sweep left to right (by x)
|
|
27
|
+
* wave-left — sweep right to left (by x)
|
|
28
|
+
* wave-down — sweep top to bottom (by y, 2D only)
|
|
29
|
+
* wave-up — sweep bottom to top (by y, 2D only)
|
|
30
|
+
* diagonal-tl — cascade from top-left corner (2D only)
|
|
31
|
+
* diagonal-tr — cascade from top-right corner (2D only)
|
|
32
|
+
* diagonal-bl — cascade from bottom-left corner (2D only)
|
|
33
|
+
* diagonal-br — cascade from bottom-right corner (2D only)
|
|
34
|
+
* center-out — explode from center to edges
|
|
35
|
+
* outside-in — implode from edges to center
|
|
36
|
+
* random — random delay per display
|
|
37
|
+
*
|
|
38
|
+
* @module @xiboplayer/sync/choreography
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Compute the stagger delay for a display.
|
|
43
|
+
*
|
|
44
|
+
* @param {Object} options
|
|
45
|
+
* @param {string} options.choreography — choreography name
|
|
46
|
+
* @param {number} [options.position] — 1D: this display's 0-indexed position
|
|
47
|
+
* @param {number} [options.totalDisplays] — 1D: total displays in the group
|
|
48
|
+
* @param {Object} [options.topology] — 2D: this display's topology { x, y, orientation? }
|
|
49
|
+
* @param {number} [options.gridCols] — 2D: grid width (columns)
|
|
50
|
+
* @param {number} [options.gridRows] — 2D: grid height (rows)
|
|
51
|
+
* @param {number} options.staggerMs — base delay between consecutive displays (ms)
|
|
52
|
+
* @returns {number} delay in ms before this display should execute its transition
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* // 1D: 4 displays, wave-right with 150ms stagger
|
|
56
|
+
* computeStagger({ choreography: 'wave-right', position: 2, totalDisplays: 4, staggerMs: 150 })
|
|
57
|
+
* // → 300
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* // 2D: 3×2 grid, diagonal from top-left
|
|
61
|
+
* computeStagger({
|
|
62
|
+
* choreography: 'diagonal-tl',
|
|
63
|
+
* topology: { x: 2, y: 1 },
|
|
64
|
+
* gridCols: 3, gridRows: 2,
|
|
65
|
+
* staggerMs: 100,
|
|
66
|
+
* })
|
|
67
|
+
* // → 300 (Manhattan distance 2+1=3, so 3×100)
|
|
68
|
+
*/
|
|
69
|
+
export function computeStagger({ choreography, position, totalDisplays, topology, gridCols, gridRows, staggerMs }) {
|
|
70
|
+
if (!choreography || choreography === 'simultaneous' || !staggerMs) {
|
|
71
|
+
return 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 2D mode: topology with grid dimensions
|
|
75
|
+
if (topology && gridCols != null && gridRows != null) {
|
|
76
|
+
if (gridCols <= 1 && gridRows <= 1) return 0;
|
|
77
|
+
return _computeStagger2D(choreography, topology, gridCols, gridRows, staggerMs);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 1D mode: position-based
|
|
81
|
+
if (totalDisplays == null || totalDisplays <= 1) return 0;
|
|
82
|
+
return _computeStagger1D(choreography, position ?? 0, totalDisplays, staggerMs);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @private 1D stagger computation */
|
|
86
|
+
function _computeStagger1D(choreography, position, totalDisplays, staggerMs) {
|
|
87
|
+
const last = totalDisplays - 1;
|
|
88
|
+
const center = last / 2;
|
|
89
|
+
|
|
90
|
+
switch (choreography) {
|
|
91
|
+
case 'wave-right':
|
|
92
|
+
return position * staggerMs;
|
|
93
|
+
|
|
94
|
+
case 'wave-left':
|
|
95
|
+
return (last - position) * staggerMs;
|
|
96
|
+
|
|
97
|
+
case 'center-out':
|
|
98
|
+
return Math.round(Math.abs(position - center)) * staggerMs;
|
|
99
|
+
|
|
100
|
+
case 'outside-in': {
|
|
101
|
+
const maxDist = Math.round(center);
|
|
102
|
+
return (maxDist - Math.round(Math.abs(position - center))) * staggerMs;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case 'random':
|
|
106
|
+
return Math.floor(Math.random() * last * staggerMs);
|
|
107
|
+
|
|
108
|
+
default:
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @private 2D stagger computation */
|
|
114
|
+
function _computeStagger2D(choreography, topology, gridCols, gridRows, staggerMs) {
|
|
115
|
+
const { x, y } = topology;
|
|
116
|
+
const maxX = gridCols - 1;
|
|
117
|
+
const maxY = gridRows - 1;
|
|
118
|
+
const centerX = maxX / 2;
|
|
119
|
+
const centerY = maxY / 2;
|
|
120
|
+
|
|
121
|
+
switch (choreography) {
|
|
122
|
+
// Axis-aligned sweeps
|
|
123
|
+
case 'wave-right':
|
|
124
|
+
return x * staggerMs;
|
|
125
|
+
|
|
126
|
+
case 'wave-left':
|
|
127
|
+
return (maxX - x) * staggerMs;
|
|
128
|
+
|
|
129
|
+
case 'wave-down':
|
|
130
|
+
return y * staggerMs;
|
|
131
|
+
|
|
132
|
+
case 'wave-up':
|
|
133
|
+
return (maxY - y) * staggerMs;
|
|
134
|
+
|
|
135
|
+
// Corner diagonals (Manhattan distance from corner)
|
|
136
|
+
case 'diagonal-tl':
|
|
137
|
+
return (x + y) * staggerMs;
|
|
138
|
+
|
|
139
|
+
case 'diagonal-tr':
|
|
140
|
+
return ((maxX - x) + y) * staggerMs;
|
|
141
|
+
|
|
142
|
+
case 'diagonal-bl':
|
|
143
|
+
return (x + (maxY - y)) * staggerMs;
|
|
144
|
+
|
|
145
|
+
case 'diagonal-br':
|
|
146
|
+
return ((maxX - x) + (maxY - y)) * staggerMs;
|
|
147
|
+
|
|
148
|
+
// Radial patterns (Euclidean distance from center)
|
|
149
|
+
case 'center-out': {
|
|
150
|
+
const dist = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
|
|
151
|
+
return Math.round(dist) * staggerMs;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
case 'outside-in': {
|
|
155
|
+
const maxDist = Math.round(Math.sqrt(centerX ** 2 + centerY ** 2));
|
|
156
|
+
const dist = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2);
|
|
157
|
+
return (maxDist - Math.round(dist)) * staggerMs;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
case 'random': {
|
|
161
|
+
const maxSteps = maxX + maxY; // Manhattan extent
|
|
162
|
+
return Math.floor(Math.random() * maxSteps * staggerMs);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
default:
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2024-2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { computeStagger } from './choreography.js';
|
|
5
|
+
|
|
6
|
+
// ── 1D helpers ────────────────────────────────────────────────────
|
|
7
|
+
const opts = (choreography, position, totalDisplays = 4, staggerMs = 150) =>
|
|
8
|
+
({ choreography, position, totalDisplays, staggerMs });
|
|
9
|
+
|
|
10
|
+
// ── 2D helpers ────────────────────────────────────────────────────
|
|
11
|
+
const opts2D = (choreography, x, y, gridCols = 3, gridRows = 2, staggerMs = 100) =>
|
|
12
|
+
({ choreography, topology: { x, y }, gridCols, gridRows, staggerMs });
|
|
13
|
+
|
|
14
|
+
// ══════════════════════════════════════════════════════════════════
|
|
15
|
+
// 1D MODE (backward compatible)
|
|
16
|
+
// ══════════════════════════════════════════════════════════════════
|
|
17
|
+
describe('computeStagger — 1D mode', () => {
|
|
18
|
+
describe('simultaneous (default)', () => {
|
|
19
|
+
it('returns 0 for simultaneous', () => {
|
|
20
|
+
expect(computeStagger(opts('simultaneous', 0))).toBe(0);
|
|
21
|
+
expect(computeStagger(opts('simultaneous', 3))).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns 0 when no choreography', () => {
|
|
25
|
+
expect(computeStagger(opts(null, 2))).toBe(0);
|
|
26
|
+
expect(computeStagger(opts(undefined, 2))).toBe(0);
|
|
27
|
+
expect(computeStagger(opts('', 2))).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns 0 for single display', () => {
|
|
31
|
+
expect(computeStagger(opts('wave-right', 0, 1, 150))).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns 0 when staggerMs is 0', () => {
|
|
35
|
+
expect(computeStagger(opts('wave-right', 2, 4, 0))).toBe(0);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('wave-right', () => {
|
|
40
|
+
it('staggers left to right', () => {
|
|
41
|
+
expect(computeStagger(opts('wave-right', 0))).toBe(0);
|
|
42
|
+
expect(computeStagger(opts('wave-right', 1))).toBe(150);
|
|
43
|
+
expect(computeStagger(opts('wave-right', 2))).toBe(300);
|
|
44
|
+
expect(computeStagger(opts('wave-right', 3))).toBe(450);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('wave-left', () => {
|
|
49
|
+
it('staggers right to left', () => {
|
|
50
|
+
expect(computeStagger(opts('wave-left', 0))).toBe(450);
|
|
51
|
+
expect(computeStagger(opts('wave-left', 1))).toBe(300);
|
|
52
|
+
expect(computeStagger(opts('wave-left', 2))).toBe(150);
|
|
53
|
+
expect(computeStagger(opts('wave-left', 3))).toBe(0);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('center-out', () => {
|
|
58
|
+
it('explodes from center (even count)', () => {
|
|
59
|
+
// 4 displays: center = 1.5
|
|
60
|
+
// pos 0: |0-1.5| = 1.5 → round(1.5)*150 = 2*150 = 300
|
|
61
|
+
// pos 1: |1-1.5| = 0.5 → round(0.5)*150 = 1*150 = 150 (JS rounds .5 up)
|
|
62
|
+
// pos 2: |2-1.5| = 0.5 → round(0.5)*150 = 1*150 = 150
|
|
63
|
+
// pos 3: |3-1.5| = 1.5 → round(1.5)*150 = 2*150 = 300
|
|
64
|
+
expect(computeStagger(opts('center-out', 0))).toBe(300);
|
|
65
|
+
expect(computeStagger(opts('center-out', 1))).toBe(150);
|
|
66
|
+
expect(computeStagger(opts('center-out', 2))).toBe(150);
|
|
67
|
+
expect(computeStagger(opts('center-out', 3))).toBe(300);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('explodes from center (odd count)', () => {
|
|
71
|
+
// 5 displays: center = 2
|
|
72
|
+
expect(computeStagger(opts('center-out', 0, 5, 100))).toBe(200);
|
|
73
|
+
expect(computeStagger(opts('center-out', 1, 5, 100))).toBe(100);
|
|
74
|
+
expect(computeStagger(opts('center-out', 2, 5, 100))).toBe(0);
|
|
75
|
+
expect(computeStagger(opts('center-out', 3, 5, 100))).toBe(100);
|
|
76
|
+
expect(computeStagger(opts('center-out', 4, 5, 100))).toBe(200);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('outside-in', () => {
|
|
81
|
+
it('implodes from edges (odd count)', () => {
|
|
82
|
+
expect(computeStagger(opts('outside-in', 0, 5, 100))).toBe(0);
|
|
83
|
+
expect(computeStagger(opts('outside-in', 1, 5, 100))).toBe(100);
|
|
84
|
+
expect(computeStagger(opts('outside-in', 2, 5, 100))).toBe(200);
|
|
85
|
+
expect(computeStagger(opts('outside-in', 3, 5, 100))).toBe(100);
|
|
86
|
+
expect(computeStagger(opts('outside-in', 4, 5, 100))).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('random', () => {
|
|
91
|
+
it('returns a value within range', () => {
|
|
92
|
+
const delay = computeStagger(opts('random', 2));
|
|
93
|
+
expect(delay).toBeGreaterThanOrEqual(0);
|
|
94
|
+
expect(delay).toBeLessThan(3 * 150); // last * staggerMs
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('unknown choreography', () => {
|
|
99
|
+
it('returns 0 for unrecognized name', () => {
|
|
100
|
+
expect(computeStagger(opts('zigzag', 2))).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// ══════════════════════════════════════════════════════════════════
|
|
106
|
+
// 2D MODE (topology-based)
|
|
107
|
+
// ══════════════════════════════════════════════════════════════════
|
|
108
|
+
describe('computeStagger — 2D mode', () => {
|
|
109
|
+
// 3×2 grid:
|
|
110
|
+
// (0,0) (1,0) (2,0)
|
|
111
|
+
// (0,1) (1,1) (2,1)
|
|
112
|
+
|
|
113
|
+
describe('edge cases', () => {
|
|
114
|
+
it('returns 0 for 1×1 grid', () => {
|
|
115
|
+
expect(computeStagger(opts2D('wave-right', 0, 0, 1, 1, 100))).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('returns 0 for simultaneous', () => {
|
|
119
|
+
expect(computeStagger(opts2D('simultaneous', 1, 1))).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('wave-right (by x only)', () => {
|
|
124
|
+
it('staggers by x coordinate', () => {
|
|
125
|
+
expect(computeStagger(opts2D('wave-right', 0, 0))).toBe(0);
|
|
126
|
+
expect(computeStagger(opts2D('wave-right', 1, 0))).toBe(100);
|
|
127
|
+
expect(computeStagger(opts2D('wave-right', 2, 0))).toBe(200);
|
|
128
|
+
// Same x = same delay regardless of y
|
|
129
|
+
expect(computeStagger(opts2D('wave-right', 1, 1))).toBe(100);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('wave-left (by x, reversed)', () => {
|
|
134
|
+
it('staggers right to left', () => {
|
|
135
|
+
expect(computeStagger(opts2D('wave-left', 0, 0))).toBe(200);
|
|
136
|
+
expect(computeStagger(opts2D('wave-left', 2, 0))).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('wave-down (by y only)', () => {
|
|
141
|
+
it('staggers by y coordinate', () => {
|
|
142
|
+
expect(computeStagger(opts2D('wave-down', 0, 0))).toBe(0);
|
|
143
|
+
expect(computeStagger(opts2D('wave-down', 0, 1))).toBe(100);
|
|
144
|
+
// Same y = same delay regardless of x
|
|
145
|
+
expect(computeStagger(opts2D('wave-down', 2, 0))).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('wave-up (by y, reversed)', () => {
|
|
150
|
+
it('staggers bottom to top', () => {
|
|
151
|
+
expect(computeStagger(opts2D('wave-up', 0, 0))).toBe(100);
|
|
152
|
+
expect(computeStagger(opts2D('wave-up', 0, 1))).toBe(0);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe('diagonal-tl (from top-left)', () => {
|
|
157
|
+
it('staggers by Manhattan distance from (0,0)', () => {
|
|
158
|
+
// (0,0)→0, (1,0)→1, (2,0)→2, (0,1)→1, (1,1)→2, (2,1)→3
|
|
159
|
+
expect(computeStagger(opts2D('diagonal-tl', 0, 0))).toBe(0);
|
|
160
|
+
expect(computeStagger(opts2D('diagonal-tl', 1, 0))).toBe(100);
|
|
161
|
+
expect(computeStagger(opts2D('diagonal-tl', 0, 1))).toBe(100);
|
|
162
|
+
expect(computeStagger(opts2D('diagonal-tl', 2, 0))).toBe(200);
|
|
163
|
+
expect(computeStagger(opts2D('diagonal-tl', 1, 1))).toBe(200);
|
|
164
|
+
expect(computeStagger(opts2D('diagonal-tl', 2, 1))).toBe(300);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe('diagonal-tr (from top-right)', () => {
|
|
169
|
+
it('staggers by Manhattan distance from (maxX,0)', () => {
|
|
170
|
+
// maxX=2: (2,0)→0, (1,0)→1, (0,0)→2, (2,1)→1, (1,1)→2, (0,1)→3
|
|
171
|
+
expect(computeStagger(opts2D('diagonal-tr', 2, 0))).toBe(0);
|
|
172
|
+
expect(computeStagger(opts2D('diagonal-tr', 1, 0))).toBe(100);
|
|
173
|
+
expect(computeStagger(opts2D('diagonal-tr', 0, 0))).toBe(200);
|
|
174
|
+
expect(computeStagger(opts2D('diagonal-tr', 0, 1))).toBe(300);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('diagonal-bl (from bottom-left)', () => {
|
|
179
|
+
it('staggers by Manhattan distance from (0,maxY)', () => {
|
|
180
|
+
// maxY=1: (0,1)→0, (1,1)→1, (0,0)→1, (2,1)→2, (1,0)→2, (2,0)→3
|
|
181
|
+
expect(computeStagger(opts2D('diagonal-bl', 0, 1))).toBe(0);
|
|
182
|
+
expect(computeStagger(opts2D('diagonal-bl', 0, 0))).toBe(100);
|
|
183
|
+
expect(computeStagger(opts2D('diagonal-bl', 2, 1))).toBe(200);
|
|
184
|
+
expect(computeStagger(opts2D('diagonal-bl', 2, 0))).toBe(300);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('diagonal-br (from bottom-right)', () => {
|
|
189
|
+
it('staggers by Manhattan distance from (maxX,maxY)', () => {
|
|
190
|
+
// (2,1)→0, (1,1)→1, (2,0)→1, (0,1)→2, (1,0)→2, (0,0)→3
|
|
191
|
+
expect(computeStagger(opts2D('diagonal-br', 2, 1))).toBe(0);
|
|
192
|
+
expect(computeStagger(opts2D('diagonal-br', 1, 1))).toBe(100);
|
|
193
|
+
expect(computeStagger(opts2D('diagonal-br', 2, 0))).toBe(100);
|
|
194
|
+
expect(computeStagger(opts2D('diagonal-br', 0, 0))).toBe(300);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('center-out (Euclidean from center)', () => {
|
|
199
|
+
it('radiates from center of 3×3 grid', () => {
|
|
200
|
+
// 3×3 grid, center = (1,1)
|
|
201
|
+
// (1,1)→0, (0,1)→1, (1,0)→1, (0,0)→√2≈1.41→round=1
|
|
202
|
+
const o = (x, y) => opts2D('center-out', x, y, 3, 3, 100);
|
|
203
|
+
expect(computeStagger(o(1, 1))).toBe(0); // center
|
|
204
|
+
expect(computeStagger(o(0, 1))).toBe(100); // dist 1
|
|
205
|
+
expect(computeStagger(o(1, 0))).toBe(100); // dist 1
|
|
206
|
+
expect(computeStagger(o(0, 0))).toBe(100); // dist √2 ≈ 1.41 → round = 1
|
|
207
|
+
expect(computeStagger(o(2, 2))).toBe(100); // dist √2 ≈ 1.41 → round = 1
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('outside-in (reverse radial)', () => {
|
|
212
|
+
it('edges first, center last on 3×3', () => {
|
|
213
|
+
// 3×3: maxDist = round(√(1²+1²)) = round(1.41) = 1
|
|
214
|
+
// (0,0): dist=√2≈1.41→round=1 → (1-1)=0 → 0ms
|
|
215
|
+
// (1,1): dist=0→round=0 → (1-0)=1 → 100ms
|
|
216
|
+
const o = (x, y) => opts2D('outside-in', x, y, 3, 3, 100);
|
|
217
|
+
expect(computeStagger(o(0, 0))).toBe(0); // corner, starts first
|
|
218
|
+
expect(computeStagger(o(1, 1))).toBe(100); // center, last
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('random (2D)', () => {
|
|
223
|
+
it('returns a value within range', () => {
|
|
224
|
+
const delay = computeStagger(opts2D('random', 1, 1));
|
|
225
|
+
// maxSteps = maxX + maxY = 2 + 1 = 3
|
|
226
|
+
expect(delay).toBeGreaterThanOrEqual(0);
|
|
227
|
+
expect(delay).toBeLessThan(3 * 100);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('unknown 2D choreography', () => {
|
|
232
|
+
it('returns 0', () => {
|
|
233
|
+
expect(computeStagger(opts2D('spiral', 1, 1))).toBe(0);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe('topology with orientation', () => {
|
|
238
|
+
it('orientation does not affect stagger (cosmetic metadata)', () => {
|
|
239
|
+
const base = computeStagger(opts2D('wave-right', 1, 0));
|
|
240
|
+
const withOrientation = computeStagger({
|
|
241
|
+
choreography: 'wave-right',
|
|
242
|
+
topology: { x: 1, y: 0, orientation: 90 },
|
|
243
|
+
gridCols: 3, gridRows: 2, staggerMs: 100,
|
|
244
|
+
});
|
|
245
|
+
expect(withOrientation).toBe(base);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
});
|
package/src/index.d.ts
CHANGED
|
@@ -9,6 +9,24 @@ export interface SyncTransport {
|
|
|
9
9
|
readonly connected: boolean;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export type Choreography =
|
|
13
|
+
| 'simultaneous'
|
|
14
|
+
| 'wave-right' | 'wave-left'
|
|
15
|
+
| 'wave-down' | 'wave-up'
|
|
16
|
+
| 'diagonal-tl' | 'diagonal-tr' | 'diagonal-bl' | 'diagonal-br'
|
|
17
|
+
| 'center-out' | 'outside-in'
|
|
18
|
+
| 'random';
|
|
19
|
+
|
|
20
|
+
/** Display topology — position and orientation in the physical grid */
|
|
21
|
+
export interface DisplayTopology {
|
|
22
|
+
/** X coordinate in the grid (0-indexed, left to right) */
|
|
23
|
+
x: number;
|
|
24
|
+
/** Y coordinate in the grid (0-indexed, top to bottom) */
|
|
25
|
+
y: number;
|
|
26
|
+
/** Screen orientation in degrees clockwise (0=landscape, 90=portrait-right, 270=portrait-left) */
|
|
27
|
+
orientation?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
12
30
|
export interface SyncConfig {
|
|
13
31
|
syncGroup: string;
|
|
14
32
|
syncPublisherPort: number;
|
|
@@ -16,6 +34,30 @@ export interface SyncConfig {
|
|
|
16
34
|
syncVideoPauseDelay: number;
|
|
17
35
|
isLead: boolean;
|
|
18
36
|
relayUrl?: string;
|
|
37
|
+
/** Auth token for relay join validation (typically CMS server key) */
|
|
38
|
+
syncToken?: string;
|
|
39
|
+
/** Wall mode: map lead layoutId → this display's position-specific layoutId */
|
|
40
|
+
layoutMap?: Record<string, string | number>;
|
|
41
|
+
|
|
42
|
+
// ── Choreography (1D mode) ─────────────────────────────────────
|
|
43
|
+
/** This display's 0-indexed position in a row (1D choreography) */
|
|
44
|
+
position?: number;
|
|
45
|
+
/** Total displays in the group (auto-detected from relay if omitted) */
|
|
46
|
+
totalDisplays?: number;
|
|
47
|
+
|
|
48
|
+
// ── Choreography (2D mode) ─────────────────────────────────────
|
|
49
|
+
/** This display's topology { x, y, orientation } (enables 2D choreography) */
|
|
50
|
+
topology?: DisplayTopology;
|
|
51
|
+
/** Grid width in columns (required for 2D choreography) */
|
|
52
|
+
gridCols?: number;
|
|
53
|
+
/** Grid height in rows (required for 2D choreography) */
|
|
54
|
+
gridRows?: number;
|
|
55
|
+
|
|
56
|
+
// ── Choreography (common) ──────────────────────────────────────
|
|
57
|
+
/** Transition choreography pattern */
|
|
58
|
+
choreography?: Choreography;
|
|
59
|
+
/** Base delay between consecutive displays in ms (default: 150) */
|
|
60
|
+
staggerMs?: number;
|
|
19
61
|
}
|
|
20
62
|
|
|
21
63
|
export class BroadcastChannelTransport implements SyncTransport {
|
|
@@ -27,13 +69,30 @@ export class BroadcastChannelTransport implements SyncTransport {
|
|
|
27
69
|
}
|
|
28
70
|
|
|
29
71
|
export class WebSocketTransport implements SyncTransport {
|
|
30
|
-
constructor(url: string
|
|
72
|
+
constructor(url: string, options?: {
|
|
73
|
+
syncGroup?: string;
|
|
74
|
+
displayId?: string;
|
|
75
|
+
topology?: DisplayTopology;
|
|
76
|
+
token?: string;
|
|
77
|
+
});
|
|
31
78
|
send(msg: any): void;
|
|
32
79
|
onMessage(callback: (msg: any) => void): void;
|
|
33
80
|
close(): void;
|
|
34
81
|
readonly connected: boolean;
|
|
35
82
|
}
|
|
36
83
|
|
|
84
|
+
export function computeStagger(options: {
|
|
85
|
+
choreography: string;
|
|
86
|
+
staggerMs: number;
|
|
87
|
+
// 1D mode
|
|
88
|
+
position?: number;
|
|
89
|
+
totalDisplays?: number;
|
|
90
|
+
// 2D mode
|
|
91
|
+
topology?: DisplayTopology;
|
|
92
|
+
gridCols?: number;
|
|
93
|
+
gridRows?: number;
|
|
94
|
+
}): number;
|
|
95
|
+
|
|
37
96
|
export class SyncManager {
|
|
38
97
|
constructor(options: {
|
|
39
98
|
displayId: string;
|
|
@@ -46,6 +105,7 @@ export class SyncManager {
|
|
|
46
105
|
onLogsReport?: (followerId: string, logsXml: string, ack: () => void) => void;
|
|
47
106
|
onStatsAck?: (targetDisplayId: string) => void;
|
|
48
107
|
onLogsAck?: (targetDisplayId: string) => void;
|
|
108
|
+
onGroupUpdate?: (totalDisplays: number, topology: Record<string, DisplayTopology>) => void;
|
|
49
109
|
});
|
|
50
110
|
|
|
51
111
|
displayId: string;
|
package/src/sync-manager.js
CHANGED
|
@@ -75,6 +75,7 @@ export class SyncManager {
|
|
|
75
75
|
this.onLogsReport = options.onLogsReport || null;
|
|
76
76
|
this.onStatsAck = options.onStatsAck || null;
|
|
77
77
|
this.onLogsAck = options.onLogsAck || null;
|
|
78
|
+
this.onGroupUpdate = options.onGroupUpdate || null;
|
|
78
79
|
|
|
79
80
|
// State
|
|
80
81
|
this.transport = options.transport || null;
|
|
@@ -104,7 +105,12 @@ export class SyncManager {
|
|
|
104
105
|
// Select transport if none injected
|
|
105
106
|
if (!this.transport) {
|
|
106
107
|
if (this.syncConfig.relayUrl) {
|
|
107
|
-
this.transport = new WebSocketTransport(this.syncConfig.relayUrl
|
|
108
|
+
this.transport = new WebSocketTransport(this.syncConfig.relayUrl, {
|
|
109
|
+
syncGroup: this.syncConfig.syncGroup,
|
|
110
|
+
displayId: this.displayId,
|
|
111
|
+
topology: this.syncConfig.topology,
|
|
112
|
+
token: this.syncConfig.syncToken,
|
|
113
|
+
});
|
|
108
114
|
} else if (typeof BroadcastChannel !== 'undefined') {
|
|
109
115
|
this.transport = new BroadcastChannelTransport();
|
|
110
116
|
} else {
|
|
@@ -294,6 +300,15 @@ export class SyncManager {
|
|
|
294
300
|
|
|
295
301
|
/** @private */
|
|
296
302
|
_handleMessage(msg) {
|
|
303
|
+
// Relay-originated messages (no displayId)
|
|
304
|
+
if (msg.type === 'group-update') {
|
|
305
|
+
this._log.info(`Group update: ${msg.totalDisplays} displays`);
|
|
306
|
+
if (this.onGroupUpdate) {
|
|
307
|
+
this.onGroupUpdate(msg.totalDisplays, msg.topology || {});
|
|
308
|
+
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
297
312
|
// Ignore our own messages
|
|
298
313
|
if (msg.displayId === this.displayId) return;
|
|
299
314
|
|
|
@@ -515,3 +530,4 @@ export class SyncManager {
|
|
|
515
530
|
|
|
516
531
|
export { BroadcastChannelTransport } from './bc-transport.js';
|
|
517
532
|
export { WebSocketTransport } from './ws-transport.js';
|
|
533
|
+
export { computeStagger } from './choreography.js';
|
package/src/sync-manager.test.js
CHANGED
|
@@ -558,4 +558,120 @@ describe('SyncManager', () => {
|
|
|
558
558
|
expect(onLogsAck).toHaveBeenCalledWith('pwa-f1');
|
|
559
559
|
});
|
|
560
560
|
});
|
|
561
|
+
|
|
562
|
+
// ── Local sync config fallback ────────────────────────────────
|
|
563
|
+
|
|
564
|
+
describe('local sync config fallback pattern', () => {
|
|
565
|
+
// Tests the pattern used in main.ts: when CMS returns syncConfig: null
|
|
566
|
+
// but config.data.sync is set, the player emits 'sync-config' on
|
|
567
|
+
// register-complete. This tests the EventEmitter contract, not the DOM.
|
|
568
|
+
|
|
569
|
+
it('should initialize SyncManager from local config when CMS provides no syncConfig', () => {
|
|
570
|
+
// Simulate the pattern from main.ts:
|
|
571
|
+
// core.on('register-complete') → if no CMS sync → emit 'sync-config' from local
|
|
572
|
+
const { EventEmitter } = require('@xiboplayer/utils');
|
|
573
|
+
const core = new EventEmitter();
|
|
574
|
+
|
|
575
|
+
const localSyncConfig = {
|
|
576
|
+
syncGroup: 'lobby',
|
|
577
|
+
syncPublisherPort: 9590,
|
|
578
|
+
syncSwitchDelay: 500,
|
|
579
|
+
syncVideoPauseDelay: 100,
|
|
580
|
+
isLead: false,
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const syncConfigReceived = vi.fn();
|
|
584
|
+
core.on('sync-config', syncConfigReceived);
|
|
585
|
+
|
|
586
|
+
// Simulate the fallback listener (mirrors main.ts logic)
|
|
587
|
+
core.on('register-complete', (regResult) => {
|
|
588
|
+
if (!regResult.syncConfig && localSyncConfig) {
|
|
589
|
+
core.emit('sync-config', localSyncConfig);
|
|
590
|
+
}
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// CMS returns no syncConfig
|
|
594
|
+
core.emit('register-complete', { syncConfig: null, settings: {} });
|
|
595
|
+
|
|
596
|
+
expect(syncConfigReceived).toHaveBeenCalledWith(localSyncConfig);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('should NOT use local config when CMS provides syncConfig', () => {
|
|
600
|
+
const { EventEmitter } = require('@xiboplayer/utils');
|
|
601
|
+
const core = new EventEmitter();
|
|
602
|
+
|
|
603
|
+
const localSyncConfig = { syncGroup: 'local', isLead: false };
|
|
604
|
+
const cmsSyncConfig = { syncGroup: 'cms-group', isLead: true };
|
|
605
|
+
|
|
606
|
+
const syncConfigReceived = vi.fn();
|
|
607
|
+
core.on('sync-config', syncConfigReceived);
|
|
608
|
+
|
|
609
|
+
// Fallback listener
|
|
610
|
+
core.on('register-complete', (regResult) => {
|
|
611
|
+
if (!regResult.syncConfig && localSyncConfig) {
|
|
612
|
+
core.emit('sync-config', localSyncConfig);
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
// CMS provides syncConfig — fallback should NOT fire
|
|
617
|
+
core.emit('register-complete', { syncConfig: cmsSyncConfig, settings: {} });
|
|
618
|
+
|
|
619
|
+
// The fallback listener should NOT have emitted sync-config
|
|
620
|
+
expect(syncConfigReceived).not.toHaveBeenCalled();
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it('should NOT emit sync-config when neither CMS nor local provides config', () => {
|
|
624
|
+
const { EventEmitter } = require('@xiboplayer/utils');
|
|
625
|
+
const core = new EventEmitter();
|
|
626
|
+
|
|
627
|
+
const syncConfigReceived = vi.fn();
|
|
628
|
+
core.on('sync-config', syncConfigReceived);
|
|
629
|
+
|
|
630
|
+
// Fallback with null local config
|
|
631
|
+
const localSyncConfig = null;
|
|
632
|
+
core.on('register-complete', (regResult) => {
|
|
633
|
+
if (!regResult.syncConfig && localSyncConfig) {
|
|
634
|
+
core.emit('sync-config', localSyncConfig);
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
core.emit('register-complete', { syncConfig: null, settings: {} });
|
|
639
|
+
|
|
640
|
+
expect(syncConfigReceived).not.toHaveBeenCalled();
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
it('should create SyncManager with local config values', () => {
|
|
644
|
+
const localConfig = {
|
|
645
|
+
syncGroup: 'lobby',
|
|
646
|
+
syncPublisherPort: 9590,
|
|
647
|
+
syncSwitchDelay: 750,
|
|
648
|
+
syncVideoPauseDelay: 100,
|
|
649
|
+
isLead: true,
|
|
650
|
+
topology: { x: 0, y: 0, orientation: 0 },
|
|
651
|
+
choreography: 'wave-right',
|
|
652
|
+
staggerMs: 150,
|
|
653
|
+
gridCols: 2,
|
|
654
|
+
gridRows: 2,
|
|
655
|
+
};
|
|
656
|
+
|
|
657
|
+
const transport = {
|
|
658
|
+
send: vi.fn(),
|
|
659
|
+
onMessage: vi.fn(),
|
|
660
|
+
close: vi.fn(),
|
|
661
|
+
connected: true,
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
const manager = new SyncManager({
|
|
665
|
+
displayId: 'test-local',
|
|
666
|
+
syncConfig: localConfig,
|
|
667
|
+
transport,
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
expect(manager.displayId).toBe('test-local');
|
|
671
|
+
expect(manager.isLead).toBe(true);
|
|
672
|
+
expect(manager.syncConfig.topology).toEqual({ x: 0, y: 0, orientation: 0 });
|
|
673
|
+
expect(manager.syncConfig.choreography).toBe('wave-right');
|
|
674
|
+
manager.stop();
|
|
675
|
+
});
|
|
676
|
+
});
|
|
561
677
|
});
|
package/src/ws-transport.js
CHANGED
|
@@ -23,9 +23,18 @@ const BACKOFF_FACTOR = 2;
|
|
|
23
23
|
export class WebSocketTransport {
|
|
24
24
|
/**
|
|
25
25
|
* @param {string} url — WebSocket URL, e.g. ws://192.168.1.100:8765/sync
|
|
26
|
+
* @param {Object} [options]
|
|
27
|
+
* @param {string} [options.syncGroup] — group name for relay isolation
|
|
28
|
+
* @param {string} [options.displayId] — this display's unique ID
|
|
29
|
+
* @param {Object} [options.topology] — this display's topology { x, y, orientation? }
|
|
30
|
+
* @param {string} [options.token] — auth token for relay join validation
|
|
26
31
|
*/
|
|
27
|
-
constructor(url) {
|
|
32
|
+
constructor(url, { syncGroup, displayId, topology, token } = {}) {
|
|
28
33
|
this._url = url;
|
|
34
|
+
this._syncGroup = syncGroup || null;
|
|
35
|
+
this._displayId = displayId || null;
|
|
36
|
+
this._topology = topology || null;
|
|
37
|
+
this._token = token || null;
|
|
29
38
|
this._callback = null;
|
|
30
39
|
this._closed = false;
|
|
31
40
|
this._retryMs = INITIAL_RETRY_MS;
|
|
@@ -87,12 +96,27 @@ export class WebSocketTransport {
|
|
|
87
96
|
this.ws.onopen = () => {
|
|
88
97
|
this._log.info(`Connected to ${this._url}`);
|
|
89
98
|
this._retryMs = INITIAL_RETRY_MS; // Reset backoff on success
|
|
99
|
+
|
|
100
|
+
// Join sync group for relay isolation (+ topology for auto-detection)
|
|
101
|
+
if (this._syncGroup) {
|
|
102
|
+
const join = { type: 'join', syncGroup: this._syncGroup };
|
|
103
|
+
if (this._displayId) join.displayId = this._displayId;
|
|
104
|
+
if (this._topology) join.topology = this._topology;
|
|
105
|
+
if (this._token) join.token = this._token;
|
|
106
|
+
this.ws.send(JSON.stringify(join));
|
|
107
|
+
}
|
|
90
108
|
};
|
|
91
109
|
|
|
92
|
-
this.ws.onmessage = (event) => {
|
|
110
|
+
this.ws.onmessage = async (event) => {
|
|
93
111
|
if (!this._callback) return;
|
|
94
112
|
try {
|
|
95
|
-
|
|
113
|
+
// Browser WebSocket delivers string; Node ws delivers Buffer;
|
|
114
|
+
// Node 22+ native WebSocket delivers Blob. Handle all three.
|
|
115
|
+
let raw = event.data;
|
|
116
|
+
if (typeof raw !== 'string') {
|
|
117
|
+
raw = (raw instanceof Blob) ? await raw.text() : String(raw);
|
|
118
|
+
}
|
|
119
|
+
const msg = JSON.parse(raw);
|
|
96
120
|
this._callback(msg);
|
|
97
121
|
} catch (e) {
|
|
98
122
|
this._log.warn('Failed to parse message:', e.message);
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2024-2026 Pau Aliagas <linuxnow@gmail.com>
|
|
3
|
+
/**
|
|
4
|
+
* WebSocketTransport tests
|
|
5
|
+
*
|
|
6
|
+
* Focuses on message parsing — the onmessage handler must correctly parse
|
|
7
|
+
* JSON from three different event.data types:
|
|
8
|
+
* 1. string — browser WebSocket (standard)
|
|
9
|
+
* 2. Buffer — Node.js `ws` package
|
|
10
|
+
* 3. Blob — Node 22+ native WebSocket (undici)
|
|
11
|
+
*
|
|
12
|
+
* Uses a mock WebSocket to test the transport in isolation.
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
15
|
+
|
|
16
|
+
// ── Mock WebSocket ──────────────────────────────────────────────
|
|
17
|
+
// Captures onopen/onmessage/onclose/onerror handlers set by the transport
|
|
18
|
+
|
|
19
|
+
class MockWebSocket {
|
|
20
|
+
static OPEN = 1;
|
|
21
|
+
static instances = [];
|
|
22
|
+
|
|
23
|
+
constructor(url) {
|
|
24
|
+
this.url = url;
|
|
25
|
+
this.readyState = MockWebSocket.OPEN;
|
|
26
|
+
this.onopen = null;
|
|
27
|
+
this.onmessage = null;
|
|
28
|
+
this.onclose = null;
|
|
29
|
+
this.onerror = null;
|
|
30
|
+
this._sent = [];
|
|
31
|
+
MockWebSocket.instances.push(this);
|
|
32
|
+
|
|
33
|
+
// Auto-fire onopen in next microtask
|
|
34
|
+
queueMicrotask(() => {
|
|
35
|
+
if (this.onopen) this.onopen();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
send(data) {
|
|
40
|
+
this._sent.push(data);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
close() {
|
|
44
|
+
this.readyState = 3; // CLOSED
|
|
45
|
+
if (this.onclose) this.onclose();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Test helper: simulate receiving a message with arbitrary event.data
|
|
49
|
+
simulateMessage(data) {
|
|
50
|
+
if (this.onmessage) {
|
|
51
|
+
this.onmessage({ data });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Install mock before importing the transport
|
|
57
|
+
const originalWebSocket = globalThis.WebSocket;
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
MockWebSocket.instances = [];
|
|
61
|
+
globalThis.WebSocket = MockWebSocket;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
globalThis.WebSocket = originalWebSocket;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Dynamic import so it picks up our mock WebSocket
|
|
69
|
+
async function createTransport(url = 'ws://localhost:9590/sync', opts = {}) {
|
|
70
|
+
const { WebSocketTransport } = await import('./ws-transport.js');
|
|
71
|
+
return new WebSocketTransport(url, opts);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Tests ───────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
describe('WebSocketTransport', () => {
|
|
77
|
+
describe('message parsing', () => {
|
|
78
|
+
const testMsg = { type: 'layout-change', layoutId: '42', showAt: 12345 };
|
|
79
|
+
|
|
80
|
+
it('parses string data (browser WebSocket)', async () => {
|
|
81
|
+
const transport = await createTransport();
|
|
82
|
+
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
|
83
|
+
const ws = MockWebSocket.instances[0];
|
|
84
|
+
|
|
85
|
+
const received = vi.fn();
|
|
86
|
+
transport.onMessage(received);
|
|
87
|
+
|
|
88
|
+
// Browser sends string
|
|
89
|
+
ws.simulateMessage(JSON.stringify(testMsg));
|
|
90
|
+
|
|
91
|
+
// Allow async onmessage to complete
|
|
92
|
+
await vi.waitFor(() => expect(received).toHaveBeenCalled());
|
|
93
|
+
expect(received).toHaveBeenCalledWith(testMsg);
|
|
94
|
+
|
|
95
|
+
transport.close();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('parses Buffer data (Node ws package)', async () => {
|
|
99
|
+
const transport = await createTransport();
|
|
100
|
+
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
|
101
|
+
const ws = MockWebSocket.instances[0];
|
|
102
|
+
|
|
103
|
+
const received = vi.fn();
|
|
104
|
+
transport.onMessage(received);
|
|
105
|
+
|
|
106
|
+
// Node ws sends Buffer
|
|
107
|
+
const buffer = Buffer.from(JSON.stringify(testMsg));
|
|
108
|
+
ws.simulateMessage(buffer);
|
|
109
|
+
|
|
110
|
+
await vi.waitFor(() => expect(received).toHaveBeenCalled());
|
|
111
|
+
expect(received).toHaveBeenCalledWith(testMsg);
|
|
112
|
+
|
|
113
|
+
transport.close();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('parses Blob data (Node 22+ native WebSocket)', async () => {
|
|
117
|
+
const transport = await createTransport();
|
|
118
|
+
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
|
119
|
+
const ws = MockWebSocket.instances[0];
|
|
120
|
+
|
|
121
|
+
const received = vi.fn();
|
|
122
|
+
transport.onMessage(received);
|
|
123
|
+
|
|
124
|
+
// Node 22+ native WS (undici) sends Blob
|
|
125
|
+
const blob = new Blob([JSON.stringify(testMsg)]);
|
|
126
|
+
ws.simulateMessage(blob);
|
|
127
|
+
|
|
128
|
+
await vi.waitFor(() => expect(received).toHaveBeenCalled());
|
|
129
|
+
expect(received).toHaveBeenCalledWith(testMsg);
|
|
130
|
+
|
|
131
|
+
transport.close();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('warns on unparseable data without crashing', async () => {
|
|
135
|
+
const transport = await createTransport();
|
|
136
|
+
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
|
137
|
+
const ws = MockWebSocket.instances[0];
|
|
138
|
+
|
|
139
|
+
const received = vi.fn();
|
|
140
|
+
transport.onMessage(received);
|
|
141
|
+
|
|
142
|
+
// Send garbage
|
|
143
|
+
ws.simulateMessage('not json {{{');
|
|
144
|
+
|
|
145
|
+
// Give async handler a tick
|
|
146
|
+
await new Promise(r => setTimeout(r, 50));
|
|
147
|
+
|
|
148
|
+
expect(received).not.toHaveBeenCalled();
|
|
149
|
+
|
|
150
|
+
transport.close();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('ignores messages when no callback registered', async () => {
|
|
154
|
+
const transport = await createTransport();
|
|
155
|
+
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
|
156
|
+
const ws = MockWebSocket.instances[0];
|
|
157
|
+
|
|
158
|
+
// No onMessage callback set — should not throw
|
|
159
|
+
ws.simulateMessage(JSON.stringify(testMsg));
|
|
160
|
+
await new Promise(r => setTimeout(r, 50));
|
|
161
|
+
|
|
162
|
+
transport.close();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('join message', () => {
|
|
167
|
+
it('sends join with syncGroup on connect', async () => {
|
|
168
|
+
const transport = await createTransport('ws://localhost:9590/sync', {
|
|
169
|
+
syncGroup: 'lobby',
|
|
170
|
+
});
|
|
171
|
+
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
|
172
|
+
const ws = MockWebSocket.instances[0];
|
|
173
|
+
|
|
174
|
+
// Wait for onopen to fire and send join
|
|
175
|
+
await vi.waitFor(() => expect(ws._sent.length).toBeGreaterThan(0));
|
|
176
|
+
|
|
177
|
+
const join = JSON.parse(ws._sent[0]);
|
|
178
|
+
expect(join.type).toBe('join');
|
|
179
|
+
expect(join.syncGroup).toBe('lobby');
|
|
180
|
+
|
|
181
|
+
transport.close();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('includes displayId and topology in join', async () => {
|
|
185
|
+
const transport = await createTransport('ws://localhost:9590/sync', {
|
|
186
|
+
syncGroup: 'wall',
|
|
187
|
+
displayId: 'display-1',
|
|
188
|
+
topology: { x: 1, y: 0, orientation: 90 },
|
|
189
|
+
});
|
|
190
|
+
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
|
191
|
+
const ws = MockWebSocket.instances[0];
|
|
192
|
+
|
|
193
|
+
await vi.waitFor(() => expect(ws._sent.length).toBeGreaterThan(0));
|
|
194
|
+
|
|
195
|
+
const join = JSON.parse(ws._sent[0]);
|
|
196
|
+
expect(join.displayId).toBe('display-1');
|
|
197
|
+
expect(join.topology).toEqual({ x: 1, y: 0, orientation: 90 });
|
|
198
|
+
|
|
199
|
+
transport.close();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('does not send join when no syncGroup', async () => {
|
|
203
|
+
const transport = await createTransport('ws://localhost:9590/sync', {});
|
|
204
|
+
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
|
205
|
+
const ws = MockWebSocket.instances[0];
|
|
206
|
+
|
|
207
|
+
// Give time for onopen
|
|
208
|
+
await new Promise(r => setTimeout(r, 50));
|
|
209
|
+
|
|
210
|
+
expect(ws._sent.length).toBe(0);
|
|
211
|
+
|
|
212
|
+
transport.close();
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('send', () => {
|
|
217
|
+
it('serializes objects to JSON', async () => {
|
|
218
|
+
const transport = await createTransport();
|
|
219
|
+
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
|
220
|
+
|
|
221
|
+
transport.send({ type: 'heartbeat', displayId: 'test' });
|
|
222
|
+
const ws = MockWebSocket.instances[0];
|
|
223
|
+
const lastSent = ws._sent[ws._sent.length - 1];
|
|
224
|
+
expect(JSON.parse(lastSent)).toEqual({ type: 'heartbeat', displayId: 'test' });
|
|
225
|
+
|
|
226
|
+
transport.close();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('does not send when connection is closed', async () => {
|
|
230
|
+
const transport = await createTransport();
|
|
231
|
+
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
|
232
|
+
|
|
233
|
+
transport.close();
|
|
234
|
+
transport.send({ type: 'heartbeat' });
|
|
235
|
+
// Should not throw
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('lifecycle', () => {
|
|
240
|
+
it('reports connected when WebSocket is OPEN', async () => {
|
|
241
|
+
const transport = await createTransport();
|
|
242
|
+
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
|
243
|
+
|
|
244
|
+
expect(transport.connected).toBe(true);
|
|
245
|
+
|
|
246
|
+
transport.close();
|
|
247
|
+
expect(transport.connected).toBe(false);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('close() prevents reconnection', async () => {
|
|
251
|
+
const transport = await createTransport();
|
|
252
|
+
await vi.waitFor(() => expect(MockWebSocket.instances.length).toBe(1));
|
|
253
|
+
|
|
254
|
+
transport.close();
|
|
255
|
+
|
|
256
|
+
// Wait to ensure no reconnect
|
|
257
|
+
await new Promise(r => setTimeout(r, 200));
|
|
258
|
+
expect(MockWebSocket.instances.length).toBe(1); // no new instance
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
});
|