Distributed Playback
Synchronized playback across multiple clients: watch parties, group listening, or any scenario where multiple users or devices must play the same content in lockstep.
Status note: The NoMercy Player SDK’s first-class group listening feature (planned for v2.1) is not yet released.
This guide covers the architectural patterns you can implement today using existing player core primitives.
The v2.1 GroupListeningPlugin will replace the manual setup here.
The core problem
Two clients playing the same HLS stream independently will drift apart. Sources of drift:
- Network buffering differences (one client buffers, the other doesn’t)
- Clock skew between devices (system clocks are rarely identical)
- Seek latency (seeking takes different amounts of time on different hardware)
- Tab visibility and OS throttling (background tabs get fewer CPU cycles)
A robust sync system must measure drift continuously and apply small corrections.
Architecture: leader + followers
The simplest distributed playback model is a single leader that broadcasts state, and N followers that apply it:
Leader: Followers:
- owns the clock - receive state from leader
- emits play/pause/seek - apply corrections
- measures own position - report their own position (optional)
- sends periodic sync - detect significant drift
Implementing with SignalR (or WebSocket)
Wire the player to a realtime channel via the player core’s realtime adapter:
import { nmplayer } from '@nomercy-entertainment/nomercy-video-player';
import { DesktopUiPlugin } from '@nomercy-entertainment/nomercy-video-player/plugins';
const player = nmplayer('main').setup({
baseUrl: 'https://raw.githubusercontent.com/NoMercy-Entertainment/nomercy-media/master/Films',
playlist: [
{
id: 'sintel',
url: '/Sintel.(2010)/Sintel.(2010).NoMercy.m3u8',
},
],
// websocketFactory defaults to the built-in nativeWebSocketAdapter, which returns an IRealtimeChannel.
// A custom factory must also return an IRealtimeChannel, not a raw WebSocket.
});
player.addPlugin(DesktopUiPlugin);
await player.ready();
Connect to the sync channel and apply state:
const ws = new WebSocket('wss://api.example.com/watch-party/room-42');
ws.onmessage = (event) => {
const msg: SyncMessage = JSON.parse(event.data);
if (msg.type === 'play') {
// Sync position before playing to account for message latency:
player.time(msg.serverPosition + estimateLatencySeconds());
player.play();
}
if (msg.type === 'pause') {
player.time(msg.serverPosition);
player.pause();
}
if (msg.type === 'seek') {
player.time(msg.serverPosition);
}
if (msg.type === 'sync-tick') {
// Periodic correction: if drift exceeds threshold, correct:
const localPosition = player.time();
const drift = localPosition - msg.serverPosition;
if (Math.abs(drift) > DRIFT_CORRECTION_THRESHOLD_SECONDS) {
player.time(msg.serverPosition);
}
}
};
Drift correction
Continuous correction without visible seeks:
const DRIFT_THRESHOLD = 0.5; // seconds, correct if drift > 500ms
const MAX_RATE_CORRECTION = 0.1; // slow/speed up by 10% max
function applyDriftCorrection(localTime: number, serverTime: number): void {
const drift = localTime - serverTime;
if (Math.abs(drift) > 2.0) {
// Drift too large, hard seek:
player.time(serverTime);
return;
}
if (Math.abs(drift) > DRIFT_THRESHOLD) {
// Small drift, adjust playback rate to catch up gradually:
const correction = Math.max(-MAX_RATE_CORRECTION, Math.min(MAX_RATE_CORRECTION, -drift * 0.5));
player.playbackRate(1.0 + correction);
} else {
// Within tolerance, restore normal rate:
player.playbackRate(1.0);
}
}
Trigger correction on periodic sync ticks from the server (every 2–5 seconds is typical).
Clock synchronization
System clocks differ between clients. Measure the offset before using server timestamps:
async function measureClockOffset(serverUrl: string): Promise<number> {
const t0 = Date.now();
const response = await fetch(`${serverUrl}/clock`);
const t1 = Date.now();
const serverTime: number = await response.json(); // server's Unix timestamp in ms
// RTT / 2 is the one-way latency estimate:
const latency = (t1 - t0) / 2;
const clockOffset = serverTime + latency - t1;
return clockOffset; // add this to Date.now() to get estimated server time
}
const clockOffset = await measureClockOffset('https://api.example.com');
function serverNow(): number {
return Date.now() + clockOffset;
}
Tab leader coordination
When a single user has the same player open in multiple tabs (e.g. they navigated away and opened a new tab), elect a leader so only one tab is “canonical”:
const LEADER_CHANNEL = new BroadcastChannel('player-leader-election');
let isLeader = false;
let leaderHeartbeatTimer: ReturnType<typeof setInterval>;
function claimLeadership(): void {
LEADER_CHANNEL.postMessage({ type: 'i-am-leader', tabId: TAB_ID });
isLeader = true;
// Broadcast a heartbeat so followers know the leader is alive:
leaderHeartbeatTimer = setInterval(() => {
LEADER_CHANNEL.postMessage({ type: 'leader-heartbeat', tabId: TAB_ID });
}, 1000);
}
LEADER_CHANNEL.onmessage = (event) => {
if (event.data.type === 'i-am-leader' && event.data.tabId !== TAB_ID) {
// Another tab claimed leadership first, so we become a follower:
isLeader = false;
clearInterval(leaderHeartbeatTimer);
}
};
// Attempt to claim leadership on load:
claimLeadership();
Roadmap: a group-listening plugin
A first-class group-listening plugin is roadmapped for v2.1 to replace the manual coordination above. It is not available in the video player yet — build on the manual patterns in this guide for now. They stay valid once the plugin lands.
What to read next
- Advanced: Custom Plugin, watch-party-lite plugin for same-device sync
- Advanced: Multi-Instance, multiple players on one page
- Core: Auth and Fetch, securing WebSocket connections with bearer tokens