Fix Windows background app reuse across queued media

This commit is contained in:
2026-03-08 18:19:03 -07:00
parent aee52e0b35
commit 9af0264792
12 changed files with 432 additions and 51 deletions

View File

@@ -0,0 +1,4 @@
type: fixed
area: windows
- Acquire the app single-instance lock earlier so Windows overlay/video launches reuse the running background SubMiner process instead of booting a second full app and repeating startup warmups.

View File

@@ -51,6 +51,7 @@ function runLauncher(argv: string[], env: NodeJS.ProcessEnv): RunResult {
}
function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv {
const pathValue = process.env.Path || process.env.PATH || '';
return {
...process.env,
HOME: homeDir,
@@ -58,6 +59,8 @@ function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv
APPDATA: xdgConfigHome,
LOCALAPPDATA: path.join(homeDir, 'AppData', 'Local'),
XDG_CONFIG_HOME: xdgConfigHome,
PATH: pathValue,
Path: pathValue,
};
}
@@ -142,6 +145,12 @@ test('mpv status exits non-zero when socket is not ready', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const socketPath = path.join(root, 'missing.sock');
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
fs.writeFileSync(
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
`socket_path=${socketPath}\n`,
);
const result = runLauncher(['mpv', 'status'], makeTestEnv(homeDir, xdgConfigHome));
assert.equal(result.status, 1);
@@ -156,6 +165,7 @@ test('doctor reports checks and exits non-zero without hard dependencies', () =>
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
PATH: '',
Path: '',
};
const result = runLauncher(['doctor'], env);
@@ -188,7 +198,7 @@ test('youtube command rejects removed --mode option', () => {
});
});
test('youtube playback generates subtitles before mpv launch', () => {
test('youtube playback generates subtitles before mpv launch', { timeout: 15000 }, () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
@@ -198,6 +208,7 @@ test('youtube playback generates subtitles before mpv launch', () => {
const mpvCapturePath = path.join(root, 'mpv-order.txt');
const mpvArgsPath = path.join(root, 'mpv-args.txt');
const socketPath = path.join(root, 'mpv.sock');
const bunBinary = JSON.stringify(process.execPath.replace(/\\/g, '/'));
fs.mkdirSync(binDir, { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
@@ -268,7 +279,7 @@ for arg in "$@"; do
;;
esac
done
bun -e "const net=require('node:net'); const fs=require('node:fs'); const socket=process.argv[1]; try { fs.rmSync(socket,{force:true}); } catch {} const server=net.createServer((conn)=>conn.end()); server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250));" "$socket_path"
${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); const socket=process.argv[1]; try { fs.rmSync(socket,{force:true}); } catch {} const server=net.createServer((conn)=>conn.end()); server.listen(socket,()=>setTimeout(()=>server.close(()=>process.exit(0)),250));" "$socket_path"
`,
'utf8',
);
@@ -276,7 +287,8 @@ bun -e "const net=require('node:net'); const fs=require('node:fs'); const socket
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`,
PATH: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
Path: `${binDir}${path.delimiter}${process.env.Path || process.env.PATH || ''}`,
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_YTDLP_LOG: ytdlpLogPath,
SUBMINER_TEST_MPV_ORDER: mpvCapturePath,
@@ -284,7 +296,7 @@ bun -e "const net=require('node:net'); const fs=require('node:fs'); const socket
};
const result = runLauncher(['youtube', 'https://www.youtube.com/watch?v=test123'], env);
assert.equal(result.status, 0);
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
assert.equal(fs.readFileSync(mpvCapturePath, 'utf8').trim(), 'generated-before-mpv');
assert.match(
fs.readFileSync(mpvArgsPath, 'utf8'),

View File

@@ -9,8 +9,10 @@ import { log, fail, getMpvLogPath } from './log.js';
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
import {
commandExists,
getPathEnv,
isExecutable,
resolveBinaryPathCandidate,
resolveCommandInvocation,
realpathMaybe,
isYoutubeTarget,
uniqueNormalizedLangCodes,
@@ -204,7 +206,8 @@ export function findAppBinary(selfPath: string): string | null {
if (isExecutable(candidate)) return candidate;
}
const fromPath = process.env.PATH?.split(path.delimiter)
const fromPath = getPathEnv()
.split(path.delimiter)
.map((dir) => path.join(dir, 'subminer'))
.find((candidate) => isExecutable(candidate));
@@ -517,7 +520,8 @@ export async function startMpv(
mpvArgs.push(`--input-ipc-server=${socketPath}`);
mpvArgs.push(target);
state.mpvProc = spawn('mpv', mpvArgs, { stdio: 'inherit' });
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);
state.mpvProc = spawn(mpvTarget.command, mpvTarget.args, { stdio: 'inherit' });
}
async function waitForOverlayStartCommandSettled(
@@ -568,7 +572,8 @@ export async function startOverlay(appPath: string, args: Args, socketPath: stri
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
if (args.useTexthooker) overlayArgs.push('--texthooker');
state.overlayProc = spawn(appPath, overlayArgs, {
const target = resolveAppSpawnTarget(appPath, overlayArgs);
state.overlayProc = spawn(target.command, target.args, {
stdio: 'inherit',
env: { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() },
});
@@ -701,33 +706,7 @@ function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget
if (process.platform !== 'win32') {
return { command: appPath, args: appArgs };
}
const normalizeBashArg = (value: string): string => {
const normalized = value.replace(/\\/g, '/');
const driveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/);
if (!driveMatch) {
return normalized;
}
const [, driveLetter, remainder] = driveMatch;
return `/mnt/${driveLetter!.toLowerCase()}/${remainder}`;
};
const extension = path.extname(appPath).toLowerCase();
if (extension === '.ps1') {
return {
command: 'powershell.exe',
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', appPath, ...appArgs],
};
}
if (extension === '.sh') {
return {
command: 'bash',
args: [normalizeBashArg(appPath), ...appArgs.map(normalizeBashArg)],
};
}
return { command: appPath, args: appArgs };
return resolveCommandInvocation(appPath, appArgs);
}
export function runAppCommandWithInherit(appPath: string, appArgs: string[]): never {
@@ -841,7 +820,8 @@ export function launchMpvIdleDetached(
);
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
mpvArgs.push(`--input-ipc-server=${socketPath}`);
const proc = spawn('mpv', mpvArgs, {
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);
const proc = spawn(mpvTarget.command, mpvTarget.args, {
stdio: 'ignore',
detached: true,
});

View File

@@ -18,14 +18,139 @@ export function isExecutable(filePath: string): boolean {
}
}
export function commandExists(command: string): boolean {
const pathEnv = process.env.PATH ?? '';
function isRunnableFile(filePath: string): boolean {
try {
if (!fs.statSync(filePath).isFile()) return false;
return process.platform === 'win32' ? true : isExecutable(filePath);
} catch {
return false;
}
}
function isPathLikeCommand(command: string): boolean {
return (
command.includes('/') ||
command.includes('\\') ||
/^[A-Za-z]:[\\/]/.test(command) ||
command.startsWith('.')
);
}
function getWindowsPathExts(): string[] {
const raw = process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD';
return raw
.split(';')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
export function getPathEnv(): string {
const pathKey = Object.keys(process.env).find((key) => key.toLowerCase() === 'path');
return pathKey ? (process.env[pathKey] ?? '') : '';
}
function resolveExecutablePath(command: string): string | null {
const tryCandidate = (candidate: string): string | null =>
isRunnableFile(candidate) ? candidate : null;
const resolveWindowsCandidate = (candidate: string): string | null => {
const direct = tryCandidate(candidate);
if (direct) return direct;
if (path.extname(candidate)) return null;
for (const ext of getWindowsPathExts()) {
const withExt = tryCandidate(`${candidate}${ext}`);
if (withExt) return withExt;
}
return null;
};
if (isPathLikeCommand(command)) {
const resolved = path.resolve(resolvePathMaybe(command));
return process.platform === 'win32' ? resolveWindowsCandidate(resolved) : tryCandidate(resolved);
}
const pathEnv = getPathEnv();
for (const dir of pathEnv.split(path.delimiter)) {
if (!dir) continue;
const full = path.join(dir, command);
if (isExecutable(full)) return true;
const candidate = path.join(dir, command);
const resolved =
process.platform === 'win32' ? resolveWindowsCandidate(candidate) : tryCandidate(candidate);
if (resolved) return resolved;
}
return false;
return null;
}
function normalizeWindowsBashArg(value: string): string {
const normalized = value.replace(/\\/g, '/');
const driveMatch = normalized.match(/^([A-Za-z]):\/(.*)$/);
if (!driveMatch) {
return normalized;
}
const [, driveLetter, remainder] = driveMatch;
return `/mnt/${driveLetter!.toLowerCase()}/${remainder}`;
}
function resolveGitBashExecutable(): string | null {
const directCandidates = [
'C:\\Program Files\\Git\\bin\\bash.exe',
'C:\\Program Files\\Git\\usr\\bin\\bash.exe',
];
for (const candidate of directCandidates) {
if (isRunnableFile(candidate)) return candidate;
}
const gitExecutable = resolveExecutablePath('git');
if (!gitExecutable) return null;
const gitDir = path.dirname(gitExecutable);
const inferredCandidates = [
path.resolve(gitDir, '..', 'bin', 'bash.exe'),
path.resolve(gitDir, '..', 'usr', 'bin', 'bash.exe'),
];
for (const candidate of inferredCandidates) {
if (isRunnableFile(candidate)) return candidate;
}
return null;
}
function resolveWindowsBashTarget(): {
command: string;
flavor: 'git' | 'wsl';
} {
const gitBash = resolveGitBashExecutable();
if (gitBash) {
return { command: gitBash, flavor: 'git' };
}
return {
command: resolveExecutablePath('bash') ?? 'bash',
flavor: 'wsl',
};
}
function normalizeWindowsShellArg(value: string, flavor: 'git' | 'wsl'): string {
if (!isPathLikeCommand(value)) {
return value;
}
return flavor === 'git' ? value.replace(/\\/g, '/') : normalizeWindowsBashArg(value);
}
function readShebang(filePath: string): string {
try {
const fd = fs.openSync(filePath, 'r');
try {
const buffer = Buffer.alloc(160);
const bytesRead = fs.readSync(fd, buffer, 0, buffer.length, 0);
return buffer.toString('utf8', 0, bytesRead).split(/\r?\n/, 1)[0] ?? '';
} finally {
fs.closeSync(fd);
}
} catch {
return '';
}
}
export function commandExists(command: string): boolean {
return resolveExecutablePath(command) !== null;
}
export function resolvePathMaybe(input: string): string {
@@ -116,6 +241,51 @@ export function inferWhisperLanguage(langCodes: string[], fallback: string): str
return fallback;
}
export function resolveCommandInvocation(
executable: string,
args: string[],
): { command: string; args: string[] } {
if (process.platform !== 'win32') {
return { command: executable, args };
}
const resolvedExecutable = resolveExecutablePath(executable) ?? executable;
const extension = path.extname(resolvedExecutable).toLowerCase();
if (extension === '.ps1') {
return {
command: 'powershell.exe',
args: ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', resolvedExecutable, ...args],
};
}
if (extension === '.sh') {
const bashTarget = resolveWindowsBashTarget();
return {
command: bashTarget.command,
args: [
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
...args.map((arg) => normalizeWindowsShellArg(arg, bashTarget.flavor)),
],
};
}
if (!extension) {
const shebang = readShebang(resolvedExecutable);
if (/^#!.*\b(?:sh|bash)\b/i.test(shebang)) {
const bashTarget = resolveWindowsBashTarget();
return {
command: bashTarget.command,
args: [
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
...args.map((arg) => normalizeWindowsShellArg(arg, bashTarget.flavor)),
],
};
}
}
return { command: resolvedExecutable, args };
}
export function runExternalCommand(
executable: string,
args: string[],
@@ -129,8 +299,13 @@ export function runExternalCommand(
const streamOutput = opts.streamOutput === true;
return new Promise((resolve, reject) => {
log('debug', configuredLogLevel, `[${commandLabel}] spawn: ${executable} ${args.join(' ')}`);
const child = spawn(executable, args, {
const target = resolveCommandInvocation(executable, args);
log(
'debug',
configuredLogLevel,
`[${commandLabel}] spawn: ${target.command} ${target.args.join(' ')}`,
);
const child = spawn(target.command, target.args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...opts.env },
});
@@ -201,7 +376,7 @@ export function runExternalCommand(
`[${commandLabel}] exit code ${code ?? 1}`,
);
if (code !== 0 && !allowFailure) {
const commandString = `${executable} ${args.join(' ')}`;
const commandString = `${target.command} ${target.args.join(' ')}`;
reject(
new Error(`Command failed (${commandString}): ${stderr.trim() || `exit code ${code}`}`),
);

View File

@@ -33,9 +33,30 @@ function makeDbPath(): string {
function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath);
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
if (!fs.existsSync(dir)) {
return;
}
const bunRuntime = globalThis as typeof globalThis & {
Bun?: {
gc?: (force?: boolean) => void;
};
};
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
fs.rmSync(dir, { recursive: true, force: true });
return;
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (process.platform !== 'win32' || err.code !== 'EBUSY') {
throw error;
}
bunRuntime.Bun?.gc?.(true);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
}
}
// libsql keeps Windows file handles alive after close when prepared statements were used.
}
test('seam: resolveBoundedInt keeps fallback for invalid values', () => {

View File

@@ -20,9 +20,30 @@ function makeDbPath(): string {
function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath);
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
if (!fs.existsSync(dir)) {
return;
}
const bunRuntime = globalThis as typeof globalThis & {
Bun?: {
gc?: (force?: boolean) => void;
};
};
for (let attempt = 0; attempt < 3; attempt += 1) {
try {
fs.rmSync(dir, { recursive: true, force: true });
return;
} catch (error) {
const err = error as NodeJS.ErrnoException;
if (process.platform !== 'win32' || err.code !== 'EBUSY') {
throw error;
}
bunRuntime.Bun?.gc?.(true);
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25);
}
}
// libsql keeps Windows file handles alive after close when prepared statements were used.
}
test('ensureSchema creates immersion core tables', () => {

View File

@@ -12,6 +12,7 @@ import {
shouldHandleHelpOnlyAtEntry,
shouldHandleLaunchMpvAtEntry,
} from './main-entry-runtime';
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
const DEFAULT_TEXTHOOKER_PORT = 5174;
@@ -67,5 +68,9 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
app.exit(result.ok ? 0 : 1);
});
} else {
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
if (!gotSingleInstanceLock) {
app.exit(0);
}
require('./main.js');
}

View File

@@ -350,6 +350,10 @@ import {
} from './main/runtime/composers';
import { createStartupBootstrapRuntimeDeps } from './main/startup';
import { createAppLifecycleRuntimeRunner } from './main/startup-lifecycle';
import {
registerSecondInstanceHandlerEarly,
requestSingleInstanceLockEarly,
} from './main/early-single-instance';
import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
import { registerIpcRuntimeServices } from './main/ipc-runtime';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
@@ -568,6 +572,22 @@ const appLogger = {
},
};
const runtimeRegistry = createMainRuntimeRegistry();
const appLifecycleApp = {
requestSingleInstanceLock: () => requestSingleInstanceLockEarly(app),
quit: () => app.quit(),
on: (event: string, listener: (...args: unknown[]) => void) => {
if (event === 'second-instance') {
registerSecondInstanceHandlerEarly(
app,
listener as (_event: unknown, argv: string[]) => void,
);
return app;
}
app.on(event as Parameters<typeof app.on>[0], listener as (...args: any[]) => void);
return app;
},
whenReady: () => app.whenReady(),
};
const buildGetDefaultSocketPathMainDepsHandler = createBuildGetDefaultSocketPathMainDepsHandler({
platform: process.platform,
@@ -2240,7 +2260,7 @@ const {
app.on('open-url', listener);
},
registerSecondInstance: (listener) => {
app.on('second-instance', listener);
registerSecondInstanceHandlerEarly(app, listener);
},
handleAnilistSetupProtocolUrl: (rawUrl) => handleAnilistSetupProtocolUrl(rawUrl),
findAnilistSetupDeepLinkArgvUrl: (argv) => findAnilistSetupDeepLinkArgvUrl(argv),
@@ -2523,7 +2543,7 @@ const { runAndApplyStartupState } = runtimeRegistry.startup.createStartupRuntime
ReturnType<typeof createStartupBootstrapRuntimeDeps>
>({
appLifecycleRuntimeRunnerMainDeps: {
app,
app: appLifecycleApp,
platform: process.platform,
shouldStartApp: (nextArgs: CliArgs) => shouldStartApp(nextArgs),
parseArgs: (argv: string[]) => parseArgs(argv),
@@ -2621,6 +2641,7 @@ const {
createMecabTokenizerAndCheck,
prewarmSubtitleDictionaries,
startBackgroundWarmups,
isTokenizationWarmupReady,
} = composeMpvRuntimeHandlers<
MpvIpcClient,
ReturnType<typeof createTokenizerDepsRuntime>,
@@ -2673,6 +2694,15 @@ const {
syncImmersionMediaState: () => {
immersionMediaRuntime.syncFromCurrentMediaState();
},
signalAutoplayReadyIfWarm: () => {
if (!isTokenizationWarmupReady()) {
return;
}
maybeSignalPluginAutoplayReady(
{ text: '__warm__', tokens: null },
{ forceWhilePaused: true },
);
},
scheduleCharacterDictionarySync: () => {
characterDictionaryAutoSyncRuntime.scheduleSync();
},

View File

@@ -0,0 +1,56 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
registerSecondInstanceHandlerEarly,
requestSingleInstanceLockEarly,
resetEarlySingleInstanceStateForTests,
} from './early-single-instance';
function createFakeApp(lockValue = true) {
let requestCalls = 0;
let secondInstanceListener: ((_event: unknown, argv: string[]) => void) | null = null;
return {
app: {
requestSingleInstanceLock: () => {
requestCalls += 1;
return lockValue;
},
on: (_event: 'second-instance', listener: (_event: unknown, argv: string[]) => void) => {
secondInstanceListener = listener;
},
},
emitSecondInstance: (argv: string[]) => {
secondInstanceListener?.({}, argv);
},
getRequestCalls: () => requestCalls,
};
}
test('requestSingleInstanceLockEarly caches the lock result per process', () => {
resetEarlySingleInstanceStateForTests();
const fake = createFakeApp(true);
assert.equal(requestSingleInstanceLockEarly(fake.app), true);
assert.equal(requestSingleInstanceLockEarly(fake.app), true);
assert.equal(fake.getRequestCalls(), 1);
});
test('registerSecondInstanceHandlerEarly replays queued argv and forwards new events', () => {
resetEarlySingleInstanceStateForTests();
const fake = createFakeApp(true);
const calls: string[][] = [];
assert.equal(requestSingleInstanceLockEarly(fake.app), true);
fake.emitSecondInstance(['SubMiner.exe', '--start', '--socket', '\\\\.\\pipe\\subminer']);
registerSecondInstanceHandlerEarly(fake.app, (_event, argv) => {
calls.push(argv);
});
fake.emitSecondInstance(['SubMiner.exe', '--start', '--show-visible-overlay']);
assert.deepEqual(calls, [
['SubMiner.exe', '--start', '--socket', '\\\\.\\pipe\\subminer'],
['SubMiner.exe', '--start', '--show-visible-overlay'],
]);
});

View File

@@ -0,0 +1,54 @@
interface ElectronSecondInstanceAppLike {
requestSingleInstanceLock: () => boolean;
on: (
event: 'second-instance',
listener: (_event: unknown, argv: string[]) => void,
) => unknown;
}
let cachedSingleInstanceLock: boolean | null = null;
let secondInstanceListenerAttached = false;
const secondInstanceArgvHistory: string[][] = [];
const secondInstanceHandlers = new Set<(_event: unknown, argv: string[]) => void>();
function attachSecondInstanceListener(app: ElectronSecondInstanceAppLike): void {
if (secondInstanceListenerAttached) return;
app.on('second-instance', (event, argv) => {
const clonedArgv = [...argv];
secondInstanceArgvHistory.push(clonedArgv);
for (const handler of secondInstanceHandlers) {
handler(event, [...clonedArgv]);
}
});
secondInstanceListenerAttached = true;
}
export function requestSingleInstanceLockEarly(app: ElectronSecondInstanceAppLike): boolean {
attachSecondInstanceListener(app);
if (cachedSingleInstanceLock !== null) {
return cachedSingleInstanceLock;
}
cachedSingleInstanceLock = app.requestSingleInstanceLock();
return cachedSingleInstanceLock;
}
export function registerSecondInstanceHandlerEarly(
app: ElectronSecondInstanceAppLike,
handler: (_event: unknown, argv: string[]) => void,
): () => void {
attachSecondInstanceListener(app);
secondInstanceHandlers.add(handler);
for (const argv of secondInstanceArgvHistory) {
handler(undefined, [...argv]);
}
return () => {
secondInstanceHandlers.delete(handler);
};
}
export function resetEarlySingleInstanceStateForTests(): void {
cachedSingleInstanceLock = null;
secondInstanceListenerAttached = false;
secondInstanceArgvHistory.length = 0;
secondInstanceHandlers.clear();
}

View File

@@ -48,6 +48,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
maybeProbeAnilistDuration: (mediaKey) => calls.push(`probe:${mediaKey}`),
ensureAnilistMediaGuess: (mediaKey) => calls.push(`guess:${mediaKey}`),
syncImmersionMediaState: () => calls.push('sync-immersion'),
signalAutoplayReadyIfWarm: (path) => calls.push(`autoplay:${path}`),
updateCurrentMediaTitle: (title) => calls.push(`title:${title}`),
resetAnilistMediaGuessState: () => calls.push('reset-guess'),
reportJellyfinRemoteProgress: (forceImmediate) => calls.push(`progress:${forceImmediate}`),
@@ -82,6 +83,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.maybeProbeAnilistDuration('media-key');
deps.ensureAnilistMediaGuess('media-key');
deps.syncImmersionMediaState();
deps.signalAutoplayReadyIfWarm('/tmp/video');
deps.updateCurrentMediaTitle('title');
deps.resetAnilistMediaGuessState();
deps.notifyImmersionTitleUpdate('title');
@@ -100,6 +102,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('anilist-post-watch'));
assert.ok(calls.includes('ensure-immersion'));
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('autoplay:/tmp/video'));
assert.ok(calls.includes('metrics'));
assert.ok(calls.includes('presence-refresh'));
assert.ok(calls.includes('restore-mpv-sub'));

View File

@@ -29,7 +29,23 @@ export abstract class BaseWindowTracker {
public onGeometryChange: GeometryChangeCallback | null = null;
public onWindowFound: WindowFoundCallback | null = null;
public onWindowLost: WindowLostCallback | null = null;
public onTargetWindowFocusChange: ((focused: boolean) => void) | null = null;
private onWindowFocusChangeCallback: ((focused: boolean) => void) | null = null;
public get onWindowFocusChange(): ((focused: boolean) => void) | null {
return this.onWindowFocusChangeCallback;
}
public set onWindowFocusChange(callback: ((focused: boolean) => void) | null) {
this.onWindowFocusChangeCallback = callback;
}
public get onTargetWindowFocusChange(): ((focused: boolean) => void) | null {
return this.onWindowFocusChange;
}
public set onTargetWindowFocusChange(callback: ((focused: boolean) => void) | null) {
this.onWindowFocusChange = callback;
}
abstract start(): void;
abstract stop(): void;
@@ -52,7 +68,11 @@ export abstract class BaseWindowTracker {
}
this.targetWindowFocused = focused;
this.onTargetWindowFocusChange?.(focused);
this.onWindowFocusChangeCallback?.(focused);
}
protected updateFocus(focused: boolean): void {
this.updateTargetWindowFocused(focused);
}
protected updateGeometry(newGeometry: WindowGeometry | null): void {