@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 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.0",
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.0"
11
+ "@xiboplayer/utils": "0.4.3"
12
12
  },
13
13
  "devDependencies": {
14
14
  "vitest": "^2.0.0"
@@ -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
  }
@@ -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
  });