diff --git a/docs/configuration.md b/docs/configuration.md index 0615c4b..be16493 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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`) | 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 @@ -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)"`) | | `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. | +| `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.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) | diff --git a/docs/mpv-plugin.md b/docs/mpv-plugin.md index 895d797..1d178a4 100644 --- a/docs/mpv-plugin.md +++ b/docs/mpv-plugin.md @@ -78,11 +78,15 @@ backend=auto # Start the overlay automatically when a file is loaded. # Runs only when mpv input-ipc-server matches socket_path. -auto_start=no +auto_start=yes # Show the visible overlay on auto-start. # 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. osd_messages=yes @@ -123,8 +127,9 @@ aniskip_button_duration=3 | `texthooker_enabled` | `yes` | `yes` / `no` | Enable texthooker server | | `texthooker_port` | `5174` | 1–65535 | Texthooker server port | | `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_visible_overlay` | `no` | `yes` / `no` | Show visible layer on auto-start 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` | `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 | | `log_level` | `info` | `debug`, `info`, `warn`, `error` | Log verbosity | | `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 - **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. - **Texthooker**: Starts as a separate subprocess before the overlay to ensure the app lock is acquired first. diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index f629d2d..c7ff8bc 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -33,6 +33,12 @@ function createContext(overrides: Partial = {}): Launche scriptPath: '/tmp/subminer', scriptName: 'subminer', mpvSocketPath: '/tmp/subminer.sock', + pluginRuntimeConfig: { + socketPath: '/tmp/subminer.sock', + autoStart: true, + autoStartVisibleOverlay: true, + autoStartPauseUntilReady: true, + }, appPath: '/tmp/subminer.app', launcherJellyfinConfig: {}, processAdapter: adapter, diff --git a/launcher/commands/context.ts b/launcher/commands/context.ts index 44db33d..ba10cf1 100644 --- a/launcher/commands/context.ts +++ b/launcher/commands/context.ts @@ -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'; export interface LauncherCommandContext { @@ -6,6 +6,7 @@ export interface LauncherCommandContext { scriptPath: string; scriptName: string; mpvSocketPath: string; + pluginRuntimeConfig: PluginRuntimeConfig; appPath: string | null; launcherJellyfinConfig: LauncherJellyfinConfig; processAdapter: ProcessAdapter; diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts index a580dac..a220d8b 100644 --- a/launcher/commands/playback-command.ts +++ b/launcher/commands/playback-command.ts @@ -86,7 +86,7 @@ function registerCleanup(context: LauncherCommandContext): void { } export async function runPlaybackCommand(context: LauncherCommandContext): Promise { - const { args, appPath, scriptPath, mpvSocketPath, processAdapter } = context; + const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context; if (!appPath) { 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'); } + 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( selectedTarget.target, selectedTarget.kind, @@ -144,6 +157,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi mpvSocketPath, appPath, preloadedSubtitles, + { startPaused: shouldPauseUntilOverlayReady }, ); if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') { @@ -167,6 +181,7 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi } const ready = await waitForUnixSocketReady(mpvSocketPath, 10000); + const pluginAutoStartEnabled = pluginRuntimeConfig.autoStart; const shouldStartOverlay = args.startOverlay || args.autoStartOverlay; if (shouldStartOverlay) { if (ready) { @@ -179,6 +194,16 @@ export async function runPlaybackCommand(context: LauncherCommandContext): Promi ); } 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) { log( 'info', diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts index fecb9f0..666962f 100644 --- a/launcher/config-domain-parsers.test.ts +++ b/launcher/config-domain-parsers.test.ts @@ -51,10 +51,27 @@ test('parseLauncherJellyfinConfig omits legacy token and user id fields', () => 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(` # 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.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); }); diff --git a/launcher/config/plugin-runtime-config.ts b/launcher/config/plugin-runtime-config.ts index 13baabe..d57470d 100644 --- a/launcher/config/plugin-runtime-config.ts +++ b/launcher/config/plugin-runtime-config.ts @@ -16,21 +16,62 @@ export function getPluginConfigCandidates(): string[] { } 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/)) { const trimmed = line.trim(); if (trimmed.length === 0 || trimmed.startsWith('#')) continue; - const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i); - if (!socketMatch) continue; - const value = (socketMatch[1] || '').split('#', 1)[0]?.trim() || ''; - if (value) runtimeConfig.socketPath = value; + const keyValueMatch = trimmed.match(/^([a-z0-9_-]+)\s*=\s*(.+)$/i); + if (!keyValueMatch) continue; + const key = (keyValueMatch[1] || '').toLowerCase(); + 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; } export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig { 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) { if (!fs.existsSync(configPath)) continue; @@ -39,7 +80,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig log( 'debug', 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; } catch { @@ -51,7 +92,7 @@ export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig log( 'debug', 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; } diff --git a/launcher/main.ts b/launcher/main.ts index 197c32f..bb0909b 100644 --- a/launcher/main.ts +++ b/launcher/main.ts @@ -19,14 +19,15 @@ import { runPlaybackCommand } from './commands/playback-command.js'; function createCommandContext( args: ReturnType, scriptPath: string, - mpvSocketPath: string, + pluginRuntimeConfig: ReturnType, appPath: string | null, ): LauncherCommandContext { return { args, scriptPath, scriptName: path.basename(scriptPath), - mpvSocketPath, + mpvSocketPath: pluginRuntimeConfig.socketPath, + pluginRuntimeConfig, appPath, launcherJellyfinConfig: loadLauncherJellyfinConfig(), processAdapter: nodeProcessAdapter, @@ -55,7 +56,7 @@ async function main(): Promise { 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)) { return; @@ -71,6 +72,7 @@ async function main(): Promise { const resolvedAppPath = ensureAppPath(context); state.appPath = resolvedAppPath; + log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`); const appContext: LauncherCommandContext = { ...context, appPath: resolvedAppPath, diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 6622a16..6abd753 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -426,6 +426,7 @@ export function startMpv( socketPath: string, appPath: string, preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string }, + options?: { startPaused?: boolean }, ): void { if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) { fail(`Video file not found: ${target}`); @@ -475,6 +476,9 @@ export function startMpv( if (preloadedSubtitles?.secondaryPath) { mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`); } + if (options?.startPaused) { + mpvArgs.push('--pause=yes'); + } const aniSkipMetadata = targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null; const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata); if (aniSkipMetadata) { diff --git a/launcher/smoke.e2e.test.ts b/launcher/smoke.e2e.test.ts index 1907f48..e606948 100644 --- a/launcher/smoke.e2e.test.ts +++ b/launcher/smoke.e2e.test.ts @@ -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); + }); + }, +); diff --git a/launcher/types.ts b/launcher/types.ts index 19b94fc..a3f434e 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -129,6 +129,9 @@ export interface LauncherJellyfinConfig { export interface PluginRuntimeConfig { socketPath: string; + autoStart: boolean; + autoStartVisibleOverlay: boolean; + autoStartPauseUntilReady: boolean; } export interface CommandExecOptions { diff --git a/plugin/subminer.conf b/plugin/subminer.conf index c1adef8..d27fd41 100644 --- a/plugin/subminer.conf +++ b/plugin/subminer.conf @@ -28,6 +28,10 @@ auto_start=yes # Runs only when mpv input-ipc-server matches socket_path. 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 osd_messages=yes diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua index 14b3476..069ae33 100644 --- a/plugin/subminer/lifecycle.lua +++ b/plugin/subminer/lifecycle.lua @@ -31,6 +31,7 @@ function M.create(ctx) local function on_file_loaded() aniskip.clear_aniskip_state() + process.disarm_auto_play_ready_gate() local should_auto_start = resolve_auto_start_enabled() if should_auto_start then @@ -59,6 +60,7 @@ function M.create(ctx) local function on_shutdown() aniskip.clear_aniskip_state() hover.clear_hover_overlay() + process.disarm_auto_play_ready_gate() if state.overlay_running or state.texthooker_running then subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process") show_osd("Shutting down...") @@ -73,6 +75,7 @@ function M.create(ctx) hover.clear_hover_overlay() end) mp.register_event("end-file", function() + process.disarm_auto_play_ready_gate() hover.clear_hover_overlay() end) mp.register_event("shutdown", function() diff --git a/plugin/subminer/log.lua b/plugin/subminer/log.lua index 6554a52..57edde8 100644 --- a/plugin/subminer/log.lua +++ b/plugin/subminer/log.lua @@ -45,7 +45,14 @@ function M.create(ctx) local function show_osd(message) 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 diff --git a/plugin/subminer/messages.lua b/plugin/subminer/messages.lua index e0e4ff2..ca93e23 100644 --- a/plugin/subminer/messages.lua +++ b/plugin/subminer/messages.lua @@ -29,6 +29,9 @@ function M.create(ctx) mp.register_script_message("subminer-status", function() process.check_status() end) + mp.register_script_message("subminer-autoplay-ready", function() + process.notify_auto_play_ready() + end) mp.register_script_message("subminer-aniskip-refresh", function() aniskip.fetch_aniskip_for_current_media("script-message") end) diff --git a/plugin/subminer/options.lua b/plugin/subminer/options.lua index 2efa5f9..9c4ce7a 100644 --- a/plugin/subminer/options.lua +++ b/plugin/subminer/options.lua @@ -8,7 +8,8 @@ function M.load(options_lib, default_socket_path) texthooker_port = 5174, backend = "auto", auto_start = true, - auto_start_visible_overlay = false, + auto_start_visible_overlay = true, + auto_start_pause_until_ready = true, osd_messages = true, log_level = "info", aniskip_enabled = true, diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index 9994f2e..e3119c4 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -2,6 +2,7 @@ local M = {} local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2 local OVERLAY_START_MAX_ATTEMPTS = 6 +local AUTO_PLAY_READY_TIMEOUT_SECONDS = 15 function M.create(ctx) local mp = ctx.mp @@ -22,6 +23,14 @@ function M.create(ctx) return options_helper.coerce_bool(raw_visible_overlay, false) 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) if type(path) ~= "string" then return nil @@ -53,6 +62,54 @@ function M.create(ctx) return selected 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) overrides = overrides or {} local args = { state.binary_path } @@ -75,14 +132,15 @@ function M.create(ctx) table.insert(args, "--socket") table.insert(args, socket_path) - local should_show_visible = resolve_visible_overlay_startup() - if should_show_visible and overrides.auto_start_trigger == true then - should_show_visible = has_matching_mpv_ipc_socket(socket_path) - end - if should_show_visible then - table.insert(args, "--show-visible-overlay") - else - table.insert(args, "--hide-visible-overlay") + -- Keep auto-start --start requests idempotent for second-instance handling. + -- Visibility is applied as a separate control command after startup. + if overrides.auto_start_trigger ~= true then + local should_show_visible = resolve_visible_overlay_startup() + if should_show_visible then + table.insert(args, "--show-visible-overlay") + else + table.insert(args, "--hide-visible-overlay") + end end end @@ -182,6 +240,8 @@ function M.create(ctx) end local function start_overlay(overrides) + overrides = overrides or {} + if not binary.ensure_binary_available() then subminer_log("error", "binary", "SubMiner binary not found") show_osd("Error: binary not found") @@ -189,16 +249,31 @@ function M.create(ctx) end 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") show_osd("Already running") return end - overrides = overrides or {} local texthooker_enabled = overrides.texthooker_enabled if texthooker_enabled == nil then texthooker_enabled = opts.texthooker_enabled 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 args = build_command_args("start", overrides) @@ -236,9 +311,19 @@ function M.create(ctx) state.overlay_running = false subminer_log("error", "process", "Overlay start failed after retries: " .. reason) show_osd("Overlay start failed") + release_auto_play_ready_gate("overlay-start-failed") return 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 @@ -277,6 +362,7 @@ function M.create(ctx) state.overlay_running = false state.texthooker_running = false + disarm_auto_play_ready_gate() show_osd("Stopped") end @@ -326,6 +412,7 @@ function M.create(ctx) run_control_command_async("stop", nil, function() state.overlay_running = false state.texthooker_running = false + disarm_auto_play_ready_gate() ensure_texthooker_running(function() local start_args = build_command_args("start") @@ -384,6 +471,8 @@ function M.create(ctx) restart_overlay = restart_overlay, check_status = check_status, 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 diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua index 6ea5ccf..d4eb4de 100644 --- a/plugin/subminer/state.lua +++ b/plugin/subminer/state.lua @@ -27,6 +27,8 @@ function M.new() found = false, prompt_shown = false, }, + auto_play_ready_gate_armed = false, + auto_play_ready_timeout = nil, } end diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 9d8bd31..cfbc349 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -8,6 +8,7 @@ local function run_plugin_scenario(config) events = {}, osd = {}, logs = {}, + property_sets = {}, } local function make_mp_stub() @@ -116,7 +117,12 @@ local function run_plugin_scenario(config) return 0 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() return "subminer" end @@ -242,6 +248,39 @@ local function find_start_call(async_calls) return nil 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 args = (call and call.args) or {} for _, value in ipairs(args) do @@ -285,6 +324,34 @@ local function has_async_curl_for(async_calls, needle) return false 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 listeners = recorded.events[name] or {} for _, listener in ipairs(listeners) do @@ -373,6 +440,7 @@ do binary_path = binary_path, auto_start = "yes", auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "no", socket_path = "/tmp/subminer-socket", }, input_ipc_server = "/tmp/subminer-socket", @@ -386,12 +454,86 @@ do local start_call = find_start_call(recorded.async_calls) assert_true(start_call ~= nil, "auto-start should issue --start command") assert_true( - call_has_arg(start_call, "--show-visible-overlay"), - "auto-start with visible overlay enabled should pass --show-visible-overlay" + not call_has_arg(start_call, "--show-visible-overlay"), + "auto-start should keep --start command free of --show-visible-overlay" ) assert_true( 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 @@ -415,12 +557,16 @@ do local start_call = find_start_call(recorded.async_calls) assert_true(start_call ~= nil, "auto-start should issue --start command") assert_true( - call_has_arg(start_call, "--hide-visible-overlay"), - "auto-start with visible overlay disabled should pass --hide-visible-overlay" + not call_has_arg(start_call, "--hide-visible-overlay"), + "auto-start should keep --start command free of --hide-visible-overlay" ) assert_true( 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 @@ -446,6 +592,10 @@ do start_call == nil, "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 print("plugin start gate regression tests: OK") diff --git a/src/main.ts b/src/main.ts index aa6b036..46b9369 100644 --- a/src/main.ts +++ b/src/main.ts @@ -844,6 +844,61 @@ const immersionMediaRuntime = createImmersionMediaRuntime( const anilistStateRuntime = createAnilistStateRuntime(buildAnilistStateRuntimeMainDepsHandler()); const configDerivedRuntime = createConfigDerivedRuntime(buildConfigDerivedRuntimeMainDepsHandler()); 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; const buildSubtitleProcessingControllerMainDepsHandler = createBuildSubtitleProcessingControllerMainDepsHandler({ @@ -861,6 +916,7 @@ const buildSubtitleProcessingControllerMainDepsHandler = topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, }); + maybeSignalPluginAutoplayReady(payload); }, logDebug: (message) => { logger.debug(`[subtitle-processing] ${message}`); @@ -2224,6 +2280,9 @@ const { ensureImmersionTrackerStarted(); }, updateCurrentMediaPath: (path) => { + if (appState.currentMediaPath !== path) { + autoPlayReadySignalMediaPath = null; + } if (path) { ensureImmersionTrackerStarted(); }