formatters, device profile, media matching, subtitle utils, syncplay, trakt
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user