@xiboplayer/sync 0.1.1
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/package.json +31 -0
- package/src/sync-manager.js +414 -0
- package/src/sync-manager.test.js +376 -0
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xiboplayer/sync",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Multi-display synchronization for Xibo Player",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/sync-manager.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/sync-manager.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"test:watch": "vitest"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"vitest": "^2.0.0"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"xibo",
|
|
20
|
+
"digital-signage",
|
|
21
|
+
"sync",
|
|
22
|
+
"multi-display"
|
|
23
|
+
],
|
|
24
|
+
"author": "Pau Aliagas <linuxnow@gmail.com>",
|
|
25
|
+
"license": "AGPL-3.0-or-later",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/xibo-players/xiboplayer.git",
|
|
29
|
+
"directory": "packages/sync"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncManager - Multi-display synchronization via BroadcastChannel
|
|
3
|
+
*
|
|
4
|
+
* Coordinates layout transitions across multiple browser tabs/windows
|
|
5
|
+
* on the same machine (video wall, multi-monitor setups).
|
|
6
|
+
*
|
|
7
|
+
* Protocol:
|
|
8
|
+
* Lead Follower(s)
|
|
9
|
+
* ──── ──────────
|
|
10
|
+
* layout-change(layoutId, showAt) → receives, loads layout
|
|
11
|
+
* ← layout-ready(layoutId, displayId)
|
|
12
|
+
* (waits for all followers ready)
|
|
13
|
+
* layout-show(layoutId) → shows layout simultaneously
|
|
14
|
+
*
|
|
15
|
+
* Heartbeat:
|
|
16
|
+
* All nodes broadcast heartbeat every 5s.
|
|
17
|
+
* Lead tracks active followers. If a follower goes silent for 15s,
|
|
18
|
+
* it's considered offline and excluded from ready-wait.
|
|
19
|
+
*
|
|
20
|
+
* @module @xiboplayer/sync
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @typedef {Object} SyncConfig
|
|
25
|
+
* @property {string} syncGroup - "lead" or leader's LAN IP
|
|
26
|
+
* @property {number} syncPublisherPort - TCP port (unused in browser, kept for compat)
|
|
27
|
+
* @property {number} syncSwitchDelay - Delay in ms before showing new content
|
|
28
|
+
* @property {number} syncVideoPauseDelay - Delay in ms before unpausing video
|
|
29
|
+
* @property {boolean} isLead - Whether this display is the leader
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const CHANNEL_NAME = 'xibo-sync';
|
|
33
|
+
const HEARTBEAT_INTERVAL = 5000; // Send heartbeat every 5s
|
|
34
|
+
const FOLLOWER_TIMEOUT = 15000; // Consider follower offline after 15s silence
|
|
35
|
+
const READY_TIMEOUT = 10000; // Max wait for followers to be ready
|
|
36
|
+
|
|
37
|
+
export class SyncManager {
|
|
38
|
+
/**
|
|
39
|
+
* @param {Object} options
|
|
40
|
+
* @param {string} options.displayId - This display's unique hardware key
|
|
41
|
+
* @param {SyncConfig} options.syncConfig - Sync configuration from RegisterDisplay
|
|
42
|
+
* @param {Function} [options.onLayoutChange] - Called when lead requests layout change
|
|
43
|
+
* @param {Function} [options.onLayoutShow] - Called when lead gives show signal
|
|
44
|
+
* @param {Function} [options.onVideoStart] - Called when lead gives video start signal
|
|
45
|
+
*/
|
|
46
|
+
constructor(options) {
|
|
47
|
+
this.displayId = options.displayId;
|
|
48
|
+
this.syncConfig = options.syncConfig;
|
|
49
|
+
this.isLead = options.syncConfig.isLead;
|
|
50
|
+
this.switchDelay = options.syncConfig.syncSwitchDelay || 750;
|
|
51
|
+
this.videoPauseDelay = options.syncConfig.syncVideoPauseDelay || 100;
|
|
52
|
+
|
|
53
|
+
// Callbacks
|
|
54
|
+
this.onLayoutChange = options.onLayoutChange || (() => {});
|
|
55
|
+
this.onLayoutShow = options.onLayoutShow || (() => {});
|
|
56
|
+
this.onVideoStart = options.onVideoStart || (() => {});
|
|
57
|
+
|
|
58
|
+
// State
|
|
59
|
+
this.channel = null;
|
|
60
|
+
this.followers = new Map(); // displayId → { lastSeen, ready }
|
|
61
|
+
this._heartbeatTimer = null;
|
|
62
|
+
this._cleanupTimer = null;
|
|
63
|
+
this._readyResolve = null; // Resolve function for current ready-wait
|
|
64
|
+
this._pendingLayoutId = null; // Layout we're waiting for readiness on
|
|
65
|
+
this._started = false;
|
|
66
|
+
|
|
67
|
+
// Log prefix for clarity in multi-tab console
|
|
68
|
+
this._tag = this.isLead ? '[Sync:LEAD]' : '[Sync:FOLLOW]';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Start the sync manager (opens BroadcastChannel, begins heartbeats)
|
|
73
|
+
*/
|
|
74
|
+
start() {
|
|
75
|
+
if (this._started) return;
|
|
76
|
+
this._started = true;
|
|
77
|
+
|
|
78
|
+
if (typeof BroadcastChannel === 'undefined') {
|
|
79
|
+
console.warn(this._tag, 'BroadcastChannel not available — sync disabled');
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.channel = new BroadcastChannel(CHANNEL_NAME);
|
|
84
|
+
this.channel.onmessage = (event) => this._handleMessage(event.data);
|
|
85
|
+
|
|
86
|
+
// Start heartbeat
|
|
87
|
+
this._heartbeatTimer = setInterval(() => this._sendHeartbeat(), HEARTBEAT_INTERVAL);
|
|
88
|
+
this._sendHeartbeat(); // Send initial heartbeat immediately
|
|
89
|
+
|
|
90
|
+
// Lead: periodically clean up stale followers
|
|
91
|
+
if (this.isLead) {
|
|
92
|
+
this._cleanupTimer = setInterval(() => this._cleanupStaleFollowers(), HEARTBEAT_INTERVAL);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
console.log(this._tag, 'Started. DisplayId:', this.displayId);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Stop the sync manager
|
|
100
|
+
*/
|
|
101
|
+
stop() {
|
|
102
|
+
if (!this._started) return;
|
|
103
|
+
this._started = false;
|
|
104
|
+
|
|
105
|
+
if (this._heartbeatTimer) {
|
|
106
|
+
clearInterval(this._heartbeatTimer);
|
|
107
|
+
this._heartbeatTimer = null;
|
|
108
|
+
}
|
|
109
|
+
if (this._cleanupTimer) {
|
|
110
|
+
clearInterval(this._cleanupTimer);
|
|
111
|
+
this._cleanupTimer = null;
|
|
112
|
+
}
|
|
113
|
+
if (this.channel) {
|
|
114
|
+
this.channel.close();
|
|
115
|
+
this.channel = null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.followers.clear();
|
|
119
|
+
console.log(this._tag, 'Stopped');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Lead API ──────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* [Lead only] Request all displays to change to a layout.
|
|
126
|
+
* Waits for followers to report ready, then sends show signal.
|
|
127
|
+
*
|
|
128
|
+
* @param {string|number} layoutId - Layout to change to
|
|
129
|
+
* @returns {Promise<void>} Resolves when show signal is sent
|
|
130
|
+
*/
|
|
131
|
+
async requestLayoutChange(layoutId) {
|
|
132
|
+
if (!this.isLead) {
|
|
133
|
+
console.warn(this._tag, 'requestLayoutChange called on follower — ignoring');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
layoutId = String(layoutId);
|
|
138
|
+
this._pendingLayoutId = layoutId;
|
|
139
|
+
|
|
140
|
+
// Mark all followers as not-ready for this layout
|
|
141
|
+
for (const [, follower] of this.followers) {
|
|
142
|
+
follower.ready = false;
|
|
143
|
+
follower.readyLayoutId = null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const showAt = Date.now() + this.switchDelay;
|
|
147
|
+
|
|
148
|
+
console.log(this._tag, `Requesting layout change: ${layoutId} (show at ${new Date(showAt).toISOString()}, ${this.followers.size} followers)`);
|
|
149
|
+
|
|
150
|
+
// Broadcast layout-change to all followers
|
|
151
|
+
this._send({
|
|
152
|
+
type: 'layout-change',
|
|
153
|
+
layoutId,
|
|
154
|
+
showAt,
|
|
155
|
+
displayId: this.displayId,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Wait for all active followers to report ready (or timeout)
|
|
159
|
+
if (this.followers.size > 0) {
|
|
160
|
+
await this._waitForFollowersReady(layoutId);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Apply switch delay (remaining time from showAt)
|
|
164
|
+
const remaining = showAt - Date.now();
|
|
165
|
+
if (remaining > 0) {
|
|
166
|
+
await new Promise(resolve => setTimeout(resolve, remaining));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Send show signal
|
|
170
|
+
console.log(this._tag, `Sending layout-show: ${layoutId}`);
|
|
171
|
+
this._send({
|
|
172
|
+
type: 'layout-show',
|
|
173
|
+
layoutId,
|
|
174
|
+
displayId: this.displayId,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// Also trigger on self (lead shows too)
|
|
178
|
+
this.onLayoutShow(layoutId);
|
|
179
|
+
|
|
180
|
+
this._pendingLayoutId = null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* [Lead only] Signal followers to start video playback.
|
|
185
|
+
*
|
|
186
|
+
* @param {string|number} layoutId - Layout containing the video
|
|
187
|
+
* @param {string} regionId - Region with the video widget
|
|
188
|
+
*/
|
|
189
|
+
async requestVideoStart(layoutId, regionId) {
|
|
190
|
+
if (!this.isLead) return;
|
|
191
|
+
|
|
192
|
+
// Wait videoPauseDelay before unpausing
|
|
193
|
+
await new Promise(resolve => setTimeout(resolve, this.videoPauseDelay));
|
|
194
|
+
|
|
195
|
+
this._send({
|
|
196
|
+
type: 'video-start',
|
|
197
|
+
layoutId: String(layoutId),
|
|
198
|
+
regionId,
|
|
199
|
+
displayId: this.displayId,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Also trigger on self
|
|
203
|
+
this.onVideoStart(String(layoutId), regionId);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Follower API ──────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* [Follower only] Report that layout is loaded and ready to show.
|
|
210
|
+
* Called by platform layer after layout content is prepared.
|
|
211
|
+
*
|
|
212
|
+
* @param {string|number} layoutId - Layout that is ready
|
|
213
|
+
*/
|
|
214
|
+
reportReady(layoutId) {
|
|
215
|
+
layoutId = String(layoutId);
|
|
216
|
+
|
|
217
|
+
console.log(this._tag, `Reporting ready for layout ${layoutId}`);
|
|
218
|
+
|
|
219
|
+
this._send({
|
|
220
|
+
type: 'layout-ready',
|
|
221
|
+
layoutId,
|
|
222
|
+
displayId: this.displayId,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── Message handling ──────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/** @private */
|
|
229
|
+
_handleMessage(msg) {
|
|
230
|
+
// Ignore our own messages
|
|
231
|
+
if (msg.displayId === this.displayId) return;
|
|
232
|
+
|
|
233
|
+
switch (msg.type) {
|
|
234
|
+
case 'heartbeat':
|
|
235
|
+
this._handleHeartbeat(msg);
|
|
236
|
+
break;
|
|
237
|
+
|
|
238
|
+
case 'layout-change':
|
|
239
|
+
// Follower: lead is requesting a layout change
|
|
240
|
+
if (!this.isLead) {
|
|
241
|
+
console.log(this._tag, `Layout change requested: ${msg.layoutId}`);
|
|
242
|
+
this.onLayoutChange(msg.layoutId, msg.showAt);
|
|
243
|
+
}
|
|
244
|
+
break;
|
|
245
|
+
|
|
246
|
+
case 'layout-ready':
|
|
247
|
+
// Lead: follower reports ready
|
|
248
|
+
if (this.isLead) {
|
|
249
|
+
this._handleFollowerReady(msg);
|
|
250
|
+
}
|
|
251
|
+
break;
|
|
252
|
+
|
|
253
|
+
case 'layout-show':
|
|
254
|
+
// Follower: lead says show now
|
|
255
|
+
if (!this.isLead) {
|
|
256
|
+
console.log(this._tag, `Layout show signal: ${msg.layoutId}`);
|
|
257
|
+
this.onLayoutShow(msg.layoutId);
|
|
258
|
+
}
|
|
259
|
+
break;
|
|
260
|
+
|
|
261
|
+
case 'video-start':
|
|
262
|
+
// Follower: lead says start video
|
|
263
|
+
if (!this.isLead) {
|
|
264
|
+
console.log(this._tag, `Video start signal: ${msg.layoutId} region ${msg.regionId}`);
|
|
265
|
+
this.onVideoStart(msg.layoutId, msg.regionId);
|
|
266
|
+
}
|
|
267
|
+
break;
|
|
268
|
+
|
|
269
|
+
default:
|
|
270
|
+
console.warn(this._tag, 'Unknown message type:', msg.type);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** @private */
|
|
275
|
+
_handleHeartbeat(msg) {
|
|
276
|
+
const existing = this.followers.get(msg.displayId);
|
|
277
|
+
if (existing) {
|
|
278
|
+
existing.lastSeen = Date.now();
|
|
279
|
+
} else {
|
|
280
|
+
// New follower discovered
|
|
281
|
+
this.followers.set(msg.displayId, {
|
|
282
|
+
lastSeen: Date.now(),
|
|
283
|
+
ready: false,
|
|
284
|
+
readyLayoutId: null,
|
|
285
|
+
role: msg.role || 'unknown',
|
|
286
|
+
});
|
|
287
|
+
console.log(this._tag, `Follower joined: ${msg.displayId} (${this.followers.size} total)`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** @private */
|
|
292
|
+
_handleFollowerReady(msg) {
|
|
293
|
+
const follower = this.followers.get(msg.displayId);
|
|
294
|
+
if (!follower) {
|
|
295
|
+
// Late joiner — register them
|
|
296
|
+
this.followers.set(msg.displayId, {
|
|
297
|
+
lastSeen: Date.now(),
|
|
298
|
+
ready: true,
|
|
299
|
+
readyLayoutId: msg.layoutId,
|
|
300
|
+
});
|
|
301
|
+
} else {
|
|
302
|
+
follower.ready = true;
|
|
303
|
+
follower.readyLayoutId = msg.layoutId;
|
|
304
|
+
follower.lastSeen = Date.now();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
console.log(this._tag, `Follower ${msg.displayId} ready for layout ${msg.layoutId}`);
|
|
308
|
+
|
|
309
|
+
// Check if all followers are now ready
|
|
310
|
+
if (this._pendingLayoutId === msg.layoutId && this._readyResolve) {
|
|
311
|
+
if (this._allFollowersReady(msg.layoutId)) {
|
|
312
|
+
console.log(this._tag, 'All followers ready');
|
|
313
|
+
this._readyResolve();
|
|
314
|
+
this._readyResolve = null;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** @private */
|
|
320
|
+
_allFollowersReady(layoutId) {
|
|
321
|
+
for (const [, follower] of this.followers) {
|
|
322
|
+
// Skip stale followers
|
|
323
|
+
if (Date.now() - follower.lastSeen > FOLLOWER_TIMEOUT) continue;
|
|
324
|
+
if (!follower.ready || follower.readyLayoutId !== layoutId) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** @private */
|
|
332
|
+
_waitForFollowersReady(layoutId) {
|
|
333
|
+
return new Promise((resolve) => {
|
|
334
|
+
// Already all ready?
|
|
335
|
+
if (this._allFollowersReady(layoutId)) {
|
|
336
|
+
resolve();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
this._readyResolve = resolve;
|
|
341
|
+
|
|
342
|
+
// Timeout: don't wait forever for unresponsive followers
|
|
343
|
+
setTimeout(() => {
|
|
344
|
+
if (this._readyResolve === resolve) {
|
|
345
|
+
const notReady = [];
|
|
346
|
+
for (const [id, f] of this.followers) {
|
|
347
|
+
if (!f.ready || f.readyLayoutId !== layoutId) {
|
|
348
|
+
notReady.push(id);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
console.warn(this._tag, `Ready timeout — proceeding without: ${notReady.join(', ')}`);
|
|
352
|
+
this._readyResolve = null;
|
|
353
|
+
resolve();
|
|
354
|
+
}
|
|
355
|
+
}, READY_TIMEOUT);
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── Heartbeat & cleanup ───────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
/** @private */
|
|
362
|
+
_sendHeartbeat() {
|
|
363
|
+
this._send({
|
|
364
|
+
type: 'heartbeat',
|
|
365
|
+
displayId: this.displayId,
|
|
366
|
+
role: this.isLead ? 'lead' : 'follower',
|
|
367
|
+
timestamp: Date.now(),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** @private */
|
|
372
|
+
_cleanupStaleFollowers() {
|
|
373
|
+
const now = Date.now();
|
|
374
|
+
for (const [id, follower] of this.followers) {
|
|
375
|
+
if (now - follower.lastSeen > FOLLOWER_TIMEOUT) {
|
|
376
|
+
console.log(this._tag, `Removing stale follower: ${id} (last seen ${Math.round((now - follower.lastSeen) / 1000)}s ago)`);
|
|
377
|
+
this.followers.delete(id);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** @private */
|
|
383
|
+
_send(msg) {
|
|
384
|
+
if (!this.channel) return;
|
|
385
|
+
try {
|
|
386
|
+
this.channel.postMessage(msg);
|
|
387
|
+
} catch (e) {
|
|
388
|
+
console.error(this._tag, 'Failed to send:', e);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ── Status ────────────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Get current sync status
|
|
396
|
+
* @returns {Object}
|
|
397
|
+
*/
|
|
398
|
+
getStatus() {
|
|
399
|
+
return {
|
|
400
|
+
started: this._started,
|
|
401
|
+
isLead: this.isLead,
|
|
402
|
+
displayId: this.displayId,
|
|
403
|
+
followers: this.followers.size,
|
|
404
|
+
pendingLayoutId: this._pendingLayoutId,
|
|
405
|
+
followerDetails: Array.from(this.followers.entries()).map(([id, f]) => ({
|
|
406
|
+
displayId: id,
|
|
407
|
+
lastSeen: f.lastSeen,
|
|
408
|
+
ready: f.ready,
|
|
409
|
+
readyLayoutId: f.readyLayoutId,
|
|
410
|
+
stale: Date.now() - f.lastSeen > FOLLOWER_TIMEOUT,
|
|
411
|
+
})),
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
}
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SyncManager unit tests
|
|
3
|
+
*
|
|
4
|
+
* Tests multi-display sync coordination via BroadcastChannel.
|
|
5
|
+
* Uses a simple BroadcastChannel mock for Node.js environment.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
8
|
+
import { SyncManager } from './sync-manager.js';
|
|
9
|
+
|
|
10
|
+
// ── BroadcastChannel mock ──────────────────────────────────────────
|
|
11
|
+
// Simulates same-origin message passing between instances
|
|
12
|
+
|
|
13
|
+
const channels = new Map(); // channelName → Set<{onmessage}>
|
|
14
|
+
|
|
15
|
+
class MockBroadcastChannel {
|
|
16
|
+
constructor(name) {
|
|
17
|
+
this.name = name;
|
|
18
|
+
this.onmessage = null;
|
|
19
|
+
this._closed = false;
|
|
20
|
+
|
|
21
|
+
if (!channels.has(name)) {
|
|
22
|
+
channels.set(name, new Set());
|
|
23
|
+
}
|
|
24
|
+
channels.get(name).add(this);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
postMessage(data) {
|
|
28
|
+
if (this._closed) return;
|
|
29
|
+
const peers = channels.get(this.name);
|
|
30
|
+
if (!peers) return;
|
|
31
|
+
|
|
32
|
+
// Deliver to all OTHER instances on the same channel (not self)
|
|
33
|
+
for (const peer of peers) {
|
|
34
|
+
if (peer !== this && peer.onmessage && !peer._closed) {
|
|
35
|
+
// Clone data to simulate structured clone
|
|
36
|
+
peer.onmessage({ data: JSON.parse(JSON.stringify(data)) });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
close() {
|
|
42
|
+
this._closed = true;
|
|
43
|
+
const peers = channels.get(this.name);
|
|
44
|
+
if (peers) {
|
|
45
|
+
peers.delete(this);
|
|
46
|
+
if (peers.size === 0) channels.delete(this.name);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Install mock globally
|
|
52
|
+
globalThis.BroadcastChannel = MockBroadcastChannel;
|
|
53
|
+
|
|
54
|
+
// ── Helper to flush microtasks ──────────────────────────────────────
|
|
55
|
+
const tick = (ms = 10) => new Promise(r => setTimeout(r, ms));
|
|
56
|
+
|
|
57
|
+
describe('SyncManager', () => {
|
|
58
|
+
let lead;
|
|
59
|
+
let follower1;
|
|
60
|
+
let follower2;
|
|
61
|
+
|
|
62
|
+
const makeSyncConfig = (isLead) => ({
|
|
63
|
+
syncGroup: isLead ? 'lead' : '192.168.1.100',
|
|
64
|
+
syncPublisherPort: 9590,
|
|
65
|
+
syncSwitchDelay: 50, // Short delays for tests
|
|
66
|
+
syncVideoPauseDelay: 10,
|
|
67
|
+
isLead,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
72
|
+
channels.clear();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
lead?.stop();
|
|
77
|
+
follower1?.stop();
|
|
78
|
+
follower2?.stop();
|
|
79
|
+
vi.useRealTimers();
|
|
80
|
+
channels.clear();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('Initialization', () => {
|
|
84
|
+
it('should create lead SyncManager', () => {
|
|
85
|
+
lead = new SyncManager({
|
|
86
|
+
displayId: 'pwa-lead',
|
|
87
|
+
syncConfig: makeSyncConfig(true),
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(lead.isLead).toBe(true);
|
|
91
|
+
expect(lead.displayId).toBe('pwa-lead');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should create follower SyncManager', () => {
|
|
95
|
+
follower1 = new SyncManager({
|
|
96
|
+
displayId: 'pwa-follower1',
|
|
97
|
+
syncConfig: makeSyncConfig(false),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(follower1.isLead).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should start and open BroadcastChannel', () => {
|
|
104
|
+
lead = new SyncManager({
|
|
105
|
+
displayId: 'pwa-lead',
|
|
106
|
+
syncConfig: makeSyncConfig(true),
|
|
107
|
+
});
|
|
108
|
+
lead.start();
|
|
109
|
+
|
|
110
|
+
expect(lead.channel).not.toBeNull();
|
|
111
|
+
expect(lead.getStatus().started).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should stop and close BroadcastChannel', () => {
|
|
115
|
+
lead = new SyncManager({
|
|
116
|
+
displayId: 'pwa-lead',
|
|
117
|
+
syncConfig: makeSyncConfig(true),
|
|
118
|
+
});
|
|
119
|
+
lead.start();
|
|
120
|
+
lead.stop();
|
|
121
|
+
|
|
122
|
+
expect(lead.channel).toBeNull();
|
|
123
|
+
expect(lead.getStatus().started).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('Heartbeat', () => {
|
|
128
|
+
it('should discover followers via heartbeat', async () => {
|
|
129
|
+
lead = new SyncManager({
|
|
130
|
+
displayId: 'pwa-lead',
|
|
131
|
+
syncConfig: makeSyncConfig(true),
|
|
132
|
+
});
|
|
133
|
+
follower1 = new SyncManager({
|
|
134
|
+
displayId: 'pwa-f1',
|
|
135
|
+
syncConfig: makeSyncConfig(false),
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
lead.start();
|
|
139
|
+
follower1.start();
|
|
140
|
+
|
|
141
|
+
// Initial heartbeat is sent on start()
|
|
142
|
+
await tick();
|
|
143
|
+
|
|
144
|
+
expect(lead.followers.size).toBe(1);
|
|
145
|
+
expect(lead.followers.has('pwa-f1')).toBe(true);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should discover multiple followers', async () => {
|
|
149
|
+
lead = new SyncManager({
|
|
150
|
+
displayId: 'pwa-lead',
|
|
151
|
+
syncConfig: makeSyncConfig(true),
|
|
152
|
+
});
|
|
153
|
+
follower1 = new SyncManager({
|
|
154
|
+
displayId: 'pwa-f1',
|
|
155
|
+
syncConfig: makeSyncConfig(false),
|
|
156
|
+
});
|
|
157
|
+
follower2 = new SyncManager({
|
|
158
|
+
displayId: 'pwa-f2',
|
|
159
|
+
syncConfig: makeSyncConfig(false),
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
lead.start();
|
|
163
|
+
follower1.start();
|
|
164
|
+
follower2.start();
|
|
165
|
+
|
|
166
|
+
await tick();
|
|
167
|
+
|
|
168
|
+
expect(lead.followers.size).toBe(2);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('Layout Change Protocol', () => {
|
|
173
|
+
it('should send layout-change to followers', async () => {
|
|
174
|
+
const onLayoutChange = vi.fn();
|
|
175
|
+
|
|
176
|
+
lead = new SyncManager({
|
|
177
|
+
displayId: 'pwa-lead',
|
|
178
|
+
syncConfig: makeSyncConfig(true),
|
|
179
|
+
});
|
|
180
|
+
follower1 = new SyncManager({
|
|
181
|
+
displayId: 'pwa-f1',
|
|
182
|
+
syncConfig: makeSyncConfig(false),
|
|
183
|
+
onLayoutChange,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
lead.start();
|
|
187
|
+
follower1.start();
|
|
188
|
+
await tick();
|
|
189
|
+
|
|
190
|
+
// Lead requests layout change (don't await — follower will report ready)
|
|
191
|
+
const changePromise = lead.requestLayoutChange('100');
|
|
192
|
+
|
|
193
|
+
await tick();
|
|
194
|
+
|
|
195
|
+
// Follower should receive layout-change callback
|
|
196
|
+
expect(onLayoutChange).toHaveBeenCalledWith('100', expect.any(Number));
|
|
197
|
+
|
|
198
|
+
// Simulate follower reporting ready
|
|
199
|
+
follower1.reportReady('100');
|
|
200
|
+
await tick();
|
|
201
|
+
|
|
202
|
+
// Advance timers for switchDelay
|
|
203
|
+
vi.advanceTimersByTime(100);
|
|
204
|
+
await changePromise;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should call onLayoutShow on both lead and follower', async () => {
|
|
208
|
+
const leadOnShow = vi.fn();
|
|
209
|
+
const followerOnShow = vi.fn();
|
|
210
|
+
|
|
211
|
+
lead = new SyncManager({
|
|
212
|
+
displayId: 'pwa-lead',
|
|
213
|
+
syncConfig: makeSyncConfig(true),
|
|
214
|
+
onLayoutShow: leadOnShow,
|
|
215
|
+
});
|
|
216
|
+
follower1 = new SyncManager({
|
|
217
|
+
displayId: 'pwa-f1',
|
|
218
|
+
syncConfig: makeSyncConfig(false),
|
|
219
|
+
onLayoutShow: followerOnShow,
|
|
220
|
+
onLayoutChange: () => {
|
|
221
|
+
// Immediately report ready when layout change is requested
|
|
222
|
+
setTimeout(() => follower1.reportReady('100'), 5);
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
lead.start();
|
|
227
|
+
follower1.start();
|
|
228
|
+
await tick();
|
|
229
|
+
|
|
230
|
+
const changePromise = lead.requestLayoutChange('100');
|
|
231
|
+
|
|
232
|
+
// Wait for follower to process and report ready
|
|
233
|
+
vi.advanceTimersByTime(10);
|
|
234
|
+
await tick();
|
|
235
|
+
|
|
236
|
+
// Wait for switchDelay
|
|
237
|
+
vi.advanceTimersByTime(100);
|
|
238
|
+
await changePromise;
|
|
239
|
+
|
|
240
|
+
expect(leadOnShow).toHaveBeenCalledWith('100');
|
|
241
|
+
expect(followerOnShow).toHaveBeenCalledWith('100');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('should proceed after timeout if follower is unresponsive', async () => {
|
|
245
|
+
const leadOnShow = vi.fn();
|
|
246
|
+
|
|
247
|
+
lead = new SyncManager({
|
|
248
|
+
displayId: 'pwa-lead',
|
|
249
|
+
syncConfig: makeSyncConfig(true),
|
|
250
|
+
onLayoutShow: leadOnShow,
|
|
251
|
+
});
|
|
252
|
+
follower1 = new SyncManager({
|
|
253
|
+
displayId: 'pwa-f1',
|
|
254
|
+
syncConfig: makeSyncConfig(false),
|
|
255
|
+
// Follower does NOT report ready (simulates unresponsive)
|
|
256
|
+
onLayoutChange: () => {},
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
lead.start();
|
|
260
|
+
follower1.start();
|
|
261
|
+
await tick();
|
|
262
|
+
|
|
263
|
+
const changePromise = lead.requestLayoutChange('200');
|
|
264
|
+
|
|
265
|
+
// Advance past ready timeout (10s) + switch delay
|
|
266
|
+
vi.advanceTimersByTime(11000);
|
|
267
|
+
await tick();
|
|
268
|
+
await changePromise;
|
|
269
|
+
|
|
270
|
+
// Lead should show anyway after timeout
|
|
271
|
+
expect(leadOnShow).toHaveBeenCalledWith('200');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should proceed immediately with no followers', async () => {
|
|
275
|
+
const leadOnShow = vi.fn();
|
|
276
|
+
|
|
277
|
+
lead = new SyncManager({
|
|
278
|
+
displayId: 'pwa-lead',
|
|
279
|
+
syncConfig: makeSyncConfig(true),
|
|
280
|
+
onLayoutShow: leadOnShow,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
lead.start();
|
|
284
|
+
await tick();
|
|
285
|
+
|
|
286
|
+
const changePromise = lead.requestLayoutChange('300');
|
|
287
|
+
|
|
288
|
+
// Just switch delay, no follower waiting
|
|
289
|
+
vi.advanceTimersByTime(100);
|
|
290
|
+
await changePromise;
|
|
291
|
+
|
|
292
|
+
expect(leadOnShow).toHaveBeenCalledWith('300');
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe('Video Start', () => {
|
|
297
|
+
it('should send video-start signal to followers', async () => {
|
|
298
|
+
const onVideoStart = vi.fn();
|
|
299
|
+
|
|
300
|
+
lead = new SyncManager({
|
|
301
|
+
displayId: 'pwa-lead',
|
|
302
|
+
syncConfig: makeSyncConfig(true),
|
|
303
|
+
});
|
|
304
|
+
follower1 = new SyncManager({
|
|
305
|
+
displayId: 'pwa-f1',
|
|
306
|
+
syncConfig: makeSyncConfig(false),
|
|
307
|
+
onVideoStart,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
lead.start();
|
|
311
|
+
follower1.start();
|
|
312
|
+
await tick();
|
|
313
|
+
|
|
314
|
+
const videoPromise = lead.requestVideoStart('100', 'region-1');
|
|
315
|
+
|
|
316
|
+
// Wait for videoPauseDelay
|
|
317
|
+
vi.advanceTimersByTime(20);
|
|
318
|
+
await videoPromise;
|
|
319
|
+
|
|
320
|
+
expect(onVideoStart).toHaveBeenCalledWith('100', 'region-1');
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe('Status', () => {
|
|
325
|
+
it('should report accurate status', async () => {
|
|
326
|
+
lead = new SyncManager({
|
|
327
|
+
displayId: 'pwa-lead',
|
|
328
|
+
syncConfig: makeSyncConfig(true),
|
|
329
|
+
});
|
|
330
|
+
follower1 = new SyncManager({
|
|
331
|
+
displayId: 'pwa-f1',
|
|
332
|
+
syncConfig: makeSyncConfig(false),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
lead.start();
|
|
336
|
+
follower1.start();
|
|
337
|
+
await tick();
|
|
338
|
+
|
|
339
|
+
const status = lead.getStatus();
|
|
340
|
+
expect(status.started).toBe(true);
|
|
341
|
+
expect(status.isLead).toBe(true);
|
|
342
|
+
expect(status.followers).toBe(1);
|
|
343
|
+
expect(status.followerDetails).toHaveLength(1);
|
|
344
|
+
expect(status.followerDetails[0].displayId).toBe('pwa-f1');
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe('Edge Cases', () => {
|
|
349
|
+
it('follower should not process requestLayoutChange', async () => {
|
|
350
|
+
follower1 = new SyncManager({
|
|
351
|
+
displayId: 'pwa-f1',
|
|
352
|
+
syncConfig: makeSyncConfig(false),
|
|
353
|
+
});
|
|
354
|
+
follower1.start();
|
|
355
|
+
|
|
356
|
+
// Should not throw, just warn and return
|
|
357
|
+
await follower1.requestLayoutChange('100');
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('should ignore own messages', async () => {
|
|
361
|
+
const onLayoutChange = vi.fn();
|
|
362
|
+
|
|
363
|
+
lead = new SyncManager({
|
|
364
|
+
displayId: 'pwa-lead',
|
|
365
|
+
syncConfig: makeSyncConfig(true),
|
|
366
|
+
onLayoutChange,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
lead.start();
|
|
370
|
+
await tick();
|
|
371
|
+
|
|
372
|
+
// Lead should not receive its own heartbeat as a follower
|
|
373
|
+
expect(lead.followers.size).toBe(0);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
});
|