@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 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
+ });