@xiboplayer/sync 0.6.3 → 0.6.4

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
@@ -1,17 +1,44 @@
1
1
  # @xiboplayer/sync
2
2
 
3
- **Multi-display synchronization for Xibo video walls.**
3
+ **Multi-display synchronization for Xibo video walls — same-machine and cross-device.**
4
4
 
5
5
  ## Overview
6
6
 
7
- BroadcastChannel-based lead/follower synchronization:
7
+ Coordinates layout transitions and video playback across multiple displays:
8
8
 
9
- - **Lead election** — automatic leader selection among browser tabs/windows
10
- - **Synchronized playback** — video start coordinated across displays
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
9
+ - **Same-machine sync** (Phase 2) BroadcastChannel for multi-tab/multi-window setups on a single device
10
+ - **Cross-device sync** (Phase 3) WebSocket relay for LAN video walls where each screen is a separate mini-PC
13
11
 
14
- Designed for video wall setups where multiple screens show synchronized content.
12
+ Both modes share the same sync protocol only the transport layer differs.
13
+
14
+ ### Capabilities
15
+
16
+ - **Synchronized layout transitions** — lead signals followers to change layout, waits for all to be ready, then sends a simultaneous "show" signal
17
+ - **Coordinated video start** — video playback begins at the same moment on all displays
18
+ - **Stats/logs delegation** — followers delegate proof-of-play stats and log submission through the lead, avoiding duplicate CMS traffic
19
+ - **Automatic follower discovery** — heartbeats every 5s, stale detection after 15s
20
+ - **Graceful degradation** — if a follower is unresponsive, the lead proceeds after a 10s timeout
21
+ - **Auto-reconnect** — WebSocket transport reconnects with exponential backoff (1s → 30s)
22
+
23
+ ## Architecture
24
+
25
+ ```
26
+ Same-machine (BroadcastChannel): Cross-device (WebSocket relay):
27
+
28
+ Tab 1 (Lead) Tab 2 (Follower) PC 1 (Lead) PC 2 (Follower)
29
+ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
30
+ │SyncMgr │ │SyncMgr │ │SyncMgr │ │SyncMgr │
31
+ │ └─BC │◄──►│ └─BC │ │ └─WS │ │ └─WS │
32
+ └──────────┘ └──────────┘ └────┬─────┘ └────┬─────┘
33
+ BroadcastChannel │ │
34
+ ▼ │
35
+ ┌────────────┐ │
36
+ │Proxy :8765 │◄────────────┘
37
+ │ └─SyncRelay│ (LAN WebSocket)
38
+ └────────────┘
39
+ ```
40
+
41
+ The relay is a dumb pipe — it broadcasts each message to all other connected clients. The sync protocol (heartbeats, ready-waits, layout changes) runs entirely in SyncManager.
15
42
 
16
43
  ## Installation
17
44
 
@@ -21,13 +48,170 @@ npm install @xiboplayer/sync
21
48
 
22
49
  ## Usage
23
50
 
