@xiboplayer/sync 0.3.7 → 0.4.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/README.md +1 -0
- package/package.json +4 -2
- package/src/sync-manager.js +95 -18
- package/src/sync-manager.test.js +100 -0
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ BroadcastChannel-based lead/follower synchronization:
|
|
|
9
9
|
- **Lead election** — automatic leader selection among browser tabs/windows
|
|
10
10
|
- **Synchronized playback** — video start coordinated across displays
|
|
11
11
|
- **Layout sync** — all displays transition to the same layout simultaneously
|
|
12
|
+
- **Stats/logs delegation** — follower tabs delegate proof-of-play stats and log submission to the sync lead via BroadcastChannel, avoiding duplicate CMS traffic in video wall setups
|
|
12
13
|
|
|
13
14
|
Designed for video wall setups where multiple screens show synchronized content.
|
|
14
15
|
|
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/sync",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Multi-display synchronization for Xibo Player",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/sync-manager.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/sync-manager.js"
|
|
9
9
|
},
|
|
10
|
-
"dependencies": {
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"@xiboplayer/utils": "0.4.1"
|
|
12
|
+
},
|
|
11
13
|
"devDependencies": {
|
|
12
14
|
"vitest": "^2.0.0"
|
|
13
15
|
},
|
package/src/sync-manager.js
CHANGED
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
* @property {boolean} isLead - Whether this display is the leader
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
|
+
import { createLogger } from '@xiboplayer/utils';
|
|
33
|
+
|
|
32
34
|
const CHANNEL_NAME = 'xibo-sync';
|
|
33
35
|
const HEARTBEAT_INTERVAL = 5000; // Send heartbeat every 5s
|
|
34
36
|
const FOLLOWER_TIMEOUT = 15000; // Consider follower offline after 15s silence
|
|
@@ -42,6 +44,10 @@ export class SyncManager {
|
|
|
42
44
|
* @param {Function} [options.onLayoutChange] - Called when lead requests layout change
|
|
43
45
|
* @param {Function} [options.onLayoutShow] - Called when lead gives show signal
|
|
44
46
|
* @param {Function} [options.onVideoStart] - Called when lead gives video start signal
|
|
47
|
+
* @param {Function} [options.onStatsReport] - (Lead) Called when follower sends stats
|
|
48
|
+
* @param {Function} [options.onLogsReport] - (Lead) Called when follower sends logs
|
|
49
|
+
* @param {Function} [options.onStatsAck] - (Follower) Called when lead confirms stats submission
|
|
50
|
+
* @param {Function} [options.onLogsAck] - (Follower) Called when lead confirms logs submission
|
|
45
51
|
*/
|
|
46
52
|
constructor(options) {
|
|
47
53
|
this.displayId = options.displayId;
|
|
@@ -54,6 +60,10 @@ export class SyncManager {
|
|
|
54
60
|
this.onLayoutChange = options.onLayoutChange || (() => {});
|
|
55
61
|
this.onLayoutShow = options.onLayoutShow || (() => {});
|
|
56
62
|
this.onVideoStart = options.onVideoStart || (() => {});
|
|
63
|
+
this.onStatsReport = options.onStatsReport || null;
|
|
64
|
+
this.onLogsReport = options.onLogsReport || null;
|
|
65
|
+
this.onStatsAck = options.onStatsAck || null;
|
|
66
|
+
this.onLogsAck = options.onLogsAck || null;
|
|
57
67
|
|
|
58
68
|
// State
|
|
59
69
|
this.channel = null;
|
|
@@ -64,8 +74,9 @@ export class SyncManager {
|
|
|
64
74
|
this._pendingLayoutId = null; // Layout we're waiting for readiness on
|
|
65
75
|
this._started = false;
|
|
66
76
|
|
|
67
|
-
//
|
|
77
|
+
// Logger with role prefix for clarity in multi-tab console
|
|
68
78
|
this._tag = this.isLead ? '[Sync:LEAD]' : '[Sync:FOLLOW]';
|
|
79
|
+
this._log = createLogger(this.isLead ? 'Sync:LEAD' : 'Sync:FOLLOW');
|
|
69
80
|
}
|
|
70
81
|
|
|
71
82
|
/**
|
|
@@ -76,7 +87,7 @@ export class SyncManager {
|
|
|
76
87
|
this._started = true;
|
|
77
88
|
|
|
78
89
|
if (typeof BroadcastChannel === 'undefined') {
|
|
79
|
-
|
|
90
|
+
this._log.warn( 'BroadcastChannel not available — sync disabled');
|
|
80
91
|
return;
|
|
81
92
|
}
|
|
82
93
|
|
|
@@ -92,7 +103,7 @@ export class SyncManager {
|
|
|
92
103
|
this._cleanupTimer = setInterval(() => this._cleanupStaleFollowers(), HEARTBEAT_INTERVAL);
|
|
93
104
|
}
|
|
94
105
|
|
|
95
|
-
|
|
106
|
+
this._log.info( 'Started. DisplayId:', this.displayId);
|
|
96
107
|
}
|
|
97
108
|
|
|
98
109
|
/**
|
|
@@ -116,7 +127,7 @@ export class SyncManager {
|
|
|
116
127
|
}
|
|
117
128
|
|
|
118
129
|
this.followers.clear();
|
|
119
|
-
|
|
130
|
+
this._log.info( 'Stopped');
|
|
120
131
|
}
|
|
121
132
|
|
|
122
133
|
// ── Lead API ──────────────────────────────────────────────────────
|
|
@@ -130,7 +141,7 @@ export class SyncManager {
|
|
|
130
141
|
*/
|
|
131
142
|
async requestLayoutChange(layoutId) {
|
|
132
143
|
if (!this.isLead) {
|
|
133
|
-
|
|
144
|
+
this._log.warn( 'requestLayoutChange called on follower — ignoring');
|
|
134
145
|
return;
|
|
135
146
|
}
|
|
136
147
|
|
|
@@ -145,7 +156,7 @@ export class SyncManager {
|
|
|
145
156
|
|
|
146
157
|
const showAt = Date.now() + this.switchDelay;
|
|
147
158
|
|
|
148
|
-
|
|
159
|
+
this._log.info( `Requesting layout change: ${layoutId} (show at ${new Date(showAt).toISOString()}, ${this.followers.size} followers)`);
|
|
149
160
|
|
|
150
161
|
// Broadcast layout-change to all followers
|
|
151
162
|
this._send({
|
|
@@ -167,7 +178,7 @@ export class SyncManager {
|
|
|
167
178
|
}
|
|
168
179
|
|
|
169
180
|
// Send show signal
|
|
170
|
-
|
|
181
|
+
this._log.info( `Sending layout-show: ${layoutId}`);
|
|
171
182
|
this._send({
|
|
172
183
|
type: 'layout-show',
|
|
173
184
|
layoutId,
|
|
@@ -214,7 +225,7 @@ export class SyncManager {
|
|
|
214
225
|
reportReady(layoutId) {
|
|
215
226
|
layoutId = String(layoutId);
|
|
216
227
|
|
|
217
|
-
|
|
228
|
+
this._log.info( `Reporting ready for layout ${layoutId}`);
|
|
218
229
|
|
|
219
230
|
this._send({
|
|
220
231
|
type: 'layout-ready',
|
|
@@ -223,6 +234,40 @@ export class SyncManager {
|
|
|
223
234
|
});
|
|
224
235
|
}
|
|
225
236
|
|
|
237
|
+
/**
|
|
238
|
+
* [Follower only] Delegate stats submission to the lead.
|
|
239
|
+
* Lead will submit on our behalf and send a stats-ack when done.
|
|
240
|
+
*
|
|
241
|
+
* @param {string} statsXml - Formatted stats XML to submit
|
|
242
|
+
*/
|
|
243
|
+
reportStats(statsXml) {
|
|
244
|
+
if (this.isLead) return;
|
|
245
|
+
|
|
246
|
+
this._log.info('Delegating stats to lead');
|
|
247
|
+
this._send({
|
|
248
|
+
type: 'stats-report',
|
|
249
|
+
displayId: this.displayId,
|
|
250
|
+
statsXml,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* [Follower only] Delegate logs submission to the lead.
|
|
256
|
+
* Lead will submit on our behalf and send a logs-ack when done.
|
|
257
|
+
*
|
|
258
|
+
* @param {string} logsXml - Formatted logs XML to submit
|
|
259
|
+
*/
|
|
260
|
+
reportLogs(logsXml) {
|
|
261
|
+
if (this.isLead) return;
|
|
262
|
+
|
|
263
|
+
this._log.info('Delegating logs to lead');
|
|
264
|
+
this._send({
|
|
265
|
+
type: 'logs-report',
|
|
266
|
+
displayId: this.displayId,
|
|
267
|
+
logsXml,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
226
271
|
// ── Message handling ──────────────────────────────────────────────
|
|
227
272
|
|
|
228
273
|
/** @private */
|
|
@@ -238,7 +283,7 @@ export class SyncManager {
|
|
|
238
283
|
case 'layout-change':
|
|
239
284
|
// Follower: lead is requesting a layout change
|
|
240
285
|
if (!this.isLead) {
|
|
241
|
-
|
|
286
|
+
this._log.info( `Layout change requested: ${msg.layoutId}`);
|
|
242
287
|
this.onLayoutChange(msg.layoutId, msg.showAt);
|
|
243
288
|
}
|
|
244
289
|
break;
|
|
@@ -253,7 +298,7 @@ export class SyncManager {
|
|
|
253
298
|
case 'layout-show':
|
|
254
299
|
// Follower: lead says show now
|
|
255
300
|
if (!this.isLead) {
|
|
256
|
-
|
|
301
|
+
this._log.info( `Layout show signal: ${msg.layoutId}`);
|
|
257
302
|
this.onLayoutShow(msg.layoutId);
|
|
258
303
|
}
|
|
259
304
|
break;
|
|
@@ -261,13 +306,45 @@ export class SyncManager {
|
|
|
261
306
|
case 'video-start':
|
|
262
307
|
// Follower: lead says start video
|
|
263
308
|
if (!this.isLead) {
|
|
264
|
-
|
|
309
|
+
this._log.info( `Video start signal: ${msg.layoutId} region ${msg.regionId}`);
|
|
265
310
|
this.onVideoStart(msg.layoutId, msg.regionId);
|
|
266
311
|
}
|
|
267
312
|
break;
|
|
268
313
|
|
|
314
|
+
case 'stats-report':
|
|
315
|
+
// Lead: follower is delegating stats submission
|
|
316
|
+
if (this.isLead && this.onStatsReport) {
|
|
317
|
+
const statsAck = () => this._send({ type: 'stats-ack', displayId: this.displayId, targetDisplayId: msg.displayId });
|
|
318
|
+
this.onStatsReport(msg.displayId, msg.statsXml, statsAck);
|
|
319
|
+
}
|
|
320
|
+
break;
|
|
321
|
+
|
|
322
|
+
case 'logs-report':
|
|
323
|
+
// Lead: follower is delegating logs submission
|
|
324
|
+
if (this.isLead && this.onLogsReport) {
|
|
325
|
+
const logsAck = () => this._send({ type: 'logs-ack', displayId: this.displayId, targetDisplayId: msg.displayId });
|
|
326
|
+
this.onLogsReport(msg.displayId, msg.logsXml, logsAck);
|
|
327
|
+
}
|
|
328
|
+
break;
|
|
329
|
+
|
|
330
|
+
case 'stats-ack':
|
|
331
|
+
// Follower: lead confirmed stats were submitted for us
|
|
332
|
+
if (!this.isLead && msg.targetDisplayId === this.displayId && this.onStatsAck) {
|
|
333
|
+
this._log.info('Stats acknowledged by lead');
|
|
334
|
+
this.onStatsAck(msg.targetDisplayId);
|
|
335
|
+
}
|
|
336
|
+
break;
|
|
337
|
+
|
|
338
|
+
case 'logs-ack':
|
|
339
|
+
// Follower: lead confirmed logs were submitted for us
|
|
340
|
+
if (!this.isLead && msg.targetDisplayId === this.displayId && this.onLogsAck) {
|
|
341
|
+
this._log.info('Logs acknowledged by lead');
|
|
342
|
+
this.onLogsAck(msg.targetDisplayId);
|
|
343
|
+
}
|
|
344
|
+
break;
|
|
345
|
+
|
|
269
346
|
default:
|
|
270
|
-
|
|
347
|
+
this._log.warn( 'Unknown message type:', msg.type);
|
|
271
348
|
}
|
|
272
349
|
}
|
|
273
350
|
|
|
@@ -284,7 +361,7 @@ export class SyncManager {
|
|
|
284
361
|
readyLayoutId: null,
|
|
285
362
|
role: msg.role || 'unknown',
|
|
286
363
|
});
|
|
287
|
-
|
|
364
|
+
this._log.info( `Follower joined: ${msg.displayId} (${this.followers.size} total)`);
|
|
288
365
|
}
|
|
289
366
|
}
|
|
290
367
|
|
|
@@ -304,12 +381,12 @@ export class SyncManager {
|
|
|
304
381
|
follower.lastSeen = Date.now();
|
|
305
382
|
}
|
|
306
383
|
|
|
307
|
-
|
|
384
|
+
this._log.info( `Follower ${msg.displayId} ready for layout ${msg.layoutId}`);
|
|
308
385
|
|
|
309
386
|
// Check if all followers are now ready
|
|
310
387
|
if (this._pendingLayoutId === msg.layoutId && this._readyResolve) {
|
|
311
388
|
if (this._allFollowersReady(msg.layoutId)) {
|
|
312
|
-
|
|
389
|
+
this._log.info( 'All followers ready');
|
|
313
390
|
this._readyResolve();
|
|
314
391
|
this._readyResolve = null;
|
|
315
392
|
}
|
|
@@ -348,7 +425,7 @@ export class SyncManager {
|
|
|
348
425
|
notReady.push(id);
|
|
349
426
|
}
|
|
350
427
|
}
|
|
351
|
-
|
|
428
|
+
this._log.warn( `Ready timeout — proceeding without: ${notReady.join(', ')}`);
|
|
352
429
|
this._readyResolve = null;
|
|
353
430
|
resolve();
|
|
354
431
|
}
|
|
@@ -373,7 +450,7 @@ export class SyncManager {
|
|
|
373
450
|
const now = Date.now();
|
|
374
451
|
for (const [id, follower] of this.followers) {
|
|
375
452
|
if (now - follower.lastSeen > FOLLOWER_TIMEOUT) {
|
|
376
|
-
|
|
453
|
+
this._log.info( `Removing stale follower: ${id} (last seen ${Math.round((now - follower.lastSeen) / 1000)}s ago)`);
|
|
377
454
|
this.followers.delete(id);
|
|
378
455
|
}
|
|
379
456
|
}
|
|
@@ -385,7 +462,7 @@ export class SyncManager {
|
|
|
385
462
|
try {
|
|
386
463
|
this.channel.postMessage(msg);
|
|
387
464
|
} catch (e) {
|
|
388
|
-
|
|
465
|
+
this._log.error( 'Failed to send:', e);
|
|
389
466
|
}
|
|
390
467
|
}
|
|
391
468
|
|
package/src/sync-manager.test.js
CHANGED
|
@@ -373,4 +373,104 @@ describe('SyncManager', () => {
|
|
|
373
373
|
expect(lead.followers.size).toBe(0);
|
|
374
374
|
});
|
|
375
375
|
});
|
|
376
|
+
|
|
377
|
+
describe('Stats/Logs Delegation', () => {
|
|
378
|
+
it('follower receives stats-ack after lead calls ack()', () => {
|
|
379
|
+
const onStatsAck = vi.fn();
|
|
380
|
+
const onStatsReport = vi.fn((_id, _xml, ack) => ack());
|
|
381
|
+
|
|
382
|
+
lead = new SyncManager({
|
|
383
|
+
displayId: 'pwa-lead',
|
|
384
|
+
syncConfig: makeSyncConfig(true),
|
|
385
|
+
onStatsReport,
|
|
386
|
+
});
|
|
387
|
+
follower1 = new SyncManager({
|
|
388
|
+
displayId: 'pwa-f1',
|
|
389
|
+
syncConfig: makeSyncConfig(false),
|
|
390
|
+
onStatsAck,
|
|
391
|
+
});
|
|
392
|
+
lead.start();
|
|
393
|
+
follower1.start();
|
|
394
|
+
|
|
395
|
+
follower1.reportStats('<stats>test</stats>');
|
|
396
|
+
|
|
397
|
+
expect(onStatsReport).toHaveBeenCalledWith(
|
|
398
|
+
'pwa-f1',
|
|
399
|
+
'<stats>test</stats>',
|
|
400
|
+
expect.any(Function),
|
|
401
|
+
);
|
|
402
|
+
expect(onStatsAck).toHaveBeenCalledWith('pwa-f1');
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('no ack when lead does not call ack() (CMS failure)', () => {
|
|
406
|
+
const onStatsAck = vi.fn();
|
|
407
|
+
const onStatsReport = vi.fn((_id, _xml, _ack) => { /* no ack */ });
|
|
408
|
+
|
|
409
|
+
lead = new SyncManager({
|
|
410
|
+
displayId: 'pwa-lead',
|
|
411
|
+
syncConfig: makeSyncConfig(true),
|
|
412
|
+
onStatsReport,
|
|
413
|
+
});
|
|
414
|
+
follower1 = new SyncManager({
|
|
415
|
+
displayId: 'pwa-f1',
|
|
416
|
+
syncConfig: makeSyncConfig(false),
|
|
417
|
+
onStatsAck,
|
|
418
|
+
});
|
|
419
|
+
lead.start();
|
|
420
|
+
follower1.start();
|
|
421
|
+
|
|
422
|
+
follower1.reportStats('<stats>test</stats>');
|
|
423
|
+
|
|
424
|
+
expect(onStatsReport).toHaveBeenCalled();
|
|
425
|
+
expect(onStatsAck).not.toHaveBeenCalled();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('lead ignores stats-report from itself (self-message guard)', () => {
|
|
429
|
+
const onStatsReport = vi.fn();
|
|
430
|
+
|
|
431
|
+
lead = new SyncManager({
|
|
432
|
+
displayId: 'pwa-lead',
|
|
433
|
+
syncConfig: makeSyncConfig(true),
|
|
434
|
+
onStatsReport,
|
|
435
|
+
});
|
|
436
|
+
lead.start();
|
|
437
|
+
|
|
438
|
+
// Simulate: lead sends a stats-report with its own displayId
|
|
439
|
+
// The _handleMessage guard should reject it (msg.displayId === this.displayId)
|
|
440
|
+
lead._send({
|
|
441
|
+
type: 'stats-report',
|
|
442
|
+
displayId: 'pwa-lead',
|
|
443
|
+
statsXml: '<stats>self</stats>',
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
expect(onStatsReport).not.toHaveBeenCalled();
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('logs delegation works same as stats', () => {
|
|
450
|
+
const onLogsAck = vi.fn();
|
|
451
|
+
const onLogsReport = vi.fn((_id, _xml, ack) => ack());
|
|
452
|
+
|
|
453
|
+
lead = new SyncManager({
|
|
454
|
+
displayId: 'pwa-lead',
|
|
455
|
+
syncConfig: makeSyncConfig(true),
|
|
456
|
+
onLogsReport,
|
|
457
|
+
});
|
|
458
|
+
follower1 = new SyncManager({
|
|
459
|
+
displayId: 'pwa-f1',
|
|
460
|
+
syncConfig: makeSyncConfig(false),
|
|
461
|
+
onLogsAck,
|
|
462
|
+
});
|
|
463
|
+
lead.start();
|
|
464
|
+
follower1.start();
|
|
465
|
+
|
|
466
|
+
follower1.reportLogs('<logs>test-logs</logs>');
|
|
467
|
+
|
|
468
|
+
expect(onLogsReport).toHaveBeenCalledWith(
|
|
469
|
+
'pwa-f1',
|
|
470
|
+
'<logs>test-logs</logs>',
|
|
471
|
+
expect.any(Function),
|
|
472
|
+
);
|
|
473
|
+
expect(onLogsAck).toHaveBeenCalledWith('pwa-f1');
|
|
474
|
+
});
|
|
475
|
+
});
|
|
376
476
|
});
|