This commit is contained in:
2026-02-17 22:50:57 -08:00
parent ffeef9c136
commit f20d019c11
315 changed files with 9876 additions and 12537 deletions

View File

@@ -1,5 +1,5 @@
import { EventEmitter } from "events";
import { Config, MpvClient, MpvSubtitleRenderMetrics } from "../../types";
import { EventEmitter } from 'events';
import { Config, MpvClient, MpvSubtitleRenderMetrics } from '../../types';
import {
dispatchMpvProtocolMessage,
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
@@ -7,21 +7,18 @@ import {
MpvMessage,
MpvProtocolHandleMessageDeps,
splitMpvMessagesFromBuffer,
} from "./mpv-protocol";
import {
requestMpvInitialState,
subscribeToMpvProperties,
} from "./mpv-properties";
import { scheduleMpvReconnect, MpvSocketTransport } from "./mpv-transport";
import { createLogger } from "../../logger";
} from './mpv-protocol';
import { requestMpvInitialState, subscribeToMpvProperties } from './mpv-properties';
import { scheduleMpvReconnect, MpvSocketTransport } from './mpv-transport';
import { createLogger } from '../../logger';
const logger = createLogger("main:mpv");
const logger = createLogger('main:mpv');
export type MpvTrackProperty = {
type?: string;
id?: number;
selected?: boolean;
"ff-index"?: number;
'ff-index'?: number;
};
export function resolveCurrentAudioStreamIndex(
@@ -32,17 +29,13 @@ export function resolveCurrentAudioStreamIndex(
return null;
}
const audioTracks = tracks.filter((track) => track.type === "audio");
const audioTracks = tracks.filter((track) => track.type === 'audio');
const activeTrack =
audioTracks.find((track) => track.id === currentAudioTrackId) ||
audioTracks.find((track) => track.selected === true);
const ffIndex = activeTrack?.["ff-index"];
return typeof ffIndex === "number" &&
Number.isInteger(ffIndex) &&
ffIndex >= 0
? ffIndex
: null;
const ffIndex = activeTrack?.['ff-index'];
return typeof ffIndex === 'number' && Number.isInteger(ffIndex) && ffIndex >= 0 ? ffIndex : null;
}
export interface MpvRuntimeClientLike {
@@ -59,22 +52,18 @@ export function showMpvOsdRuntime(
fallbackLog: (text: string) => void = (line) => logger.info(line),
): void {
if (mpvClient && mpvClient.connected) {
mpvClient.send({ command: ["show-text", text, "3000"] });
mpvClient.send({ command: ['show-text', text, '3000'] });
return;
}
fallbackLog(`OSD (MPV not connected): ${text}`);
}
export function replayCurrentSubtitleRuntime(
mpvClient: MpvRuntimeClientLike | null,
): void {
export function replayCurrentSubtitleRuntime(mpvClient: MpvRuntimeClientLike | null): void {
if (!mpvClient?.replayCurrentSubtitle) return;
mpvClient.replayCurrentSubtitle();
}
export function playNextSubtitleRuntime(
mpvClient: MpvRuntimeClientLike | null,
): void {
export function playNextSubtitleRuntime(mpvClient: MpvRuntimeClientLike | null): void {
if (!mpvClient?.playNextSubtitle) return;
mpvClient.playNextSubtitle();
}
@@ -95,7 +84,7 @@ export function setMpvSubVisibilityRuntime(
mpvClient.setSubVisibility(visible);
}
export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from "./mpv-protocol";
export { MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY } from './mpv-protocol';
export interface MpvIpcClientProtocolDeps {
getResolvedConfig: () => Config;
@@ -110,17 +99,17 @@ export interface MpvIpcClientProtocolDeps {
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
export interface MpvIpcClientEventMap {
"connection-change": { connected: boolean };
"subtitle-change": { text: string; isOverlayVisible: boolean };
"subtitle-ass-change": { text: string };
"subtitle-timing": { text: string; start: number; end: number };
"time-pos-change": { time: number };
"pause-change": { paused: boolean };
"secondary-subtitle-change": { text: string };
"media-path-change": { path: string };
"media-title-change": { title: string | null };
"subtitle-metrics-change": { patch: Partial<MpvSubtitleRenderMetrics> };
"secondary-subtitle-visibility": { visible: boolean };
'connection-change': { connected: boolean };
'subtitle-change': { text: string; isOverlayVisible: boolean };
'subtitle-ass-change': { text: string };
'subtitle-timing': { text: string; start: number; end: number };
'time-pos-change': { time: number };
'pause-change': { paused: boolean };
'secondary-subtitle-change': { text: string };
'media-path-change': { path: string };
'media-title-change': { title: string | null };
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
'secondary-subtitle-visibility': { visible: boolean };
}
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
@@ -128,20 +117,20 @@ type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
export class MpvIpcClient implements MpvClient {
private deps: MpvIpcClientProtocolDeps;
private transport: MpvSocketTransport;
public socket: ReturnType<MpvSocketTransport["getSocket"]> = null;
public socket: ReturnType<MpvSocketTransport['getSocket']> = null;
private eventBus = new EventEmitter();
private buffer = "";
private buffer = '';
public connected = false;
private connecting = false;
private reconnectAttempt = 0;
private firstConnection = true;
private hasConnectedOnce = false;
public currentVideoPath = "";
public currentVideoPath = '';
public currentTimePos = 0;
public currentSubStart = 0;
public currentSubEnd = 0;
public currentSubText = "";
public currentSecondarySubText = "";
public currentSubText = '';
public currentSecondarySubText = '';
public currentAudioStreamIndex: number | null = null;
private currentAudioTrackId: number | null = null;
private mpvSubtitleRenderMetrics: MpvSubtitleRenderMetrics = {
@@ -150,13 +139,13 @@ export class MpvIpcClient implements MpvClient {
subScale: 1,
subMarginY: 0,
subMarginX: 0,
subFont: "",
subFont: '',
subSpacing: 0,
subBold: false,
subItalic: false,
subBorderSize: 0,
subShadowOffset: 0,
subAssOverride: "yes",
subAssOverride: 'yes',
subScaleByWindow: true,
subUseMargins: true,
osdHeight: 0,
@@ -174,11 +163,11 @@ export class MpvIpcClient implements MpvClient {
this.transport = new MpvSocketTransport({
socketPath,
onConnect: () => {
logger.debug("Connected to MPV socket");
logger.debug('Connected to MPV socket');
this.connected = true;
this.connecting = false;
this.socket = this.transport.getSocket();
this.emit("connection-change", { connected: true });
this.emit('connection-change', { connected: true });
this.reconnectAttempt = 0;
this.hasConnectedOnce = true;
this.setSecondarySubVisibility(false);
@@ -186,10 +175,9 @@ export class MpvIpcClient implements MpvClient {
requestMpvInitialState(this.send.bind(this));
const shouldAutoStart =
this.deps.autoStartOverlay ||
this.deps.getResolvedConfig().auto_start_overlay === true;
this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true;
if (this.firstConnection && shouldAutoStart) {
logger.debug("Auto-starting overlay, hiding mpv subtitles");
logger.debug('Auto-starting overlay, hiding mpv subtitles');
setTimeout(() => {
this.deps.setOverlayVisible(true);
}, 100);
@@ -204,15 +192,15 @@ export class MpvIpcClient implements MpvClient {
this.processBuffer();
},
onError: (err: Error) => {
logger.debug("MPV socket error:", err.message);
logger.debug('MPV socket error:', err.message);
this.failPendingRequests();
},
onClose: () => {
logger.debug("MPV socket closed");
logger.debug('MPV socket closed');
this.connected = false;
this.connecting = false;
this.socket = null;
this.emit("connection-change", { connected: false });
this.emit('connection-change', { connected: false });
this.failPendingRequests();
this.scheduleReconnect();
},
@@ -240,14 +228,12 @@ export class MpvIpcClient implements MpvClient {
this.eventBus.emit(event as string, payload);
}
private emitSubtitleMetricsChange(
patch: Partial<MpvSubtitleRenderMetrics>,
): void {
private emitSubtitleMetricsChange(patch: Partial<MpvSubtitleRenderMetrics>): void {
this.mpvSubtitleRenderMetrics = {
...this.mpvSubtitleRenderMetrics,
...patch,
};
this.emit("subtitle-metrics-change", { patch });
this.emit('subtitle-metrics-change', { patch });
}
setSocketPath(socketPath: string): void {
@@ -262,7 +248,7 @@ export class MpvIpcClient implements MpvClient {
return;
}
logger.info("MPV IPC connect requested.");
logger.info('MPV IPC connect requested.');
this.connecting = true;
this.transport.connect();
}
@@ -274,9 +260,7 @@ export class MpvIpcClient implements MpvClient {
getReconnectTimer: () => this.deps.getReconnectTimer(),
setReconnectTimer: (timer) => this.deps.setReconnectTimer(timer),
onReconnectAttempt: (attempt, delay) => {
logger.debug(
`Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`,
);
logger.debug(`Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`);
},
connect: () => {
this.connect();
@@ -291,7 +275,7 @@ export class MpvIpcClient implements MpvClient {
this.handleMessage(message);
},
(line, error) => {
logger.error("Failed to parse MPV message:", line, error);
logger.error('Failed to parse MPV message:', line, error);
},
);
this.buffer = parsed.nextBuffer;
@@ -307,22 +291,22 @@ export class MpvIpcClient implements MpvClient {
getSubtitleMetrics: () => this.mpvSubtitleRenderMetrics,
isVisibleOverlayVisible: () => this.deps.isVisibleOverlayVisible(),
emitSubtitleChange: (payload) => {
this.emit("subtitle-change", payload);
this.emit('subtitle-change', payload);
},
emitSubtitleAssChange: (payload) => {
this.emit("subtitle-ass-change", payload);
this.emit('subtitle-ass-change', payload);
},
emitSubtitleTiming: (payload) => {
this.emit("subtitle-timing", payload);
this.emit('subtitle-timing', payload);
},
emitTimePosChange: (payload) => {
this.emit("time-pos-change", payload);
this.emit('time-pos-change', payload);
},
emitPauseChange: (payload) => {
this.emit("pause-change", payload);
this.emit('pause-change', payload);
},
emitSecondarySubtitleChange: (payload) => {
this.emit("secondary-subtitle-change", payload);
this.emit('secondary-subtitle-change', payload);
},
getCurrentSubText: () => this.currentSubText,
setCurrentSubText: (text: string) => {
@@ -337,10 +321,10 @@ export class MpvIpcClient implements MpvClient {
},
getCurrentSubEnd: () => this.currentSubEnd,
emitMediaPathChange: (payload) => {
this.emit("media-path-change", payload);
this.emit('media-path-change', payload);
},
emitMediaTitleChange: (payload) => {
this.emit("media-title-change", payload);
this.emit('media-title-change', payload);
},
emitSubtitleMetricsChange: (patch) => {
this.emitSubtitleMetricsChange(patch);
@@ -350,8 +334,7 @@ export class MpvIpcClient implements MpvClient {
},
resolvePendingRequest: (requestId: number, message: MpvMessage) =>
this.tryResolvePendingRequest(requestId, message),
setSecondarySubVisibility: (visible: boolean) =>
this.setSecondarySubVisibility(visible),
setSecondarySubVisibility: (visible: boolean) => this.setSecondarySubVisibility(visible),
syncCurrentAudioStreamIndex: () => {
this.syncCurrentAudioStreamIndex();
},
@@ -377,7 +360,7 @@ export class MpvIpcClient implements MpvClient {
this.currentVideoPath = value;
},
emitSecondarySubtitleVisibility: (payload) => {
this.emit("secondary-subtitle-visibility", payload);
this.emit('secondary-subtitle-visibility', payload);
},
setPreviousSecondarySubVisibility: (visible: boolean) => {
this.previousSecondarySubVisibility = visible;
@@ -400,7 +383,7 @@ export class MpvIpcClient implements MpvClient {
setTimeout(() => {
this.send({
command: ["get_property", "track-list"],
command: ['get_property', 'track-list'],
request_id: MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
});
}, 500);
@@ -408,7 +391,7 @@ export class MpvIpcClient implements MpvClient {
private syncCurrentAudioStreamIndex(): void {
this.send({
command: ["get_property", "track-list"],
command: ['get_property', 'track-list'],
request_id: MPV_REQUEST_ID_TRACK_LIST_AUDIO,
});
}
@@ -418,13 +401,10 @@ export class MpvIpcClient implements MpvClient {
type?: string;
id?: number;
selected?: boolean;
"ff-index"?: number;
'ff-index'?: number;
}>,
): void {
this.currentAudioStreamIndex = resolveCurrentAudioStreamIndex(
tracks,
this.currentAudioTrackId,
);
this.currentAudioStreamIndex = resolveCurrentAudioStreamIndex(tracks, this.currentAudioTrackId);
}
send(command: { command: unknown[]; request_id?: number }): boolean {
@@ -437,7 +417,7 @@ export class MpvIpcClient implements MpvClient {
request(command: unknown[]): Promise<MpvMessage> {
return new Promise((resolve, reject) => {
if (!this.connected || !this.socket) {
reject(new Error("MPV not connected"));
reject(new Error('MPV not connected'));
return;
}
@@ -446,39 +426,34 @@ export class MpvIpcClient implements MpvClient {
const sent = this.send({ command, request_id: requestId });
if (!sent) {
this.pendingRequests.delete(requestId);
reject(new Error("Failed to send MPV request"));
reject(new Error('Failed to send MPV request'));
return;
}
setTimeout(() => {
if (this.pendingRequests.delete(requestId)) {
reject(new Error("MPV request timed out"));
reject(new Error('MPV request timed out'));
}
}, 4000);
});
}
async requestProperty(name: string): Promise<unknown> {
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}`,
);
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" });
resolve({ request_id: requestId, error: 'disconnected' });
}
this.pendingRequests.clear();
}
private tryResolvePendingRequest(
requestId: number,
message: MpvMessage,
): boolean {
private tryResolvePendingRequest(requestId: number, message: MpvMessage): boolean {
const pending = this.pendingRequests.get(requestId);
if (!pending) {
return false;
@@ -490,40 +465,32 @@ export class MpvIpcClient implements MpvClient {
setSubVisibility(visible: boolean): void {
this.send({
command: ["set_property", "sub-visibility", visible ? "yes" : "no"],
command: ['set_property', 'sub-visibility', visible ? 'yes' : 'no'],
});
}
replayCurrentSubtitle(): void {
this.pendingPauseAtSubEnd = true;
this.send({ command: ["sub-seek", 0] });
this.send({ command: ['sub-seek', 0] });
}
playNextSubtitle(): void {
this.pendingPauseAtSubEnd = true;
this.send({ command: ["sub-seek", 1] });
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",
],
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",
],
command: ['set_property', 'secondary-sub-visibility', visible ? 'yes' : 'no'],
});
}
}