@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 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.7",
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
  },
@@ -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
- // Log prefix for clarity in multi-tab console
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
- console.warn(this._tag, 'BroadcastChannel not available — sync disabled');
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
- console.log(this._tag, 'Started. DisplayId:', this.displayId);
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
- console.log(this._tag, 'Stopped');
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
- console.warn(this._tag, 'requestLayoutChange called on follower — ignoring');
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
- console.log(this._tag, `Requesting layout change: ${layoutId} (show at ${new Date(showAt).toISOString()}, ${this.followers.size} followers)`);
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
- console.log(this._tag, `Sending layout-show: ${layoutId}`);
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
- console.log(this._tag, `Reporting ready for layout ${layoutId}`);
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
- console.log(this._tag, `Layout change requested: ${msg.layoutId}`);
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
- console.log(this._tag, `Layout show signal: ${msg.layoutId}`);
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
- console.log(this._tag, `Video start signal: ${msg.layoutId} region ${msg.regionId}`);
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
- console.warn(this._tag, 'Unknown message type:', msg.type);
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
- console.log(this._tag, `Follower joined: ${msg.displayId} (${this.followers.size} total)`);
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
- console.log(this._tag, `Follower ${msg.displayId} ready for layout ${msg.layoutId}`);
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
- console.log(this._tag, 'All followers ready');
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
- console.warn(this._tag, `Ready timeout — proceeding without: ${notReady.join(', ')}`);
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
- console.log(this._tag, `Removing stale follower: ${id} (last seen ${Math.round((now - follower.lastSeen) / 1000)}s ago)`);
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
- console.error(this._tag, 'Failed to send:', e);
465
+ this._log.error( 'Failed to send:', e);
389
466
  }
390
467
  }
391
468
 
@@ -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
  });