feat(launcher): pause auto-start playback until overlay is ready

This commit is contained in:
2026-02-28 22:19:49 -08:00
parent 33007b3f40
commit a46f90d085
20 changed files with 502 additions and 36 deletions

View File

@@ -352,6 +352,8 @@ Control whether the overlay automatically becomes visible when it connects to mp
| `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `false`) | | `auto_start_overlay` | `true`, `false` | Auto-show overlay on mpv connection (default: `false`) |
The mpv plugin controls startup overlay visibility via `auto_start_visible_overlay` in `subminer.conf`. The mpv plugin controls startup overlay visibility via `auto_start_visible_overlay` in `subminer.conf`.
For wrapper-driven playback, `subminer.conf` can also enable startup pause gating with
`auto_start_pause_until_ready` (requires `auto_start=yes` + `auto_start_visible_overlay=yes`).
### Auto Subtitle Sync ### Auto Subtitle Sync
@@ -767,6 +769,7 @@ See `config.example.jsonc` for detailed configuration options.
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) | | `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"rgb(30, 32, 48, 0.88)"`) |
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) | | `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. | | `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) | | `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. | | `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) | | `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |

View File

@@ -78,11 +78,15 @@ backend=auto
# Start the overlay automatically when a file is loaded. # Start the overlay automatically when a file is loaded.
# Runs only when mpv input-ipc-server matches socket_path. # Runs only when mpv input-ipc-server matches socket_path.
auto_start=no auto_start=yes
# Show the visible overlay on auto-start. # Show the visible overlay on auto-start.
# Runs only when mpv input-ipc-server matches socket_path. # Runs only when mpv input-ipc-server matches socket_path.
auto_start_visible_overlay=no auto_start_visible_overlay=yes
# Pause mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
# Requires auto_start=yes and auto_start_visible_overlay=yes.
auto_start_pause_until_ready=yes
# Show OSD messages for overlay status changes. # Show OSD messages for overlay status changes.
osd_messages=yes osd_messages=yes
@@ -123,8 +127,9 @@ aniskip_button_duration=3
| `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server | | `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server |
| `texthooker_port` | `5174` | 165535 | Texthooker server port | | `texthooker_port` | `5174` | 165535 | Texthooker server port |
| `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend | | `backend` | `auto` | `auto`, `hyprland`, `sway`, `x11`, `macos` | Window manager backend |
| `auto_start` | `no` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` | | `auto_start` | `yes` | `yes` / `no` | Auto-start overlay on file load when mpv socket matches `socket_path` |
| `auto_start_visible_overlay` | `no` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` | | `auto_start_visible_overlay` | `yes` | `yes` / `no` | Show visible layer on auto-start when mpv socket matches `socket_path` |
| `auto_start_pause_until_ready` | `yes` | `yes` / `no` | Pause mpv on visible auto-start; resume when SubMiner signals tokenization-ready |
| `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages | | `osd_messages` | `yes` | `yes` / `no` | Show OSD status messages |
| `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity | | `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity |
| `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection | | `aniskip_enabled` | `yes` | `yes` / `no` | Enable AniSkip intro detection |
@@ -211,6 +216,7 @@ script-message subminer-start backend=hyprland socket=/custom/path texthooker=no
## Lifecycle ## Lifecycle
- **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay. - **File loaded**: If `auto_start=yes`, the plugin starts the overlay, then defers AniSkip lookup until after startup delay.
- **Auto-start pause gate**: If `auto_start_visible_overlay=yes` and `auto_start_pause_until_ready=yes`, launcher starts mpv paused and the plugin resumes playback after SubMiner reports tokenization-ready (with timeout fallback).
- **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server. - **MPV shutdown**: The plugin sends a stop command to gracefully shut down both the overlay and the texthooker server.
- **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first. - **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first.

View File

@@ -33,6 +33,12 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
scriptPath: '/tmp/subminer', scriptPath: '/tmp/subminer',
scriptName: 'subminer', scriptName: 'subminer',
mpvSocketPath: '/tmp/subminer.sock', mpvSocketPath: '/tmp/subminer.sock',
pluginRuntimeConfig: {
socketPath: '/tmp/subminer.sock',
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
},
appPath: '/tmp/subminer.app', appPath: '/tmp/subminer.app',
launcherJellyfinConfig: {}, launcherJellyfinConfig: {},
processAdapter: adapter, processAdapter: adapter,

View File

@@ -1,4 +1,4 @@
import type { Args, LauncherJellyfinConfig } from '../types.js'; import type { Args, LauncherJellyfinConfig, PluginRuntimeConfig } from '../types.js';
import type { ProcessAdapter } from '../process-adapter.js'; import type { ProcessAdapter } from '../process-adapter.js';
export interface LauncherCommandContext { export interface LauncherCommandContext {
@@ -6,6 +6,7 @@ export interface LauncherCommandContext {
scriptPath: string; scriptPath: string;
scriptName: string; scriptName: string;
mpvSocketPath: string; mpvSocketPath: string;
pluginRuntimeConfig: PluginRuntimeConfig;
appPath: string | null; appPath: string | null;
launcherJellyfinConfig: LauncherJellyfinConfig; launcherJellyfinConfig: LauncherJellyfinConfig;
processAdapter: ProcessAdapter; processAdapter: ProcessAdapter;

View File

@@ -86,7 +86,7 @@ function registerCleanup(context: LauncherCommandContext): void {
} }
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> { export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
const { args, appPath, scriptPath, mpvSocketPath, processAdapter } = context; const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context;
if (!appPath) { if (!appPath) {
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.'); fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
} }
@@ -137,6 +137,19 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
log('info', args.logLevel, 'YouTube subtitle mode: off'); log('info', args.logLevel, 'YouTube subtitle mode: off');
} }
const shouldPauseUntilOverlayReady =
pluginRuntimeConfig.autoStart &&
pluginRuntimeConfig.autoStartVisibleOverlay &&
pluginRuntimeConfig.autoStartPauseUntilReady;
if (shouldPauseUntilOverlayReady) {
log(
'info',
args.logLevel,
'Configured to pause mpv until overlay and tokenization are ready',
);
}
startMpv( startMpv(
selectedTarget.target, selectedTarget.target,
selectedTarget.kind, selectedTarget.kind,
@@ -144,6 +157,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
mpvSocketPath, mpvSocketPath,
appPath, appPath,
preloadedSubtitles, preloadedSubtitles,
{ startPaused: shouldPauseUntilOverlayReady },
); );
if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') { if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
@@ -167,6 +181,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
} }
const ready = await waitForUnixSocketReady(mpvSocketPath, 10000); const ready = await waitForUnixSocketReady(mpvSocketPath, 10000);
const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart;
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay; const shouldStartOverlay = args.startOverlay || args.autoStartOverlay;
if (shouldStartOverlay) { if (shouldStartOverlay) {
if (ready) { if (ready) {
@@ -179,6 +194,16 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi
); );
} }
await startOverlay(appPath, args, mpvSocketPath); await startOverlay(appPath, args, mpvSocketPath);
} else if (pluginAutoStartEnabled) {
if (ready) {
log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
} else {
log(
'info',
args.logLevel,
'MPV IPC socket not ready yet, relying on mpv plugin auto-start',
);
}
} else if (ready) { } else if (ready) {
log( log(
'info', 'info',

View File

@@ -51,10 +51,27 @@ test('parseLauncherJellyfinConfig omits legacy token and user id fields', () =>
assert.equal('userId' in parsed, false); assert.equal('userId' in parsed, false);
}); });
test('parsePluginRuntimeConfigContent reads socket_path and ignores inline comments', () => { test('parsePluginRuntimeConfigContent reads socket path and startup gate options', () => {
const parsed = parsePluginRuntimeConfigContent(` const parsed = parsePluginRuntimeConfigContent(`
# comment # comment
socket_path = /tmp/custom.sock # trailing comment socket_path = /tmp/custom.sock # trailing comment
auto_start = yes
auto_start_visible_overlay = true
auto_start_pause_until_ready = 1
`); `);
assert.equal(parsed.socketPath, '/tmp/custom.sock'); assert.equal(parsed.socketPath, '/tmp/custom.sock');
assert.equal(parsed.autoStart, true);
assert.equal(parsed.autoStartVisibleOverlay, true);
assert.equal(parsed.autoStartPauseUntilReady, true);
});
test('parsePluginRuntimeConfigContent falls back to disabled startup gate options', () => {
const parsed = parsePluginRuntimeConfigContent(`
auto_start = maybe
auto_start_visible_overlay = no
auto_start_pause_until_ready = off
`);
assert.equal(parsed.autoStart, true);
assert.equal(parsed.autoStartVisibleOverlay, false);
assert.equal(parsed.autoStartPauseUntilReady, false);
}); });

View File

@@ -16,21 +16,62 @@ export function getPluginConfigCandidates(): string[] {
} }
export function parsePluginRuntimeConfigContent(content: string): PluginRuntimeConfig { export function parsePluginRuntimeConfigContent(content: string): PluginRuntimeConfig {
const runtimeConfig: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH }; const runtimeConfig: PluginRuntimeConfig = {
socketPath: DEFAULT_SOCKET_PATH,
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
};
const parseBooleanValue = (value: string, fallback: boolean): boolean => {
const normalized = value.trim().toLowerCase();
if (['yes', 'true', '1', 'on'].includes(normalized)) return true;
if (['no', 'false', '0', 'off'].includes(normalized)) return false;
return fallback;
};
for (const line of content.split(/\r?\n/)) { for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim(); const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith('#')) continue; if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i); const keyValueMatch = trimmed.match(/^([a-z0-9_-]+)\s*=\s*(.+)$/i);
if (!socketMatch) continue; if (!keyValueMatch) continue;
const value = (socketMatch[1] || '').split('#', 1)[0]?.trim() || ''; const key = (keyValueMatch[1] || '').toLowerCase();
if (value) runtimeConfig.socketPath = value; const value = (keyValueMatch[2] || '').split('#', 1)[0]?.trim() || '';
if (!value) continue;
if (key === 'socket_path') {
runtimeConfig.socketPath = value;
continue;
}
if (key === 'auto_start') {
runtimeConfig.autoStart = parseBooleanValue(value, runtimeConfig.autoStart);
continue;
}
if (key === 'auto_start_visible_overlay') {
runtimeConfig.autoStartVisibleOverlay = parseBooleanValue(
value,
runtimeConfig.autoStartVisibleOverlay,
);
continue;
}
if (key === 'auto_start_pause_until_ready') {
runtimeConfig.autoStartPauseUntilReady = parseBooleanValue(
value,
runtimeConfig.autoStartPauseUntilReady,
);
}
} }
return runtimeConfig; return runtimeConfig;
} }
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig { export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
const candidates = getPluginConfigCandidates(); const candidates = getPluginConfigCandidates();
const defaults: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH }; const defaults: PluginRuntimeConfig = {
socketPath: DEFAULT_SOCKET_PATH,
autoStart: true,
autoStartVisibleOverlay: true,
autoStartPauseUntilReady: true,
};
for (const configPath of candidates) { for (const configPath of candidates) {
if (!fs.existsSync(configPath)) continue; if (!fs.existsSync(configPath)) continue;
@@ -39,7 +80,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
log( log(
'debug', 'debug',
logLevel, logLevel,
`Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}`, `Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}`,
); );
return parsed; return parsed;
} catch { } catch {
@@ -51,7 +92,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig
log( log(
'debug', 'debug',
logLevel, logLevel,
`No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath})`, `No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath}, auto_start=${defaults.autoStart}, auto_start_visible_overlay=${defaults.autoStartVisibleOverlay}, auto_start_pause_until_ready=${defaults.autoStartPauseUntilReady})`,
); );
return defaults; return defaults;
} }

View File

@@ -19,14 +19,15 @@ import { runPlaybackCommand } from './commands/playback-command.js';
function createCommandContext( function createCommandContext(
args: ReturnType<typeof parseArgs>, args: ReturnType<typeof parseArgs>,
scriptPath: string, scriptPath: string,
mpvSocketPath: string, pluginRuntimeConfig: ReturnType<typeof readPluginRuntimeConfig>,
appPath: string | null, appPath: string | null,
): LauncherCommandContext { ): LauncherCommandContext {
return { return {
args, args,
scriptPath, scriptPath,
scriptName: path.basename(scriptPath), scriptName: path.basename(scriptPath),
mpvSocketPath, mpvSocketPath: pluginRuntimeConfig.socketPath,
pluginRuntimeConfig,
appPath, appPath,
launcherJellyfinConfig: loadLauncherJellyfinConfig(), launcherJellyfinConfig: loadLauncherJellyfinConfig(),
processAdapter: nodeProcessAdapter, processAdapter: nodeProcessAdapter,
@@ -55,7 +56,7 @@ async function main(): Promise<void> {
log('debug', args.logLevel, `Wrapper log level set to: ${args.logLevel}`); log('debug', args.logLevel, `Wrapper log level set to: ${args.logLevel}`);
const context = createCommandContext(args, scriptPath, pluginRuntimeConfig.socketPath, appPath); const context = createCommandContext(args, scriptPath, pluginRuntimeConfig, appPath);
if (runDoctorCommand(context)) { if (runDoctorCommand(context)) {
return; return;
@@ -71,6 +72,7 @@ async function main(): Promise<void> {
const resolvedAppPath = ensureAppPath(context); const resolvedAppPath = ensureAppPath(context);
state.appPath = resolvedAppPath; state.appPath = resolvedAppPath;
log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`);
const appContext: LauncherCommandContext = { const appContext: LauncherCommandContext = {
...context, ...context,
appPath: resolvedAppPath, appPath: resolvedAppPath,

View File

@@ -426,6 +426,7 @@ export function startMpv(
socketPath: string, socketPath: string,
appPath: string, appPath: string,
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string }, preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
options?: { startPaused?: boolean },
): void { ): void {
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) { if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
fail(`Video file not found: ${target}`); fail(`Video file not found: ${target}`);
@@ -475,6 +476,9 @@ export function startMpv(
if (preloadedSubtitles?.secondaryPath) { if (preloadedSubtitles?.secondaryPath) {
mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`); mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`);
} }
if (options?.startPaused) {
mpvArgs.push('--pause=yes');
}
const aniSkipMetadata = targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null; const aniSkipMetadata = targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata); const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
if (aniSkipMetadata) { if (aniSkipMetadata) {

View File

@@ -319,3 +319,43 @@ test(
}); });
}, },
); );
test(
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
{ timeout: 20000 },
async () => {
await withSmokeCase('autoplay-ready-gate', async (smokeCase) => {
fs.writeFileSync(
path.join(smokeCase.xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
[
`socket_path=${smokeCase.socketPath}`,
'auto_start=yes',
'auto_start_visible_overlay=yes',
'auto_start_pause_until_ready=yes',
'',
].join('\n'),
);
const env = makeTestEnv(smokeCase);
const result = runLauncher(
smokeCase,
[smokeCase.videoPath, '--log-level', 'debug'],
env,
'autoplay-ready-gate',
);
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);
const mpvFirstArgs = mpvEntries[0]?.argv;
assert.equal(result.status, unixSocketDenied ? 3 : 0);
assert.equal(Array.isArray(mpvFirstArgs), true);
assert.equal((mpvFirstArgs as string[]).includes('--pause=yes'), true);
assert.match(result.stdout, /pause mpv until overlay and tokenization are ready/i);
});
},
);

