feat: add mark-watched action, background app reuse, and N+1 compat

- Add `--mark-watched` CLI flag + mpv session binding; marks video watched, shows OSD, advances playlist
- Launcher detects running background app via `--app-ping` and borrows it instead of owning its lifecycle
- Preserve N+1 highlighting for existing configs with `knownWords.highlightEnabled` set
- Fix `resolveConfiguredShortcuts` to respect explicit `null` overrides (disabling defaults)
- Split session-help modal into focused modules (colors, render, sections, tabs)
This commit is contained in:
2026-05-19 01:30:49 -07:00
parent 24b95eda9d
commit f4845513f3
42 changed files with 1429 additions and 505 deletions
+42
View File
@@ -655,6 +655,48 @@ test('startOverlay captures app stdout and stderr into app log', async () => {
}
});
test('startOverlay borrows an already-running background app instead of owning its lifecycle', async () => {
const { dir, socketPath } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
const appInvocationsPath = path.join(dir, 'app-invocations.log');
fs.writeFileSync(
appPath,
[
'#!/bin/sh',
`printf '%s\\n' "$@" >> ${JSON.stringify(appInvocationsPath)}`,
'if [ "$1" = "--app-ping" ]; then exit 0; fi',
'exit 0',
'',
].join('\n'),
);
fs.chmodSync(appPath, 0o755);
fs.writeFileSync(socketPath, '');
const originalCreateConnection = net.createConnection;
try {
net.createConnection = (() => {
const socket = new EventEmitter() as net.Socket;
socket.destroy = (() => socket) as net.Socket['destroy'];
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
setTimeout(() => socket.emit('connect'), 10);
return socket;
}) as typeof net.createConnection;
await startOverlay(appPath, makeArgs(), socketPath);
const invocationText = fs.readFileSync(appInvocationsPath, 'utf8');
assert.match(invocationText, /--app-ping/);
assert.match(invocationText, /--start/);
assert.equal(state.overlayManagedByLauncher, false);
assert.equal(state.appPath, '');
} finally {
net.createConnection = originalCreateConnection;
state.overlayProc = null;
state.overlayManagedByLauncher = false;
state.appPath = '';
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('cleanupPlaybackSession stops launcher-managed overlay app and mpv-owned children', async () => {
const { dir } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
+25 -1
View File
@@ -1004,6 +1004,7 @@ export async function startOverlay(
): Promise<void> {
const backend = detectBackend(args.backend);
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
const appAlreadyRunning = isAppAlreadyRunning(appPath, args.logLevel);
const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath, ...extraAppArgs];
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
@@ -1015,7 +1016,16 @@ export async function startOverlay(
env: buildAppEnv(process.env, target.env),
});
attachAppProcessLogging(state.overlayProc);
markOverlayManagedByLauncher(appPath);
if (appAlreadyRunning) {
log(
'debug',
args.logLevel,
'SubMiner app is already running; launcher will not stop it after playback',
);
clearOverlayManagedByLauncher();
} else {
markOverlayManagedByLauncher(appPath);
}
const [socketReady] = await Promise.all([
waitForUnixSocketReady(socketPath, OVERLAY_START_SOCKET_READY_TIMEOUT_MS),
@@ -1042,6 +1052,20 @@ export function markOverlayManagedByLauncher(appPath?: string): void {
state.overlayManagedByLauncher = true;
}
function clearOverlayManagedByLauncher(): void {
state.appPath = '';
state.overlayManagedByLauncher = false;
}
function isAppAlreadyRunning(appPath: string, logLevel: LogLevel): boolean {
const result = runSyncAppCommand(appPath, ['--app-ping'], false);
if (result.error) {
log('debug', logLevel, `App ping failed before overlay start: ${result.error.message}`);
return false;
}
return result.status === 0;
}
export function openUrlInDefaultBrowser(url: string, logLevel: LogLevel): void {
const target =
process.platform === 'darwin'
+46
View File
@@ -133,6 +133,9 @@ if (entry.argv.includes('--start')) {
if (entry.argv.includes('--stop')) {
fs.appendFileSync(stopPath, JSON.stringify(entry) + '\\n');
}
if (entry.argv.includes('--app-ping')) {
process.exit(process.env.SUBMINER_FAKE_APP_RUNNING === '1' ? 0 : 1);
}
process.exit(0);
`,
@@ -347,6 +350,49 @@ test(
},
);
test(
'launcher start-overlay borrows a running background app and does not stop it after mpv exits',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
async () => {
await withSmokeCase('overlay-borrow-background', async (smokeCase) => {
const env = {
...makeTestEnv(smokeCase),
SUBMINER_FAKE_APP_RUNNING: '1',
};
const result = runLauncher(
smokeCase,
['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
env,
'overlay-borrow-background',
);
const appLogPath = path.join(smokeCase.artifactsDir, 'fake-app.log');
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
await waitForJsonLines(appStartPath, 1);
const appEntries = readJsonLines(appLogPath);
const appStartEntries = readJsonLines(appStartPath);
const appStopEntries = readJsonLines(appStopPath);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
const mpvError = mpvEntries.find(
(entry): entry is { error: string } => typeof entry.error === 'string',
)?.error;
const unixSocketDenied =
typeof mpvError === 'string' && /eperm|operation not permitted/i.test(mpvError);
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.ok(
appEntries.some(
(entry) => Array.isArray(entry.argv) && (entry.argv as string[]).includes('--app-ping'),
),
);
assert.equal(appStartEntries.length, 1);
assert.equal(appStopEntries.length, 0);
});
},
);
test(
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },