diff --git a/AGENTS.md b/AGENTS.md index 540806bf..f99059ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/changes/macos-controller-background-input.md b/changes/macos-controller-background-input.md new file mode 100644 index 00000000..95f56e01 --- /dev/null +++ b/changes/macos-controller-background-input.md @@ -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. diff --git a/docs-site/usage.md b/docs-site/usage.md index cbd808b8..f88b37f2 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -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). diff --git a/package.json b/package.json index 350a98de..9f675908 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/scripts/electron-builder-after-pack.cjs b/scripts/electron-builder-after-pack.cjs index 52310ab2..73c22aac 100644 --- a/scripts/electron-builder-after-pack.cjs +++ b/scripts/electron-builder-after-pack.cjs @@ -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, }; diff --git a/scripts/electron-builder-after-pack.test.ts b/scripts/electron-builder-after-pack.test.ts index d8cb3a36..d932cc13 100644 --- a/scripts/electron-builder-after-pack.test.ts +++ b/scripts/electron-builder-after-pack.test.ts @@ -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; - verifyMacOSWindowHelper: (context: { + verifyMacOSHelpers: (context: { appOutDir: string; electronPlatformName: string; }) => Promise; @@ -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 }); diff --git a/scripts/macos-controller-watch.swift b/scripts/macos-controller-watch.swift new file mode 100644 index 00000000..778ed89a --- /dev/null +++ b/scripts/macos-controller-watch.swift @@ -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 " 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() diff --git a/scripts/prepare-build-assets.mjs b/scripts/prepare-build-assets.mjs index 652b7c4c..a5a3a9dc 100644 --- a/scripts/prepare-build-assets.mjs +++ b/scripts/prepare-build-assets.mjs @@ -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(); } diff --git a/src/main.ts b/src/main.ts index 4d32d94f..28dd425c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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()); + }); +} diff --git a/src/main/runtime/macos-controller-watch.test.ts b/src/main/runtime/macos-controller-watch.test.ts new file mode 100644 index 00000000..82ab7832 --- /dev/null +++ b/src/main/runtime/macos-controller-watch.test.ts @@ -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) { + return { + existsSync: (filePath: string) => filePath in files, + statSync: (filePath: string) => ({ mtimeMs: files[filePath] }), + } as unknown as Parameters[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 | null; + main?: ReturnType | 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) }), []); +}); diff --git a/src/main/runtime/macos-controller-watch.ts b/src/main/runtime/macos-controller-watch.ts new file mode 100644 index 00000000..8bbe4160 --- /dev/null +++ b/src/main/runtime/macos-controller-watch.ts @@ -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 = 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 "). */ +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( + 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); +} diff --git a/src/preload.ts b/src/preload.ts index 5511d159..49eb53e8 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -197,6 +197,10 @@ const onPlaybackPauseChangedEvent = createQueuedIpcListenerWithPayload( const onControllerConfigChangedEvent = createQueuedIpcListener( IPC_CHANNELS.event.controllerConfigChanged, ); +const onControllerBackgroundPressEvent = createQueuedIpcListenerWithPayload( + 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 => diff --git a/src/renderer/handlers/controller-press-replay.test.ts b/src/renderer/handlers/controller-press-replay.test.ts new file mode 100644 index 00000000..af3e5caf --- /dev/null +++ b/src/renderer/handlers/controller-press-replay.test.ts @@ -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]), []); +}); diff --git a/src/renderer/handlers/controller-press-replay.ts b/src/renderer/handlers/controller-press-replay.ts new file mode 100644 index 00000000..5818fed9 --- /dev/null +++ b/src/renderer/handlers/controller-press-replay.ts @@ -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(); + + 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, now: number): Array { + 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, + ); + }, + }; +} diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 51606d4a..b7456114 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -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 | 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 diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 028bbb52..bb015494 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -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', diff --git a/src/types/runtime.ts b/src/types/runtime.ts index 3ab9eeb1..c1442c01 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -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;