mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
Track mpv overlays by configured socket window only
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import Foundation
|
||||
|
||||
private struct WindowGeometry {
|
||||
let x: Int
|
||||
@@ -19,6 +20,58 @@ private struct WindowGeometry {
|
||||
let height: Int
|
||||
}
|
||||
|
||||
private let targetMpvSocketPath: String? = {
|
||||
guard CommandLine.arguments.count > 1 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let value = CommandLine.arguments[1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return value.isEmpty ? nil : value
|
||||
}()
|
||||
|
||||
private func windowCommandLineForPid(_ pid: pid_t) -> String? {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/ps")
|
||||
process.arguments = ["-p", "\(pid)", "-o", "args="]
|
||||
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
process.standardError = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
|
||||
if process.terminationStatus != 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
guard let commandLine = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return commandLine
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "\n", with: " ")
|
||||
}
|
||||
|
||||
private func windowHasTargetSocket(_ pid: pid_t) -> Bool {
|
||||
guard let socketPath = targetMpvSocketPath else {
|
||||
return true
|
||||
}
|
||||
|
||||
guard let commandLine = windowCommandLineForPid(pid) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return commandLine.contains("--input-ipc-server=\(socketPath)") ||
|
||||
commandLine.contains("--input-ipc-server \(socketPath)")
|
||||
}
|
||||
|
||||
private func geometryFromRect(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat) -> WindowGeometry {
|
||||
let minX = Int(floor(x))
|
||||
let minY = Int(floor(y))
|
||||
@@ -93,6 +146,10 @@ private func geometryFromAccessibilityAPI() -> WindowGeometry? {
|
||||
|
||||
for app in runningApps {
|
||||
let appElement = AXUIElementCreateApplication(app.processIdentifier)
|
||||
if !windowHasTargetSocket(app.processIdentifier) {
|
||||
continue
|
||||
}
|
||||
|
||||
var windowsRef: CFTypeRef?
|
||||
let status = AXUIElementCopyAttributeValue(appElement, kAXWindowsAttribute as CFString, &windowsRef)
|
||||
guard status == .success, let windows = windowsRef as? [AXUIElement], !windows.isEmpty else {
|
||||
@@ -106,6 +163,15 @@ private func geometryFromAccessibilityAPI() -> WindowGeometry? {
|
||||
continue
|
||||
}
|
||||
|
||||
var windowPid: pid_t = 0
|
||||
if AXUIElementGetPid(window, &windowPid) != .success {
|
||||
continue
|
||||
}
|
||||
|
||||
if !windowHasTargetSocket(windowPid) {
|
||||
continue
|
||||
}
|
||||
|
||||
if let geometry = geometryFromAXWindow(window) {
|
||||
return geometry
|
||||
}
|
||||
@@ -127,6 +193,14 @@ private func geometryFromCoreGraphics() -> WindowGeometry? {
|
||||
continue
|
||||
}
|
||||
|
||||
guard let ownerPid = window[kCGWindowOwnerPID as String] as? pid_t else {
|
||||
continue
|
||||
}
|
||||
|
||||
if !windowHasTargetSocket(ownerPid) {
|
||||
continue
|
||||
}
|
||||
|
||||
if let layer = window[kCGWindowLayer as String] as? Int, layer != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
498
src/main.ts
498
src/main.ts
@@ -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),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user