User A Signaling Server User B
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Jellyfin │ │ Room State │ │ Jellyfin │
│ Instance (4K) │ │ WebSocket Hub │ │ Instance (4K) │
│ │◄──sync─────│ │─────sync►│ │
│ SameRow Client │ │ Clock Sync │ │ SameRow Client │
│ (WebRTC) │◄──p2p─────────────────────────────p2p───►│ (WebRTC) │
└─────────────────┘ └──────────────────┘ └─────────────────┘ │ │ │ │
Cloudflare Tunnel Cloudflare Tunnel
(CGNAT bypass) (CGNAT bypass)
User A Signaling Server User B
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Jellyfin │ │ Room State │ │ Jellyfin │
│ Instance (4K) │ │ WebSocket Hub │ │ Instance (4K) │
│ │◄──sync─────│ │─────sync►│ │
│ SameRow Client │ │ Clock Sync │ │ SameRow Client │
│ (WebRTC) │◄──p2p─────────────────────────────p2p───►│ (WebRTC) │
└─────────────────┘ └──────────────────┘ └─────────────────┘ │ │ │ │
Cloudflare Tunnel Cloudflare Tunnel
(CGNAT bypass) (CGNAT bypass)
User A Signaling Server User B
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Jellyfin │ │ Room State │ │ Jellyfin │
│ Instance (4K) │ │ WebSocket Hub │ │ Instance (4K) │
│ │◄──sync─────│ │─────sync►│ │
│ SameRow Client │ │ Clock Sync │ │ SameRow Client │
│ (WebRTC) │◄──p2p─────────────────────────────p2p───►│ (WebRTC) │
└─────────────────┘ └──────────────────┘ └─────────────────┘ │ │ │ │
Cloudflare Tunnel Cloudflare Tunnel
(CGNAT bypass) (CGNAT bypass)
# Install cloudflared
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o cloudflared
chmod +x cloudflared # Authenticate and create tunnel
./cloudflared tunnel login
./cloudflared tunnel create samerow-jellyfin # Configure the tunnel
cat > ~/.cloudflared/config.yml << EOF
tunnel: <YOUR_TUNNEL_ID>
credentials-file: /root/.cloudflared/<YOUR_TUNNEL_ID>.json ingress: - hostname: jellyfin.yourdomain.com service: http://localhost:8096 - service: http_status:404
EOF # Run as service
./cloudflared tunnel run samerow-jellyfin
# Install cloudflared
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o cloudflared
chmod +x cloudflared # Authenticate and create tunnel
./cloudflared tunnel login
./cloudflared tunnel create samerow-jellyfin # Configure the tunnel
cat > ~/.cloudflared/config.yml << EOF
tunnel: <YOUR_TUNNEL_ID>
credentials-file: /root/.cloudflared/<YOUR_TUNNEL_ID>.json ingress: - hostname: jellyfin.yourdomain.com service: http://localhost:8096 - service: http_status:404
EOF # Run as service
./cloudflared tunnel run samerow-jellyfin
# Install cloudflared
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 -o cloudflared
chmod +x cloudflared # Authenticate and create tunnel
./cloudflared tunnel login
./cloudflared tunnel create samerow-jellyfin # Configure the tunnel
cat > ~/.cloudflared/config.yml << EOF
tunnel: <YOUR_TUNNEL_ID>
credentials-file: /root/.cloudflared/<YOUR_TUNNEL_ID>.json ingress: - hostname: jellyfin.yourdomain.com service: http://localhost:8096 - service: http_status:404
EOF # Run as service
./cloudflared tunnel run samerow-jellyfin
# Install Tailscale
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up # Signaling server is now accessible at the Tailscale IP
# No public exposure needed for the coordination layer
# Install Tailscale
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up # Signaling server is now accessible at the Tailscale IP
# No public exposure needed for the coordination layer
# Install Tailscale
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up # Signaling server is now accessible at the Tailscale IP
# No public exposure needed for the coordination layer
// server/index.js
const express = require('express')
const { createServer } = require('http')
const { Server } = require('socket.io') const app = express()
const httpServer = createServer(app)
const io = new Server(httpServer, { cors: { origin: '*' }
}) // Room state store
const rooms = new Map() io.on('connection', (socket) => { console.log(`Client connected: ${socket.id}`) // Room creation socket.on('create-room', ({ roomId, jellyfinUrl }) => { rooms.set(roomId, { host: socket.id, jellyfinUrl, playbackState: { isPlaying: false, currentTime: 0, itemId: null, lastUpdated: Date.now() }, clients: new Set([socket.id]) }) socket.join(roomId) socket.emit('room-created', { roomId }) }) // Room joining socket.on('join-room', ({ roomId }) => { const room = rooms.get(roomId) if (!room) return socket.emit('error', { message: 'Room not found' }) room.clients.add(socket.id) socket.join(roomId) // Send current state to joining client socket.emit('room-state', room.playbackState) socket.to(roomId).emit('peer-joined', { peerId: socket.id }) }) // Playback state sync socket.on('playback-update', ({ roomId, state }) => { const room = rooms.get(roomId) if (!room) return // Only host can update state // (prevents feedback loops from multiple simultaneous updates) if (socket.id !== room.host) return room.playbackState = { ...state, lastUpdated: Date.now() } socket.to(roomId).emit('playback-sync', room.playbackState) }) socket.on('disconnect', () => { rooms.forEach((room, roomId) => { room.clients.delete(socket.id) if (room.clients.size === 0) rooms.delete(roomId) }) })
}) httpServer.listen(3001)
// server/index.js
const express = require('express')
const { createServer } = require('http')
const { Server } = require('socket.io') const app = express()
const httpServer = createServer(app)
const io = new Server(httpServer, { cors: { origin: '*' }
}) // Room state store
const rooms = new Map() io.on('connection', (socket) => { console.log(`Client connected: ${socket.id}`) // Room creation socket.on('create-room', ({ roomId, jellyfinUrl }) => { rooms.set(roomId, { host: socket.id, jellyfinUrl, playbackState: { isPlaying: false, currentTime: 0, itemId: null, lastUpdated: Date.now() }, clients: new Set([socket.id]) }) socket.join(roomId) socket.emit('room-created', { roomId }) }) // Room joining socket.on('join-room', ({ roomId }) => { const room = rooms.get(roomId) if (!room) return socket.emit('error', { message: 'Room not found' }) room.clients.add(socket.id) socket.join(roomId) // Send current state to joining client socket.emit('room-state', room.playbackState) socket.to(roomId).emit('peer-joined', { peerId: socket.id }) }) // Playback state sync socket.on('playback-update', ({ roomId, state }) => { const room = rooms.get(roomId) if (!room) return // Only host can update state // (prevents feedback loops from multiple simultaneous updates) if (socket.id !== room.host) return room.playbackState = { ...state, lastUpdated: Date.now() } socket.to(roomId).emit('playback-sync', room.playbackState) }) socket.on('disconnect', () => { rooms.forEach((room, roomId) => { room.clients.delete(socket.id) if (room.clients.size === 0) rooms.delete(roomId) }) })
}) httpServer.listen(3001)
// server/index.js
const express = require('express')
const { createServer } = require('http')
const { Server } = require('socket.io') const app = express()
const httpServer = createServer(app)
const io = new Server(httpServer, { cors: { origin: '*' }
}) // Room state store
const rooms = new Map() io.on('connection', (socket) => { console.log(`Client connected: ${socket.id}`) // Room creation socket.on('create-room', ({ roomId, jellyfinUrl }) => { rooms.set(roomId, { host: socket.id, jellyfinUrl, playbackState: { isPlaying: false, currentTime: 0, itemId: null, lastUpdated: Date.now() }, clients: new Set([socket.id]) }) socket.join(roomId) socket.emit('room-created', { roomId }) }) // Room joining socket.on('join-room', ({ roomId }) => { const room = rooms.get(roomId) if (!room) return socket.emit('error', { message: 'Room not found' }) room.clients.add(socket.id) socket.join(roomId) // Send current state to joining client socket.emit('room-state', room.playbackState) socket.to(roomId).emit('peer-joined', { peerId: socket.id }) }) // Playback state sync socket.on('playback-update', ({ roomId, state }) => { const room = rooms.get(roomId) if (!room) return // Only host can update state // (prevents feedback loops from multiple simultaneous updates) if (socket.id !== room.host) return room.playbackState = { ...state, lastUpdated: Date.now() } socket.to(roomId).emit('playback-sync', room.playbackState) }) socket.on('disconnect', () => { rooms.forEach((room, roomId) => { room.clients.delete(socket.id) if (room.clients.size === 0) rooms.delete(roomId) }) })
}) httpServer.listen(3001)
// client/webrtc.js
class SameRowPeer { constructor(socket, roomId) { this.socket = socket this.roomId = roomId this.peers = new Map() } async initializeMedia() { // Get camera and microphone this.localStream = await navigator.mediaDevices.getUserMedia({ video: { width: 1280, height: 720 }, audio: true }) return this.localStream } async startScreenShare() { // Capture display — this is traditional screen sharing // but in SameRow it's used for the UI overlay, not the media this.screenStream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true }) return this.screenStream } async createPeerConnection(peerId) { const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, // Add TURN server here for production ] }) // Add local tracks this.localStream.getTracks().forEach(track => { pc.addTrack(track, this.localStream) }) // ICE candidate handling pc.onicecandidate = ({ candidate }) => { if (candidate) { this.socket.emit('ice-candidate', { roomId: this.roomId, peerId, candidate }) } } // Handle incoming tracks pc.ontrack = ({ streams }) => { const remoteVideo = document.getElementById('remote-video') remoteVideo.srcObject = streams[0] } this.peers.set(peerId, pc) return pc } async makeOffer(peerId) { const pc = await this.createPeerConnection(peerId) const offer = await pc.createOffer() await pc.setLocalDescription(offer) this.socket.emit('webrtc-offer', { roomId: this.roomId, peerId, offer }) } async handleOffer(peerId, offer) { const pc = await this.createPeerConnection(peerId) await pc.setRemoteDescription(offer) const answer = await pc.createAnswer() await pc.setLocalDescription(answer) this.socket.emit('webrtc-answer', { roomId: this.roomId, peerId, answer }) }
}
// client/webrtc.js
class SameRowPeer { constructor(socket, roomId) { this.socket = socket this.roomId = roomId this.peers = new Map() } async initializeMedia() { // Get camera and microphone this.localStream = await navigator.mediaDevices.getUserMedia({ video: { width: 1280, height: 720 }, audio: true }) return this.localStream } async startScreenShare() { // Capture display — this is traditional screen sharing // but in SameRow it's used for the UI overlay, not the media this.screenStream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true }) return this.screenStream } async createPeerConnection(peerId) { const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, // Add TURN server here for production ] }) // Add local tracks this.localStream.getTracks().forEach(track => { pc.addTrack(track, this.localStream) }) // ICE candidate handling pc.onicecandidate = ({ candidate }) => { if (candidate) { this.socket.emit('ice-candidate', { roomId: this.roomId, peerId, candidate }) } } // Handle incoming tracks pc.ontrack = ({ streams }) => { const remoteVideo = document.getElementById('remote-video') remoteVideo.srcObject = streams[0] } this.peers.set(peerId, pc) return pc } async makeOffer(peerId) { const pc = await this.createPeerConnection(peerId) const offer = await pc.createOffer() await pc.setLocalDescription(offer) this.socket.emit('webrtc-offer', { roomId: this.roomId, peerId, offer }) } async handleOffer(peerId, offer) { const pc = await this.createPeerConnection(peerId) await pc.setRemoteDescription(offer) const answer = await pc.createAnswer() await pc.setLocalDescription(answer) this.socket.emit('webrtc-answer', { roomId: this.roomId, peerId, answer }) }
}
// client/webrtc.js
class SameRowPeer { constructor(socket, roomId) { this.socket = socket this.roomId = roomId this.peers = new Map() } async initializeMedia() { // Get camera and microphone this.localStream = await navigator.mediaDevices.getUserMedia({ video: { width: 1280, height: 720 }, audio: true }) return this.localStream } async startScreenShare() { // Capture display — this is traditional screen sharing // but in SameRow it's used for the UI overlay, not the media this.screenStream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, audio: true }) return this.screenStream } async createPeerConnection(peerId) { const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, // Add TURN server here for production ] }) // Add local tracks this.localStream.getTracks().forEach(track => { pc.addTrack(track, this.localStream) }) // ICE candidate handling pc.onicecandidate = ({ candidate }) => { if (candidate) { this.socket.emit('ice-candidate', { roomId: this.roomId, peerId, candidate }) } } // Handle incoming tracks pc.ontrack = ({ streams }) => { const remoteVideo = document.getElementById('remote-video') remoteVideo.srcObject = streams[0] } this.peers.set(peerId, pc) return pc } async makeOffer(peerId) { const pc = await this.createPeerConnection(peerId) const offer = await pc.createOffer() await pc.setLocalDescription(offer) this.socket.emit('webrtc-offer', { roomId: this.roomId, peerId, offer }) } async handleOffer(peerId, offer) { const pc = await this.createPeerConnection(peerId) await pc.setRemoteDescription(offer) const answer = await pc.createAnswer() await pc.setLocalDescription(answer) this.socket.emit('webrtc-answer', { roomId: this.roomId, peerId, answer }) }
}
// client/jellyfin.js
class JellyfinSync { constructor(serverUrl, apiKey) { this.serverUrl = serverUrl this.apiKey = apiKey this.headers = { 'X-Emby-Token': apiKey, 'Content-Type': 'application/json' } } // Poll current playback state async getPlaybackState(sessionId) { const response = await fetch( `${this.serverUrl}/Sessions?api_key=${this.apiKey}` ) const sessions = await response.json() const session = sessions.find(s => s.Id === sessionId) if (!session?.NowPlayingItem) return null return { itemId: session.NowPlayingItem.Id, currentTime: session.PlayState.PositionTicks / 10000000, // Convert ticks to seconds isPlaying: !session.PlayState.IsPaused, mediaTitle: session.NowPlayingItem.Name } } // Apply playback state to local Jellyfin instance async applyPlaybackState(sessionId, state) { const positionTicks = Math.floor(state.currentTime * 10000000) // Seek to position await fetch( `${this.serverUrl}/Sessions/${sessionId}/Playing/Seek`, { method: 'POST', headers: this.headers, body: JSON.stringify({ SeekPositionTicks: positionTicks }) } ) // Play or pause const command = state.isPlaying ? 'Unpause' : 'Pause' await fetch( `${this.serverUrl}/Sessions/${sessionId}/Playing/${command}`, { method: 'POST', headers: this.headers } ) } // Start polling for state changes (host only) startPolling(sessionId, onStateChange, interval = 1000) { this.pollingInterval = setInterval(async () => { const state = await this.getPlaybackState(sessionId) if (state) onStateChange(state) }, interval) } stopPolling() { clearInterval(this.pollingInterval) }
}
// client/jellyfin.js
class JellyfinSync { constructor(serverUrl, apiKey) { this.serverUrl = serverUrl this.apiKey = apiKey this.headers = { 'X-Emby-Token': apiKey, 'Content-Type': 'application/json' } } // Poll current playback state async getPlaybackState(sessionId) { const response = await fetch( `${this.serverUrl}/Sessions?api_key=${this.apiKey}` ) const sessions = await response.json() const session = sessions.find(s => s.Id === sessionId) if (!session?.NowPlayingItem) return null return { itemId: session.NowPlayingItem.Id, currentTime: session.PlayState.PositionTicks / 10000000, // Convert ticks to seconds isPlaying: !session.PlayState.IsPaused, mediaTitle: session.NowPlayingItem.Name } } // Apply playback state to local Jellyfin instance async applyPlaybackState(sessionId, state) { const positionTicks = Math.floor(state.currentTime * 10000000) // Seek to position await fetch( `${this.serverUrl}/Sessions/${sessionId}/Playing/Seek`, { method: 'POST', headers: this.headers, body: JSON.stringify({ SeekPositionTicks: positionTicks }) } ) // Play or pause const command = state.isPlaying ? 'Unpause' : 'Pause' await fetch( `${this.serverUrl}/Sessions/${sessionId}/Playing/${command}`, { method: 'POST', headers: this.headers } ) } // Start polling for state changes (host only) startPolling(sessionId, onStateChange, interval = 1000) { this.pollingInterval = setInterval(async () => { const state = await this.getPlaybackState(sessionId) if (state) onStateChange(state) }, interval) } stopPolling() { clearInterval(this.pollingInterval) }
}
// client/jellyfin.js
class JellyfinSync { constructor(serverUrl, apiKey) { this.serverUrl = serverUrl this.apiKey = apiKey this.headers = { 'X-Emby-Token': apiKey, 'Content-Type': 'application/json' } } // Poll current playback state async getPlaybackState(sessionId) { const response = await fetch( `${this.serverUrl}/Sessions?api_key=${this.apiKey}` ) const sessions = await response.json() const session = sessions.find(s => s.Id === sessionId) if (!session?.NowPlayingItem) return null return { itemId: session.NowPlayingItem.Id, currentTime: session.PlayState.PositionTicks / 10000000, // Convert ticks to seconds isPlaying: !session.PlayState.IsPaused, mediaTitle: session.NowPlayingItem.Name } } // Apply playback state to local Jellyfin instance async applyPlaybackState(sessionId, state) { const positionTicks = Math.floor(state.currentTime * 10000000) // Seek to position await fetch( `${this.serverUrl}/Sessions/${sessionId}/Playing/Seek`, { method: 'POST', headers: this.headers, body: JSON.stringify({ SeekPositionTicks: positionTicks }) } ) // Play or pause const command = state.isPlaying ? 'Unpause' : 'Pause' await fetch( `${this.serverUrl}/Sessions/${sessionId}/Playing/${command}`, { method: 'POST', headers: this.headers } ) } // Start polling for state changes (host only) startPolling(sessionId, onStateChange, interval = 1000) { this.pollingInterval = setInterval(async () => { const state = await this.getPlaybackState(sessionId) if (state) onStateChange(state) }, interval) } stopPolling() { clearInterval(this.pollingInterval) }
}
// client/clockSync.js
class ClockSync { constructor(socket) { this.socket = socket this.offset = 0 this.rtt = 0 } async calculateOffset() { return new Promise((resolve) => { const t1 = Date.now() this.socket.emit('clock-ping', { t1 }) this.socket.once('clock-pong', ({ t1, t2, t3 }) => { const t4 = Date.now() // NTP offset formula this.rtt = (t4 - t1) - (t3 - t2) this.offset = ((t2 - t1) + (t3 - t4)) / 2 console.log(`Clock offset: ${this.offset}ms, RTT: ${this.rtt}ms`) resolve(this.offset) }) }) } // Schedule playback to start at a future agreed timestamp // Both clients receive the same startAt value // Network delay is already accounted for in the offset schedulePlayback(startAt) { const localStartAt = startAt + this.offset const delay = localStartAt - Date.now() if (delay > 0) { setTimeout(() => this.triggerPlayback(), delay) } else { this.triggerPlayback() } }
}
// client/clockSync.js
class ClockSync { constructor(socket) { this.socket = socket this.offset = 0 this.rtt = 0 } async calculateOffset() { return new Promise((resolve) => { const t1 = Date.now() this.socket.emit('clock-ping', { t1 }) this.socket.once('clock-pong', ({ t1, t2, t3 }) => { const t4 = Date.now() // NTP offset formula this.rtt = (t4 - t1) - (t3 - t2) this.offset = ((t2 - t1) + (t3 - t4)) / 2 console.log(`Clock offset: ${this.offset}ms, RTT: ${this.rtt}ms`) resolve(this.offset) }) }) } // Schedule playback to start at a future agreed timestamp // Both clients receive the same startAt value // Network delay is already accounted for in the offset schedulePlayback(startAt) { const localStartAt = startAt + this.offset const delay = localStartAt - Date.now() if (delay > 0) { setTimeout(() => this.triggerPlayback(), delay) } else { this.triggerPlayback() } }
}
// client/clockSync.js
class ClockSync { constructor(socket) { this.socket = socket this.offset = 0 this.rtt = 0 } async calculateOffset() { return new Promise((resolve) => { const t1 = Date.now() this.socket.emit('clock-ping', { t1 }) this.socket.once('clock-pong', ({ t1, t2, t3 }) => { const t4 = Date.now() // NTP offset formula this.rtt = (t4 - t1) - (t3 - t2) this.offset = ((t2 - t1) + (t3 - t4)) / 2 console.log(`Clock offset: ${this.offset}ms, RTT: ${this.rtt}ms`) resolve(this.offset) }) }) } // Schedule playback to start at a future agreed timestamp // Both clients receive the same startAt value // Network delay is already accounted for in the offset schedulePlayback(startAt) { const localStartAt = startAt + this.offset const delay = localStartAt - Date.now() if (delay > 0) { setTimeout(() => this.triggerPlayback(), delay) } else { this.triggerPlayback() } }
}
// client/driftCompensation.js
class DriftCompensation { constructor(jellyfinClient) { this.jellyfin = jellyfinClient this.checkInterval = null } start(sessionId, getExpectedTime) { this.checkInterval = setInterval(async () => { const state = await this.jellyfin.getPlaybackState(sessionId) if (!state || !state.isPlaying) return const expectedTime = getExpectedTime() const drift = state.currentTime - expectedTime await this.compensate(sessionId, drift) }, 1000) } async compensate(sessionId, drift) { const absDrift = Math.abs(drift) if (absDrift < 0.1) { // Under 100ms — within acceptable tolerance, do nothing return } if (absDrift >= 0.1 && absDrift < 0.5) { // 100ms to 500ms — silent rate adjustment // User never notices a 5% speed change const rate = drift > 0 ? 0.95 : 1.05 await this.jellyfin.setPlaybackRate(sessionId, rate) } else if (absDrift >= 0.5 && absDrift < 2.0) { // 500ms to 2s — more aggressive rate adjustment const rate = drift > 0 ? 0.90 : 1.10 await this.jellyfin.setPlaybackRate(sessionId, rate) } else { // Over 2s — hard resync, pause both clients await this.hardResync(sessionId) } } async hardResync(sessionId) { // Pause, seek to correct position, resume // This is the last resort — visible to user but necessary console.log('Drift exceeded 2s threshold — executing hard resync') // Implementation: emit resync event to signaling server // Server broadcasts pause + seek + resume to all clients } stop() { clearInterval(this.checkInterval) }
}
// client/driftCompensation.js
class DriftCompensation { constructor(jellyfinClient) { this.jellyfin = jellyfinClient this.checkInterval = null } start(sessionId, getExpectedTime) { this.checkInterval = setInterval(async () => { const state = await this.jellyfin.getPlaybackState(sessionId) if (!state || !state.isPlaying) return const expectedTime = getExpectedTime() const drift = state.currentTime - expectedTime await this.compensate(sessionId, drift) }, 1000) } async compensate(sessionId, drift) { const absDrift = Math.abs(drift) if (absDrift < 0.1) { // Under 100ms — within acceptable tolerance, do nothing return } if (absDrift >= 0.1 && absDrift < 0.5) { // 100ms to 500ms — silent rate adjustment // User never notices a 5% speed change const rate = drift > 0 ? 0.95 : 1.05 await this.jellyfin.setPlaybackRate(sessionId, rate) } else if (absDrift >= 0.5 && absDrift < 2.0) { // 500ms to 2s — more aggressive rate adjustment const rate = drift > 0 ? 0.90 : 1.10 await this.jellyfin.setPlaybackRate(sessionId, rate) } else { // Over 2s — hard resync, pause both clients await this.hardResync(sessionId) } } async hardResync(sessionId) { // Pause, seek to correct position, resume // This is the last resort — visible to user but necessary console.log('Drift exceeded 2s threshold — executing hard resync') // Implementation: emit resync event to signaling server // Server broadcasts pause + seek + resume to all clients } stop() { clearInterval(this.checkInterval) }
}
// client/driftCompensation.js
class DriftCompensation { constructor(jellyfinClient) { this.jellyfin = jellyfinClient this.checkInterval = null } start(sessionId, getExpectedTime) { this.checkInterval = setInterval(async () => { const state = await this.jellyfin.getPlaybackState(sessionId) if (!state || !state.isPlaying) return const expectedTime = getExpectedTime() const drift = state.currentTime - expectedTime await this.compensate(sessionId, drift) }, 1000) } async compensate(sessionId, drift) { const absDrift = Math.abs(drift) if (absDrift < 0.1) { // Under 100ms — within acceptable tolerance, do nothing return } if (absDrift >= 0.1 && absDrift < 0.5) { // 100ms to 500ms — silent rate adjustment // User never notices a 5% speed change const rate = drift > 0 ? 0.95 : 1.05 await this.jellyfin.setPlaybackRate(sessionId, rate) } else if (absDrift >= 0.5 && absDrift < 2.0) { // 500ms to 2s — more aggressive rate adjustment const rate = drift > 0 ? 0.90 : 1.10 await this.jellyfin.setPlaybackRate(sessionId, rate) } else { // Over 2s — hard resync, pause both clients await this.hardResync(sessionId) } } async hardResync(sessionId) { // Pause, seek to correct position, resume // This is the last resort — visible to user but necessary console.log('Drift exceeded 2s threshold — executing hard resync') // Implementation: emit resync event to signaling server // Server broadcasts pause + seek + resume to all clients } stop() { clearInterval(this.checkInterval) }
}
# docker-compose.yml
version: '3.8' services: signaling: build: ./signaling ports: - "3001:3001" environment: - NODE_ENV=production restart: unless-stopped client: build: ./client ports: - "3000:3000" environment: - NEXT_PUBLIC_SIGNALING_URL=http://localhost:3001 depends_on: - signaling restart: unless-stopped networks: default: driver: bridge
# docker-compose.yml
version: '3.8' services: signaling: build: ./signaling ports: - "3001:3001" environment: - NODE_ENV=production restart: unless-stopped client: build: ./client ports: - "3000:3000" environment: - NEXT_PUBLIC_SIGNALING_URL=http://localhost:3001 depends_on: - signaling restart: unless-stopped networks: default: driver: bridge
# docker-compose.yml
version: '3.8' services: signaling: build: ./signaling ports: - "3001:3001" environment: - NODE_ENV=production restart: unless-stopped client: build: ./client ports: - "3000:3000" environment: - NEXT_PUBLIC_SIGNALING_URL=http://localhost:3001 depends_on: - signaling restart: unless-stopped networks: default: driver: bridge
# signaling/Dockerfile
FROM node:18-alpine WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production COPY . .
EXPOSE 3001 CMD ["node", "index.js"]
# signaling/Dockerfile
FROM node:18-alpine WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production COPY . .
EXPOSE 3001 CMD ["node", "index.js"]
# signaling/Dockerfile
FROM node:18-alpine WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production COPY . .
EXPOSE 3001 CMD ["node", "index.js"]
# One command deployment
JELLYFIN_URL=https://jellyfin.yourdomain.com \
JELLYFIN_API_KEY=your_api_key_here \
docker-compose up -d
# One command deployment
JELLYFIN_URL=https://jellyfin.yourdomain.com \
JELLYFIN_API_KEY=your_api_key_here \
docker-compose up -d
# One command deployment
JELLYFIN_URL=https://jellyfin.yourdomain.com \
JELLYFIN_API_KEY=your_api_key_here \
docker-compose up -d - Signaling layer — a lightweight server managing room state and clock synchronization
- P2P layer — WebRTC direct connection for video calling and screen sharing
- Media layer — each client's local Jellyfin instance, playing content independently but in sync