View File

@@ -129,6 +129,9 @@ export interface LauncherJellyfinConfig {
export interface PluginRuntimeConfig { export interface PluginRuntimeConfig {
socketPath: string; socketPath: string;
autoStart: boolean;
autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean;
} }
export interface CommandExecOptions { export interface CommandExecOptions {

View File

@@ -28,6 +28,10 @@ auto_start=yes
# Runs only when mpv input-ipc-server matches socket_path. # Runs only when mpv input-ipc-server matches socket_path.
auto_start_visible_overlay=yes auto_start_visible_overlay=yes
# Pause mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
# Requires auto_start=yes and auto_start_visible_overlay=yes.
auto_start_pause_until_ready=yes
# Show OSD messages for overlay status # Show OSD messages for overlay status
osd_messages=yes osd_messages=yes

View File

@@ -31,6 +31,7 @@ function M.create(ctx)
local function on_file_loaded() local function on_file_loaded()
aniskip.clear_aniskip_state() aniskip.clear_aniskip_state()
process.disarm_auto_play_ready_gate()
local should_auto_start = resolve_auto_start_enabled() local should_auto_start = resolve_auto_start_enabled()
if should_auto_start then if should_auto_start then
@@ -59,6 +60,7 @@ function M.create(ctx)
local function on_shutdown() local function on_shutdown()
aniskip.clear_aniskip_state() aniskip.clear_aniskip_state()
hover.clear_hover_overlay() hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate()
if state.overlay_running or state.texthooker_running then if state.overlay_running or state.texthooker_running then
subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process") subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process")
show_osd("Shutting down...") show_osd("Shutting down...")
@@ -73,6 +75,7 @@ function M.create(ctx)
hover.clear_hover_overlay() hover.clear_hover_overlay()
end) end)
mp.register_event("end-file", function() mp.register_event("end-file", function()
process.disarm_auto_play_ready_gate()
hover.clear_hover_overlay() hover.clear_hover_overlay()
end) end)
mp.register_event("shutdown", function() mp.register_event("shutdown", function()

View File

@@ -45,7 +45,14 @@ function M.create(ctx)
local function show_osd(message) local function show_osd(message)
if opts.osd_messages then if opts.osd_messages then
mp.osd_message("SubMiner: " .. message, 3) local payload = "SubMiner: " .. message
local sent = false
if type(mp.osd_message) == "function" then
sent = pcall(mp.osd_message, payload, 3)
end
if not sent and type(mp.commandv) == "function" then
pcall(mp.commandv, "show-text", payload, "3000")
end
end end
end end

View File

@@ -29,6 +29,9 @@ function M.create(ctx)
mp.register_script_message("subminer-status", function() mp.register_script_message("subminer-status", function()
process.check_status() process.check_status()
end) end)
mp.register_script_message("subminer-autoplay-ready", function()
process.notify_auto_play_ready()
end)
mp.register_script_message("subminer-aniskip-refresh", function() mp.register_script_message("subminer-aniskip-refresh", function()
aniskip.fetch_aniskip_for_current_media("script-message") aniskip.fetch_aniskip_for_current_media("script-message")
end) end)

View File

@@ -8,7 +8,8 @@ function M.load(options_lib, default_socket_path)
texthooker_port = 5174, texthooker_port = 5174,
backend = "auto", backend = "auto",
auto_start = true, auto_start = true,
auto_start_visible_overlay = false, auto_start_visible_overlay = true,
auto_start_pause_until_ready = true,
osd_messages = true, osd_messages = true,
log_level = "info", log_level = "info",
aniskip_enabled = true, aniskip_enabled = true,

View File

@@ -2,6 +2,7 @@ local M = {}
local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2 local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
local OVERLAY_START_MAX_ATTEMPTS = 6 local OVERLAY_START_MAX_ATTEMPTS = 6
local AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
function M.create(ctx) function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
@@ -22,6 +23,14 @@ function M.create(ctx)
return options_helper.coerce_bool(raw_visible_overlay, false) return options_helper.coerce_bool(raw_visible_overlay, false)
end end
local function resolve_pause_until_ready()
local raw_pause_until_ready = opts.auto_start_pause_until_ready
if raw_pause_until_ready == nil then
raw_pause_until_ready = opts["auto-start-pause-until-ready"]
end
return options_helper.coerce_bool(raw_pause_until_ready, false)
end
local function normalize_socket_path(path) local function normalize_socket_path(path)
if type(path) ~= "string" then if type(path) ~= "string" then
return nil return nil
@@ -53,6 +62,54 @@ function M.create(ctx)
return selected return selected
end end
local function clear_auto_play_ready_timeout()
local timeout = state.auto_play_ready_timeout
if timeout and timeout.kill then
timeout:kill()
end
state.auto_play_ready_timeout = nil
end
local function disarm_auto_play_ready_gate()
clear_auto_play_ready_timeout()
state.auto_play_ready_gate_armed = false
end
local function release_auto_play_ready_gate(reason)
if not state.auto_play_ready_gate_armed then
return
end
disarm_auto_play_ready_gate()
mp.set_property_native("pause", false)
show_osd("Subtitle annotations loaded")
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
end
local function arm_auto_play_ready_gate()
if state.auto_play_ready_gate_armed then
clear_auto_play_ready_timeout()
end
state.auto_play_ready_gate_armed = true
mp.set_property_native("pause", true)
show_osd("Loading subtitle annotations...")
subminer_log("info", "process", "Pausing playback until SubMiner overlay/tokenization readiness signal")
state.auto_play_ready_timeout = mp.add_timeout(AUTO_PLAY_READY_TIMEOUT_SECONDS, function()
if not state.auto_play_ready_gate_armed then
return
end
subminer_log(
"warn",
"process",
"Startup readiness signal timed out; resuming playback to avoid stalled pause"
)
release_auto_play_ready_gate("timeout")
end)
end
local function notify_auto_play_ready()
release_auto_play_ready_gate("tokenization-ready")
end
local function build_command_args(action, overrides) local function build_command_args(action, overrides)
overrides = overrides or {} overrides = overrides or {}
local args = { state.binary_path } local args = { state.binary_path }
@@ -75,14 +132,15 @@ function M.create(ctx)
table.insert(args, "--socket") table.insert(args, "--socket")
table.insert(args, socket_path) table.insert(args, socket_path)
local should_show_visible = resolve_visible_overlay_startup() -- Keep auto-start --start requests idempotent for second-instance handling.
if should_show_visible and overrides.auto_start_trigger == true then -- Visibility is applied as a separate control command after startup.
should_show_visible = has_matching_mpv_ipc_socket(socket_path) if overrides.auto_start_trigger ~= true then
end local should_show_visible = resolve_visible_overlay_startup()
if should_show_visible then if should_show_visible then
table.insert(args, "--show-visible-overlay") table.insert(args, "--show-visible-overlay")
else else
table.insert(args, "--hide-visible-overlay") table.insert(args, "--hide-visible-overlay")
end
end end
end end
@@ -182,6 +240,8 @@ function M.create(ctx)
end end
local function start_overlay(overrides) local function start_overlay(overrides)
overrides = overrides or {}
if not binary.ensure_binary_available() then if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found") subminer_log("error", "binary", "SubMiner binary not found")
show_osd("Error: binary not found") show_osd("Error: binary not found")
@@ -189,16 +249,31 @@ function M.create(ctx)
end end
if state.overlay_running then if state.overlay_running then
if overrides.auto_start_trigger == true then
subminer_log("debug", "process", "Auto-start ignored because overlay is already running")
return
end
subminer_log("info", "process", "Overlay already running") subminer_log("info", "process", "Overlay already running")
show_osd("Already running") show_osd("Already running")
return return
end end
overrides = overrides or {}
local texthooker_enabled = overrides.texthooker_enabled local texthooker_enabled = overrides.texthooker_enabled
if texthooker_enabled == nil then if texthooker_enabled == nil then
texthooker_enabled = opts.texthooker_enabled texthooker_enabled = opts.texthooker_enabled
end end
local socket_path = overrides.socket_path or opts.socket_path
local should_pause_until_ready = (
overrides.auto_start_trigger == true
and resolve_visible_overlay_startup()
and resolve_pause_until_ready()
and has_matching_mpv_ipc_socket(socket_path)
)
if should_pause_until_ready then
arm_auto_play_ready_gate()
else
disarm_auto_play_ready_gate()
end
local function launch_overlay_with_retry(attempt) local function launch_overlay_with_retry(attempt)
local args = build_command_args("start", overrides) local args = build_command_args("start", overrides)
@@ -236,9 +311,19 @@ function M.create(ctx)
state.overlay_running = false state.overlay_running = false
subminer_log("error", "process", "Overlay start failed after retries: " .. reason) subminer_log("error", "process", "Overlay start failed after retries: " .. reason)
show_osd("Overlay start failed") show_osd("Overlay start failed")
release_auto_play_ready_gate("overlay-start-failed")
return return
end end
if overrides.auto_start_trigger == true then
local visibility_action = resolve_visible_overlay_startup()
and "show-visible-overlay"
or "hide-visible-overlay"
run_control_command_async(visibility_action, {
log_level = overrides.log_level,
})
end
end) end)
end end
@@ -277,6 +362,7 @@ function M.create(ctx)
state.overlay_running = false state.overlay_running = false
state.texthooker_running = false state.texthooker_running = false
disarm_auto_play_ready_gate()
show_osd("Stopped") show_osd("Stopped")
end end
@@ -326,6 +412,7 @@ function M.create(ctx)
run_control_command_async("stop", nil, function() run_control_command_async("stop", nil, function()
state.overlay_running = false state.overlay_running = false
state.texthooker_running = false state.texthooker_running = false
disarm_auto_play_ready_gate()
ensure_texthooker_running(function() ensure_texthooker_running(function()
local start_args = build_command_args("start") local start_args = build_command_args("start")
@@ -384,6 +471,8 @@ function M.create(ctx)
restart_overlay = restart_overlay, restart_overlay = restart_overlay,
check_status = check_status, check_status = check_status,
check_binary_available = check_binary_available, check_binary_available = check_binary_available,
notify_auto_play_ready = notify_auto_play_ready,
disarm_auto_play_ready_gate = disarm_auto_play_ready_gate,
} }
end end

View File

@@ -27,6 +27,8 @@ function M.new()
found = false, found = false,
prompt_shown = false, prompt_shown = false,
}, },
auto_play_ready_gate_armed = false,
auto_play_ready_timeout = nil,
} }
end end

