142 lines
3.7 KiB
TypeScript
142 lines
3.7 KiB
TypeScript
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<Listener>()
|
|
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)
|
|
}
|
|
}
|