Files
SubMiner/src/main-entry.ts
T
sudacode b1bdeabca8 fix(jellyfin): show overlay, inject plugin, and fix stats title on playback (#77)
* fix(jellyfin): show overlay, inject plugin, and fix stats title on playb

- Show visible overlay automatically during Jellyfin playback so subtitleStyle applies
- Inject bundled mpv plugin on auto-launch so keybindings work without overlay focus
- Group Jellyfin playback stats under item metadata (jellyfin://host/item/id) instead of stream URLs so episodes merge with matching local titles
- Mark ffsubsync unavailable in subsync modal for remote media paths
- Drain queued second-instance commands even when onReady throws

* fix(overlay): stabilize macOS focus handoff and sidebar Yomitan pause

- Keep overlay visible during macOS foreground probe after overlay blur
- Hold sidebar hover-pause while a Yomitan lookup popup remains open

* fix(jellyfin): fix discovery loop, device identity, tray state, and Disc

- Derive device identity from OS hostname; remove legacy configurable client/device fields
- Prevent discovery playback from reloading active item, misreporting pause state, and duplicate overlay restores
- Restart stale tray discovery sessions without re-login when server drops SubMiner cast target
- Sync tray discovery checkbox state on Linux after CLI/startup/remote-session changes
- Stop Discord presence falling back to stream URLs; prime title before tokenized stream loads
- Fix picker library discovery when log level is above info
- Fix config.example.jsonc trailing commas and array formatting

* docs(release): trim and consolidate prerelease notes for 0.15.0

- Remove breaking changes section and several redundant bullet points
- Consolidate per-platform updater notes into a single entry
- Normalize em-dash separators to hyphens in section headers

* fix(config): remove trailing commas from config.example.jsonc

- Strip trailing commas throughout both config.example.jsonc copies
- Reformat inline arrays to multi-line for JSON strictness
- Update Jellyfin subtitle preload and playback launch tests and impl

* fix(tokenizer): preserve known-word highlight when POS filters suppress

- Known-word cache matches now set isKnown=true even for tokens excluded by POS filters
- POS exclusion gate suppresses N+1, frequency, and JLPT only; known status is computed before the gate
- Jellyfin subtitle preload continues after cleanup failures instead of aborting
- Update config docs and option description to document the known-word bypass behavior

* fix(jellyfin): send explicit hide/show overlay instead of toggle

- Track overlay visibility in plugin state; y-t uses explicit hide/show commands when state is known
- Prevent paused Jellyfin playback from resuming on overlay hide
- Fix subtitle cache cleanup to only remove dirs after successful cleanup

* fix(jellyfin): fix remote progress sync, seek reporting, and startup sto

- arm active playback before loadfile with loadedMediaPath: null to suppress premature stop events
- force immediate progress report on seek-like position jumps at the mpv time-pos level
- send positionTicks and failed=false in reportStopped payload
- remove EventName from HTTP timeline payloads (websocket-only field)
- add startup grace window to drop stop events before media finishes loading

* fix(jellyfin): fix overlay toggle sync, redirect reload, and AppImage bi

- Sync visible-overlay state back to plugin via script messages to avoid toggle/hide drift
- Collapse duplicate toggle events within 250ms to prevent hide-then-show on single keypress
- Preserve manual hide across Jellyfin path-changing redirects even when media-title drops
- Rearm managed subtitle defaults on path-changing redirects
- Route toggleVisibleOverlay session binding through plugin toggle instead of app-side IPC
- Show Linux/Hyprland overlay passively (showInactive) to avoid stealing mpv keyboard focus
- Fix AppImage binary resolution to prefer $APPIMAGE env over mounted inner binary
- Add stats window layer management so delete/update dialogs appear above stats window
- Fix Jellyfin remote progress sync during Linux websocket reconnect windows

* Fix CodeRabbit review feedback

* fix(jellyfin): subtitle timing, resume progress, and overlay sync

- Add per-stream subtitle delay persistence and auto timeline-offset correction
- Strip server-selected subtitle stream from mpv load URL; suppress plugin subtitle rearm and auto-start during app-managed preload
- Fix resume position lost when mpv resets on stop; use last known position for final progress/stopped reports
- Keep Play vs Resume distinct to avoid early seek race on normal play
- Fix discovery resume when remote play sends StartPositionTicks=0 despite saved progress
- Deduplicate show/hide overlay commands using recorded visibility state
- Rewrite docs-site Jellyfin page around cast-to-device UX

* test: update lifecycle cleanup assertion

* fix: clear aborted playback state, fix overlay passthrough, and guard du

- Reset app_managed_playback_pending on lifecycle cleanup to prevent state leak into next item
- Record visible overlay action only after command succeeds, not before
- Non-native passive overlay now always click-through on re-show (fix isNonNativePassiveOverlay ordering)
- Defer activeParsedSubtitleMediaPath assignment until after prefetch completes
- Move autoplay gate release into the hide branch of toggleVisibleOverlay
- Clear active Jellyfin playback when stopping media that never loaded
- Reset managed subtitle delay and delay key when no external tracks are available
- Await async removeDir in subtitle cache cleanup
- Guard duplicate delete clicks in MediaDetailView and SessionsTab with refs
- Escape key in DeleteConfirmDialog now calls stopPropagation and stopImmediatePropagation
2026-05-24 18:40:56 -07:00

289 lines
9.1 KiB
TypeScript

import path from 'node:path';
import os from 'node:os';
import { spawn } from 'node:child_process';
import { app, dialog, shell } from 'electron';
import { printHelp } from './cli/help';
import { loadRawConfigStrict } from './config/load';
import {
configureEarlyAppPaths,
normalizeLaunchMpvExtraArgs,
normalizeLaunchMpvTargets,
normalizeStartupArgv,
applyEarlyLinuxCommandLineSwitches,
sanitizeStartupEnv,
sanitizeBackgroundEnv,
sanitizeHelpEnv,
sanitizeLaunchMpvEnv,
hasTransportedStartupArgs,
shouldForwardStartupArgvViaAppControl,
shouldDetachBackgroundLaunch,
shouldHandleHelpOnlyAtEntry,
shouldHandleLaunchMpvAtEntry,
shouldHandleStatsDaemonCommandAtEntry,
} from './main-entry-runtime';
import { requestSingleInstanceLockEarly } from './main/early-single-instance';
import { sendAppControlCommand } from './shared/app-control-client';
import {
detectInstalledFirstRunPluginCandidates,
detectInstalledMpvPlugin,
removeLegacyMpvPluginCandidates,
resolvePackagedRuntimePluginPath,
} from './main/runtime/first-run-setup-plugin';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
import { parseMpvLaunchMode } from './shared/mpv-launch-mode';
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
const DEFAULT_TEXTHOOKER_PORT = 5174;
function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
if (sanitizedEnv.NODE_NO_WARNINGS) {
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
}
if (sanitizedEnv.VK_INSTANCE_LAYERS) {
process.env.VK_INSTANCE_LAYERS = sanitizedEnv.VK_INSTANCE_LAYERS;
} else {
delete process.env.VK_INSTANCE_LAYERS;
}
}
function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
return (
resolvePackagedRuntimePluginPath({
dirname: __dirname,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath,
}) ?? undefined
);
}
function buildInstalledWindowsMpvPluginMessage(pathValue: string, version: string | null): string {
return [
'SubMiner detected an installed mpv plugin at:',
pathValue,
'',
"This mpv session will use the installed plugin. Remove it to use SubMiner's bundled runtime plugin automatically.",
`Detected plugin version: ${version ?? 'unknown or legacy'}`,
].join('\n');
}
async function promptForWindowsLegacyMpvPluginRemoval(
mpvPath: string,
detection: { path: string | null; version: string | null },
): Promise<'removed' | 'continue' | 'cancel'> {
const response = await dialog.showMessageBox({
type: 'warning',
title: 'SubMiner mpv plugin detected',
message: buildInstalledWindowsMpvPluginMessage(
detection.path ?? 'unknown path',
detection.version,
),
detail:
'Remove the legacy SubMiner mpv plugin files from mpv before launching this video? This moves the files to the OS trash. SubMiner-managed playback will then use the bundled runtime plugin.',
buttons: ['Remove legacy plugin', 'Continue with installed plugin', 'Cancel'],
defaultId: 0,
cancelId: 2,
});
if (response.response === 2) {
return 'cancel';
}
if (response.response === 1) {
return 'continue';
}
const candidates = detectInstalledFirstRunPluginCandidates({
platform: 'win32',
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
mpvExecutablePath: mpvPath,
});
const result = await removeLegacyMpvPluginCandidates({
candidates,
trashItem: (candidatePath) => shell.trashItem(candidatePath),
});
if (result.ok) {
await dialog.showMessageBox({
type: 'info',
title: 'Legacy mpv plugin removed',
message:
'Legacy mpv plugin removed. SubMiner-managed playback will use the bundled runtime plugin.',
});
return 'removed';
}
await dialog.showMessageBox({
type: 'error',
title: 'Could not remove legacy mpv plugin',
message: 'Some legacy SubMiner mpv plugin files could not be moved to the trash.',
detail: result.failedPaths.map((failure) => `${failure.path}: ${failure.message}`).join('\n'),
});
return 'cancel';
}
function createWindowsRuntimePluginPolicy() {
return {
detectInstalledMpvPlugin: (mpvPath: string) =>
detectInstalledMpvPlugin({
platform: 'win32',
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
mpvExecutablePath: mpvPath,
}),
notifyInstalledPluginDetected: (detection: {
installed: boolean;
path: string | null;
version: string | null;
}) => {
if (!detection.installed || !detection.path) return;
dialog.showMessageBoxSync({
type: 'warning',
title: 'SubMiner mpv plugin detected',
message: buildInstalledWindowsMpvPluginMessage(detection.path, detection.version),
});
},
resolveInstalledPluginBeforeLaunch: (
detection: { path: string | null; version: string | null },
mpvPath: string,
) => promptForWindowsLegacyMpvPluginRemoval(mpvPath, detection),
};
}
function readConfiguredWindowsMpvLaunch(configDir: string): {
executablePath: string;
launchMode: 'normal' | 'maximized' | 'fullscreen';
} {
const loadResult = loadRawConfigStrict({
configDir,
configFileJsonc: path.join(configDir, 'config.jsonc'),
configFileJson: path.join(configDir, 'config.json'),
});
if (!loadResult.ok) {
return {
executablePath: '',
launchMode: 'normal',
};
}
return {
executablePath:
typeof loadResult.config.mpv?.executablePath === 'string'
? loadResult.config.mpv.executablePath.trim()
: '',
launchMode: parseMpvLaunchMode(loadResult.config.mpv?.launchMode) ?? 'normal',
};
}
process.argv = normalizeStartupArgv(process.argv, process.env);
applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv);
applySanitizedEnv(sanitizeStartupEnv(process.env));
const userDataPath = configureEarlyAppPaths(app);
const reportFatalError = createFatalErrorReporter({
showErrorBox: (title, details) => dialog.showErrorBox(title, details),
consoleError: (message, error) => console.error(message, error),
});
registerFatalErrorHandlers({
reportFatalError,
exit: (code) => app.exit(code),
});
function startMainProcess(): void {
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
if (!gotSingleInstanceLock) {
app.exit(0);
return;
}
try {
require('./main.js');
} catch (error) {
reportFatalError(error, {
title: 'SubMiner startup failed',
context: 'SubMiner failed while loading the main process.',
});
app.exit(1);
}
}
async function forwardStartupArgvViaAppControlIfAvailable(): Promise<boolean> {
if (!shouldForwardStartupArgvViaAppControl(process.argv, process.env)) {
return false;
}
const result = await sendAppControlCommand(process.argv, {
configDir: userDataPath,
timeoutMs: 500,
});
if (result.ok) {
app.exit(0);
return true;
}
if (!result.unavailable) {
console.error(`SubMiner app-control handoff failed: ${result.error ?? 'unknown error'}`);
app.exit(1);
return true;
}
return false;
}
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
const childArgs = hasTransportedStartupArgs(process.env) ? [] : process.argv.slice(1);
const child = spawn(process.execPath, childArgs, {
detached: true,
stdio: 'ignore',
env: sanitizeBackgroundEnv(process.env),
});
child.unref();
process.exit(0);
}
if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
const sanitizedEnv = sanitizeHelpEnv(process.env);
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
if (!sanitizedEnv.VK_INSTANCE_LAYERS) {
delete process.env.VK_INSTANCE_LAYERS;
}
printHelp(DEFAULT_TEXTHOOKER_PORT);
process.exit(0);
}
if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
const sanitizedEnv = sanitizeLaunchMpvEnv(process.env);
applySanitizedEnv(sanitizedEnv);
void app.whenReady().then(async () => {
const configuredMpvLaunch = readConfiguredWindowsMpvLaunch(userDataPath);
const result = await launchWindowsMpv(
normalizeLaunchMpvTargets(process.argv),
createWindowsMpvLaunchDeps({
getEnv: (name) => process.env[name],
showError: (title, content) => {
dialog.showErrorBox(title, content);
},
}),
normalizeLaunchMpvExtraArgs(process.argv),
process.execPath,
resolveBundledWindowsMpvPluginEntrypoint(),
configuredMpvLaunch.executablePath,
configuredMpvLaunch.launchMode,
createWindowsRuntimePluginPolicy(),
);
app.exit(result.ok ? 0 : 1);
});
} else if (shouldHandleStatsDaemonCommandAtEntry(process.argv, process.env)) {
void app.whenReady().then(async () => {
const exitCode = await runStatsDaemonControlFromProcess(app.getPath('userData'));
app.exit(exitCode);
});
} else {
void forwardStartupArgvViaAppControlIfAvailable()
.then((forwarded) => {
if (!forwarded) {
startMainProcess();
}
})
.catch((error) => {
console.error('SubMiner app-control handoff failed:', error);
startMainProcess();
});
}