View File

@@ -8,6 +8,7 @@ local function run_plugin_scenario(config)
events = {}, events = {},
osd = {}, osd = {},
logs = {}, logs = {},
property_sets = {},
} }
local function make_mp_stub() local function make_mp_stub()
@@ -116,7 +117,12 @@ local function run_plugin_scenario(config)
return 0 return 0
end end
function mp.commandv(...) end function mp.commandv(...) end
function mp.set_property_native(...) end function mp.set_property_native(name, value)
recorded.property_sets[#recorded.property_sets + 1] = {
name = name,
value = value,
}
end
function mp.get_script_name() function mp.get_script_name()
return "subminer" return "subminer"
end end
@@ -242,6 +248,39 @@ local function find_start_call(async_calls)
return nil return nil
end end
local function count_start_calls(async_calls)
local count = 0
for _, call in ipairs(async_calls) do
local args = call.args or {}
for _, value in ipairs(args) do
if value == "--start" then
count = count + 1
break
end
end
end
return count
end
local function find_control_call(async_calls, flag)
for _, call in ipairs(async_calls) do
local args = call.args or {}
local has_flag = false
local has_start = false
for _, value in ipairs(args) do
if value == flag then
has_flag = true
elseif value == "--start" then
has_start = true
end
end
if has_flag and not has_start then
return call
end
end
return nil
end
local function call_has_arg(call, target) local function call_has_arg(call, target)
local args = (call and call.args) or {} local args = (call and call.args) or {}
for _, value in ipairs(args) do for _, value in ipairs(args) do
@@ -285,6 +324,34 @@ local function has_async_curl_for(async_calls, needle)
return false return false
end end
local function has_property_set(property_sets, name, value)
for _, call in ipairs(property_sets) do
if call.name == name and call.value == value then
return true
end
end
return false
end
local function has_osd_message(messages, target)
for _, message in ipairs(messages) do
if message == target then
return true
end
end
return false
end
local function count_osd_message(messages, target)
local count = 0
for _, message in ipairs(messages) do
if message == target then
count = count + 1
end
end
return count
end
local function fire_event(recorded, name) local function fire_event(recorded, name)
local listeners = recorded.events[name] or {} local listeners = recorded.events[name] or {}
for _, listener in ipairs(listeners) do for _, listener in ipairs(listeners) do
@@ -373,6 +440,7 @@ do
binary_path = binary_path, binary_path = binary_path,
auto_start = "yes", auto_start = "yes",
auto_start_visible_overlay = "yes", auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "no",
socket_path = "/tmp/subminer-socket", socket_path = "/tmp/subminer-socket",
}, },
input_ipc_server = "/tmp/subminer-socket", input_ipc_server = "/tmp/subminer-socket",
@@ -386,12 +454,86 @@ do
local start_call = find_start_call(recorded.async_calls) local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "auto-start should issue --start command") assert_true(start_call ~= nil, "auto-start should issue --start command")
assert_true( assert_true(
call_has_arg(start_call, "--show-visible-overlay"), not call_has_arg(start_call, "--show-visible-overlay"),
"auto-start with visible overlay enabled should pass --show-visible-overlay" "auto-start should keep --start command free of --show-visible-overlay"
) )
assert_true( assert_true(
not call_has_arg(start_call, "--hide-visible-overlay"), not call_has_arg(start_call, "--hide-visible-overlay"),
"auto-start with visible overlay enabled should not pass --hide-visible-overlay" "auto-start should keep --start command free of --hide-visible-overlay"
)
assert_true(
find_control_call(recorded.async_calls, "--show-visible-overlay") ~= nil,
"auto-start with visible overlay enabled should issue a separate --show-visible-overlay command"
)
assert_true(
not has_property_set(recorded.property_sets, "pause", true),
"auto-start visible overlay should not force pause without explicit pause-until-ready option"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for duplicate auto-start scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
fire_event(recorded, "file-loaded")
assert_true(
count_start_calls(recorded.async_calls) == 1,
"duplicate file-loaded events should not issue duplicate --start commands while overlay is already running"
)
assert_true(
count_osd_message(recorded.osd, "SubMiner: Already running") == 0,
"duplicate auto-start events should not show Already running OSD"
)
end
do
local recorded, err = run_plugin_scenario({
process_list = "",
option_overrides = {
binary_path = binary_path,
auto_start = "yes",
auto_start_visible_overlay = "yes",
auto_start_pause_until_ready = "yes",
socket_path = "/tmp/subminer-socket",
},
input_ipc_server = "/tmp/subminer-socket",
media_title = "Random Movie",
files = {
[binary_path] = true,
},
})
assert_true(recorded ~= nil, "plugin failed to load for pause-until-ready scenario: " .. tostring(err))
fire_event(recorded, "file-loaded")
assert_true(
has_property_set(recorded.property_sets, "pause", true),
"pause-until-ready auto-start should pause mpv before overlay ready"
)
assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered")
recorded.script_messages["subminer-autoplay-ready"]()
assert_true(
has_property_set(recorded.property_sets, "pause", false),
"autoplay-ready script message should resume mpv playback"
)
assert_true(
has_osd_message(recorded.osd, "SubMiner: Loading subtitle annotations..."),
"pause-until-ready auto-start should show loading OSD message"
)
assert_true(
has_osd_message(recorded.osd, "SubMiner: Subtitle annotations loaded"),
"autoplay-ready should show loaded OSD message"
) )
end end
@@ -415,12 +557,16 @@ do
local start_call = find_start_call(recorded.async_calls) local start_call = find_start_call(recorded.async_calls)
assert_true(start_call ~= nil, "auto-start should issue --start command") assert_true(start_call ~= nil, "auto-start should issue --start command")
assert_true( assert_true(
call_has_arg(start_call, "--hide-visible-overlay"), not call_has_arg(start_call, "--hide-visible-overlay"),
"auto-start with visible overlay disabled should pass --hide-visible-overlay" "auto-start should keep --start command free of --hide-visible-overlay"
) )
assert_true( assert_true(
not call_has_arg(start_call, "--show-visible-overlay"), not call_has_arg(start_call, "--show-visible-overlay"),
"auto-start with visible overlay disabled should not pass --show-visible-overlay" "auto-start should keep --start command free of --show-visible-overlay"
)
assert_true(
find_control_call(recorded.async_calls, "--hide-visible-overlay") ~= nil,
"auto-start with visible overlay disabled should issue a separate --hide-visible-overlay command"
) )
end end
@@ -446,6 +592,10 @@ do
start_call == nil, start_call == nil,
"auto-start should be skipped when mpv input-ipc-server does not match configured socket_path" "auto-start should be skipped when mpv input-ipc-server does not match configured socket_path"
) )
assert_true(
not has_property_set(recorded.property_sets, "pause", true),
"pause-until-ready gate should not arm when socket_path does not match"
)
end end
print("plugin start gate regression tests: OK") print("plugin start gate regression tests: OK")

