@xiboplayer/sync 0.4.0 → 0.4.3
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 +2 -2
- package/src/sync-manager.js +74 -0
- 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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xiboplayer/sync",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "Multi-display synchronization for Xibo Player",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/sync-manager.js",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
".": "./src/sync-manager.js"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@xiboplayer/utils": "0.4.
|
|
11
|
+
"@xiboplayer/utils": "0.4.3"
|
|
12
12
|
},
|
|
13
13
|
"devDependencies": {
|
|
14
14
|
"vitest": "^2.0.0"
|
package/src/sync-manager.js
CHANGED
|
@@ -44,6 +44,10 @@ export class SyncManager {
|
|
|
44
44
|
* @param {Function} [options.onLayoutChange] - Called when lead requests layout change
|
|
45
45
|
* @param {Function} [options.onLayoutShow] - Called when lead gives show signal
|
|
46
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
|
|
47
51
|
*/
|
|
48
52
|
constructor(options) {
|
|
49
53
|
this.displayId = options.displayId;
|
|
@@ -56,6 +60,10 @@ export class SyncManager {
|
|
|
56
60
|
this.onLayoutChange = options.onLayoutChange || (() => {});
|
|
57
61
|
this.onLayoutShow = options.onLayoutShow || (() => {});
|
|
58
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;
|
|
59
67
|
|
|
60
68
|
// State
|
|
61
69
|
this.channel = null;
|
|
@@ -226,6 +234,40 @@ export class SyncManager {
|
|
|
226
234
|
});
|
|
227
235
|
}
|
|
228
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
|
+
|
|
229
271
|
// ── Message handling ──────────────────────────────────────────────
|
|
230
272
|
|
|
231
273
|
/** @private */
|
|
@@ -269,6 +311,38 @@ export class SyncManager {
|
|
|
269
311
|
}
|
|
270
312
|
break;
|
|
271
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
|
+
|
|
272
346
|
default:
|
|
273
347
|
this._log.warn( 'Unknown message type:', msg.type);
|
|
274
348
|
}
|
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
|
});
|