51
+ ### Same-machine sync (default)
52
+
53
+ No extra configuration needed. When multiple tabs/windows run on the same origin, BroadcastChannel handles message passing automatically.
54
+
24
55
  ```javascript
25
56
  import { SyncManager } from '@xiboplayer/sync';
26
57
 
27
- const sync = new SyncManager({ displayId: 'screen-1' });
28
- sync.on('layout-sync', ({ layoutId }) => renderer.show(layoutId));
29
- sync.init();
58
+ // Lead display
59
+ const lead = new SyncManager({
60
+ displayId: 'screen-1',
61
+ syncConfig: { isLead: true, syncGroup: 'lead', syncSwitchDelay: 750 },
62
+ onLayoutShow: (layoutId) => renderer.show(layoutId),
63
+ });
64
+ lead.start();
65
+
66
+ // Request synchronized layout change (waits for followers)
67
+ await lead.requestLayoutChange('42');
68
+ ```
69
+
70
+ ```javascript
71
+ // Follower display (different tab)
72
+ const follower = new SyncManager({
73
+ displayId: 'screen-2',
74
+ syncConfig: { isLead: false, syncGroup: '192.168.1.100', syncSwitchDelay: 750 },
75
+ onLayoutChange: async (layoutId) => {
76
+ await renderer.prepareLayout(layoutId);
77
+ follower.reportReady(layoutId);
78
+ },
79
+ onLayoutShow: (layoutId) => renderer.show(layoutId),
80
+ });
81
+ follower.start();
82
+ ```
83
+
84
+ ### Cross-device sync (LAN video wall)
85
+
86
+ When `syncGroup` is an IP address (not `"lead"`) and `syncPublisherPort` is set, the PWA automatically builds a WebSocket relay URL. The lead connects to its own proxy at `ws://localhost:<port>/sync`; followers connect to `ws://<lead-ip>:<port>/sync`.
87
+
88
+ **Lead config.json** (e.g. `~/.config/xiboplayer/electron/config.json`):
89
+
90
+ ```json
91
+ {
92
+ "cmsUrl": "https://cms.example.com",
93
+ "cmsKey": "yourKey",
94
+ "displayName": "videowall-lead",
95
+ "listenAddress": "0.0.0.0"
96
+ }
97
+ ```
98
+
99
+ The `listenAddress: "0.0.0.0"` makes the proxy reachable from the LAN. The CMS sync settings (`syncGroup`, `syncPublisherPort`) are sent via the RegisterDisplay response.
100
+
101
+ **CMS Display Settings:**
102
+
103
+ | Setting | Lead | Follower |
104
+ |---------|------|----------|
105
+ | Sync Group | `lead` | `192.168.1.100` (lead's IP) |
106
+ | Sync Publisher Port | `8765` | `8765` |
107
+
108
+ The SyncManager detects this configuration and selects the WebSocket transport:
109
+
110
+ ```javascript
111
+ // This happens automatically in packages/pwa/src/main.ts:
112
+ if (syncConfig.syncPublisherPort && syncConfig.syncGroup !== 'lead') {
113
+ const host = syncConfig.isLead ? 'localhost' : syncConfig.syncGroup;
114
+ syncConfig.relayUrl = `ws://${host}:${syncConfig.syncPublisherPort}/sync`;
115
+ }
116
+ ```
117
+
118
+ ### Injecting a custom transport
119
+
120
+ For testing or custom setups, you can inject any object that implements the transport interface:
121
+
122
+ ```javascript
123
+ const transport = {
124
+ send(msg) { /* ... */ },
125
+ onMessage(callback) { /* ... */ },
126
+ close() { /* ... */ },
127
+ get connected() { return true; },
128
+ };
129
+
130
+ const sync = new SyncManager({
131
+ displayId: 'test-1',
132
+ syncConfig: { isLead: true },
133
+ transport,
134
+ });
135
+ sync.start();
136
+ ```
137
+
138
+ ## Transport Interface
139
+
140
+ Both `BroadcastChannelTransport` and `WebSocketTransport` implement:
141
+
142
+ ```typescript
143
+ interface SyncTransport {
144
+ send(msg: any): void; // Send message to peers
145
+ onMessage(cb: (msg) => void); // Register message handler
146
+ close(): void; // Clean up resources
147
+ readonly connected: boolean; // Connection status
148
+ }
149
+ ```
150
+
151
+ ## Sync Protocol
152
+
30
153
  ```
