Track mpv overlays by configured socket window only

This commit is contained in:
2026-02-14 13:44:10 -08:00
parent df4f2f2a11
commit 7ef337e3c1
8 changed files with 527 additions and 268 deletions

View File

@@ -23,6 +23,7 @@ export function initializeOverlayRuntimeService(options: {
getOverlayWindows: () => BrowserWindow[];
syncOverlayShortcuts: () => void;
setWindowTracker: (tracker: BaseWindowTracker | null) => void;
getMpvSocketPath: () => string;
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
getSubtitleTimingTracker: () => unknown | null;
getMpvClient: () => { send?: (payload: { command: string[] }) => void } | null;
@@ -42,7 +43,10 @@ export function initializeOverlayRuntimeService(options: {
const invisibleOverlayVisible = options.getInitialInvisibleOverlayVisibility();
options.registerGlobalShortcuts();
const windowTracker = createWindowTracker(options.backendOverride);
const windowTracker = createWindowTracker(
options.backendOverride,
options.getMpvSocketPath(),
);
options.setWindowTracker(windowTracker);
if (windowTracker) {
windowTracker.onGeometryChange = (geometry: WindowGeometry) => {

View File

@@ -46,6 +46,7 @@ import { BaseWindowTracker } from "./window-trackers";
import type {
JimakuApiResponse,
JimakuLanguagePreference,
RuntimeOptionId,
SubtitleData,
SubtitlePosition,
Keybinding,
@@ -59,6 +60,7 @@ import type {
KikuMergePreviewResponse,
RuntimeOptionState,
MpvSubtitleRenderMetrics,
ResolvedConfig,
} from "./types";
import { SubtitleTimingTracker } from "./subtitle-timing-tracker";
import { AnkiIntegration } from "./anki-integration";
@@ -155,13 +157,27 @@ import {
updateVisibleOverlayVisibilityService,
} from "./core/services";
import { runOverlayShortcutLocalFallback } from "./core/services/overlay-shortcut-handler";
import { runAppReadyRuntimeService } from "./core/services/startup-service";
import {
runAppReadyRuntimeService,
} from "./core/services/startup-service";
import type { AppReadyRuntimeDeps } from "./core/services/startup-service";
import type { SubsyncRuntimeDeps } from "./core/services/subsync-runner-service";
import {
applyRuntimeOptionResultRuntimeService,
} from "./core/services/runtime-options-ipc-service";
import { createRuntimeOptionsIpcDeps, createSubsyncRuntimeDeps } from "./main/dependencies";
import {
createRuntimeOptionsIpcDeps,
createAnkiJimakuIpcRuntimeServiceDeps,
createCliCommandRuntimeServiceDeps,
createMainIpcRuntimeServiceDeps,
createMpvCommandRuntimeServiceDeps,
createSubsyncRuntimeDeps,
} from "./main/dependencies";
import {
createAppLifecycleRuntimeDeps as createAppLifecycleRuntimeDepsBuilder,
createAppReadyRuntimeDeps as createAppReadyRuntimeDepsBuilder,
} from "./main/app-lifecycle";
import { createStartupBootstrapRuntimeDeps } from "./main/startup";
import {
ConfigService,
DEFAULT_CONFIG,
@@ -172,11 +188,6 @@ import {
import type {
AppLifecycleDepsRuntimeOptions,
} from "./core/services/app-lifecycle-service";
import type { CliCommandDepsRuntimeOptions } from "./core/services/cli-command-service";
import type { HandleMpvCommandFromIpcOptions } from "./core/services/ipc-command-service";
import type { IpcDepsRuntimeOptions } from "./core/services/ipc-service";
import type { AnkiJimakuIpcRuntimeOptions } from "./core/services/anki-jimaku-service";
import type { StartupBootstrapRuntimeDeps } from "./core/services/startup-service";
if (process.platform === "linux") {
app.commandLine.appendSwitch("enable-features", "GlobalShortcutsPortal");
@@ -620,7 +631,53 @@ function resolveMediaPathForJimaku(mediaPath: string | null): string | null {
: mediaPath;
}
const startupState = runStartupBootstrapRuntimeService(createStartupBootstrapRuntimeDeps());
const startupState = runStartupBootstrapRuntimeService(
createStartupBootstrapRuntimeDeps({
argv: process.argv,
parseArgs: (argv: string[]) => parseArgs(argv),
setLogLevelEnv: (level: string) => {
process.env.SUBMINER_LOG_LEVEL = level;
},
enableVerboseLogging: () => {
process.env.SUBMINER_LOG_LEVEL = "debug";
},
forceX11Backend: (args: CliArgs) => {
forceX11Backend(args);
},
enforceUnsupportedWaylandMode: (args: CliArgs) => {
enforceUnsupportedWaylandMode(args);
},
shouldStartApp: (args: CliArgs) => shouldStartApp(args),
getDefaultSocketPath: () => getDefaultSocketPath(),
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
configDir: CONFIG_DIR,
defaultConfig: DEFAULT_CONFIG,
generateConfigTemplate: (config: ResolvedConfig) => generateConfigTemplate(config),
generateDefaultConfigFile: (
args: CliArgs,
options: {
configDir: string;
defaultConfig: unknown;
generateTemplate: (config: unknown) => string;
},
) => generateDefaultConfigFile(args, options),
onConfigGenerated: (exitCode: number) => {
process.exitCode = exitCode;
app.quit();
},
onGenerateConfigError: (error: Error) => {
console.error(`Failed to generate config: ${error.message}`);
process.exitCode = 1;
app.quit();
},
startAppLifecycle: (args: CliArgs) => {
startAppLifecycleService(
args,
createAppLifecycleDepsRuntimeService(createAppLifecycleRuntimeDeps()),
);
},
}),
);
appState.initialArgs = startupState.initialArgs;
appState.mpvSocketPath = startupState.mpvSocketPath;
@@ -630,12 +687,15 @@ appState.autoStartOverlay = startupState.autoStartOverlay;
appState.texthookerOnlyMode = startupState.texthookerOnlyMode;
function createAppLifecycleRuntimeDeps(): AppLifecycleDepsRuntimeOptions {
return {
return createAppLifecycleRuntimeDepsBuilder({
app,
platform: process.platform,
shouldStartApp: (nextArgs) => shouldStartApp(nextArgs),
parseArgs: (argv) => parseArgs(argv),
handleCliCommand: (nextArgs, source) => handleCliCommand(nextArgs, source),
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
parseArgs: (argv: string[]) => parseArgs(argv),
handleCliCommand: (
nextArgs: CliArgs,
source: CliCommandSource,
) => handleCliCommand(nextArgs, source),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
logNoRunningInstance: () => appLogger.logNoRunningInstance(),
onReady: async () => {
@@ -676,11 +736,11 @@ function createAppLifecycleRuntimeDeps(): AppLifecycleDepsRuntimeOptions {
updateVisibleOverlayVisibility();
updateInvisibleOverlayVisibility();
},
};
});
}
function createAppReadyRuntimeDeps(): AppReadyRuntimeDeps {
return {
return createAppReadyRuntimeDepsBuilder({
loadSubtitlePosition: () => loadSubtitlePosition(),
resolveKeybindings: () => {
appState.keybindings = resolveKeybindings(getResolvedConfig(), DEFAULT_KEYBINDINGS);
@@ -711,13 +771,13 @@ function createAppReadyRuntimeDeps(): AppReadyRuntimeDeps {
},
);
},
setSecondarySubMode: (mode) => {
setSecondarySubMode: (mode: SecondarySubMode) => {
appState.secondarySubMode = mode;
},
defaultSecondarySubMode: "hover",
defaultWebsocketPort: DEFAULT_CONFIG.websocket.port,
hasMpvWebsocketPlugin: () => hasMpvWebsocketPlugin(),
startSubtitleWebsocket: (port) => {
startSubtitleWebsocket: (port: number) => {
subtitleWsService.start(port, () => appState.currentSubText);
},
log: (message) => appLogger.logInfo(message),
@@ -738,147 +798,122 @@ function createAppReadyRuntimeDeps(): AppReadyRuntimeDeps {
shouldAutoInitializeOverlayRuntimeFromConfig(),
initializeOverlayRuntime: () => initializeOverlayRuntime(),
handleInitialArgs: () => handleInitialArgs(),
};
}
function createStartupBootstrapRuntimeDeps(): StartupBootstrapRuntimeDeps {
return {
argv: process.argv,
parseArgs: (argv) => parseArgs(argv),
setLogLevelEnv: (level) => {
process.env.SUBMINER_LOG_LEVEL = level;
},
enableVerboseLogging: () => {
process.env.SUBMINER_LOG_LEVEL = "debug";
},
forceX11Backend: (args) => {
forceX11Backend(args);
},
enforceUnsupportedWaylandMode: (args) => {
enforceUnsupportedWaylandMode(args);
},
getDefaultSocketPath: () => getDefaultSocketPath(),
defaultTexthookerPort: DEFAULT_TEXTHOOKER_PORT,
runGenerateConfigFlow: (args) => {
if (!args.generateConfig || shouldStartApp(args)) {
return false;
}
generateDefaultConfigFile(args, {
configDir: CONFIG_DIR,
defaultConfig: DEFAULT_CONFIG,
generateTemplate: (config) => generateConfigTemplate(config as never),
})
.then((exitCode) => {
process.exitCode = exitCode;
app.quit();
})
.catch((error: Error) => {
console.error(`Failed to generate config: ${error.message}`);
process.exitCode = 1;
app.quit();
});
return true;
},
startAppLifecycle: (args) => {
startAppLifecycleService(
args,
createAppLifecycleDepsRuntimeService(createAppLifecycleRuntimeDeps()),
);
},
};
});
}
function handleCliCommand(
args: CliArgs,
source: CliCommandSource = "initial",
): void {
const deps = createCliCommandDepsRuntimeService(createCliCommandRuntimeServiceDeps());
const deps = createCliCommandDepsRuntimeService(
createCliCommandRuntimeServiceDeps({
mpv: {
getSocketPath: () => appState.mpvSocketPath,
setSocketPath: (socketPath: string) => {
appState.mpvSocketPath = socketPath;
},
getClient: () => appState.mpvClient,
showOsd: (text: string) => showMpvOsd(text),
},
texthooker: {
service: texthookerService,
getPort: () => appState.texthookerPort,
setPort: (port: number) => {
appState.texthookerPort = port;
},
shouldOpenBrowser: () =>
getResolvedConfig().texthooker?.openBrowser !== false,
openInBrowser: (url: string) => {
void shell.openExternal(url).catch((error) => {
console.error(`Failed to open browser for texthooker URL: ${url}`, error);
});
},
},
overlay: {
isInitialized: () => appState.overlayRuntimeInitialized,
initialize: () => initializeOverlayRuntime(),
toggleVisible: () => toggleVisibleOverlay(),
toggleInvisible: () => toggleInvisibleOverlay(),
setVisible: (visible: boolean) => setVisibleOverlayVisible(visible),
setInvisible: (visible: boolean) => setInvisibleOverlayVisible(visible),
},
mining: {
copyCurrentSubtitle: () => copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs: number) => startPendingMultiCopy(timeoutMs),
mineSentenceCard: () => mineSentenceCard(),
startPendingMineSentenceMultiple: (timeoutMs: number) =>
startPendingMineSentenceMultiple(timeoutMs),
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
},
ui: {
openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
},
app: {
stop: () => app.quit(),
hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
},
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
schedule: (fn: () => void, delayMs: number) => setTimeout(fn, delayMs),
log: (message: string) => {
console.log(message);
},
warn: (message: string) => {
console.warn(message);
},
error: (message: string, err: unknown) => {
console.error(message, err);
},
}),
);
handleCliCommandService(args, source, deps);
}
function createCliCommandRuntimeServiceDeps(): CliCommandDepsRuntimeOptions {
return {
mpv: {
getSocketPath: () => appState.mpvSocketPath,
setSocketPath: (socketPath) => {
appState.mpvSocketPath = socketPath;
},
getClient: () => appState.mpvClient,
showOsd: (text) => showMpvOsd(text),
},
texthooker: {
service: texthookerService,
getPort: () => appState.texthookerPort,
setPort: (port) => {
appState.texthookerPort = port;
},
shouldOpenBrowser: () => getResolvedConfig().texthooker?.openBrowser !== false,
openInBrowser: (url) => {
void shell.openExternal(url).catch((error) => {
console.error(`Failed to open browser for texthooker URL: ${url}`, error);
});
},
},
overlay: {
isInitialized: () => appState.overlayRuntimeInitialized,
initialize: () => initializeOverlayRuntime(),
toggleVisible: () => toggleVisibleOverlay(),
toggleInvisible: () => toggleInvisibleOverlay(),
setVisible: (visible) => setVisibleOverlayVisible(visible),
setInvisible: (visible) => setInvisibleOverlayVisible(visible),
},
mining: {
copyCurrentSubtitle: () => copyCurrentSubtitle(),
startPendingMultiCopy: (timeoutMs) => startPendingMultiCopy(timeoutMs),
mineSentenceCard: () => mineSentenceCard(),
startPendingMineSentenceMultiple: (timeoutMs) =>
startPendingMineSentenceMultiple(timeoutMs),
updateLastCardFromClipboard: () => updateLastCardFromClipboard(),
triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
markLastCardAsAudioCard: () => markLastCardAsAudioCard(),
},
ui: {
openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => cycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
printHelp: () => printHelp(DEFAULT_TEXTHOOKER_PORT),
},
app: {
stop: () => app.quit(),
hasMainWindow: () => Boolean(overlayManager.getMainWindow()),
},
getMultiCopyTimeoutMs: () => getConfiguredShortcuts().multiCopyTimeoutMs,
schedule: (fn, delayMs) => setTimeout(fn, delayMs),
log: (message) => {
console.log(message);
},
warn: (message) => {
console.warn(message);
},
error: (message, err) => {
console.error(message, err);
},
};
}
function handleInitialArgs(): void {
if (!appState.initialArgs) return;
handleCliCommand(appState.initialArgs, "initial");
}
function bindMpvClientEventHandlers(mpvClient: MpvIpcClient): void {
mpvClient.on("subtitle-change", ({ text }) => {
appState.currentSubText = text;
subtitleWsService.broadcast(text);
void (async () => {
if (getOverlayWindows().length > 0) {
const subtitleData = await tokenizeSubtitle(text);
broadcastToOverlayWindows("subtitle:set", subtitleData);
}
})();
});
mpvClient.on("subtitle-ass-change", ({ text }) => {
appState.currentSubAssText = text;
broadcastToOverlayWindows("subtitle-ass:set", text);
});
mpvClient.on("secondary-subtitle-change", ({ text }) => {
broadcastToOverlayWindows("secondary-subtitle:set", text);
});
mpvClient.on("subtitle-timing", ({ text, start, end }) => {
if (!text.trim() || !appState.subtitleTimingTracker) {
return;
}
appState.subtitleTimingTracker.recordSubtitle(text, start, end);
});
mpvClient.on("media-path-change", ({ path }) => {
updateCurrentMediaPath(path);
});
mpvClient.on("media-title-change", ({ title }) => {
updateCurrentMediaTitle(title);
});
mpvClient.on("subtitle-ass-change", ({ text }) => {
appState.currentSubAssText = text;
mpvClient.on("subtitle-metrics-change", ({ patch }) => {
updateMpvSubtitleRenderMetrics(patch);
});
mpvClient.on("subtitle-change", ({ text }) => {
appState.currentSubText = text;
mpvClient.on("secondary-subtitle-visibility", ({ visible }) => {
appState.previousSecondarySubVisibility = visible;
});
}
@@ -886,41 +921,14 @@ function createMpvClientRuntimeService(): MpvIpcClient {
const mpvClient = new MpvIpcClient(appState.mpvSocketPath, {
getResolvedConfig: () => getResolvedConfig(),
autoStartOverlay: appState.autoStartOverlay,
setOverlayVisible: (visible) => setOverlayVisible(visible),
setOverlayVisible: (visible: boolean) => setOverlayVisible(visible),
shouldBindVisibleOverlayToMpvSubVisibility: () =>
shouldBindVisibleOverlayToMpvSubVisibility(),
isVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
getReconnectTimer: () => appState.reconnectTimer,
setReconnectTimer: (timer) => {
setReconnectTimer: (timer: ReturnType<typeof setTimeout> | null) => {
appState.reconnectTimer = timer;
},
getCurrentSubText: () => appState.currentSubText,
setCurrentSubText: (text) => {
appState.currentSubText = text;
},
setCurrentSubAssText: (text) => {
appState.currentSubAssText = text;
},
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
subtitleWsBroadcast: (text) => {
subtitleWsService.broadcast(text);
},
getOverlayWindowsCount: () => getOverlayWindows().length,
tokenizeSubtitle: (text) => tokenizeSubtitle(text),
broadcastToOverlayWindows: (channel, ...channelArgs) => {
broadcastToOverlayWindows(channel, ...channelArgs);
},
updateMpvSubtitleRenderMetrics: (patch) => {
updateMpvSubtitleRenderMetrics(patch);
},
getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics,
getPreviousSecondarySubVisibility: () => appState.previousSecondarySubVisibility,
setPreviousSecondarySubVisibility: (value) => {
appState.previousSecondarySubVisibility = value;
},
showMpvOsd: (text) => {
showMpvOsd(text);
},
});
bindMpvClientEventHandlers(mpvClient);
return mpvClient;
@@ -1085,6 +1093,7 @@ function initializeOverlayRuntime(): void {
getResolvedConfig: () => getResolvedConfig(),
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
getMpvClient: () => appState.mpvClient,
getMpvSocketPath: () => appState.mpvSocketPath,
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
setAnkiIntegration: (integration) => {
appState.ankiIntegration = integration as AnkiIntegration | null;
@@ -1126,7 +1135,7 @@ function getConfiguredShortcuts() { return resolveConfiguredShortcuts(getResolve
function getOverlayShortcutRuntimeHandlers() {
return createOverlayShortcutRuntimeHandlers(
{
showMpvOsd: (text) => showMpvOsd(text),
showMpvOsd: (text: string) => showMpvOsd(text),
openRuntimeOptions: () => {
openRuntimeOptionsPalette();
},
@@ -1136,7 +1145,7 @@ function getOverlayShortcutRuntimeHandlers() {
});
},
markAudioCard: () => markLastCardAsAudioCard(),
copySubtitleMultiple: (timeoutMs) => {
copySubtitleMultiple: (timeoutMs: number) => {
startPendingMultiCopy(timeoutMs);
},
copySubtitle: () => {
@@ -1147,7 +1156,7 @@ function getOverlayShortcutRuntimeHandlers() {
triggerFieldGrouping: () => triggerFieldGrouping(),
triggerSubsync: () => triggerSubsyncFromConfig(),
mineSentence: () => mineSentenceCard(),
mineSentenceMultiple: (timeoutMs) => {
mineSentenceMultiple: (timeoutMs: number) => {
startPendingMineSentenceMultiple(timeoutMs);
},
},
@@ -1495,31 +1504,30 @@ function handleOverlayModalClosed(modal: OverlayHostedModal): void {
}
function handleMpvCommandFromIpc(command: (string | number)[]): void {
handleMpvCommandFromIpcService(command, createMpvCommandRuntimeServiceDeps());
}
function createMpvCommandRuntimeServiceDeps(): HandleMpvCommandFromIpcOptions {
return {
specialCommands: SPECIAL_COMMANDS,
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
runtimeOptionsCycle: (id, direction) => {
if (!appState.runtimeOptionsManager) {
return { ok: false, error: "Runtime options manager unavailable" };
}
return applyRuntimeOptionResultRuntimeService(
appState.runtimeOptionsManager.cycleOption(id, direction),
(text) => showMpvOsd(text),
);
},
showMpvOsd: (text) => showMpvOsd(text),
mpvReplaySubtitle: () => replayCurrentSubtitleRuntimeService(appState.mpvClient),
mpvPlayNextSubtitle: () => playNextSubtitleRuntimeService(appState.mpvClient),
mpvSendCommand: (rawCommand) =>
sendMpvCommandRuntimeService(appState.mpvClient, rawCommand),
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null,
};
handleMpvCommandFromIpcService(
command,
createMpvCommandRuntimeServiceDeps({
specialCommands: SPECIAL_COMMANDS,
triggerSubsyncFromConfig: () => triggerSubsyncFromConfig(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
runtimeOptionsCycle: (id: RuntimeOptionId, direction: 1 | -1) => {
if (!appState.runtimeOptionsManager) {
return { ok: false, error: "Runtime options manager unavailable" };
}
return applyRuntimeOptionResultRuntimeService(
appState.runtimeOptionsManager.cycleOption(id, direction),
(text) => showMpvOsd(text),
);
},
showMpvOsd: (text: string) => showMpvOsd(text),
mpvReplaySubtitle: () => replayCurrentSubtitleRuntimeService(appState.mpvClient),
mpvPlayNextSubtitle: () => playNextSubtitleRuntimeService(appState.mpvClient),
mpvSendCommand: (rawCommand: (string | number)[]) =>
sendMpvCommandRuntimeService(appState.mpvClient, rawCommand),
isMpvConnected: () => Boolean(appState.mpvClient && appState.mpvClient.connected),
hasRuntimeOptionsManager: () => appState.runtimeOptionsManager !== null,
}),
);
}
async function runSubsyncManualFromIpc(
@@ -1534,52 +1542,47 @@ const runtimeOptionsIpcDeps = createRuntimeOptionsIpcDeps({
});
registerIpcHandlersService(
createIpcDepsRuntimeService(createMainIpcRuntimeServiceDeps()),
createIpcDepsRuntimeService(
createMainIpcRuntimeServiceDeps({
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisibility: () => overlayManager.getInvisibleOverlayVisible(),
onOverlayModalClosed: (modal) => {
handleOverlayModalClosed(modal as OverlayHostedModal);
},
openYomitanSettings: () => openYomitanSettings(),
quitApp: () => app.quit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
getCurrentSubtitleAss: () => appState.currentSubAssText,
getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics,
getSubtitlePosition: () => loadSubtitlePosition(),
getSubtitleStyle: () => getResolvedConfig().subtitleStyle ?? null,
saveSubtitlePosition: (position: unknown) =>
saveSubtitlePosition(position as SubtitlePosition),
getMecabTokenizer: () => appState.mecabTokenizer,
handleMpvCommand: (command: (string | number)[]) =>
handleMpvCommandFromIpc(command),
getKeybindings: () => appState.keybindings,
getSecondarySubMode: () => appState.secondarySubMode,
getMpvClient: () => appState.mpvClient,
runSubsyncManual: (request: unknown) =>
runSubsyncManualFromIpc(request as SubsyncManualRunRequest),
getAnkiConnectStatus: () => appState.ankiIntegration !== null,
getRuntimeOptions: () => getRuntimeOptionsState(),
setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption,
cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption,
reportOverlayContentBounds: (payload: unknown) => {
overlayContentMeasurementStore.report(payload);
},
}),
),
);
registerAnkiJimakuIpcRuntimeService(
createAnkiJimakuIpcRuntimeServiceDeps(),
);
function createMainIpcRuntimeServiceDeps(): IpcDepsRuntimeOptions {
return {
getInvisibleWindow: () => overlayManager.getInvisibleWindow(),
getMainWindow: () => overlayManager.getMainWindow(),
getVisibleOverlayVisibility: () => overlayManager.getVisibleOverlayVisible(),
getInvisibleOverlayVisibility: () =>
overlayManager.getInvisibleOverlayVisible(),
onOverlayModalClosed: (modal) =>
handleOverlayModalClosed(modal as OverlayHostedModal),
openYomitanSettings: () => openYomitanSettings(),
quitApp: () => app.quit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: () => tokenizeSubtitle(appState.currentSubText),
getCurrentSubtitleAss: () => appState.currentSubAssText,
getMpvSubtitleRenderMetrics: () => appState.mpvSubtitleRenderMetrics,
getSubtitlePosition: () => loadSubtitlePosition(),
getSubtitleStyle: () => getResolvedConfig().subtitleStyle ?? null,
saveSubtitlePosition: (position) =>
saveSubtitlePosition(position as SubtitlePosition),
getMecabTokenizer: () => appState.mecabTokenizer,
handleMpvCommand: (command) => handleMpvCommandFromIpc(command),
getKeybindings: () => appState.keybindings,
getSecondarySubMode: () => appState.secondarySubMode,
getMpvClient: () => appState.mpvClient,
runSubsyncManual: (request) =>
runSubsyncManualFromIpc(request as SubsyncManualRunRequest),
getAnkiConnectStatus: () => appState.ankiIntegration !== null,
getRuntimeOptions: () => getRuntimeOptionsState(),
setRuntimeOption: runtimeOptionsIpcDeps.setRuntimeOption,
cycleRuntimeOption: runtimeOptionsIpcDeps.cycleRuntimeOption,
reportOverlayContentBounds: (payload) => {
overlayContentMeasurementStore.report(payload);
},
};
}
function createAnkiJimakuIpcRuntimeServiceDeps(): AnkiJimakuIpcRuntimeOptions {
return {
patchAnkiConnectEnabled: (enabled) => {
createAnkiJimakuIpcRuntimeServiceDeps({
patchAnkiConnectEnabled: (enabled: boolean) => {
configService.patchRawConfig({ ankiConnect: { enabled } });
},
getResolvedConfig: () => getResolvedConfig(),
@@ -1587,25 +1590,32 @@ function createAnkiJimakuIpcRuntimeServiceDeps(): AnkiJimakuIpcRuntimeOptions {
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
getMpvClient: () => appState.mpvClient,
getAnkiIntegration: () => appState.ankiIntegration,
setAnkiIntegration: (integration) => {
setAnkiIntegration: (integration: AnkiIntegration | null) => {
appState.ankiIntegration = integration;
},
showDesktopNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback(),
broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged(),
getFieldGroupingResolver: () => getFieldGroupingResolver(),
setFieldGroupingResolver: (resolver) => setFieldGroupingResolver(resolver),
parseMediaInfo: (mediaPath) => parseMediaInfo(resolveMediaPathForJimaku(mediaPath)),
setFieldGroupingResolver: (
resolver: ((choice: KikuFieldGroupingChoice) => void) | null,
) => setFieldGroupingResolver(resolver),
parseMediaInfo: (mediaPath: string | null) =>
parseMediaInfo(resolveMediaPathForJimaku(mediaPath)),
getCurrentMediaPath: () => appState.currentMediaPath,
jimakuFetchJson: <T>(
endpoint: string,
query?: Record<string, string | number | boolean | null | undefined>,
): Promise<JimakuApiResponse<T>> => jimakuFetchJson(endpoint, query),
): Promise<JimakuApiResponse<T>> =>
jimakuFetchJson<T>(endpoint, query),
getJimakuMaxEntryResults: () => getJimakuMaxEntryResults(),
getJimakuLanguagePreference: () => getJimakuLanguagePreference(),
resolveJimakuApiKey: () => resolveJimakuApiKey(),
isRemoteMediaPath: (mediaPath) => isRemoteMediaPath(mediaPath),
downloadToFile: (url, destPath, headers) =>
downloadToFile(url, destPath, headers),
};
}
isRemoteMediaPath: (mediaPath: string) => isRemoteMediaPath(mediaPath),
downloadToFile: (
url: string,
destPath: string,
headers: Record<string, string>,
) => downloadToFile(url, destPath, headers),
}),
);

View File

@@ -27,11 +27,18 @@ interface HyprlandClient {
class: string;
at: [number, number];
size: [number, number];
pid?: number;
}
export class HyprlandWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private eventSocket: net.Socket | null = null;
private readonly targetMpvSocketPath: string | null;
constructor(targetMpvSocketPath?: string) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
}
start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
@@ -95,7 +102,7 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
try {
const output = execSync("hyprctl clients -j", { encoding: "utf-8" });
const clients: HyprlandClient[] = JSON.parse(output);
const mpvWindow = clients.find((c) => c.class === "mpv");
const mpvWindow = this.findTargetWindow(clients);
if (mpvWindow) {
this.updateGeometry({
@@ -111,4 +118,38 @@ export class HyprlandWindowTracker extends BaseWindowTracker {
// hyprctl not available or failed - silent fail
}
}
private findTargetWindow(clients: HyprlandClient[]): HyprlandClient | null {
const mpvWindows = clients.filter((client) => client.class === "mpv");
if (!this.targetMpvSocketPath) {
return mpvWindows[0] || null;
}
for (const mpvWindow of mpvWindows) {
if (!mpvWindow.pid) {
continue;
}
const commandLine = this.getWindowCommandLine(mpvWindow.pid);
if (!commandLine) {
continue;
}
if (
commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) ||
commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`)
) {
return mpvWindow;
}
}
return null;
}
private getWindowCommandLine(pid: number): string | null {
const commandLine = execSync(`ps -p ${pid} -o args=`, {
encoding: "utf-8",
}).trim();
return commandLine || null;
}
}

View File

@@ -47,6 +47,7 @@ function normalizeCompositor(value: string): Compositor | null {
export function createWindowTracker(
override?: string | null,
targetMpvSocketPath?: string | null,
): BaseWindowTracker | null {
let compositor = detectCompositor();
@@ -64,13 +65,21 @@ export function createWindowTracker(
switch (compositor) {
case "hyprland":
return new HyprlandWindowTracker();
return new HyprlandWindowTracker(
targetMpvSocketPath?.trim() || undefined,
);
case "sway":
return new SwayWindowTracker();
return new SwayWindowTracker(
targetMpvSocketPath?.trim() || undefined,
);
case "x11":
return new X11WindowTracker();
return new X11WindowTracker(
targetMpvSocketPath?.trim() || undefined,
);
case "macos":
return new MacOSWindowTracker();
return new MacOSWindowTracker(
targetMpvSocketPath?.trim() || undefined,
);
default:
log.warn("No supported compositor detected. Window tracking disabled.");
return null;

View File

@@ -32,9 +32,11 @@ export class MacOSWindowTracker extends BaseWindowTracker {
private helperType: "binary" | "swift" | null = null;
private lastExecErrorFingerprint: string | null = null;
private lastExecErrorLoggedAtMs = 0;
private readonly targetMpvSocketPath: string | null;
constructor() {
constructor(targetMpvSocketPath?: string) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
this.detectHelper();
}
@@ -85,6 +87,21 @@ export class MacOSWindowTracker extends BaseWindowTracker {
}
private detectHelper(): void {
const shouldFilterBySocket = this.targetMpvSocketPath !== null;
// Fall back to Swift helper first when filtering by socket path to avoid
// stale prebuilt binaries that don't support the new socket filter argument.
const swiftPath = path.join(
__dirname,
"..",
"..",
"scripts",
"get-mpv-window-macos.swift",
);
if (shouldFilterBySocket && this.tryUseHelper(swiftPath, "swift")) {
return;
}
// Prefer resources path (outside asar) in packaged apps.
const resourcesPath = process.resourcesPath;
if (resourcesPath) {
@@ -110,14 +127,8 @@ export class MacOSWindowTracker extends BaseWindowTracker {
return;
}
// Fall back to Swift script for development.
const swiftPath = path.join(
__dirname,
"..",
"..",
"scripts",
"get-mpv-window-macos.swift",
);
// Fall back to Swift script for development or if binary filtering is not
// supported in the current environment.
if (this.tryUseHelper(swiftPath, "swift")) {
return;
}
@@ -167,6 +178,9 @@ export class MacOSWindowTracker extends BaseWindowTracker {
// This works with both bundled and unbundled mpv installations
const command = this.helperType === "binary" ? this.helperPath : "swift";
const args = this.helperType === "binary" ? [] : [this.helperPath];
if (this.targetMpvSocketPath) {
args.push(this.targetMpvSocketPath);
}
execFile(
command,

View File

@@ -27,6 +27,7 @@ interface SwayRect {
}
interface SwayNode {
pid?: number;
app_id?: string;
window_properties?: { class?: string };
rect?: SwayRect;
@@ -36,6 +37,12 @@ interface SwayNode {
export class SwayWindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private readonly targetMpvSocketPath: string | null;
constructor(targetMpvSocketPath?: string) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
}
start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
@@ -49,33 +56,66 @@ export class SwayWindowTracker extends BaseWindowTracker {
}
}
private findMpvWindow(node: SwayNode): SwayNode | null {
private collectMpvWindows(node: SwayNode): SwayNode[] {
const windows: SwayNode[] = [];
if (node.app_id === "mpv" || node.window_properties?.class === "mpv") {
return node;
windows.push(node);
}
if (node.nodes) {
for (const child of node.nodes) {
const found = this.findMpvWindow(child);
if (found) return found;
windows.push(...this.collectMpvWindows(child));
}
}
if (node.floating_nodes) {
for (const child of node.floating_nodes) {
const found = this.findMpvWindow(child);
if (found) return found;
windows.push(...this.collectMpvWindows(child));
}
}
return null;
return windows;
}
private findTargetSocketWindow(node: SwayNode): SwayNode | null {
const windows = this.collectMpvWindows(node);
if (!this.targetMpvSocketPath) {
return windows[0] || null;
}
return windows.find((candidate) =>
this.isWindowForTargetSocket(candidate),
) || null;
}
private isWindowForTargetSocket(node: SwayNode): boolean {
if (!node.pid) {
return false;
}
const commandLine = this.getWindowCommandLine(node.pid);
if (!commandLine) {
return false;
}
return (
commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) ||
commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`)
);
}
private getWindowCommandLine(pid: number): string | null {
const commandLine = execSync(`ps -p ${pid} -o args=`, {
encoding: "utf-8",
}).trim();
return commandLine || null;
}
private pollGeometry(): void {
try {
const output = execSync("swaymsg -t get_tree", { encoding: "utf-8" });
const tree: SwayNode = JSON.parse(output);
const mpvWindow = this.findMpvWindow(tree);
const mpvWindow = this.findTargetSocketWindow(tree);
if (mpvWindow && mpvWindow.rect) {
this.updateGeometry({

View File

@@ -21,6 +21,12 @@ import { BaseWindowTracker } from "./base-tracker";
export class X11WindowTracker extends BaseWindowTracker {
private pollInterval: ReturnType<typeof setInterval> | null = null;
private readonly targetMpvSocketPath: string | null;
constructor(targetMpvSocketPath?: string) {
super();
this.targetMpvSocketPath = targetMpvSocketPath?.trim() || null;
}
start(): void {
this.pollInterval = setInterval(() => this.pollGeometry(), 250);
@@ -45,7 +51,17 @@ export class X11WindowTracker extends BaseWindowTracker {
return;
}
const windowId = windowIds.split("\n")[0];
const windowIdList = windowIds.split(/\s+/).filter(Boolean);
if (windowIdList.length === 0) {
this.updateGeometry(null);
return;
}
const windowId = this.findTargetWindowId(windowIdList);
if (!windowId) {
this.updateGeometry(null);
return;
}
const winInfo = execSync(`xwininfo -id ${windowId}`, {
encoding: "utf-8",
@@ -70,4 +86,55 @@ export class X11WindowTracker extends BaseWindowTracker {
this.updateGeometry(null);
}
}
private findTargetWindowId(windowIds: string[]): string | null {
if (!this.targetMpvSocketPath) {
return windowIds[0] ?? null;
}
for (const windowId of windowIds) {
if (this.isWindowForTargetSocket(windowId)) {
return windowId;
}
}
return null;
}
private isWindowForTargetSocket(windowId: string): boolean {
const pid = this.getWindowPid(windowId);
if (pid === null) {
return false;
}
const commandLine = this.getWindowCommandLine(pid);
if (!commandLine) {
return false;
}
return (
commandLine.includes(`--input-ipc-server=${this.targetMpvSocketPath}`) ||
commandLine.includes(`--input-ipc-server ${this.targetMpvSocketPath}`)
);
}
private getWindowPid(windowId: string): number | null {
const windowPid = execSync(`xprop -id ${windowId} _NET_WM_PID`, {
encoding: "utf-8",
});
const pidMatch = windowPid.match(/= (\d+)/);
if (!pidMatch) {
return null;
}
const pid = Number.parseInt(pidMatch[1], 10);
return Number.isInteger(pid) ? pid : null;
}
private getWindowCommandLine(pid: number): string | null {
const commandLine = execSync(`ps -p ${pid} -o args=`, {
encoding: "utf-8",
}).trim();
return commandLine || null;
}
}