import { jellyfinClient } from '../api/jellyfin' /** * Lightweight Jellyfin WebSocket wrapper scoped to SyncPlay messages. * The socket is opened on demand (when a SyncPlay group is joined), * pushes JSON-decoded messages to subscribers, and reconnects with * exponential backoff if the server drops us. * * We intentionally don't ship a full WS client - other features in the * app (push notifications, library updates) would need different * subscription scopes, so a generic event bus would do more harm than * good. When those land they can graduate this into a shared singleton. */ export type SyncPlayMessage = | { MessageType: 'SyncPlayGroupUpdate'; Data: SyncPlayGroupUpdate } | { MessageType: 'SyncPlayCommand'; Data: SyncPlayCommand } | { MessageType: string; Data?: unknown } export interface SyncPlayGroupUpdate { GroupId?: string Type?: string Data?: unknown } export interface SyncPlayCommand { GroupId?: string Command?: 'Pause' | 'Unpause' | 'Stop' | 'Seek' | string PositionTicks?: number When?: string EmittedAt?: string } type Listener = (msg: SyncPlayMessage) => void let socket: WebSocket | null = null const listeners = new Set() let retry = 0 let retryTimer: number | null = null let pingTimer: number | null = null let stopped = false function wsUrl(): string | null { const auth = jellyfinClient.getAuthState() const api = jellyfinClient.getApi() if (!auth || !api) return null const base = auth.serverUrl.replace(/^http/, 'ws') const deviceId = (api.deviceInfo?.id) || 'jf-desktop' return `${base}/socket?api_key=${encodeURIComponent(auth.token)}&deviceId=${encodeURIComponent(deviceId)}` } function clearRetryTimer() { if (retryTimer != null) { clearTimeout(retryTimer) retryTimer = null } } function clearPingTimer() { if (pingTimer != null) { clearInterval(pingTimer) pingTimer = null } } function connect() { if (stopped) return const url = wsUrl() if (!url) return try { socket = new WebSocket(url) } catch { scheduleReconnect() return } socket.onopen = () => { retry = 0 // Server keeps the socket alive while it sees periodic KeepAlive // pings. We send the start-keepalive sentinel, then heartbeat every // 30s ourselves so we look active to the gateway in case the server // skips its own pings. try { socket?.send(JSON.stringify({ MessageType: 'KeepAlive' })) } catch { /* noop */ } clearPingTimer() pingTimer = window.setInterval(() => { try { socket?.send(JSON.stringify({ MessageType: 'KeepAlive' })) } catch { /* noop */ } }, 30_000) } socket.onmessage = ev => { try { const data = JSON.parse(ev.data) as SyncPlayMessage if (!data.MessageType) return if (data.MessageType.startsWith('SyncPlay')) { for (const l of listeners) l(data) } } catch { /* skip malformed frames */ } } socket.onclose = () => { clearPingTimer() socket = null scheduleReconnect() } socket.onerror = () => { try { socket?.close() } catch { /* noop */ } } } function scheduleReconnect() { if (stopped) return clearRetryTimer() const delay = Math.min(30_000, 1000 * 2 ** Math.min(retry, 5)) retry++ retryTimer = window.setTimeout(connect, delay) } export function startSyncPlaySocket() { stopped = false if (socket) return connect() } export function stopSyncPlaySocket() { stopped = true listeners.clear() clearRetryTimer() clearPingTimer() if (socket) { try { socket.close() } catch { /* noop */ } socket = null } } export function subscribeSyncPlay(listener: Listener): () => void { listeners.add(listener) return () => { listeners.delete(listener) } }