import * as net from "net"; import { EventEmitter } from "events"; import { Config, MpvClient, MpvSubtitleRenderMetrics, } from "../../types"; import { dispatchMpvProtocolMessage, MPV_REQUEST_ID_TRACK_LIST_AUDIO, MPV_REQUEST_ID_TRACK_LIST_SECONDARY, MpvMessage, MpvProtocolHandleMessageDeps, splitMpvMessagesFromBuffer, } from "./mpv-protocol"; import { requestMpvInitialState, subscribeToMpvProperties } from "./mpv-properties"; import { scheduleMpvReconnect, } from "./mpv-transport"; import { resolveCurrentAudioStreamIndex } from "./mpv-state"; export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY, } from "./mpv-protocol"; export interface MpvIpcClientProtocolDeps { getResolvedConfig: () => Config; autoStartOverlay: boolean; setOverlayVisible: (visible: boolean) => void; shouldBindVisibleOverlayToMpvSubVisibility: () => boolean; isVisibleOverlayVisible: () => boolean; getReconnectTimer: () => ReturnType | null; setReconnectTimer: (timer: ReturnType | null) => void; } export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {} export interface MpvIpcClientEventMap { "subtitle-change": { text: string; isOverlayVisible: boolean }; "subtitle-ass-change": { text: string }; "subtitle-timing": { text: string; start: number; end: number }; "secondary-subtitle-change": { text: string }; "media-path-change": { path: string }; "media-title-change": { title: string | null }; "subtitle-metrics-change": { patch: Partial }; "secondary-subtitle-visibility": { visible: boolean }; } type MpvIpcClientEventName = keyof MpvIpcClientEventMap; export class MpvIpcClient implements MpvClient { private socketPath: string; private deps: MpvIpcClientProtocolDeps; public socket: net.Socket | null = null; private eventBus = new EventEmitter(); private buffer = ""; public connected = false; private connecting = false; private reconnectAttempt = 0; private firstConnection = true; private hasConnectedOnce = false; public currentVideoPath = ""; public currentTimePos = 0; public currentSubStart = 0; public currentSubEnd = 0; public currentSubText = ""; public currentSecondarySubText = ""; public currentAudioStreamIndex: number | null = null; private currentAudioTrackId: number | null = null; private mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics = { subPos: 100, subFontSize: 36, subScale: 1, subMarginY: 0, subMarginX: 0, subFont: "", subSpacing: 0, subBold: false, subItalic: false, subBorderSize: 0, subShadowOffset: 0, subAssOverride: "yes", subScaleByWindow: true, subUseMargins: true, osdHeight: 0, osdDimensions: null, }; private previousSecondarySubVisibility: boolean | null = null; private pauseAtTime: number | null = null; private pendingPauseAtSubEnd = false; private nextDynamicRequestId = 1000; private pendingRequests = new Map void>(); constructor( socketPath: string, deps: MpvIpcClientDeps, ) { this.socketPath = socketPath; this.deps = deps; } on( event: EventName, listener: (payload: MpvIpcClientEventMap[EventName]) => void, ): void { this.eventBus.on(event as string, listener); } off( event: EventName, listener: (payload: MpvIpcClientEventMap[EventName]) => void, ): void { this.eventBus.off(event as string, listener); } private emit( event: EventName, payload: MpvIpcClientEventMap[EventName], ): void { this.eventBus.emit(event as string, payload); } private emitSubtitleMetricsChange( patch: Partial, ): void { this.mpvSubtitleRenderMetrics = { ...this.mpvSubtitleRenderMetrics, ...patch, }; this.emit("subtitle-metrics-change", { patch }); } setSocketPath(socketPath: string): void { this.socketPath = socketPath; } connect(): void { if (this.connected || this.connecting) { return; } if (this.socket) { this.socket.destroy(); } this.connecting = true; this.socket = new net.Socket(); this.socket.on("connect", () => { console.log("Connected to MPV socket"); this.connected = true; this.connecting = false; this.reconnectAttempt = 0; this.hasConnectedOnce = true; this.setSecondarySubVisibility(false); subscribeToMpvProperties(this.send.bind(this)); requestMpvInitialState(this.send.bind(this)); const shouldAutoStart = this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true; if (this.firstConnection && shouldAutoStart) { console.log("Auto-starting overlay, hiding mpv subtitles"); setTimeout(() => { this.deps.setOverlayVisible(true); }, 100); } else if (this.deps.shouldBindVisibleOverlayToMpvSubVisibility()) { this.setSubVisibility(!this.deps.isVisibleOverlayVisible()); } this.firstConnection = false; }); this.socket.on("data", (data: Buffer) => { this.buffer += data.toString(); this.processBuffer(); }); this.socket.on("error", (err: Error) => { console.error("MPV socket error:", err.message); this.connected = false; this.connecting = false; this.failPendingRequests(); }); this.socket.on("close", () => { console.log("MPV socket closed"); this.connected = false; this.connecting = false; this.failPendingRequests(); this.scheduleReconnect(); }); this.socket.connect(this.socketPath); } private scheduleReconnect(): void { this.reconnectAttempt = scheduleMpvReconnect({ attempt: this.reconnectAttempt, hasConnectedOnce: this.hasConnectedOnce, getReconnectTimer: () => this.deps.getReconnectTimer(), setReconnectTimer: (timer) => this.deps.setReconnectTimer(timer), onReconnectAttempt: (attempt, delay) => { console.log( `Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`, ); }, connect: () => { this.connect(); }, }); } private processBuffer(): void { const parsed = splitMpvMessagesFromBuffer( this.buffer, (message) => { this.handleMessage(message); }, (line, error) => { console.error("Failed to parse MPV message:", line, error); }, ); this.buffer = parsed.nextBuffer; } private async handleMessage(msg: MpvMessage): Promise { await dispatchMpvProtocolMessage(msg, this.createProtocolMessageDeps()); } private createProtocolMessageDeps(): MpvProtocolHandleMessageDeps { return { getResolvedConfig: () => this.deps.getResolvedConfig(), getSubtitleMetrics: () => this.mpvSubtitleRenderMetrics, isVisibleOverlayVisible: () => this.deps.isVisibleOverlayVisible(), emitSubtitleChange: (payload) => { this.emit("subtitle-change", payload); }, emitSubtitleAssChange: (payload) => { this.emit("subtitle-ass-change", payload); }, emitSubtitleTiming: (payload) => { this.emit("subtitle-timing", payload); }, emitSecondarySubtitleChange: (payload) => { this.emit("secondary-subtitle-change", payload); }, getCurrentSubText: () => this.currentSubText, setCurrentSubText: (text: string) => { this.currentSubText = text; }, setCurrentSubStart: (value: number) => { this.currentSubStart = value; }, getCurrentSubStart: () => this.currentSubStart, setCurrentSubEnd: (value: number) => { this.currentSubEnd = value; }, getCurrentSubEnd: () => this.currentSubEnd, emitMediaPathChange: (payload) => { this.emit("media-path-change", payload); }, emitMediaTitleChange: (payload) => { this.emit("media-title-change", payload); }, emitSubtitleMetricsChange: (patch) => { this.emitSubtitleMetricsChange(patch); }, setCurrentSecondarySubText: (text: string) => { this.currentSecondarySubText = text; }, resolvePendingRequest: (requestId: number, message: MpvMessage) => this.tryResolvePendingRequest(requestId, message), setSecondarySubVisibility: (visible: boolean) => this.setSecondarySubVisibility(visible), syncCurrentAudioStreamIndex: () => { this.syncCurrentAudioStreamIndex(); }, setCurrentAudioTrackId: (value: number | null) => { this.currentAudioTrackId = value; }, setCurrentTimePos: (value: number) => { this.currentTimePos = value; }, getCurrentTimePos: () => this.currentTimePos, getPendingPauseAtSubEnd: () => this.pendingPauseAtSubEnd, setPendingPauseAtSubEnd: (value: boolean) => { this.pendingPauseAtSubEnd = value; }, getPauseAtTime: () => this.pauseAtTime, setPauseAtTime: (value: number | null) => { this.pauseAtTime = value; }, autoLoadSecondarySubTrack: () => { this.autoLoadSecondarySubTrack(); }, setCurrentVideoPath: (value: string) => { this.currentVideoPath = value; }, emitSecondarySubtitleVisibility: (payload) => { this.emit("secondary-subtitle-visibility", payload); }, setCurrentAudioStreamIndex: (tracks) => { this.updateCurrentAudioStreamIndex(tracks); }, sendCommand: (payload) => this.send(payload), restorePreviousSecondarySubVisibility: () => { this.restorePreviousSecondarySubVisibility(); }, }; } private autoLoadSecondarySubTrack(): void { const config = this.deps.getResolvedConfig(); if (!config.secondarySub?.autoLoadSecondarySub) return; const languages = config.secondarySub.secondarySubLanguages; if (!languages || languages.length === 0) return; setTimeout(() => { this.send({ command: ["get_property", "track-list"], request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY, }); }, 500); } private syncCurrentAudioStreamIndex(): void { this.send({ command: ["get_property", "track-list"], request_id: MPV_REQUEST_ID_TRACK_LIST_AUDIO, }); } private updateCurrentAudioStreamIndex( tracks: Array<{ type?: string; id?: number; selected?: boolean; "ff-index"?: number; }>, ): void { this.currentAudioStreamIndex = resolveCurrentAudioStreamIndex( tracks, this.currentAudioTrackId, ); } send(command: { command: unknown[]; request_id?: number }): boolean { if (!this.connected || !this.socket) { return false; } const msg = JSON.stringify(command) + "\n"; this.socket.write(msg); return true; } request(command: unknown[]): Promise { return new Promise((resolve, reject) => { if (!this.connected || !this.socket) { reject(new Error("MPV not connected")); return; } const requestId = this.nextDynamicRequestId++; this.pendingRequests.set(requestId, resolve); const sent = this.send({ command, request_id: requestId }); if (!sent) { this.pendingRequests.delete(requestId); reject(new Error("Failed to send MPV request")); return; } setTimeout(() => { if (this.pendingRequests.delete(requestId)) { reject(new Error("MPV request timed out")); } }, 4000); }); } async requestProperty(name: string): Promise { const response = await this.request(["get_property", name]); if (response.error && response.error !== "success") { throw new Error( `Failed to read MPV property '${name}': ${response.error}`, ); } return response.data; } private failPendingRequests(): void { for (const [requestId, resolve] of this.pendingRequests.entries()) { resolve({ request_id: requestId, error: "disconnected" }); } this.pendingRequests.clear(); } private tryResolvePendingRequest( requestId: number, message: MpvMessage, ): boolean { const pending = this.pendingRequests.get(requestId); if (!pending) { return false; } this.pendingRequests.delete(requestId); pending(message); return true; } setSubVisibility(visible: boolean): void { this.send({ command: ["set_property", "sub-visibility", visible ? "yes" : "no"], }); } replayCurrentSubtitle(): void { this.pendingPauseAtSubEnd = true; this.send({ command: ["sub-seek", 0] }); } playNextSubtitle(): void { this.pendingPauseAtSubEnd = true; this.send({ command: ["sub-seek", 1] }); } restorePreviousSecondarySubVisibility(): void { const previous = this.previousSecondarySubVisibility; if (previous === null) return; this.send({ command: ["set_property", "secondary-sub-visibility", previous ? "yes" : "no"], }); this.previousSecondarySubVisibility = null; } private setSecondarySubVisibility(visible: boolean): void { this.send({ command: [ "set_property", "secondary-sub-visibility", visible ? "yes" : "no", ], }); } }