mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
feat(config): unify mpv plugin options under main config and add CSS/Ani
- Replace subminer.conf plugin config with mpv.* fields in config.jsonc - Add socketPath, backend, autoStartSubMiner, pauseUntilOverlayReady, aniskipEnabled/buttonKey, subminerBinaryPath to mpv config - Add subtitleSidebar.css field; migrate legacy sidebar appearance fields - Add paintOrder and WebkitTextStroke to subtitle style options - Update default subtitle/sidebar fontFamily to CJK-first stack - Fix overlay visible state surviving mpv y-r restart - Fix live config saves applying subtitle CSS immediately to open overlays - Migrate legacy primary/secondary subtitle appearance into subtitleStyle.css on load - Switch AniSkip button key setting to click-to-learn key capture
This commit is contained in:
@@ -9,8 +9,8 @@ const fields: ConfigSettingsField[] = [
|
||||
label: 'Launch mode',
|
||||
description: 'Launch mode setting.',
|
||||
configPath: 'mpv.launchMode',
|
||||
category: 'playback-sources',
|
||||
section: 'mpv launcher',
|
||||
category: 'behavior',
|
||||
section: 'MPV Launcher',
|
||||
control: 'select',
|
||||
defaultValue: 'windowed',
|
||||
restartBehavior: 'restart',
|
||||
|
||||
@@ -10,7 +10,10 @@ import type {
|
||||
} from '../../types/settings';
|
||||
import type { ReloadConfigStrictResult } from '../../config';
|
||||
import { classifyConfigHotReloadDiff } from '../../core/services/config-hot-reload';
|
||||
import { createSaveConfigSettingsPatchHandler } from './config-settings-save';
|
||||
import {
|
||||
createSaveConfigSettingsPatchHandler,
|
||||
type ConfigSettingsHotReloadDiff,
|
||||
} from './config-settings-save';
|
||||
import {
|
||||
createOpenConfigSettingsWindowHandler,
|
||||
type ConfigSettingsWindowLike,
|
||||
@@ -46,6 +49,7 @@ export interface ConfigSettingsRuntimeDeps<TWindow extends ConfigSettingsWindowL
|
||||
getConfig(): ResolvedConfig;
|
||||
getWarnings(): ConfigValidationWarning[];
|
||||
reloadConfigStrict(): ReloadConfigStrictResult;
|
||||
onHotReloadApplied?: (diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig) => void;
|
||||
getSettingsWindow(): TWindow | null;
|
||||
setSettingsWindow(window: TWindow | null): void;
|
||||
createSettingsWindow(): TWindow;
|
||||
@@ -122,6 +126,7 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
||||
reloadConfigStrict: () => deps.reloadConfigStrict(),
|
||||
classifyDiff: (previous, next) => classifyConfigHotReloadDiff(previous, next),
|
||||
getRestartRequiredSections: (fields) => getRestartRequiredSettingsSections(deps.fields, fields),
|
||||
onHotReloadApplied: deps.onHotReloadApplied,
|
||||
});
|
||||
|
||||
function ensureConfigFileExists(): string {
|
||||
@@ -199,20 +204,24 @@ export function createConfigSettingsRuntime<TWindow extends ConfigSettingsWindow
|
||||
);
|
||||
deps.ipcMain.handle(
|
||||
deps.ipcChannels.getConfigSettingsAnkiDeckFieldNames,
|
||||
(_event, deckName, draftUrl) =>
|
||||
typeof deckName === 'string'
|
||||
? getAnkiList(draftUrl, (client) => client.fieldNamesForDeck(deckName))
|
||||
: invalidAnkiListResult('Deck name is required.'),
|
||||
(_event, deckName, draftUrl) => {
|
||||
const normalizedDeckName = typeof deckName === 'string' ? deckName.trim() : '';
|
||||
return normalizedDeckName
|
||||
? getAnkiList(draftUrl, (client) => client.fieldNamesForDeck(normalizedDeckName))
|
||||
: invalidAnkiListResult('Deck name is required.');
|
||||
},
|
||||
);
|
||||
deps.ipcMain.handle(deps.ipcChannels.getConfigSettingsAnkiModelNames, (_event, draftUrl) =>
|
||||
getAnkiList(draftUrl, (client) => client.modelNames()),
|
||||
);
|
||||
deps.ipcMain.handle(
|
||||
deps.ipcChannels.getConfigSettingsAnkiModelFieldNames,
|
||||
(_event, modelName, draftUrl) =>
|
||||
typeof modelName === 'string'
|
||||
? getAnkiList(draftUrl, (client) => client.modelFieldNames(modelName))
|
||||
: invalidAnkiListResult('Note type is required.'),
|
||||
(_event, modelName, draftUrl) => {
|
||||
const normalizedModelName = typeof modelName === 'string' ? modelName.trim() : '';
|
||||
return normalizedModelName
|
||||
? getAnkiList(draftUrl, (client) => client.modelFieldNames(normalizedModelName))
|
||||
: invalidAnkiListResult('Note type is required.');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,76 @@ test('config settings save returns hot-reloadable diff for watcher path', () =>
|
||||
assert.deepEqual(result.restartRequiredFields, []);
|
||||
});
|
||||
|
||||
test('config settings save immediately applies hot-reloadable subtitle CSS changes', () => {
|
||||
const previous = DEFAULT_CONFIG;
|
||||
const next: ResolvedConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
subtitleStyle: {
|
||||
...DEFAULT_CONFIG.subtitleStyle,
|
||||
css: {
|
||||
'font-size': '50px',
|
||||
},
|
||||
secondary: {
|
||||
...DEFAULT_CONFIG.subtitleStyle.secondary,
|
||||
css: {
|
||||
'font-size': '28px',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const applied: Array<{
|
||||
hotReloadFields: string[];
|
||||
config: ResolvedConfig;
|
||||
}> = [];
|
||||
const save = createSaveConfigSettingsPatchHandler({
|
||||
getConfigPath: () => '/tmp/config.jsonc',
|
||||
getCurrentConfig: () => previous,
|
||||
getWarnings: () => [],
|
||||
getSnapshot: () => snapshot(),
|
||||
fileExists: () => true,
|
||||
readText: () => '{}',
|
||||
writeTextAtomically: () => {},
|
||||
reloadConfigStrict: (): ReloadConfigStrictResult => ({
|
||||
ok: true,
|
||||
config: next,
|
||||
warnings: [],
|
||||
path: '/tmp/config.jsonc',
|
||||
}),
|
||||
classifyDiff: () => ({
|
||||
hotReloadFields: ['subtitleStyle'],
|
||||
restartRequiredFields: [],
|
||||
}),
|
||||
getRestartRequiredSections: () => [],
|
||||
onHotReloadApplied: (diff, config) => {
|
||||
applied.push({
|
||||
hotReloadFields: diff.hotReloadFields,
|
||||
config,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const result = save({
|
||||
operations: [
|
||||
{
|
||||
op: 'set',
|
||||
path: 'subtitleStyle.css',
|
||||
value: { 'font-size': '50px' },
|
||||
},
|
||||
{
|
||||
op: 'set',
|
||||
path: 'subtitleStyle.secondary.css',
|
||||
value: { 'font-size': '28px' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(applied.length, 1);
|
||||
assert.deepEqual(applied[0]?.hotReloadFields, ['subtitleStyle']);
|
||||
assert.equal(applied[0]?.config.subtitleStyle.css['font-size'], '50px');
|
||||
assert.equal(applied[0]?.config.subtitleStyle.secondary.css['font-size'], '28px');
|
||||
});
|
||||
|
||||
test('config settings save returns restart-required sections without applying hot reload', () => {
|
||||
const calls: string[] = [];
|
||||
const previous = DEFAULT_CONFIG;
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface ConfigSettingsSaveDeps {
|
||||
reloadConfigStrict(): ReloadConfigStrictResult;
|
||||
classifyDiff(prev: ResolvedConfig, next: ResolvedConfig): ConfigSettingsHotReloadDiff;
|
||||
getRestartRequiredSections(restartRequiredFields: string[]): string[];
|
||||
onHotReloadApplied?: (diff: ConfigSettingsHotReloadDiff, config: ResolvedConfig) => void;
|
||||
}
|
||||
|
||||
export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDeps) {
|
||||
@@ -86,6 +87,9 @@ export function createSaveConfigSettingsPatchHandler(deps: ConfigSettingsSaveDep
|
||||
}
|
||||
|
||||
const diff = deps.classifyDiff(previousConfig, reloadResult.config);
|
||||
if (diff.hotReloadFields.length > 0) {
|
||||
deps.onHotReloadApplied?.(diff, reloadResult.config);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { SubtitleData } from '../../types';
|
||||
import { resolveCurrentSubtitleForRenderer } from './current-subtitle-snapshot';
|
||||
|
||||
function withTiming(payload: SubtitleData): SubtitleData {
|
||||
return {
|
||||
...payload,
|
||||
startTime: 1,
|
||||
endTime: 2,
|
||||
};
|
||||
}
|
||||
|
||||
test('renderer current subtitle snapshot reuses cached payload for first paint', async () => {
|
||||
const payload = await resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: '字幕',
|
||||
currentSubtitleData: { text: '字幕', tokens: [{ text: '字' } as never] },
|
||||
withCurrentSubtitleTiming: withTiming,
|
||||
});
|
||||
|
||||
assert.equal(payload.text, '字幕');
|
||||
assert.equal(payload.startTime, 1);
|
||||
assert.deepEqual(payload.tokens, [{ text: '字' }]);
|
||||
});
|
||||
|
||||
test('renderer current subtitle snapshot does not block on tokenizer for empty text', async () => {
|
||||
const payload = await resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: '',
|
||||
currentSubtitleData: null,
|
||||
withCurrentSubtitleTiming: withTiming,
|
||||
});
|
||||
|
||||
assert.equal(payload.text, '');
|
||||
assert.equal(payload.tokens, null);
|
||||
});
|
||||
|
||||
test('renderer current subtitle snapshot falls back to raw text for uncached subtitles', async () => {
|
||||
const payload = await resolveCurrentSubtitleForRenderer({
|
||||
currentSubText: 'まだキャッシュされていない字幕',
|
||||
currentSubtitleData: null,
|
||||
withCurrentSubtitleTiming: withTiming,
|
||||
});
|
||||
|
||||
assert.equal(payload.text, 'まだキャッシュされていない字幕');
|
||||
assert.equal(payload.startTime, 1);
|
||||
assert.equal(payload.tokens, null);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { SubtitleData } from '../../types';
|
||||
|
||||
export async function resolveCurrentSubtitleForRenderer(deps: {
|
||||
currentSubText: string;
|
||||
currentSubtitleData: SubtitleData | null;
|
||||
withCurrentSubtitleTiming: (payload: SubtitleData) => SubtitleData;
|
||||
}): Promise<SubtitleData> {
|
||||
if (deps.currentSubtitleData?.text === deps.currentSubText) {
|
||||
return deps.withCurrentSubtitleTiming(deps.currentSubtitleData);
|
||||
}
|
||||
|
||||
if (!deps.currentSubText.trim()) {
|
||||
return deps.withCurrentSubtitleTiming({
|
||||
text: deps.currentSubText,
|
||||
tokens: null,
|
||||
});
|
||||
}
|
||||
|
||||
return deps.withCurrentSubtitleTiming({
|
||||
text: deps.currentSubText,
|
||||
tokens: null,
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
removeLegacyMpvPluginCandidates,
|
||||
resolvePackagedFirstRunPluginAssets,
|
||||
resolvePackagedRuntimePluginPath,
|
||||
syncInstalledFirstRunPluginBinaryPath,
|
||||
} from './first-run-setup-plugin';
|
||||
import { resolveDefaultMpvInstallPaths } from '../../shared/setup-state';
|
||||
|
||||
@@ -66,66 +65,6 @@ test('resolvePackagedRuntimePluginPath returns packaged plugin entrypoint', () =
|
||||
});
|
||||
});
|
||||
|
||||
test('syncInstalledFirstRunPluginBinaryPath fills blank binary_path for existing installs', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
|
||||
|
||||
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
installPaths.pluginConfigPath,
|
||||
'binary_path=\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
|
||||
const result = syncInstalledFirstRunPluginBinaryPath({
|
||||
platform: 'linux',
|
||||
homeDir,
|
||||
xdgConfigHome,
|
||||
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
updated: true,
|
||||
configPath: installPaths.pluginConfigPath,
|
||||
});
|
||||
assert.equal(
|
||||
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
|
||||
'binary_path=/Applications/SubMiner.app/Contents/MacOS/SubMiner\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('syncInstalledFirstRunPluginBinaryPath preserves explicit binary_path overrides', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
|
||||
|
||||
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
installPaths.pluginConfigPath,
|
||||
'binary_path=/tmp/SubMiner/scripts/subminer-dev.sh\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
|
||||
const result = syncInstalledFirstRunPluginBinaryPath({
|
||||
platform: 'linux',
|
||||
homeDir,
|
||||
xdgConfigHome,
|
||||
binaryPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
updated: false,
|
||||
configPath: installPaths.pluginConfigPath,
|
||||
});
|
||||
assert.equal(
|
||||
fs.readFileSync(installPaths.pluginConfigPath, 'utf8'),
|
||||
'binary_path=/tmp/SubMiner/scripts/subminer-dev.sh\nsocket_path=/tmp/subminer-socket\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('detectInstalledFirstRunPlugin detects plugin installed in canonical mpv config location on macOS', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveDefaultMpvInstallPaths, type MpvInstallPaths } from '../../shared/setup-state';
|
||||
import type { MpvInstallPaths } from '../../shared/setup-state';
|
||||
|
||||
export interface InstalledFirstRunPluginCandidate {
|
||||
path: string;
|
||||
@@ -27,51 +27,6 @@ export interface LegacyMpvPluginRemovalResult {
|
||||
failedPaths: Array<{ path: string; message: string }>;
|
||||
}
|
||||
|
||||
function rewriteInstalledWindowsPluginConfig(configPath: string): void {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const updated = content.replace(/^socket_path=.*$/m, 'socket_path=\\\\.\\pipe\\subminer-socket');
|
||||
if (updated !== content) {
|
||||
fs.writeFileSync(configPath, updated, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizePluginConfigValue(value: string): string {
|
||||
return value.replace(/[\r\n]/g, '').trim();
|
||||
}
|
||||
|
||||
function upsertPluginConfigLine(content: string, key: string, value: string): string {
|
||||
const normalizedValue = sanitizePluginConfigValue(value);
|
||||
const line = `${key}=${normalizedValue}`;
|
||||
const pattern = new RegExp(`^${key}=.*$`, 'm');
|
||||
if (pattern.test(content)) {
|
||||
return content.replace(pattern, line);
|
||||
}
|
||||
|
||||
const suffix = content.endsWith('\n') || content.length === 0 ? '' : '\n';
|
||||
return `${content}${suffix}${line}\n`;
|
||||
}
|
||||
|
||||
function rewriteInstalledPluginBinaryPath(configPath: string, binaryPath: string): boolean {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const updated = upsertPluginConfigLine(content, 'binary_path', binaryPath);
|
||||
if (updated === content) {
|
||||
return false;
|
||||
}
|
||||
fs.writeFileSync(configPath, updated, 'utf8');
|
||||
return true;
|
||||
}
|
||||
|
||||
function readInstalledPluginBinaryPath(configPath: string): string | null {
|
||||
const content = fs.readFileSync(configPath, 'utf8');
|
||||
const match = content.match(/^binary_path=(.*)$/m);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
const rawValue = match[1] ?? '';
|
||||
const value = sanitizePluginConfigValue(rawValue);
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function resolvePackagedFirstRunPluginAssets(deps: {
|
||||
dirname: string;
|
||||
appPath: string;
|
||||
@@ -338,36 +293,3 @@ export async function removeLegacyMpvPluginCandidates(options: {
|
||||
failedPaths,
|
||||
};
|
||||
}
|
||||
|
||||
export function syncInstalledFirstRunPluginBinaryPath(options: {
|
||||
platform: NodeJS.Platform;
|
||||
homeDir: string;
|
||||
xdgConfigHome?: string;
|
||||
binaryPath: string;
|
||||
}): { updated: boolean; configPath: string | null } {
|
||||
const installPaths = resolveDefaultMpvInstallPaths(
|
||||
options.platform,
|
||||
options.homeDir,
|
||||
options.xdgConfigHome,
|
||||
);
|
||||
if (!installPaths.supported || !fs.existsSync(installPaths.pluginConfigPath)) {
|
||||
return { updated: false, configPath: null };
|
||||
}
|
||||
|
||||
const configuredBinaryPath = readInstalledPluginBinaryPath(installPaths.pluginConfigPath);
|
||||
if (configuredBinaryPath) {
|
||||
return { updated: false, configPath: installPaths.pluginConfigPath };
|
||||
}
|
||||
|
||||
const updated = rewriteInstalledPluginBinaryPath(
|
||||
installPaths.pluginConfigPath,
|
||||
options.binaryPath,
|
||||
);
|
||||
if (options.platform === 'win32') {
|
||||
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
|
||||
}
|
||||
return {
|
||||
updated,
|
||||
configPath: installPaths.pluginConfigPath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
|
||||
getLaunchMode: () => deps.getLaunchMode(),
|
||||
platform: deps.platform,
|
||||
execPath: deps.execPath,
|
||||
getPluginRuntimeConfig: deps.getPluginRuntimeConfig,
|
||||
defaultMpvLogPath: deps.defaultMpvLogPath,
|
||||
defaultMpvArgs: deps.defaultMpvArgs,
|
||||
removeSocketPath: (socketPath: string) => deps.removeSocketPath(socketPath),
|
||||
|
||||
@@ -56,6 +56,51 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
|
||||
assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback')));
|
||||
});
|
||||
|
||||
test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin config', () => {
|
||||
const spawnedArgs: string[][] = [];
|
||||
const launch = createLaunchMpvIdleForJellyfinPlaybackHandler({
|
||||
getSocketPath: () => '/tmp/subminer.sock',
|
||||
getLaunchMode: () => 'normal',
|
||||
platform: 'linux',
|
||||
execPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
getPluginRuntimeConfig: () => ({
|
||||
socketPath: '/tmp/ignored-config.sock',
|
||||
binaryPath: '/custom/SubMiner.AppImage',
|
||||
backend: 'x11',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'F8',
|
||||
}),
|
||||
defaultMpvLogPath: '/tmp/mp.log',
|
||||
defaultMpvArgs: ['--sid=auto'],
|
||||
removeSocketPath: () => {},
|
||||
spawnMpv: (args) => {
|
||||
spawnedArgs.push(args);
|
||||
return {
|
||||
on: () => {},
|
||||
unref: () => {},
|
||||
};
|
||||
},
|
||||
logWarn: () => {},
|
||||
logInfo: () => {},
|
||||
});
|
||||
|
||||
launch();
|
||||
const scriptOpts = spawnedArgs[0]?.find((arg) => arg.startsWith('--script-opts='));
|
||||
assert.match(scriptOpts ?? '', /subminer-binary_path=\/custom\/SubMiner\.AppImage/);
|
||||
assert.match(scriptOpts ?? '', /subminer-socket_path=\/tmp\/subminer\.sock/);
|
||||
assert.match(scriptOpts ?? '', /subminer-backend=x11/);
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start=yes/);
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start_visible_overlay=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start_pause_until_ready=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-texthooker_enabled=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_enabled=yes/);
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
|
||||
});
|
||||
|
||||
test('createEnsureMpvConnectedForJellyfinPlaybackHandler auto-launches once', async () => {
|
||||
let autoLaunchInFlight: Promise<boolean> | null = null;
|
||||
let launchCalls = 0;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
|
||||
import {
|
||||
buildSubminerPluginRuntimeScriptOptParts,
|
||||
type SubminerPluginRuntimeScriptOptConfig,
|
||||
} from '../../shared/subminer-plugin-script-opts';
|
||||
import type { MpvLaunchMode } from '../../types/config';
|
||||
|
||||
type MpvClientLike = {
|
||||
@@ -40,6 +44,7 @@ export type LaunchMpvForJellyfinDeps = {
|
||||
getLaunchMode: () => MpvLaunchMode;
|
||||
platform: NodeJS.Platform;
|
||||
execPath: string;
|
||||
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
|
||||
defaultMpvLogPath: string;
|
||||
defaultMpvArgs: readonly string[];
|
||||
removeSocketPath: (socketPath: string) => void;
|
||||
@@ -59,7 +64,17 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
|
||||
}
|
||||
}
|
||||
|
||||
const scriptOpts = `--script-opts=subminer-binary_path=${deps.execPath},subminer-socket_path=${socketPath}`;
|
||||
const pluginRuntimeConfig = deps.getPluginRuntimeConfig?.();
|
||||
const scriptOptParts = pluginRuntimeConfig
|
||||
? buildSubminerPluginRuntimeScriptOptParts(
|
||||
{
|
||||
...pluginRuntimeConfig,
|
||||
socketPath,
|
||||
},
|
||||
deps.execPath,
|
||||
)
|
||||
: [`subminer-binary_path=${deps.execPath}`, `subminer-socket_path=${socketPath}`];
|
||||
const scriptOpts = `--script-opts=${scriptOptParts.join(',')}`;
|
||||
const mpvArgs = [
|
||||
...deps.defaultMpvArgs,
|
||||
...buildMpvLaunchModeArgs(deps.getLaunchMode()),
|
||||
|
||||
@@ -54,6 +54,34 @@ test('mpv connection handler syncs overlay subtitle suppression on connect', ()
|
||||
assert.deepEqual(calls, ['presence-refresh', 'sync-overlay-mpv-sub']);
|
||||
});
|
||||
|
||||
test('mpv connection handler runs connected hook on connect', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvConnectionChangeHandler({
|
||||
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
|
||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||
onConnected: () => calls.push('connected-hook'),
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => false,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
|
||||
isQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => calls.push('schedule'),
|
||||
isMpvConnected: () => false,
|
||||
quitApp: () => calls.push('quit'),
|
||||
});
|
||||
|
||||
handler({ connected: true });
|
||||
handler({ connected: false });
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'presence-refresh',
|
||||
'sync-overlay-mpv-sub',
|
||||
'connected-hook',
|
||||
'presence-refresh',
|
||||
'report-stop',
|
||||
]);
|
||||
});
|
||||
|
||||
test('mpv connection handler quits standalone youtube playback even after overlay runtime init', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvConnectionChangeHandler({
|
||||
|
||||
@@ -27,6 +27,7 @@ export function createHandleMpvConnectionChangeHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
onConnected?: () => void;
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => boolean;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => boolean;
|
||||
@@ -39,6 +40,7 @@ export function createHandleMpvConnectionChangeHandler(deps: {
|
||||
deps.refreshDiscordPresence();
|
||||
if (connected) {
|
||||
deps.syncOverlayMpvSubtitleSuppression();
|
||||
deps.onConnected?.();
|
||||
return;
|
||||
}
|
||||
deps.reportJellyfinRemoteStopped();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { MpvRuntimeClientLike } from '../../core/services/mpv';
|
||||
import { getDefaultMpvSocketPath } from '../../shared/mpv-socket-path';
|
||||
|
||||
export function createApplyJellyfinMpvDefaultsHandler(deps: {
|
||||
sendMpvCommandRuntime: (client: MpvRuntimeClientLike, command: [string, string, string]) => void;
|
||||
@@ -17,9 +18,6 @@ export function createApplyJellyfinMpvDefaultsHandler(deps: {
|
||||
|
||||
export function createGetDefaultSocketPathHandler(deps: { platform: string }) {
|
||||
return (): string => {
|
||||
if (deps.platform === 'win32') {
|
||||
return '\\\\.\\pipe\\subminer-socket';
|
||||
}
|
||||
return '/tmp/subminer-socket';
|
||||
return getDefaultMpvSocketPath(deps.platform as NodeJS.Platform);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,3 +95,66 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
assert.ok(calls.includes('sync-immersion'));
|
||||
assert.ok(calls.includes('flush-playback'));
|
||||
});
|
||||
|
||||
test('main mpv event binder runs mpv-connected callback on connection', () => {
|
||||
const handlers = new Map<string, (payload: unknown) => void>();
|
||||
const calls: string[] = [];
|
||||
|
||||
const bind = createBindMpvMainEventHandlersHandler({
|
||||
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||
onMpvConnected: () => calls.push('mpv-connected'),
|
||||
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => false,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
|
||||
isQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {},
|
||||
isMpvConnected: () => true,
|
||||
quitApp: () => {},
|
||||
|
||||
recordImmersionSubtitleLine: () => {},
|
||||
hasSubtitleTimingTracker: () => false,
|
||||
recordSubtitleTiming: () => {},
|
||||
maybeRunAnilistPostWatchUpdate: async () => {},
|
||||
logSubtitleTimingError: () => {},
|
||||
setCurrentSubText: () => {},
|
||||
broadcastSubtitle: () => {},
|
||||
onSubtitleChange: () => {},
|
||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||
|
||||
setCurrentSubAssText: () => {},
|
||||
broadcastSubtitleAss: () => {},
|
||||
broadcastSecondarySubtitle: () => {},
|
||||
|
||||
updateCurrentMediaPath: () => {},
|
||||
restoreMpvSubVisibility: () => {},
|
||||
getCurrentAnilistMediaKey: () => null,
|
||||
resetAnilistMediaTracking: () => {},
|
||||
maybeProbeAnilistDuration: () => {},
|
||||
ensureAnilistMediaGuess: () => {},
|
||||
syncImmersionMediaState: () => {},
|
||||
|
||||
updateCurrentMediaTitle: () => {},
|
||||
resetAnilistMediaGuessState: () => {},
|
||||
notifyImmersionTitleUpdate: () => {},
|
||||
|
||||
recordPlaybackPosition: () => {},
|
||||
recordMediaDuration: () => {},
|
||||
reportJellyfinRemoteProgress: () => {},
|
||||
recordPauseState: () => {},
|
||||
|
||||
updateSubtitleRenderMetrics: () => {},
|
||||
setPreviousSecondarySubVisibility: () => {},
|
||||
});
|
||||
|
||||
bind({
|
||||
on: (event, handler) => {
|
||||
handlers.set(event, handler as (payload: unknown) => void);
|
||||
},
|
||||
});
|
||||
|
||||
handlers.get('connection-change')?.({ connected: true });
|
||||
|
||||
assert.ok(calls.includes('mpv-connected'));
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ type AnilistPostWatchRunOptions = {
|
||||
export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
onMpvConnected?: () => void;
|
||||
resetSubtitleSidebarEmbeddedLayout: () => void;
|
||||
scheduleCharacterDictionarySync?: () => void;
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => boolean;
|
||||
@@ -83,6 +84,7 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
||||
onConnected: () => deps.onMpvConnected?.(),
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => deps.hasInitialPlaybackQuitOnDisconnectArg(),
|
||||
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () =>
|
||||
|
||||
@@ -46,6 +46,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
quitApp: () => void;
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
onMpvConnected?: () => void;
|
||||
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) => Promise<void>;
|
||||
recordAnilistMediaDuration?: (durationSec: number) => void;
|
||||
logSubtitleTimingError: (message: string, error: unknown) => void;
|
||||
@@ -93,6 +94,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
return () => ({
|
||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
||||
onMpvConnected: deps.onMpvConnected ? () => deps.onMpvConnected!() : undefined,
|
||||
hasInitialPlaybackQuitOnDisconnectArg,
|
||||
isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized,
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: hasInitialPlaybackQuitOnDisconnectArg,
|
||||
|
||||
@@ -191,6 +191,38 @@ test('buildWindowsMpvLaunchArgs includes socket script opts when plugin entrypoi
|
||||
);
|
||||
});
|
||||
|
||||
test('buildWindowsMpvLaunchArgs uses runtime plugin config script opts', () => {
|
||||
const args = buildWindowsMpvLaunchArgs(
|
||||
['C:\\video.mkv'],
|
||||
['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket'],
|
||||
'C:\\SubMiner\\SubMiner.exe',
|
||||
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
|
||||
'normal',
|
||||
{
|
||||
socketPath: '\\\\.\\pipe\\ignored-config-socket',
|
||||
binaryPath: 'C:\\Custom\\SubMiner.exe',
|
||||
backend: 'windows',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
);
|
||||
|
||||
const scriptOpts = args.find((arg) => arg.startsWith('--script-opts='));
|
||||
assert.match(scriptOpts ?? '', /subminer-binary_path=C:\\Custom\\SubMiner\.exe/);
|
||||
assert.match(scriptOpts ?? '', /subminer-socket_path=\\\\\.\\pipe\\custom-subminer-socket/);
|
||||
assert.match(scriptOpts ?? '', /subminer-backend=windows/);
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start=yes/);
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start_visible_overlay=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-auto_start_pause_until_ready=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-texthooker_enabled=no/);
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_enabled=yes/);
|
||||
assert.match(scriptOpts ?? '', /subminer-aniskip_button_key=F8/);
|
||||
});
|
||||
|
||||
test('launchWindowsMpv reports missing mpv path', async () => {
|
||||
const errors: string[] = [];
|
||||
const result = await launchWindowsMpv(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import fs from 'node:fs';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
|
||||
import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-plugin-script-opts';
|
||||
import type { MpvLaunchMode } from '../../types/config';
|
||||
import type { SubminerPluginRuntimeScriptOptConfig } from '../../shared/subminer-plugin-script-opts';
|
||||
import type { InstalledMpvPluginDetection } from './first-run-setup-plugin';
|
||||
|
||||
export interface WindowsMpvLaunchDeps {
|
||||
@@ -102,6 +104,7 @@ export function buildWindowsMpvLaunchArgs(
|
||||
binaryPath?: string,
|
||||
pluginEntrypointPath?: string,
|
||||
launchMode: MpvLaunchMode = 'normal',
|
||||
pluginRuntimeConfig?: SubminerPluginRuntimeScriptOptConfig,
|
||||
): string[] {
|
||||
const launchIdle = targets.length === 0;
|
||||
const inputIpcServer =
|
||||
@@ -112,10 +115,18 @@ export function buildWindowsMpvLaunchArgs(
|
||||
: null;
|
||||
const hasBinaryPath = typeof binaryPath === 'string' && binaryPath.trim().length > 0;
|
||||
const shouldPassSubminerScriptOpts = scriptEntrypoint || hasBinaryPath;
|
||||
const scriptOptPairs = shouldPassSubminerScriptOpts
|
||||
? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`]
|
||||
: [];
|
||||
if (hasBinaryPath) {
|
||||
const scriptOptPairs = pluginRuntimeConfig
|
||||
? buildSubminerPluginRuntimeScriptOptParts(
|
||||
{
|
||||
...pluginRuntimeConfig,
|
||||
socketPath: inputIpcServer,
|
||||
},
|
||||
binaryPath ?? '',
|
||||
)
|
||||
: shouldPassSubminerScriptOpts
|
||||
? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`]
|
||||
: [];
|
||||
if (!pluginRuntimeConfig && hasBinaryPath) {
|
||||
scriptOptPairs.unshift(`subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')}`);
|
||||
}
|
||||
const scriptOpts = scriptOptPairs.length > 0 ? `--script-opts=${scriptOptPairs.join(',')}` : null;
|
||||
@@ -149,6 +160,7 @@ export async function launchWindowsMpv(
|
||||
configuredMpvPath?: string,
|
||||
launchMode: MpvLaunchMode = 'normal',
|
||||
runtimePluginPolicy?: WindowsMpvRuntimePluginPolicy,
|
||||
pluginRuntimeConfig?: SubminerPluginRuntimeScriptOptConfig,
|
||||
): Promise<{ ok: boolean; mpvPath: string }> {
|
||||
const normalizedConfiguredPath = normalizeCandidate(configuredMpvPath);
|
||||
const mpvPath = resolveWindowsMpvPath(deps, normalizedConfiguredPath);
|
||||
@@ -192,6 +204,7 @@ export async function launchWindowsMpv(
|
||||
binaryPath,
|
||||
runtimePluginEntrypointPath,
|
||||
launchMode,
|
||||
pluginRuntimeConfig,
|
||||
),
|
||||
);
|
||||
return { ok: true, mpvPath };
|
||||
|
||||
Reference in New Issue
Block a user