mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
fix(controller): pick up macOS presses while mpv is in front (#321)
This commit is contained in:
1 parent
fa3051f9cb
commit
8e29cb3c55
17 files changed
+614
-63
No files matched your search
@@ -49,7 +49,7 @@ Start here, then leave this file.
|
||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||
- Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb`
|
||||
- User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build`
|
||||
- macOS mpv window helper (`scripts/get-mpv-window-macos.swift`): compile it on macOS with `bun run build`
|
||||
- macOS Swift helpers (`scripts/get-mpv-window-macos.swift`, `scripts/macos-controller-watch.swift`): compile them on macOS with `bun run build`
|
||||
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
|
||||
|
||||
## Docs Upkeep
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- On macOS, controller buttons now work while mpv is in front: a press brings the overlay forward and runs that button's action, instead of doing nothing until you click the overlay.
|
||||
@@ -195,6 +195,8 @@ The media timing review and duplicate card (field grouping) dialogs have their o
|
||||
|
||||
On controllers that report the W3C standard layout, the default quit button lands on `L2` instead of `Select`, and the default pause button on `Start` instead of `L3`. The default right-stick popup jump does nothing on these controllers; click **Learn** on **Popup Jump (Stick)** and move the right stick up or down to set it. Remap anything else with `Alt+C`, which names buttons according to the layout your controller reports. All options are in [Configuration](/configuration#controller-support).
|
||||
|
||||
On macOS, a controller only reaches the app in front. When mpv is in front, a controller button brings the overlay to the front and then runs that button's action. With more than one controller connected, that first press only brings the overlay to the front; press the button again to run its action. Stick movements only work once the overlay is in front. Buttons pressed while another app is in front are left alone.
|
||||
|
||||
## Development profiles
|
||||
|
||||
`--dev` and `--debug` use a separate `SubMiner-dev` profile. They do not read or modify dictionaries and configuration from the installed app unless `SUBMINER_USE_PRODUCTION_PROFILE=1` is explicitly set. See [Development](/development#run-locally).
|
||||
|
||||
@@ -185,6 +185,10 @@
|
||||
{
|
||||
"from": "dist/scripts/get-mpv-window-macos",
|
||||
"to": "scripts/get-mpv-window-macos"
|
||||
},
|
||||
{
|
||||
"from": "dist/scripts/macos-controller-watch",
|
||||
"to": "scripts/macos-controller-watch"
|
||||
}
|
||||
],
|
||||
"files": [
|
||||
|
||||
@@ -4,6 +4,8 @@ const path = require('node:path');
|
||||
|
||||
const LINUX_FFMPEG_LIBRARY = 'libffmpeg.so';
|
||||
const MACOS_WINDOW_HELPER = 'get-mpv-window-macos';
|
||||
const MACOS_CONTROLLER_HELPER = 'macos-controller-watch';
|
||||
const MACOS_HELPERS = [MACOS_WINDOW_HELPER, MACOS_CONTROLLER_HELPER];
|
||||
const DEFAULT_MACOS_APP_BUNDLE = 'SubMiner.app';
|
||||
|
||||
function resolveMacOSAppBundlePath(context) {
|
||||
@@ -52,7 +54,7 @@ async function stageLinuxAppImageSharedLibrary(
|
||||
return true;
|
||||
}
|
||||
|
||||
async function verifyMacOSWindowHelper(
|
||||
async function verifyMacOSHelpers(
|
||||
context,
|
||||
deps = {
|
||||
access: (filePath, mode) => fs.access(filePath, mode),
|
||||
@@ -60,27 +62,28 @@ async function verifyMacOSWindowHelper(
|
||||
) {
|
||||
if (context.electronPlatformName !== 'darwin') return false;
|
||||
|
||||
const appBundlePath = resolveMacOSAppBundlePath(context);
|
||||
const helperPath = path.join(
|
||||
appBundlePath,
|
||||
const scriptsDir = path.join(
|
||||
resolveMacOSAppBundlePath(context),
|
||||
'Contents',
|
||||
'Resources',
|
||||
'scripts',
|
||||
MACOS_WINDOW_HELPER,
|
||||
);
|
||||
|
||||
try {
|
||||
await deps.access(helperPath, fsSync.constants.X_OK);
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
throw new Error(
|
||||
`macOS packaging requires ${MACOS_WINDOW_HELPER} at ${helperPath}. Run bun run build:assets on macOS with swiftc available before packaging.`,
|
||||
);
|
||||
for (const helperName of MACOS_HELPERS) {
|
||||
const helperPath = path.join(scriptsDir, helperName);
|
||||
try {
|
||||
await deps.access(helperPath, fsSync.constants.X_OK);
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
throw new Error(
|
||||
`macOS packaging requires ${helperName} at ${helperPath}. Run bun run build:assets on macOS with swiftc available before packaging.`,
|
||||
);
|
||||
}
|
||||
if (error && typeof error === 'object' && error.code === 'EACCES') {
|
||||
throw new Error(`macOS helper is not executable: ${helperPath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (error && typeof error === 'object' && error.code === 'EACCES') {
|
||||
throw new Error(`macOS window helper is not executable: ${helperPath}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -103,17 +106,17 @@ async function stageBundledBunRuntime(context, deps = {}) {
|
||||
|
||||
async function afterPack(context, deps = {}) {
|
||||
await stageLinuxAppImageSharedLibrary(context);
|
||||
await verifyMacOSWindowHelper(context);
|
||||
await verifyMacOSHelpers(context);
|
||||
await stageBundledBunRuntime(context, deps);
|
||||
await (deps.auditPackage ?? require('./package-audit.cjs').auditPackage)(context);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LINUX_FFMPEG_LIBRARY,
|
||||
MACOS_WINDOW_HELPER,
|
||||
MACOS_HELPERS,
|
||||
resolveMacOSAppBundlePath,
|
||||
stageBundledBunRuntime,
|
||||
stageLinuxAppImageSharedLibrary,
|
||||
verifyMacOSWindowHelper,
|
||||
verifyMacOSHelpers,
|
||||
default: afterPack,
|
||||
};
|
||||
@@ -6,13 +6,13 @@ import test from 'node:test';
|
||||
|
||||
const {
|
||||
LINUX_FFMPEG_LIBRARY,
|
||||
MACOS_WINDOW_HELPER,
|
||||
MACOS_HELPERS,
|
||||
default: afterPack,
|
||||
stageLinuxAppImageSharedLibrary,
|
||||
verifyMacOSWindowHelper,
|
||||
verifyMacOSHelpers,
|
||||
} = require('./electron-builder-after-pack.cjs') as {
|
||||
LINUX_FFMPEG_LIBRARY: string;
|
||||
MACOS_WINDOW_HELPER: string;
|
||||
MACOS_HELPERS: string[];
|
||||
default: (
|
||||
context: {
|
||||
appOutDir: string;
|
||||
@@ -34,7 +34,7 @@ const {
|
||||
appOutDir: string;
|
||||
electronPlatformName: string;
|
||||
}) => Promise<boolean>;
|
||||
verifyMacOSWindowHelper: (context: {
|
||||
verifyMacOSHelpers: (context: {
|
||||
appOutDir: string;
|
||||
electronPlatformName: string;
|
||||
}) => Promise<boolean>;
|
||||
@@ -88,24 +88,19 @@ test('stageLinuxAppImageSharedLibrary skips non-Linux packaging contexts', async
|
||||
}
|
||||
});
|
||||
|
||||
test('verifyMacOSWindowHelper accepts packaged macOS helper binary', async () => {
|
||||
test('verifyMacOSHelpers accepts packaged macOS helper binaries', async () => {
|
||||
const workspace = createWorkspace('subminer-after-pack-macos-helper');
|
||||
const appOutDir = path.join(workspace, 'SubMiner-darwin-arm64');
|
||||
const helperPath = path.join(
|
||||
appOutDir,
|
||||
'SubMiner.app',
|
||||
'Contents',
|
||||
'Resources',
|
||||
'scripts',
|
||||
MACOS_WINDOW_HELPER,
|
||||
);
|
||||
const scriptsDir = path.join(appOutDir, 'SubMiner.app', 'Contents', 'Resources', 'scripts');
|
||||
|
||||
fs.mkdirSync(path.dirname(helperPath), { recursive: true });
|
||||
fs.writeFileSync(helperPath, 'compiled helper', 'utf8');
|
||||
fs.chmodSync(helperPath, 0o755);
|
||||
fs.mkdirSync(scriptsDir, { recursive: true });
|
||||
for (const helperName of MACOS_HELPERS) {
|
||||
fs.writeFileSync(path.join(scriptsDir, helperName), 'compiled helper', 'utf8');
|
||||
fs.chmodSync(path.join(scriptsDir, helperName), 0o755);
|
||||
}
|
||||
|
||||
try {
|
||||
const verified = await verifyMacOSWindowHelper({
|
||||
const verified = await verifyMacOSHelpers({
|
||||
appOutDir,
|
||||
electronPlatformName: 'darwin',
|
||||
});
|
||||
@@ -116,19 +111,22 @@ test('verifyMacOSWindowHelper accepts packaged macOS helper binary', async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test('verifyMacOSWindowHelper throws when macOS helper binary is missing', async () => {
|
||||
test('verifyMacOSHelpers throws when a macOS helper binary is missing', async () => {
|
||||
const workspace = createWorkspace('subminer-after-pack-macos-helper-missing');
|
||||
const appOutDir = path.join(workspace, 'SubMiner-darwin-arm64');
|
||||
const scriptsDir = path.join(appOutDir, 'SubMiner.app', 'Contents', 'Resources', 'scripts');
|
||||
|
||||
fs.mkdirSync(path.join(appOutDir, 'SubMiner.app', 'Contents', 'Resources'), { recursive: true });
|
||||
fs.mkdirSync(scriptsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(scriptsDir, 'get-mpv-window-macos'), 'compiled helper', 'utf8');
|
||||
fs.chmodSync(path.join(scriptsDir, 'get-mpv-window-macos'), 0o755);
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
verifyMacOSWindowHelper({
|
||||
verifyMacOSHelpers({
|
||||
appOutDir,
|
||||
electronPlatformName: 'darwin',
|
||||
}),
|
||||
/macOS packaging requires get-mpv-window-macos/,
|
||||
/macOS packaging requires macos-controller-watch/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(workspace, { recursive: true, force: true });
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env swift
|
||||
//
|
||||
// macos-controller-watch.swift
|
||||
// SubMiner - Report controller button presses on macOS while SubMiner is in the background
|
||||
//
|
||||
// macOS only delivers GameController input to the frontmost app, so the overlay sees no
|
||||
// presses while mpv is in front. This helper opts in to background events and prints one
|
||||
// line per press so SubMiner can focus the overlay and replay the press there.
|
||||
//
|
||||
// Usage: macos-controller-watch (long-running; exits when stdin closes)
|
||||
// Output: "press <index> <connected pads>" per button press, using the W3C standard gamepad
|
||||
// button indices. SubMiner only replays a press when one pad is connected: nothing identifies
|
||||
// a pad to both GameController and Chromium, so with several it could hit the wrong one.
|
||||
//
|
||||
|
||||
import Cocoa
|
||||
import Foundation
|
||||
import GameController
|
||||
|
||||
private func emit(_ line: String) {
|
||||
FileHandle.standardOutput.write(Data((line + "\n").utf8))
|
||||
}
|
||||
|
||||
/// Standard gamepad mapping (https://w3c.github.io/gamepad/#remapping), matching the
|
||||
/// indices Chromium reports for the same pad.
|
||||
private func standardButtons(_ pad: GCExtendedGamepad) -> [(Int, GCControllerButtonInput?)] {
|
||||
return [
|
||||
(0, pad.buttonA),
|
||||
(1, pad.buttonB),
|
||||
(2, pad.buttonX),
|
||||
(3, pad.buttonY),
|
||||
(4, pad.leftShoulder),
|
||||
(5, pad.rightShoulder),
|
||||
(6, pad.leftTrigger),
|
||||
(7, pad.rightTrigger),
|
||||
(8, pad.buttonOptions),
|
||||
(9, pad.buttonMenu),
|
||||
(10, pad.leftThumbstickButton),
|
||||
(11, pad.rightThumbstickButton),
|
||||
(12, pad.dpad.up),
|
||||
(13, pad.dpad.down),
|
||||
(14, pad.dpad.left),
|
||||
(15, pad.dpad.right),
|
||||
(16, pad.buttonHome),
|
||||
]
|
||||
}
|
||||
|
||||
private func connectedPadCount() -> Int {
|
||||
return GCController.controllers().filter { $0.extendedGamepad != nil }.count
|
||||
}
|
||||
|
||||
private func watch(_ controller: GCController) {
|
||||
guard let pad = controller.extendedGamepad else { return }
|
||||
for (index, button) in standardButtons(pad) {
|
||||
button?.pressedChangedHandler = { _, _, pressed in
|
||||
if pressed {
|
||||
emit("press \(index) \(connectedPadCount())")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Exit with SubMiner: it holds our stdin open for as long as it runs.
|
||||
private func exitWhenStdinCloses() {
|
||||
Thread.detachNewThread {
|
||||
while !FileHandle.standardInput.availableData.isEmpty {}
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
let app = NSApplication.shared
|
||||
app.setActivationPolicy(.prohibited)
|
||||
GCController.shouldMonitorBackgroundEvents = true
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .GCControllerDidConnect,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { notification in
|
||||
if let controller = notification.object as? GCController {
|
||||
watch(controller)
|
||||
}
|
||||
}
|
||||
GCController.controllers().forEach(watch)
|
||||
|
||||
exitWhenStdinCloses()
|
||||
app.run()
|
||||
@@ -14,9 +14,8 @@ const settingsOutputDir = path.join(repoRoot, 'dist', 'settings');
|
||||
const syncUiSourceDir = path.join(repoRoot, 'src', 'syncui');
|
||||
const syncUiOutputDir = path.join(repoRoot, 'dist', 'syncui');
|
||||
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
|
||||
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
|
||||
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
|
||||
const macosHelperSourceCopyPath = path.join(scriptsOutputDir, 'get-mpv-window-macos.swift');
|
||||
// Swift helpers compiled on macOS and shipped in Resources/scripts.
|
||||
const MACOS_HELPERS = ['get-mpv-window-macos', 'macos-controller-watch'];
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
@@ -45,9 +44,10 @@ function copySyncUiAssets() {
|
||||
copyAssets(syncUiSourceDir, syncUiOutputDir, 'syncui');
|
||||
}
|
||||
|
||||
function fallbackToMacosSource() {
|
||||
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
|
||||
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
|
||||
function fallbackToMacosSource(helperName) {
|
||||
const sourceCopyPath = path.join(scriptsOutputDir, `${helperName}.swift`);
|
||||
copyFile(path.join(scriptDir, `${helperName}.swift`), sourceCopyPath);
|
||||
process.stdout.write(`Staged macOS helper source fallback: ${sourceCopyPath}\n`);
|
||||
}
|
||||
|
||||
// Pin the minimum macOS to the app's own floor (Electron's `minos`). Without an
|
||||
@@ -64,34 +64,34 @@ function shouldSkipMacosHelperBuild() {
|
||||
return process.env.SUBMINER_SKIP_MACOS_HELPER_BUILD === '1';
|
||||
}
|
||||
|
||||
function buildMacosHelper() {
|
||||
function buildMacosHelper(helperName) {
|
||||
if (shouldSkipMacosHelperBuild()) {
|
||||
process.stdout.write('Skipping macOS helper build (SUBMINER_SKIP_MACOS_HELPER_BUILD=1)\n');
|
||||
fallbackToMacosSource();
|
||||
fallbackToMacosSource(helperName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
process.stdout.write('Skipping macOS helper build (not on macOS)\n');
|
||||
fallbackToMacosSource();
|
||||
fallbackToMacosSource(helperName);
|
||||
return;
|
||||
}
|
||||
|
||||
ensureDir(scriptsOutputDir);
|
||||
const sourcePath = path.join(scriptDir, `${helperName}.swift`);
|
||||
const binaryPath = path.join(scriptsOutputDir, helperName);
|
||||
|
||||
try {
|
||||
execFileSync(
|
||||
'swiftc',
|
||||
['-O', '-target', macosHelperTarget(), macosHelperSourcePath, '-o', macosHelperBinaryPath],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
},
|
||||
);
|
||||
fs.chmodSync(macosHelperBinaryPath, 0o755);
|
||||
process.stdout.write(`Built macOS helper: ${macosHelperBinaryPath}\n`);
|
||||
execFileSync('swiftc', ['-O', '-target', macosHelperTarget(), sourcePath, '-o', binaryPath], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
fs.chmodSync(binaryPath, 0o755);
|
||||
process.stdout.write(`Built macOS helper: ${binaryPath}\n`);
|
||||
} catch (error) {
|
||||
process.stdout.write('Failed to compile macOS helper; using source fallback.\n');
|
||||
fallbackToMacosSource();
|
||||
process.stdout.write(`Failed to compile macOS helper ${helperName}; using source fallback.\n`);
|
||||
// A binary from an earlier build would otherwise pass the after-pack check and ship stale.
|
||||
fs.rmSync(binaryPath, { force: true });
|
||||
fallbackToMacosSource(helperName);
|
||||
if (error instanceof Error) {
|
||||
process.stderr.write(`${error.message}\n`);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ function main() {
|
||||
copyRendererAssets();
|
||||
copySettingsAssets();
|
||||
copySyncUiAssets();
|
||||
buildMacosHelper();
|
||||
MACOS_HELPERS.forEach(buildMacosHelper);
|
||||
stageIntegrationKeys();
|
||||
}
|
||||
|
||||
|
||||
+39
@@ -35,6 +35,11 @@ import { createDiscordRpcClient } from './main/runtime/discord-rpc-client.js';
|
||||
import { startAppControlServer } from './main/runtime/app-control-server';
|
||||
import { getAppControlSocketPath } from './shared/app-control';
|
||||
import { focusMacOSOverlayWindow } from './main/runtime/macos-overlay-window-focus';
|
||||
import {
|
||||
handleBackgroundControllerPress,
|
||||
resolveMacOSControllerWatchCommand,
|
||||
startMacOSControllerWatch,
|
||||
} from './main/runtime/macos-controller-watch';
|
||||
import { restoreMacOSMpvFocusAfterModalClose } from './main/runtime/macos-modal-focus-handoff';
|
||||
import {
|
||||
getDefaultPasswordStore,
|
||||
@@ -2417,3 +2422,37 @@ registerSubtitleGenerationIpc({
|
||||
openModal: () => openSubtitleGenerationModal(overlayModals.createOverlayHostedModalOpenDeps()),
|
||||
});
|
||||
app.on('before-quit', () => subtitleGenerationRuntime.cancel());
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
void app.whenReady().then(() => {
|
||||
const command = resolveMacOSControllerWatchCommand({
|
||||
resourcesPath: process.resourcesPath,
|
||||
mainDirname: __dirname,
|
||||
});
|
||||
if (!command) {
|
||||
logger.warn('macOS controller watch helper not found; controller needs overlay focus');
|
||||
return;
|
||||
}
|
||||
const controllerWatch = startMacOSControllerWatch({
|
||||
command,
|
||||
logger,
|
||||
onPress: (press) =>
|
||||
handleBackgroundControllerPress(press, {
|
||||
isControllerEnabled: () => configService.getConfig().controller.enabled,
|
||||
isMpvFocused: () => appState.windowTracker?.isTargetWindowFocused() ?? false,
|
||||
getModalWindow: () => overlayManager.getModalWindow(),
|
||||
getMainWindow: () => overlayManager.getMainWindow(),
|
||||
focusWindow: (window) =>
|
||||
focusMacOSOverlayWindow({
|
||||
platform: process.platform,
|
||||
getOverlayWindow: () => window,
|
||||
stealAppFocus: () => app.focus({ steal: true }),
|
||||
warn: (message, details) => logger.warn(message, details),
|
||||
}),
|
||||
replayPress: (window, index) =>
|
||||
window.webContents.send(IPC_CHANNELS.event.controllerBackgroundPress, index),
|
||||
}),
|
||||
});
|
||||
app.on('will-quit', () => controllerWatch.stop());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
handleBackgroundControllerPress,
|
||||
parseControllerWatchLine,
|
||||
resolveMacOSControllerWatchCommand,
|
||||
} from './macos-controller-watch';
|
||||
|
||||
test('parseControllerWatchLine reads press lines only', () => {
|
||||
assert.deepEqual(parseControllerWatchLine('press 7 2\n'), { buttonIndex: 7, controllerCount: 2 });
|
||||
assert.equal(parseControllerWatchLine('press 7'), null);
|
||||
assert.equal(parseControllerWatchLine('release 7 1'), null);
|
||||
});
|
||||
|
||||
function fakeFs(files: Record<string, number>) {
|
||||
return {
|
||||
existsSync: (filePath: string) => filePath in files,
|
||||
statSync: (filePath: string) => ({ mtimeMs: files[filePath] }),
|
||||
} as unknown as Parameters<typeof resolveMacOSControllerWatchCommand>[1];
|
||||
}
|
||||
|
||||
test('resolveMacOSControllerWatchCommand prefers the packaged binary', () => {
|
||||
const packaged = path.join('/res', 'scripts', 'macos-controller-watch');
|
||||
assert.deepEqual(
|
||||
resolveMacOSControllerWatchCommand(
|
||||
{ resourcesPath: '/res', mainDirname: '/repo/dist' },
|
||||
fakeFs({ [packaged]: 1 }),
|
||||
),
|
||||
{ command: packaged, args: [] },
|
||||
);
|
||||
});
|
||||
|
||||
test('resolveMacOSControllerWatchCommand runs the source when the build is stale', () => {
|
||||
const built = path.join('/repo/dist', 'scripts', 'macos-controller-watch');
|
||||
const source = path.join('/repo', 'scripts', 'macos-controller-watch.swift');
|
||||
const options = { resourcesPath: undefined, mainDirname: '/repo/dist' };
|
||||
|
||||
assert.deepEqual(
|
||||
resolveMacOSControllerWatchCommand(options, fakeFs({ [built]: 2, [source]: 1 })),
|
||||
{
|
||||
command: built,
|
||||
args: [],
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
resolveMacOSControllerWatchCommand(options, fakeFs({ [built]: 1, [source]: 2 })),
|
||||
{
|
||||
command: 'swift',
|
||||
args: [source],
|
||||
},
|
||||
);
|
||||
assert.equal(resolveMacOSControllerWatchCommand(options, fakeFs({})), null);
|
||||
});
|
||||
|
||||
function window(name: string, visible = true) {
|
||||
return { name, isDestroyed: () => false, isVisible: () => visible };
|
||||
}
|
||||
|
||||
function run(overrides: {
|
||||
controllerCount?: number;
|
||||
enabled?: boolean;
|
||||
mpvFocused?: boolean;
|
||||
modal?: ReturnType<typeof window> | null;
|
||||
main?: ReturnType<typeof window> | null;
|
||||
}): string[] {
|
||||
const calls: string[] = [];
|
||||
const press = { buttonIndex: 4, controllerCount: overrides.controllerCount ?? 1 };
|
||||
handleBackgroundControllerPress(press, {
|
||||
isControllerEnabled: () => overrides.enabled ?? true,
|
||||
isMpvFocused: () => overrides.mpvFocused ?? true,
|
||||
getModalWindow: () => overrides.modal ?? null,
|
||||
getMainWindow: () => (overrides.main === undefined ? window('main') : overrides.main),
|
||||
focusWindow: (target) => calls.push(`focus:${target.name}`),
|
||||
replayPress: (target, index) => calls.push(`replay:${target.name}:${index}`),
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
test('a press while mpv is in front focuses the overlay and replays it', () => {
|
||||
assert.deepEqual(run({}), ['focus:main', 'replay:main:4']);
|
||||
});
|
||||
|
||||
test('with several pads connected a press focuses the overlay without replaying', () => {
|
||||
assert.deepEqual(run({ controllerCount: 2 }), ['focus:main']);
|
||||
});
|
||||
|
||||
test('an open modal window takes the press instead of the main overlay', () => {
|
||||
assert.deepEqual(run({ modal: window('modal') }), ['focus:modal', 'replay:modal:4']);
|
||||
});
|
||||
|
||||
test('presses are ignored unless mpv is in front, the controller is on and an overlay shows', () => {
|
||||
assert.deepEqual(run({ mpvFocused: false }), []);
|
||||
assert.deepEqual(run({ enabled: false }), []);
|
||||
assert.deepEqual(run({ main: window('main', false) }), []);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { spawn as spawnProcess } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as readline from 'node:readline';
|
||||
import { isCompiledMacOSHelperCurrent } from '../../window-trackers/macos-tracker';
|
||||
|
||||
// macOS only delivers GameController input to the frontmost app, so the overlay's Gamepad API
|
||||
// sees nothing while mpv is in front. `scripts/macos-controller-watch.swift` opts in to
|
||||
// background events and reports each button press; SubMiner then focuses the overlay (after
|
||||
// which Chromium reads the pad itself) and replays that first press there.
|
||||
|
||||
const HELPER_NAME = 'macos-controller-watch';
|
||||
|
||||
export type MacOSControllerWatchCommand = { command: string; args: string[] };
|
||||
|
||||
/**
|
||||
* Finds the helper: the packaged binary in Resources, a compiled build in dist/scripts that is
|
||||
* newer than its source, or the Swift source run through `swift` in a dev checkout.
|
||||
*/
|
||||
export function resolveMacOSControllerWatchCommand(
|
||||
options: {
|
||||
resourcesPath: string | undefined;
|
||||
/** Directory holding the built main process (dist/ in a checkout). */
|
||||
mainDirname: string;
|
||||
},
|
||||
helperFs: Pick<typeof fs, 'existsSync' | 'statSync'> = fs,
|
||||
): MacOSControllerWatchCommand | null {
|
||||
const sourcePath = path.join(options.mainDirname, '..', 'scripts', `${HELPER_NAME}.swift`);
|
||||
if (options.resourcesPath) {
|
||||
const packagedPath = path.join(options.resourcesPath, 'scripts', HELPER_NAME);
|
||||
if (helperFs.existsSync(packagedPath)) return { command: packagedPath, args: [] };
|
||||
}
|
||||
const builtPath = path.join(options.mainDirname, 'scripts', HELPER_NAME);
|
||||
if (isCompiledMacOSHelperCurrent(builtPath, sourcePath, helperFs)) {
|
||||
return { command: builtPath, args: [] };
|
||||
}
|
||||
if (helperFs.existsSync(sourcePath)) return { command: 'swift', args: [sourcePath] };
|
||||
return null;
|
||||
}
|
||||
|
||||
export type ControllerWatchPress = { buttonIndex: number; controllerCount: number };
|
||||
|
||||
/** Parses a helper line ("press <standard-mapping button index> <connected pads>"). */
|
||||
export function parseControllerWatchLine(line: string): ControllerWatchPress | null {
|
||||
const match = /^press (\d+) (\d+)$/.exec(line.trim());
|
||||
return match ? { buttonIndex: Number(match[1]), controllerCount: Number(match[2]) } : null;
|
||||
}
|
||||
|
||||
type ControllerWatchLogger = {
|
||||
info: (message: string, ...args: unknown[]) => void;
|
||||
warn: (message: string, ...args: unknown[]) => void;
|
||||
};
|
||||
|
||||
/** Runs the helper until `stop` is called; the helper also exits when SubMiner does. */
|
||||
export function startMacOSControllerWatch(options: {
|
||||
command: MacOSControllerWatchCommand;
|
||||
onPress: (press: ControllerWatchPress) => void;
|
||||
logger: ControllerWatchLogger;
|
||||
spawn?: typeof spawnProcess;
|
||||
}): { stop: () => void } {
|
||||
const spawn = options.spawn ?? spawnProcess;
|
||||
const child = spawn(options.command.command, options.command.args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
let stopped = false;
|
||||
|
||||
readline.createInterface({ input: child.stdout }).on('line', (line) => {
|
||||
const press = parseControllerWatchLine(line);
|
||||
if (press) options.onPress(press);
|
||||
});
|
||||
readline.createInterface({ input: child.stderr }).on('line', (line) => {
|
||||
options.logger.warn(`[${HELPER_NAME}] ${line}`);
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
options.logger.warn(`Failed to start ${HELPER_NAME}`, error);
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
if (!stopped) options.logger.warn(`${HELPER_NAME} exited`, { code, signal });
|
||||
});
|
||||
options.logger.info(`Started ${HELPER_NAME}: ${options.command.command}`);
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
child.stdin?.end();
|
||||
child.kill();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type FocusableWindow = {
|
||||
isDestroyed: () => boolean;
|
||||
isVisible: () => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A press the overlay missed because mpv was in front: focus the overlay window that owns the
|
||||
* controller (a visible modal, else the main overlay) and replay the press in it. Presses made
|
||||
* while another app is in front are left alone, so a pad used elsewhere is not hijacked. With
|
||||
* several pads connected the press only focuses: it might not be from the pad the overlay reads.
|
||||
*/
|
||||
export function handleBackgroundControllerPress<TWindow extends FocusableWindow>(
|
||||
press: ControllerWatchPress,
|
||||
deps: {
|
||||
isControllerEnabled: () => boolean;
|
||||
isMpvFocused: () => boolean;
|
||||
getModalWindow: () => TWindow | null;
|
||||
getMainWindow: () => TWindow | null;
|
||||
focusWindow: (window: TWindow) => void;
|
||||
replayPress: (window: TWindow, buttonIndex: number) => void;
|
||||
},
|
||||
): void {
|
||||
if (!deps.isControllerEnabled() || !deps.isMpvFocused()) return;
|
||||
const target = [deps.getModalWindow(), deps.getMainWindow()].find(
|
||||
(window): window is TWindow => window !== null && !window.isDestroyed() && window.isVisible(),
|
||||
);
|
||||
if (!target) return;
|
||||
deps.focusWindow(target);
|
||||
if (press.controllerCount === 1) deps.replayPress(target, press.buttonIndex);
|
||||
}
|
||||
@@ -197,6 +197,10 @@ const onPlaybackPauseChangedEvent = createQueuedIpcListenerWithPayload<boolean>(
|
||||
const onControllerConfigChangedEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.controllerConfigChanged,
|
||||
);
|
||||
const onControllerBackgroundPressEvent = createQueuedIpcListenerWithPayload<number>(
|
||||
IPC_CHANNELS.event.controllerBackgroundPress,
|
||||
(payload) => (typeof payload === 'number' && Number.isInteger(payload) ? payload : -1),
|
||||
);
|
||||
const onKeyboardModeToggleRequestedEvent = createQueuedIpcListener(
|
||||
IPC_CHANNELS.event.keyboardModeToggleRequested,
|
||||
);
|
||||
@@ -509,6 +513,7 @@ const electronAPI: ElectronAPI = {
|
||||
onKeyboardModeToggleRequested: onKeyboardModeToggleRequestedEvent,
|
||||
onControllerSuspendedChanged: onControllerSuspendedChangedEvent,
|
||||
onControllerConfigChanged: onControllerConfigChangedEvent,
|
||||
onControllerBackgroundPress: onControllerBackgroundPressEvent,
|
||||
onPlaybackPauseChanged: onPlaybackPauseChangedEvent,
|
||||
onLookupWindowToggleRequested: onLookupWindowToggleRequestedEvent,
|
||||
getPlaylistBrowserSnapshot: (): Promise<PlaylistBrowserSnapshot> =>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createControllerPressReplay } from './controller-press-replay';
|
||||
import type { GamepadLike } from './gamepad-controller';
|
||||
|
||||
function pad(pressed: number[] = [], mapping = 'standard'): GamepadLike {
|
||||
return {
|
||||
id: 'pad',
|
||||
index: 0,
|
||||
connected: true,
|
||||
mapping,
|
||||
axes: [0, 0, 0, 0],
|
||||
buttons: Array.from({ length: 17 }, (_, index) => ({
|
||||
value: pressed.includes(index) ? 1 : 0,
|
||||
pressed: pressed.includes(index),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const pressedIndices = (gamepad: GamepadLike | null | undefined): number[] =>
|
||||
gamepad?.buttons.flatMap((button, index) => (button.pressed ? [index] : [])) ?? [];
|
||||
|
||||
test('a queued press reads as pressed for exactly one poll', () => {
|
||||
const replay = createControllerPressReplay();
|
||||
replay.queue(0, 1000);
|
||||
|
||||
assert.deepEqual(pressedIndices(replay.apply([pad()], 1010)[0]), [0]);
|
||||
assert.deepEqual(pressedIndices(replay.apply([pad()], 1026)[0]), []);
|
||||
});
|
||||
|
||||
test('a press the Gamepad API already reported is not replayed', () => {
|
||||
const replay = createControllerPressReplay();
|
||||
replay.apply([pad([1])], 990);
|
||||
replay.queue(1, 1000);
|
||||
|
||||
assert.deepEqual(pressedIndices(replay.apply([pad()], 1010)[0]), []);
|
||||
});
|
||||
|
||||
test('a press seen after the replay was queued is not replayed again', () => {
|
||||
const replay = createControllerPressReplay();
|
||||
replay.queue(2, 1000);
|
||||
|
||||
assert.deepEqual(pressedIndices(replay.apply([pad([2])], 1010)[0]), [2]);
|
||||
assert.deepEqual(pressedIndices(replay.apply([pad()], 1026)[0]), []);
|
||||
});
|
||||
|
||||
test('an older press of the same button does not block the replay', () => {
|
||||
const replay = createControllerPressReplay();
|
||||
replay.apply([pad([3])], 0);
|
||||
replay.queue(3, 5000);
|
||||
|
||||
assert.deepEqual(pressedIndices(replay.apply([pad()], 5010)[0]), [3]);
|
||||
});
|
||||
|
||||
test('a replayed native pad keeps its fields', () => {
|
||||
// Like the browser's Gamepad, these fields are prototype getters that object spread skips.
|
||||
class NativePad implements GamepadLike {
|
||||
get id() {
|
||||
return 'pad';
|
||||
}
|
||||
get index() {
|
||||
return 2;
|
||||
}
|
||||
get connected() {
|
||||
return true;
|
||||
}
|
||||
get mapping() {
|
||||
return 'standard';
|
||||
}
|
||||
get timestamp() {
|
||||
return 900;
|
||||
}
|
||||
get axes() {
|
||||
return [0.5, 0];
|
||||
}
|
||||
get buttons() {
|
||||
return pad().buttons;
|
||||
}
|
||||
}
|
||||
const replay = createControllerPressReplay();
|
||||
replay.queue(0, 1000);
|
||||
|
||||
const replayed = replay.apply([new NativePad()], 1010)[0];
|
||||
assert.deepEqual(pressedIndices(replayed), [0]);
|
||||
assert.equal(replayed?.id, 'pad');
|
||||
assert.equal(replayed?.index, 2);
|
||||
assert.equal(replayed?.connected, true);
|
||||
assert.equal(replayed?.mapping, 'standard');
|
||||
assert.equal(replayed?.timestamp, 900);
|
||||
assert.deepEqual(replayed?.axes, [0.5, 0]);
|
||||
});
|
||||
|
||||
test('nothing is replayed while several pads are connected', () => {
|
||||
const replay = createControllerPressReplay();
|
||||
replay.queue(0, 1000);
|
||||
|
||||
const pads = replay.apply([pad(), { ...pad(), index: 1 }], 1010);
|
||||
assert.deepEqual(pads.map(pressedIndices), [[], []]);
|
||||
assert.deepEqual(pressedIndices(replay.apply([pad()], 1026)[0]), []);
|
||||
});
|
||||
|
||||
test('pads without the standard mapping are left alone', () => {
|
||||
const replay = createControllerPressReplay();
|
||||
replay.queue(0, 1000);
|
||||
|
||||
assert.deepEqual(pressedIndices(replay.apply([pad([], '')], 1010)[0]), []);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { GamepadLike } from './gamepad-controller.js';
|
||||
|
||||
/**
|
||||
* How far back a press the Gamepad API reported itself still counts as the same press the
|
||||
* main process is replaying. Covers the helper -> main -> renderer IPC hop.
|
||||
*/
|
||||
const SAME_PRESS_WINDOW_MS = 500;
|
||||
|
||||
/**
|
||||
* Replays macOS background presses (see `src/main/runtime/macos-controller-watch.ts`) into the
|
||||
* controller poll. A queued button reads as pressed for one poll, so the controller sees a
|
||||
* normal press edge; the next poll reads the real pad again, so a button still held does not
|
||||
* fire twice. A press the Gamepad API already reported is dropped.
|
||||
*
|
||||
* Only a lone standard-mapping pad gets the replay: the press carries no pad identity, so with
|
||||
* several visible it could run an action on the wrong one. With none visible yet, or a pad
|
||||
* mapped differently, the press only served to focus the overlay.
|
||||
*/
|
||||
export function createControllerPressReplay() {
|
||||
const pendingPresses: Array<{ buttonIndex: number; queuedAt: number }> = [];
|
||||
const lastRealPressAt = new Map<number, number>();
|
||||
|
||||
function recordRealPresses(gamepads: GamepadLike[], now: number): void {
|
||||
for (const gamepad of gamepads) {
|
||||
gamepad.buttons.forEach((button, buttonIndex) => {
|
||||
if (button.pressed) lastRealPressAt.set(buttonIndex, now);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function withPressed(gamepad: GamepadLike, buttonIndices: number[]): GamepadLike {
|
||||
const buttons = gamepad.buttons.map((button, index) =>
|
||||
buttonIndices.includes(index) ? { value: 1, pressed: true, touched: true } : button,
|
||||
);
|
||||
// Native Gamepad fields are prototype getters, so object spread would drop them.
|
||||
return {
|
||||
id: gamepad.id,
|
||||
index: gamepad.index,
|
||||
connected: gamepad.connected,
|
||||
mapping: gamepad.mapping,
|
||||
timestamp: gamepad.timestamp,
|
||||
axes: gamepad.axes,
|
||||
buttons,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
queue(buttonIndex: number, now: number): void {
|
||||
pendingPresses.push({ buttonIndex, queuedAt: now });
|
||||
},
|
||||
|
||||
/** Called once per poll with the Gamepad API's pads; returns the pads to read. */
|
||||
apply(gamepads: Array<GamepadLike | null>, now: number): Array<GamepadLike | null> {
|
||||
const connected = gamepads.filter((gamepad): gamepad is GamepadLike =>
|
||||
Boolean(gamepad?.connected),
|
||||
);
|
||||
recordRealPresses(connected, now);
|
||||
if (pendingPresses.length === 0) return gamepads;
|
||||
|
||||
const replayed = pendingPresses
|
||||
.filter(({ buttonIndex, queuedAt }) => {
|
||||
const seenAt = lastRealPressAt.get(buttonIndex);
|
||||
return seenAt === undefined || seenAt < queuedAt - SAME_PRESS_WINDOW_MS;
|
||||
})
|
||||
.map(({ buttonIndex }) => buttonIndex);
|
||||
pendingPresses.length = 0;
|
||||
const onlyPad = connected.length === 1 ? connected[0] : undefined;
|
||||
if (replayed.length === 0 || onlyPad?.mapping !== 'standard') return gamepads;
|
||||
|
||||
return gamepads.map((gamepad) =>
|
||||
gamepad === onlyPad ? withPressed(onlyPad, replayed) : gamepad,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
import type { OverlayHostedModal } from '../shared/ipc/contracts';
|
||||
import { createKeyboardHandlers } from './handlers/keyboard.js';
|
||||
import { createGamepadController } from './handlers/gamepad-controller.js';
|
||||
import { createControllerPressReplay } from './handlers/controller-press-replay.js';
|
||||
import { createMouseHandlers } from './handlers/mouse.js';
|
||||
import { createControllerStatusIndicator } from './controller-status-indicator.js';
|
||||
import { createControllerDebugModal } from './modals/controller-debug.js';
|
||||
@@ -394,6 +395,7 @@ let lastSubtitlePreview = '';
|
||||
let lastSecondarySubtitlePreview = '';
|
||||
let overlayErrorToastTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let controllerAnimationFrameId: number | null = null;
|
||||
const controllerPressReplay = createControllerPressReplay();
|
||||
|
||||
function truncateForErrorLog(text: string): string {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||
@@ -528,7 +530,8 @@ function startControllerPolling(): void {
|
||||
}
|
||||
|
||||
const gamepadController = createGamepadController({
|
||||
getGamepads: () => Array.from(navigator.getGamepads?.() ?? []),
|
||||
getGamepads: () =>
|
||||
controllerPressReplay.apply(Array.from(navigator.getGamepads?.() ?? []), performance.now()),
|
||||
getConfig: () =>
|
||||
withAltCSelectedController(
|
||||
ctx.state.controllerConfig ?? {
|
||||
@@ -862,6 +865,11 @@ function registerKeyboardCommandHandlers(): void {
|
||||
keyboardModeAutoPause.handlePlaybackPauseChanged(paused);
|
||||
});
|
||||
|
||||
// macOS: replay a press made while mpv was in front; main has just focused this window.
|
||||
window.electronAPI.onControllerBackgroundPress((buttonIndex) => {
|
||||
if (buttonIndex >= 0) controllerPressReplay.queue(buttonIndex, performance.now());
|
||||
});
|
||||
|
||||
// Alt+C saves in its own window; pick up remaps and the button style without a restart.
|
||||
window.electronAPI.onControllerConfigChanged(() => {
|
||||
window.electronAPI
|
||||
|
||||
@@ -162,6 +162,7 @@ export const IPC_CHANNELS = {
|
||||
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
|
||||
controllerSuspendedChanged: 'controller:suspended-changed',
|
||||
controllerConfigChanged: 'controller:config-changed',
|
||||
controllerBackgroundPress: 'controller:background-press',
|
||||
playbackPauseChanged: 'mpv:pause-changed',
|
||||
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
|
||||
sessionHelpOpen: 'session-help:open',
|
||||
|
||||
@@ -695,6 +695,8 @@ export interface ElectronAPI {
|
||||
onControllerSuspendedChanged: (callback: (suspended: boolean) => void) => void;
|
||||
/** Alt+C saved controller settings; reload them with `getControllerConfig`. */
|
||||
onControllerConfigChanged: (callback: () => void) => void;
|
||||
/** macOS: a button pressed while mpv was in front, which the Gamepad API never saw. */
|
||||
onControllerBackgroundPress: (callback: (buttonIndex: number) => void) => void;
|
||||
/** mpv's pause property changed, from any source. */
|
||||
onPlaybackPauseChanged: (callback: (paused: boolean) => void) => void;
|
||||
onLookupWindowToggleRequested: (callback: () => void) => void;
|
||||
|
||||
Reference in new issue
Block a user