View File

@@ -844,6 +844,61 @@ const immersionMediaRuntime = createImmersionMediaRuntime(
const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler()); const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler());
const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntimeMainDepsHandler()); const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntimeMainDepsHandler());
const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler()); const subsyncRuntime = createMainSubsyncRuntime(buildMainSubsyncRuntimeMainDepsHandler());
let autoPlayReadySignalMediaPath: string | null = null;
function maybeSignalPluginAutoplayReady(payload: SubtitleData): void {
if (!payload.text.trim()) {
return;
}
const mediaPath = appState.currentMediaPath;
if (!mediaPath) {
return;
}
if (autoPlayReadySignalMediaPath === mediaPath) {
return;
}
autoPlayReadySignalMediaPath = mediaPath;
logger.debug(`[autoplay-ready] signaling mpv for media: ${mediaPath}`);
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']);
// Fallback: unpause directly in case plugin readiness handler is unavailable/outdated.
void (async () => {
const mpvClient = appState.mpvClient;
if (!mpvClient?.connected) {
logger.debug('[autoplay-ready] skipped unpause fallback; mpv not connected');
return;
}
let shouldUnpause = appState.playbackPaused !== false;
try {
const pauseProperty = await mpvClient.requestProperty('pause');
if (typeof pauseProperty === 'boolean') {
shouldUnpause = pauseProperty;
} else if (typeof pauseProperty === 'string') {
shouldUnpause = pauseProperty.toLowerCase() !== 'no' && pauseProperty !== '0';
}
logger.debug(`[autoplay-ready] mpv pause property before fallback: ${String(pauseProperty)}`);
} catch (error) {
logger.debug(
`[autoplay-ready] failed to read pause property before fallback: ${(error as Error).message}`,
);
}
if (!shouldUnpause) {
logger.debug('[autoplay-ready] mpv already playing; no fallback unpause needed');
return;
}
mpvClient.send({ command: ['set_property', 'pause', false] });
setTimeout(() => {
const followupClient = appState.mpvClient;
if (followupClient?.connected) {
followupClient.send({ command: ['set_property', 'pause', false] });
}
}, 500);
logger.debug('[autoplay-ready] issued direct mpv unpause fallback');
})();
}
let appTray: Tray | null = null; let appTray: Tray | null = null;
const buildSubtitleProcessingControllerMainDepsHandler = const buildSubtitleProcessingControllerMainDepsHandler =
createBuildSubtitleProcessingControllerMainDepsHandler({ createBuildSubtitleProcessingControllerMainDepsHandler({
@@ -861,6 +916,7 @@ const buildSubtitleProcessingControllerMainDepsHandler =
topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX,
mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode,
}); });
maybeSignalPluginAutoplayReady(payload);
}, },
logDebug: (message) => { logDebug: (message) => {
logger.debug(`[subtitle-processing] ${message}`); logger.debug(`[subtitle-processing] ${message}`);
@@ -2224,6 +2280,9 @@ const {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
}, },
updateCurrentMediaPath: (path) => { updateCurrentMediaPath: (path) => {
if (appState.currentMediaPath !== path) {
autoPlayReadySignalMediaPath = null;
}
if (path) { if (path) {
ensureImmersionTrackerStarted(); ensureImmersionTrackerStarted();
} }