mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 06:22:42 -08:00
feat(launcher): pause auto-start playback until overlay is ready
This commit is contained in:
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -86,7 +86,7 @@ function registerCleanup(context: LauncherCommandContext): 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) {
|
||||
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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -19,14 +19,15 @@ import { runPlaybackCommand } from './commands/playback-command.js';
|
||||
function createCommandContext(
|
||||
args: ReturnType<typeof parseArgs>,
|
||||
scriptPath: string,
|
||||
mpvSocketPath: string,
|
||||
pluginRuntimeConfig: ReturnType<typeof readPluginRuntimeConfig>,
|
||||
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<void> {
|
||||
|
||||
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<void> {
|
||||
|
||||
const resolvedAppPath = ensureAppPath(context);
|
||||
state.appPath = resolvedAppPath;
|
||||
log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`);
|
||||
const appContext: LauncherCommandContext = {
|
||||
...context,
|
||||
appPath: resolvedAppPath,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -129,6 +129,9 @@ export interface LauncherJellyfinConfig {
|
||||
|
||||
export interface PluginRuntimeConfig {
|
||||
socketPath: string;
|
||||
autoStart: boolean;
|
||||
autoStartVisibleOverlay: boolean;
|
||||
autoStartPauseUntilReady: boolean;
|
||||
}
|
||||
|
||||
export interface CommandExecOptions {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ function M.new()
|
||||
found = false,
|
||||
prompt_shown = false,
|
||||
},
|
||||
auto_play_ready_gate_armed = false,
|
||||
auto_play_ready_timeout = nil,
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
59
src/main.ts
59
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user