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`) |
|
| `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) |
|
||||||
|
|||||||
@@ -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` | 1–65535 | Texthooker server port |
|
| `texthooker_port` | `5174` | 1–65535 | 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
export interface PluginRuntimeConfig {
|
||||||
socketPath: string;
|
socketPath: string;
|
||||||
|
autoStart: boolean;
|
||||||
|
autoStartVisibleOverlay: boolean;
|
||||||
|
autoStartPauseUntilReady: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CommandExecOptions {
|
export interface CommandExecOptions {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,16 +132,17 @@ function M.create(ctx)
|
|||||||
table.insert(args, "--socket")
|
table.insert(args, "--socket")
|
||||||
table.insert(args, socket_path)
|
table.insert(args, socket_path)
|
||||||
|
|
||||||
|
-- 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()
|
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
|
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
|
||||||
|
|
||||||
return args
|
return args
|
||||||
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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
59
src/main.ts
59
src/main.ts
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user