154
+ Lead Follower(s)
155
+ ──── ──────────
156
+ heartbeat (every 5s) → discovers peers
157
+ layout-change(layoutId, showAt) → loads layout, prepares DOM
158
+ ← layout-ready(layoutId, displayId)
159
+ (waits for all or timeout 10s)
160
+ layout-show(layoutId) → shows layout simultaneously
161
+ video-start(layoutId, regionId) → unpauses video
162
+ stats-report / logs-report ← delegates stats to lead
163
+ stats-ack / logs-ack → confirms submission
164
+ ```
165
+
166
+ ## Example: 4-screen video wall
167
+
168
+ ```
169
+ ┌─────────────┬─────────────┐
170
+ │ Screen 1 │ Screen 2 │
171
+ │ (LEAD) │ (follower) │
172
+ │ 192.168.1.10│ 192.168.1.11│
173
+ ├─────────────┼─────────────┤
174
+ │ Screen 3 │ Screen 4 │
175
+ │ (follower) │ (follower) │
176
+ │ 192.168.1.12│ 192.168.1.13│
177
+ └─────────────┴─────────────┘
178
+ ```
179
+
180
+ **CMS setup:** Create 4 displays. Set Screen 1's sync group to `lead`. Set Screens 2-4's sync group to `192.168.1.10`. Set sync publisher port to `8765` on all four.
181
+
182
+ **Screen 1 config.json:** Add `"listenAddress": "0.0.0.0"` so the proxy listens on all interfaces.
183
+
184
+ All four screens run the same Electron/Chromium player. The lead drives layout transitions; followers load content in parallel and show simultaneously when all are ready.
185
+
186
+ ## API
187
+
188
+ ### `new SyncManager(options)`
189
+
190
+ | Option | Type | Description |
191
+ |--------|------|-------------|
192
+ | `displayId` | string | This display's unique hardware key |
193
+ | `syncConfig` | SyncConfig | Sync configuration from CMS RegisterDisplay |
194
+ | `transport` | SyncTransport? | Optional pre-built transport (for testing) |
195
+ | `onLayoutChange` | Function? | Called when lead requests layout change |
196
+ | `onLayoutShow` | Function? | Called when lead gives show signal |
197
+ | `onVideoStart` | Function? | Called when lead gives video start signal |
198
+ | `onStatsReport` | Function? | (Lead) Called when follower sends stats |
199
+ | `onLogsReport` | Function? | (Lead) Called when follower sends logs |
200
+ | `onStatsAck` | Function? | (Follower) Called when lead confirms stats |
201
+ | `onLogsAck` | Function? | (Follower) Called when lead confirms logs |
202
+
203
+ ### Methods
204
+
205
+ | Method | Role | Description |
206
+ |--------|------|-------------|
207
+ | `start()` | Both | Opens transport, begins heartbeats |
208
+ | `stop()` | Both | Closes transport, clears timers |
209
+ | `requestLayoutChange(layoutId)` | Lead | Sends layout-change, waits for ready, sends show |
210
+ | `requestVideoStart(layoutId, regionId)` | Lead | Signals synchronized video start |
211
+ | `reportReady(layoutId)` | Follower | Reports layout is loaded and ready |
212
+ | `reportStats(statsXml)` | Follower | Delegates stats submission to lead |
213
+ | `reportLogs(logsXml)` | Follower | Delegates logs submission to lead |
214
+ | `getStatus()` | Both | Returns sync status including follower details |
31
215
 
32
216
  ---
33
217
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/sync",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Multi-display synchronization for Xibo Player",
5
5
  "type": "module",
6
6
  "main": "src/sync-manager.js",
@@ -12,7 +12,7 @@
12
12
  }
13
13
  },
14
14
  "dependencies": {
15
- "@xiboplayer/utils": "0.6.3"
15
+ "@xiboplayer/utils": "0.6.4"
16
16
  },
17
17
  "devDependencies": {
18
18
  "vitest": "^2.0.0"
@@ -0,0 +1,51 @@
1
+ /**
2
+ * BroadcastChannelTransport — same-machine sync transport
3
+ *
4
+ * Wraps the browser BroadcastChannel API behind the sync transport interface.
5
+ * Used for multi-tab / multi-window sync on a single device.
6
+ *
7
+ * Transport interface: { send(msg), onMessage(callback), close(), get connected() }
8
+ */
9
+
10
+ const DEFAULT_CHANNEL = 'xibo-sync';
11
+
12
+ export class BroadcastChannelTransport {
13
+ /**
14
+ * @param {string} [channelName='xibo-sync']
15
+ */
16
+ constructor(channelName = DEFAULT_CHANNEL) {
17
+ this.channel = new BroadcastChannel(channelName);
18
+ this._connected = true;
19
+ }
20
+
21
+ /**
22
+ * Send a message to all other tabs/windows on this channel.
23
+ * @param {Object} msg — plain object (structured-cloned by BroadcastChannel)
24
+ */
25
+ send(msg) {
26
+ if (!this.channel) return;
27
+ this.channel.postMessage(msg);
28
+ }
29
+
30
+ /**
31
+ * Register a callback for incoming messages.
32
+ * @param {Function} callback — receives the message data (already deserialized)
33
+ */
34
+ onMessage(callback) {
35
+ this.channel.onmessage = (e) => callback(e.data);
36
+ }
37
+
38
+ /** Close the channel. */
39
+ close() {
40
+ if (this.channel) {
41
+ this.channel.close();
42
+ this.channel = null;
43
+ }
44
+ this._connected = false;
45
+ }
46
+
47
+ /** @returns {boolean} Whether the channel is open */
48
+ get connected() {
49
+ return this._connected && !!this.channel;
50
+ }
51
+ }
package/src/index.d.ts CHANGED
@@ -1,22 +1,79 @@
1
1
  export const VERSION: string;
2
2
 
3
+ export interface SyncTransport {
4
+ send(msg: any): void;
5
+ onMessage(callback: (msg: any) => void): void;
6
+ close(): void;
7
+ readonly connected: boolean;
8
+ }
9
+
3
10
  export interface SyncConfig {
4
11
  syncGroup: string;
5
12
  syncPublisherPort: number;
6
13
  syncSwitchDelay: number;
7
14
  syncVideoPauseDelay: number;
8
15
  isLead: boolean;
16
+ relayUrl?: string;
17
+ }
18
+
19
+ export class BroadcastChannelTransport implements SyncTransport {
20
+ constructor(channelName?: string);
21
+ send(msg: any): void;
22
+ onMessage(callback: (msg: any) => void): void;
23
+ close(): void;
24
+ readonly connected: boolean;
25
+ }
26
+
27
+ export class WebSocketTransport implements SyncTransport {
28
+ constructor(url: string);
29
+ send(msg: any): void;
30
+ onMessage(callback: (msg: any) => void): void;
31
+ close(): void;
32
+ readonly connected: boolean;
9
33
  }
10
34
 
11
35
  export class SyncManager {
12
- constructor(config: SyncConfig, displayId: string);
13
- config: SyncConfig;
36
+ constructor(options: {
37
+ displayId: string;
38
+ syncConfig: SyncConfig;
39
+ transport?: SyncTransport;
40
+ onLayoutChange?: (layoutId: string, showAt: number) => void;
41
+ onLayoutShow?: (layoutId: string) => void;
42
+ onVideoStart?: (layoutId: string, regionId: string) => void;
43
+ onStatsReport?: (followerId: string, statsXml: string, ack: () => void) => void;
44
+ onLogsReport?: (followerId: string, logsXml: string, ack: () => void) => void;
45
+ onStatsAck?: (targetDisplayId: string) => void;
46
+ onLogsAck?: (targetDisplayId: string) => void;
47
+ });
48
+
49
+ displayId: string;
50
+ syncConfig: SyncConfig;
14
51
  isLead: boolean;
52
+ transport: SyncTransport | null;
53
+ /** Backward-compatible alias for transport */
54
+ channel: SyncTransport | null;
55
+ followers: Map<string, any>;
15
56
 
16
57
  start(): void;
17
58
  stop(): void;
18
- requestLayoutChange(layoutId: number, showDelay?: number): void;
19
- notifyLayoutReady(layoutId: number): void;
20
- onLayoutShow(callback: (layoutId: number) => void): void;
21
- onLayoutChange(callback: (layoutId: number) => void): void;
59
+ requestLayoutChange(layoutId: string | number): Promise<void>;
60
+ requestVideoStart(layoutId: string | number, regionId: string): Promise<void>;
61
+ reportReady(layoutId: string | number): void;
62
+ reportStats(statsXml: string): void;
63
+ reportLogs(logsXml: string): void;
64
+ getStatus(): {
65
+ started: boolean;
66
+ isLead: boolean;
67
+ displayId: string;
68
+ followers: number;
69
+ pendingLayoutId: string | null;
70
+ transport: 'websocket' | 'broadcast-channel';
71
+ followerDetails: Array<{
72
+ displayId: string;
73
+ lastSeen: number;
74
+ ready: boolean;
75
+ readyLayoutId: string | null;
76
+ stale: boolean;
77
+ }>;
78
+ };
22
79
  }
@@ -1,8 +1,9 @@
1
1
  /**
2
- * SyncManager - Multi-display synchronization via BroadcastChannel
2
+ * SyncManager - Multi-display synchronization
3
3
  *
4
4
  * Coordinates layout transitions across multiple browser tabs/windows
5
- * on the same machine (video wall, multi-monitor setups).
5
+ * (same machine via BroadcastChannel) or across devices on a LAN
6
+ * (via WebSocket relay on the lead's proxy server).
6
7
  *
7
8
  * Protocol:
8
9
  * Lead Follower(s)
@@ -17,21 +18,28 @@
17
18
  * Lead tracks active followers. If a follower goes silent for 15s,
18
19
  * it's considered offline and excluded from ready-wait.
19
20
  *
21
+ * Transport:
22
+ * Pluggable — BroadcastChannelTransport (same-machine) or
23
+ * WebSocketTransport (cross-device via relay). Selected automatically
24
+ * based on syncConfig.relayUrl.
25
+ *
20
26
  * @module @xiboplayer/sync
21
27
  */
22
28
 
23
29
  /**
24
30
  * @typedef {Object} SyncConfig
25
31
  * @property {string} syncGroup - "lead" or leader's LAN IP
26
- * @property {number} syncPublisherPort - TCP port (unused in browser, kept for compat)
32
+ * @property {number} syncPublisherPort - TCP port (used for WebSocket relay URL)
27
33
  * @property {number} syncSwitchDelay - Delay in ms before showing new content
28
34
  * @property {number} syncVideoPauseDelay - Delay in ms before unpausing video
29
35
  * @property {boolean} isLead - Whether this display is the leader
36
+ * @property {string} [relayUrl] - WebSocket relay URL for cross-device sync
30
37
  */
31
38
 
32
39
  import { createLogger } from '@xiboplayer/utils';
40
+ import { BroadcastChannelTransport } from './bc-transport.js';
41
+ import { WebSocketTransport } from './ws-transport.js';
33
42
 
34
- const CHANNEL_NAME = 'xibo-sync';
35
43
  const HEARTBEAT_INTERVAL = 5000; // Send heartbeat every 5s
36
44
  const FOLLOWER_TIMEOUT = 15000; // Consider follower offline after 15s silence
37
45
  const READY_TIMEOUT = 10000; // Max wait for followers to be ready
@@ -41,6 +49,7 @@ export class SyncManager {
41
49
  * @param {Object} options
42
50
  * @param {string} options.displayId - This display's unique hardware key
43
51
  * @param {SyncConfig} options.syncConfig - Sync configuration from RegisterDisplay
52
+ * @param {Object} [options.transport] - Optional pre-built transport (for testing)
44
53
  * @param {Function} [options.onLayoutChange] - Called when lead requests layout change
45
54
  * @param {Function} [options.onLayoutShow] - Called when lead gives show signal
46
55
  * @param {Function} [options.onVideoStart] - Called when lead gives video start signal
@@ -66,7 +75,7 @@ export class SyncManager {
66
75
  this.onLogsAck = options.onLogsAck || null;
67
76
 
68
77
  // State
69
- this.channel = null;
78
+ this.transport = options.transport || null;
70
79
  this.followers = new Map(); // displayId → { lastSeen, ready }
71
80
  this._heartbeatTimer = null;
72
81
  this._cleanupTimer = null;
@@ -79,20 +88,30 @@ export class SyncManager {
79
88
  this._log = createLogger(this.isLead ? 'Sync:LEAD' : 'Sync:FOLLOW');
80
89
  }
81
90
 
91
+ /** Backward-compatible alias for transport */
92
+ get channel() { return this.transport; }
93
+ set channel(v) { this.transport = v; }
94
+
82
95
  /**
83
- * Start the sync manager (opens BroadcastChannel, begins heartbeats)
96
+ * Start the sync manager selects transport, begins heartbeats.
84
97
  */
85
98
  start() {
86
99
  if (this._started) return;
87
100
  this._started = true;
88
101
 
89
- if (typeof BroadcastChannel === 'undefined') {
90
- this._log.warn( 'BroadcastChannel not available — sync disabled');
91
- return;
102
+ // Select transport if none injected
103
+ if (!this.transport) {
104
+ if (this.syncConfig.relayUrl) {
105
+ this.transport = new WebSocketTransport(this.syncConfig.relayUrl);
106
+ } else if (typeof BroadcastChannel !== 'undefined') {
107
+ this.transport = new BroadcastChannelTransport();
108
+ } else {
109
+ this._log.warn('No transport available — sync disabled');
110
+ return;
111
+ }
92
112
  }
93
113
 
94
- this.channel = new BroadcastChannel(CHANNEL_NAME);
95
- this.channel.onmessage = (event) => this._handleMessage(event.data);
114
+ this.transport.onMessage((msg) => this._handleMessage(msg));
96
115
 
97
116
  // Start heartbeat
98
117
  this._heartbeatTimer = setInterval(() => this._sendHeartbeat(), HEARTBEAT_INTERVAL);
@@ -103,7 +122,8 @@ export class SyncManager {
103
122
  this._cleanupTimer = setInterval(() => this._cleanupStaleFollowers(), HEARTBEAT_INTERVAL);
104
123
  }
105
124
 
106
- this._log.info( 'Started. DisplayId:', this.displayId);
125
+ this._log.info('Started. DisplayId:', this.displayId,
126
+ this.syncConfig.relayUrl ? `(relay: ${this.syncConfig.relayUrl})` : '(BroadcastChannel)');
107
127
  }
108
128
 
109
129
  /**
@@ -121,13 +141,13 @@ export class SyncManager {
121
141
  clearInterval(this._cleanupTimer);
122
142
  this._cleanupTimer = null;
123
143
  }
124
- if (this.channel) {
125
- this.channel.close();
126
- this.channel = null;
144
+ if (this.transport) {
145
+ this.transport.close();
146
+ this.transport = null;
127
147
  }
128
148
 
129
149
  this.followers.clear();
130
- this._log.info( 'Stopped');
150
+ this._log.info('Stopped');
131
151
  }
132
152
 
133
153
  // ── Lead API ──────────────────────────────────────────────────────
@@ -141,7 +161,7 @@ export class SyncManager {
141
161
  */
142
162
  async requestLayoutChange(layoutId) {
143
163
  if (!this.isLead) {
144
- this._log.warn( 'requestLayoutChange called on follower — ignoring');
164
+ this._log.warn('requestLayoutChange called on follower — ignoring');
145
165
  return;
146
166
  }
147
167
 
@@ -156,7 +176,7 @@ export class SyncManager {
156
176
 
157
177
  const showAt = Date.now() + this.switchDelay;
158
178
 
159
- this._log.info( `Requesting layout change: ${layoutId} (show at ${new Date(showAt).toISOString()}, ${this.followers.size} followers)`);
179
+ this._log.info(`Requesting layout change: ${layoutId} (show at ${new Date(showAt).toISOString()}, ${this.followers.size} followers)`);
160
180
 
161
181
  // Broadcast layout-change to all followers
162
182
  this._send({
@@ -178,7 +198,7 @@ export class SyncManager {
178
198
  }
179
199
 
180
200
  // Send show signal
181
- this._log.info( `Sending layout-show: ${layoutId}`);
201
+ this._log.info(`Sending layout-show: ${layoutId}`);
182
202
  this._send({
183
203
  type: 'layout-show',
184
204
  layoutId,
@@ -225,7 +245,7 @@ export class SyncManager {
225
245
  reportReady(layoutId) {
226
246
  layoutId = String(layoutId);
227
247
 
228
- this._log.info( `Reporting ready for layout ${layoutId}`);
248
+ this._log.info(`Reporting ready for layout ${layoutId}`);
229
249
 
230
250
  this._send({
231
251
  type: 'layout-ready',
@@ -283,7 +303,7 @@ export class SyncManager {
283
303
  case 'layout-change':
284
304
  // Follower: lead is requesting a layout change
285
305
  if (!this.isLead) {
286
- this._log.info( `Layout change requested: ${msg.layoutId}`);
306
+ this._log.info(`Layout change requested: ${msg.layoutId}`);
287
307
  this.onLayoutChange(msg.layoutId, msg.showAt);
288
308
  }
289
309
  break;
@@ -298,7 +318,7 @@ export class SyncManager {
298
318
  case 'layout-show':
299
319
  // Follower: lead says show now
300
320
  if (!this.isLead) {
301
- this._log.info( `Layout show signal: ${msg.layoutId}`);
321
+ this._log.info(`Layout show signal: ${msg.layoutId}`);
302
322
  this.onLayoutShow(msg.layoutId);
303
323
  }
304
324
  break;
@@ -306,7 +326,7 @@ export class SyncManager {
306
326
  case 'video-start':
307
327
  // Follower: lead says start video
308
328
  if (!this.isLead) {
309
- this._log.info( `Video start signal: ${msg.layoutId} region ${msg.regionId}`);
329
+ this._log.info(`Video start signal: ${msg.layoutId} region ${msg.regionId}`);
310
330
  this.onVideoStart(msg.layoutId, msg.regionId);
311
331
  }
312
332
  break;
@@ -344,7 +364,7 @@ export class SyncManager {
344
364
  break;
345
365
 
346
366
  default:
347
- this._log.warn( 'Unknown message type:', msg.type);
367
+ this._log.warn('Unknown message type:', msg.type);
348
368
  }
349
369
  }
350
370
 
@@ -361,7 +381,7 @@ export class SyncManager {
361
381
  readyLayoutId: null,
362
382
  role: msg.role || 'unknown',
363
383
  });
364
- this._log.info( `Follower joined: ${msg.displayId} (${this.followers.size} total)`);
384
+ this._log.info(`Follower joined: ${msg.displayId} (${this.followers.size} total)`);
365
385
  }
366
386
  }
367
387
 
@@ -381,12 +401,12 @@ export class SyncManager {
381
401
  follower.lastSeen = Date.now();
382
402
  }
383
403
 
384
- this._log.info( `Follower ${msg.displayId} ready for layout ${msg.layoutId}`);
404
+ this._log.info(`Follower ${msg.displayId} ready for layout ${msg.layoutId}`);
385
405
 
386
406
  // Check if all followers are now ready
387
407
  if (this._pendingLayoutId === msg.layoutId && this._readyResolve) {
388
408
  if (this._allFollowersReady(msg.layoutId)) {
389
- this._log.info( 'All followers ready');
409
+ this._log.info('All followers ready');
390
410
  this._readyResolve();
391
411
  this._readyResolve = null;
392
412
  }
@@ -425,7 +445,7 @@ export class SyncManager {
425
445
  notReady.push(id);
426
446
  }
427
447
  }
428
- this._log.warn( `Ready timeout — proceeding without: ${notReady.join(', ')}`);
448
+ this._log.warn(`Ready timeout — proceeding without: ${notReady.join(', ')}`);
429
449
  this._readyResolve = null;
430
450
  resolve();
431
451
  }
@@ -450,7 +470,7 @@ export class SyncManager {
450
470
  const now = Date.now();
451
471
  for (const [id, follower] of this.followers) {
452
472
  if (now - follower.lastSeen > FOLLOWER_TIMEOUT) {
453
- this._log.info( `Removing stale follower: ${id} (last seen ${Math.round((now - follower.lastSeen) / 1000)}s ago)`);
473
+ this._log.info(`Removing stale follower: ${id} (last seen ${Math.round((now - follower.lastSeen) / 1000)}s ago)`);
454
474
  this.followers.delete(id);
455
475
  }
456
476
  }
@@ -458,11 +478,11 @@ export class SyncManager {
458
478
 
459
479
  /** @private */
460
480
  _send(msg) {
461
- if (!this.channel) return;
481
+ if (!this.transport) return;
462
482
  try {
463
- this.channel.postMessage(msg);
483
+ this.transport.send(msg);
464
484
  } catch (e) {
465
- this._log.error( 'Failed to send:', e);
485
+ this._log.error('Failed to send:', e);
466
486
  }
467
487
  }
468
488
 
@@ -479,6 +499,7 @@ export class SyncManager {
479
499
  displayId: this.displayId,
480
500
  followers: this.followers.size,
481
501
  pendingLayoutId: this._pendingLayoutId,
502
+ transport: this.syncConfig.relayUrl ? 'websocket' : 'broadcast-channel',
482
503
  followerDetails: Array.from(this.followers.entries()).map(([id, f]) => ({
483
504
  displayId: id,
484
505
  lastSeen: f.lastSeen,
@@ -489,3 +510,6 @@ export class SyncManager {
489
510
  };
490
511
  }
491
512
  }
513
+
514
+ export { BroadcastChannelTransport } from './bc-transport.js';
515
+ export { WebSocketTransport } from './ws-transport.js';
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * SyncManager unit tests
3
3
  *
4
- * Tests multi-display sync coordination via BroadcastChannel.
5
- * Uses a simple BroadcastChannel mock for Node.js environment.
4
+ * Tests multi-display sync coordination via pluggable transports.
5
+ * Uses a simple BroadcastChannel mock for the default transport path
6
+ * and a mock transport for the transport-injection path.
6
7
  */
7
8
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
8
- import { SyncManager } from './sync-manager.js';
9
+ import { SyncManager, BroadcastChannelTransport, WebSocketTransport } from './sync-manager.js';
9
10
 
10
11
  // ── BroadcastChannel mock ──────────────────────────────────────────
11
12
  // Simulates same-origin message passing between instances
@@ -51,6 +52,24 @@ class MockBroadcastChannel {
51
52
  // Install mock globally
52
53
  globalThis.BroadcastChannel = MockBroadcastChannel;
53
54
 
55
+ // ── Mock Transport (for transport-injection tests) ──────────────────
56
+ class MockTransport {
57
+ constructor() {
58
+ this._callback = null;
59
+ this._sent = [];
60
+ this._connected = true;
61
+ }
62
+ send(msg) { this._sent.push(msg); }
63
+ onMessage(callback) { this._callback = callback; }
64
+ close() { this._connected = false; }
65
+ get connected() { return this._connected; }
66
+
67
+ /** Simulate receiving a message from remote */
68
+ _receive(msg) {
69
+ if (this._callback) this._callback(msg);
70
+ }
71
+ }
72
+
54
73
  // ── Helper to flush microtasks ──────────────────────────────────────
55
74
  const tick = (ms = 10) => new Promise(r => setTimeout(r, ms));
56
75
 
@@ -100,7 +119,7 @@ describe('SyncManager', () => {
100
119
  expect(follower1.isLead).toBe(false);
101
120
  });
102
121
 
103
- it('should start and open BroadcastChannel', () => {
122
+ it('should start and open BroadcastChannel transport', () => {
104
123
  lead = new SyncManager({
105
124
  displayId: 'pwa-lead',
106
125
  syncConfig: makeSyncConfig(true),
@@ -108,10 +127,11 @@ describe('SyncManager', () => {
108
127
  lead.start();
109
128
 
110
129
  expect(lead.channel).not.toBeNull();
130
+ expect(lead.transport).not.toBeNull();
111
131
  expect(lead.getStatus().started).toBe(true);
112
132
  });
113
133
 
114
- it('should stop and close BroadcastChannel', () => {
134
+ it('should stop and close transport', () => {
115
135
  lead = new SyncManager({
116
136
  displayId: 'pwa-lead',
117
137
  syncConfig: makeSyncConfig(true),
@@ -120,10 +140,73 @@ describe('SyncManager', () => {
120
140
  lead.stop();
121
141
 
122
142
  expect(lead.channel).toBeNull();
143
+ expect(lead.transport).toBeNull();
123
144
  expect(lead.getStatus().started).toBe(false);
124
145
  });
125
146
  });
126
147
 
148
+ describe('Transport injection', () => {
149
+ it('should use injected transport instead of BroadcastChannel', () => {
150
+ const transport = new MockTransport();
151
+ lead = new SyncManager({
152
+ displayId: 'pwa-lead',
153
+ syncConfig: makeSyncConfig(true),
154
+ transport,
155
+ });
156
+ lead.start();
157
+
158
+ expect(lead.transport).toBe(transport);
159
+ // Should have sent initial heartbeat
160
+ expect(transport._sent.length).toBe(1);
161
+ expect(transport._sent[0].type).toBe('heartbeat');
162
+ });
163
+
164
+ it('should handle messages from injected transport', () => {
165
+ const onLayoutChange = vi.fn();
166
+ const transport = new MockTransport();
167
+
168
+ follower1 = new SyncManager({
169
+ displayId: 'pwa-f1',
170
+ syncConfig: makeSyncConfig(false),
171
+ transport,
172
+ onLayoutChange,
173
+ });
174
+ follower1.start();
175
+
176
+ // Simulate a layout-change message from lead
177
+ transport._receive({
178
+ type: 'layout-change',
179
+ layoutId: '42',
180
+ showAt: Date.now() + 1000,
181
+ displayId: 'pwa-lead',
182
+ });
183
+
184
+ expect(onLayoutChange).toHaveBeenCalledWith('42', expect.any(Number));
185
+ });
186
+
187
+ it('should report websocket transport type in status when relayUrl set', () => {
188
+ const transport = new MockTransport();
189
+ lead = new SyncManager({
190
+ displayId: 'pwa-lead',
191
+ syncConfig: { ...makeSyncConfig(true), relayUrl: 'ws://localhost:8765/sync' },
192
+ transport,
193
+ });
194
+ lead.start();
195
+
196
+ expect(lead.getStatus().transport).toBe('websocket');
197
+ });
198
+
199
+ it('should report broadcast-channel transport type by default', () => {
200
+ lead = new SyncManager({
201
+ displayId: 'pwa-lead',
202
+ syncConfig: makeSyncConfig(true),
203
+ });
204
+ lead.start();
205
+
206
+ expect(lead.getStatus().transport).toBe('broadcast-channel');
207
+ });
208
+ });
209
+
127
210
  describe('Heartbeat', () => {
128
211
  it('should discover followers via heartbeat', async () => {
129
212
  lead = new SyncManager({
@@ -0,0 +1,125 @@
1
+ /**
2
+ * WebSocketTransport — cross-device sync transport
3
+ *
4
+ * Connects to the lead player's proxy WebSocket relay at /sync.
5
+ * Used for LAN video walls where each screen is a separate device.
6
+ *
7
+ * Features:
8
+ * - Auto-reconnect with exponential backoff (1s → 2s → 4s → max 30s)
9
+ * - JSON serialization (WebSocket sends strings, not structured clones)
10
+ * - Same transport interface as BroadcastChannelTransport
11
+ *
12
+ * Transport interface: { send(msg), onMessage(callback), close(), get connected() }
13
+ */
14
+
15
+ import { createLogger } from '@xiboplayer/utils';
16
+
17
+ const INITIAL_RETRY_MS = 1000;
18
+ const MAX_RETRY_MS = 30000;
19
+ const BACKOFF_FACTOR = 2;
20
+
21
+ export class WebSocketTransport {
22
+ /**
23
+ * @param {string} url — WebSocket URL, e.g. ws://192.168.1.100:8765/sync
24
+ */
25
+ constructor(url) {
26
+ this._url = url;
27
+ this._callback = null;
28
+ this._closed = false;
29
+ this._retryMs = INITIAL_RETRY_MS;
30
+ this._retryTimer = null;
31
+ this._log = createLogger('WS-Sync');
32
+ this.ws = null;
33
+
34
+ this._connect();
35
+ }
36
+
37
+ /**
38
+ * Send a message to the relay (which broadcasts to other clients).
39
+ * @param {Object} msg — plain object (JSON-serialized for WebSocket)
40
+ */
41
+ send(msg) {
42
+ if (this.ws?.readyState === WebSocket.OPEN) {
43
+ this.ws.send(JSON.stringify(msg));
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Register a callback for incoming messages.
49
+ * @param {Function} callback — receives the parsed message object
50
+ */
51
+ onMessage(callback) {
52
+ this._callback = callback;
53
+ }
54
+
55
+ /** Close the connection and stop reconnecting. */
56
+ close() {
57
+ this._closed = true;
58
+ if (this._retryTimer) {
59
+ clearTimeout(this._retryTimer);
60
+ this._retryTimer = null;
61
+ }
62
+ if (this.ws) {
63
+ this.ws.close();
64
+ this.ws = null;
65
+ }
66
+ }
67
+
68
+ /** @returns {boolean} Whether the WebSocket is open */
69
+ get connected() {
70
+ return this.ws?.readyState === WebSocket.OPEN;
71
+ }
72
+
73
+ /** @private */
74
+ _connect() {
75
+ if (this._closed) return;
76
+
77
+ try {
78
+ this.ws = new WebSocket(this._url);
79
+ } catch (e) {
80
+ this._log.error('WebSocket creation failed:', e.message);
81
+ this._scheduleReconnect();
82
+ return;
83
+ }
84
+
85
+ this.ws.onopen = () => {
86
+ this._log.info(`Connected to ${this._url}`);
87
+ this._retryMs = INITIAL_RETRY_MS; // Reset backoff on success
88
+ };
89
+
90
+ this.ws.onmessage = (event) => {
91
+ if (!this._callback) return;
92
+ try {
93
+ const msg = JSON.parse(event.data);
94
+ this._callback(msg);
95
+ } catch (e) {
96
+ this._log.warn('Failed to parse message:', e.message);
97
+ }
98
+ };
99
+
100
+ this.ws.onclose = () => {
101
+ if (!this._closed) {
102
+ this._log.info('Connection closed — will reconnect');
103
+ this._scheduleReconnect();
104
+ }
105
+ };
106
+
107
+ this.ws.onerror = (e) => {
108
+ // onclose will fire after onerror, triggering reconnect
109
+ this._log.warn('WebSocket error');
110
+ };
111
+ }
112
+
113
+ /** @private */
114
+ _scheduleReconnect() {
115
+ if (this._closed || this._retryTimer) return;
116
+
117
+ this._log.info(`Reconnecting in ${this._retryMs}ms...`);
118
+ this._retryTimer = setTimeout(() => {
119
+ this._retryTimer = null;
120
+ this._connect();
121
+ }, this._retryMs);
122
+
123
+ this._retryMs = Math.min(this._retryMs * BACKOFF_FACTOR, MAX_RETRY_MS);
124
+ }
125
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'node',
6
+ globals: true,
7
+ },
8
+ });