feat(overlay): add subtitle selection modal and Jellyfin 12 fixes

- Add an optional subtitle selection modal for primary/secondary mpv tracks (subtitleSelection.enabled, g-s sequence shortcut) with key-sequence conflict handling
- Authenticate Jellyfin URLs with the ApiKey query, answer remote keep-alives, clear now-playing on stop, and restore episode titles in Anki misc info
- Honor the configured mpv executable when Jellyfin starts playback via a shared mpv-process launcher
- Bump electron-builder to 26.16.1
- Condense and reconcile changelog fragments; update config example and docs
This commit is contained in:
2026-09-22 18:51:59 -07:00
114 changed files with 2404 additions and 381 deletions
+66 -43
View File
@@ -84,7 +84,6 @@ protocol.registerSchemesAsPrivileged([
]);
import * as fs from 'fs';
import { spawn } from 'node:child_process';
import * as os from 'os';
import * as path from 'path';
import { MecabTokenizer } from './mecab-tokenizer';
@@ -131,11 +130,6 @@ import {
import { printHelp } from './cli/help';
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
import {
MPV_X11_BACKEND_ARGS,
applyX11EnvOverrides,
shouldForceX11WaylandSession,
} from './shared/mpv-x11-backend';
import { AnkiConnectClient } from './anki-connect';
import {
getStartupModeFlags,
@@ -402,6 +396,7 @@ import {
getConfiguredWindowsMpvPathStatus,
launchWindowsMpv,
} from './main/runtime/windows-mpv-launch';
import { resolveMpvExecutablePath, spawnMpvProcess } from './main/runtime/mpv-process';
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
import {
DEFAULT_JELLYFIN_CLIENT_NAME,
@@ -475,6 +470,11 @@ import { handleMpvCommandFromIpcRuntime } from './main/ipc-mpv-command';
import { registerIpcRuntimeServices } from './main/ipc-runtime';
import { createSubtitleGenerationRuntime } from './main/runtime/subtitle-generation-runtime';
import { registerSubtitleGenerationIpc } from './main/runtime/subtitle-generation-ipc';
import {
createSubtitleSelectionRuntime,
openSubtitleSelectionModal,
registerSubtitleSelectionIpc,
} from './main/runtime/subtitle-selection';
import { openSubtitleGenerationModal } from './main/runtime/subtitle-generation-open';
import { createAnkiJimakuIpcRuntimeServiceDeps } from './main/dependencies';
import { createMainBootServices, type MainBootServicesResult } from './main/boot/services';
@@ -686,22 +686,6 @@ const MPV_JELLYFIN_DEFAULT_ARGS = [
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
] as const;
/**
* Spawn a SubMiner-managed mpv (Jellyfin/YouTube) detached. On unsupported Wayland
* sessions it is pinned to XWayland — Wayland-hint env stripped and an X11 GPU context
* appended — so the XWayland overlay can stay above it, matching the `subminer` launcher.
*/
function spawnManagedMpvProcess(args: string[]): ReturnType<typeof spawn> {
if (!shouldForceX11WaylandSession(process.env)) {
return spawn('mpv', args, { detached: true, stdio: 'ignore' });
}
return spawn('mpv', [...args, ...MPV_X11_BACKEND_ARGS], {
detached: true,
stdio: 'ignore',
env: applyX11EnvOverrides({ ...process.env }),
});
}
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
let jellyfinRemoteLastProgressAtMs = 0;
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
@@ -3231,18 +3215,20 @@ const {
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
},
launchMpvIdleForJellyfinPlaybackMainDeps: {
getMpvExecutablePath: () =>
resolveMpvExecutablePath(configService.getConfig().mpv.executablePath),
getSocketPath: () => appState.mpvSocketPath,
getLaunchMode: () => configService.getConfig().mpv.launchMode,
platform: process.platform,
execPath: process.execPath,
getRuntimePluginEntrypoint: () => resolveBundledMpvRuntimePluginEntrypoint(),
getInstalledPluginDetection: () =>
getInstalledPluginDetection: (mpvExecutablePath) =>
detectInstalledMpvPlugin({
platform: process.platform,
homeDir: os.homedir(),
xdgConfigHome: process.env.XDG_CONFIG_HOME,
appDataDir: app.getPath('appData'),
mpvExecutablePath: configService.getConfig().mpv.executablePath,
mpvExecutablePath,
}),
getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(),
getDefaultMpvLogPath: () => (isLogFileEnabled('mpv') ? DEFAULT_MPV_LOG_PATH : ''),
@@ -3250,7 +3236,7 @@ const {
removeSocketPath: (socketPath) => {
fs.rmSync(socketPath, { force: true });
},
spawnMpv: (args) => spawnManagedMpvProcess(args),
spawnMpv: spawnMpvProcess,
logWarn: (message, error) => logger.warn(message, error),
logInfo: (message) => logger.info(message),
},
@@ -4664,6 +4650,7 @@ const {
maybeStartOverlayLoadingOsd();
flushQueuedMpvOsdNotifications();
secondarySubtitleTrackController.scheduleRefresh(0);
void refreshMpvSessionBindings();
if (appState.sessionBindingsInitialized) {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
@@ -5384,20 +5371,32 @@ const {
},
});
const { persistSessionBindings, refreshCurrentSessionBindings } = createSessionBindingsRuntime({
configDir: CONFIG_DIR,
getKeybindings: () => appState.keybindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getResolvedConfig: () => configService.getConfig(),
getMpvClient: () => appState.mpvClient,
setSessionBindings: (bindings) => {
appState.sessionBindings = bindings;
},
setSessionBindingsInitialized: (initialized) => {
appState.sessionBindingsInitialized = initialized;
},
logWarn: (message) => logger.warn(message),
});
const { persistSessionBindings, refreshCurrentSessionBindings, refreshMpvSessionBindings } =
createSessionBindingsRuntime({
configDir: CONFIG_DIR,
getKeybindings: () => appState.keybindings,
getConfiguredShortcuts: () => getConfiguredShortcuts(),
getResolvedConfig: () => configService.getConfig(),
getMpvClient: () => appState.mpvClient,
setSessionBindings: (bindings) => {
appState.sessionBindings = bindings;
},
setSessionBindingsInitialized: (initialized) => {
appState.sessionBindingsInitialized = initialized;
},
logWarn: (message) => logger.warn(message),
onBindingsChanged: (bindings) =>
overlayManager.broadcastToOverlayWindows(IPC_CHANNELS.event.sessionBindingsChanged, bindings),
onWarning: (warning) => {
if (warning.kind !== 'conflict') return;
overlayNotificationsRuntime.showOverlayNotification({
id: `session-binding-conflict:${warning.path}`,
title: 'Shortcut conflict',
body: warning.message,
variant: 'warning',
});
},
});
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
appendToMpvLogMainDeps: {
@@ -5652,6 +5651,14 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
openJimaku: () => openJimakuOverlay(),
openTsukihime: () => openTsukihimeOverlay(),
openSessionHelp: () => openSessionHelpOverlay(),
openSubtitleSelection: () => {
if (!configService.getConfig().subtitleSelection.enabled) return;
openOverlayHostedModalWithOsd(
openSubtitleSelectionModal,
'Subtitle selection overlay unavailable.',
'Failed to open subtitle selection overlay.',
);
},
openSubtitleGeneration: () => openSubtitleGenerationOverlay(),
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
openControllerSelect: () => openControllerSelectOverlay(),
@@ -6004,8 +6011,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
saveSubtitlePosition: (position) => saveSubtitlePosition(position),
getMecabTokenizer: () => appState.mecabTokenizer,
getKeybindings: () => appState.keybindings,
getMpvInputBindings: () =>
readMpvInputBindings({
getMpvInputBindings: async () => {
await refreshMpvSessionBindings();
return readMpvInputBindings({
getMpvClient: () => appState.mpvClient,
getConfiguredKeybindings: () => configService.getConfig().keybindings ?? [],
platform:
@@ -6014,8 +6022,12 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
: process.platform === 'win32'
? 'win32'
: 'linux',
}),
getSessionBindings: () => appState.sessionBindings,
});
},
getSessionBindings: async () => {
await refreshMpvSessionBindings();
return appState.sessionBindings;
},
getConfiguredShortcuts: () => getConfiguredShortcuts(),
dispatchSessionAction: (request) => dispatchSessionAction(request),
getStatsToggleKey: () => configService.getConfig().stats.toggleKey,
@@ -6811,6 +6823,17 @@ function setOverlayVisible(visible: boolean): void {
}
registerIpcRuntimeHandlers();
registerSubtitleSelectionIpc({
ipc: ipcMain,
isAllowedSender: (sender) =>
[overlayManager.getMainWindow(), overlayManager.getModalWindow()].some(
(window) => window && !window.isDestroyed() && window.webContents === sender,
),
runtime: createSubtitleSelectionRuntime({
isEnabled: () => configService.getConfig().subtitleSelection.enabled,
getMpvClient: () => appState.mpvClient,
}),
});
const subtitleGenerationRuntime = createSubtitleGenerationRuntime({
getConfig: () => configService.getConfig().subtitleGeneration,
getModelDirectory: () =>