mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
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
This commit is contained in:
@@ -244,7 +244,7 @@ test('suspended visible overlay hides without refreshing bounds or z-order', ()
|
||||
assert.ok(!calls.includes('focus'));
|
||||
});
|
||||
|
||||
test('untracked non-macOS overlay keeps fallback visible behavior when no tracker exists', () => {
|
||||
test('untracked non-macOS overlay shows passively when no tracker exists', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
let trackerWarning = false;
|
||||
|
||||
@@ -279,11 +279,49 @@ test('untracked non-macOS overlay keeps fallback visible behavior when no tracke
|
||||
} as never);
|
||||
|
||||
assert.equal(trackerWarning, false);
|
||||
assert.ok(calls.includes('show'));
|
||||
assert.ok(calls.includes('focus'));
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
assert.ok(!calls.includes('osd'));
|
||||
});
|
||||
|
||||
test('passive Linux visible overlay does not take keyboard focus', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
isTracking: () => true,
|
||||
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||
};
|
||||
|
||||
updateVisibleOverlayVisibility({
|
||||
visibleOverlayVisible: true,
|
||||
mainWindow: window as never,
|
||||
windowTracker: tracker as never,
|
||||
trackerNotReadyWarningShown: false,
|
||||
setTrackerNotReadyWarningShown: () => {},
|
||||
updateVisibleOverlayBounds: () => {
|
||||
calls.push('update-bounds');
|
||||
},
|
||||
ensureOverlayWindowLevel: () => {
|
||||
calls.push('ensure-level');
|
||||
},
|
||||
syncPrimaryOverlayWindowLayer: () => {
|
||||
calls.push('sync-layer');
|
||||
},
|
||||
enforceOverlayLayerOrder: () => {
|
||||
calls.push('enforce-order');
|
||||
},
|
||||
syncOverlayShortcuts: () => {
|
||||
calls.push('sync-shortcuts');
|
||||
},
|
||||
isMacOSPlatform: false,
|
||||
isWindowsPlatform: false,
|
||||
} as never);
|
||||
|
||||
assert.ok(calls.includes('show-inactive'));
|
||||
assert.ok(!calls.includes('show'));
|
||||
assert.ok(!calls.includes('focus'));
|
||||
});
|
||||
|
||||
test('tracked non-macOS overlay reapplies bounds after first show', () => {
|
||||
const { window, calls } = createMainWindowRecorder();
|
||||
const tracker: WindowTrackerStub = {
|
||||
@@ -317,8 +355,8 @@ test('tracked non-macOS overlay reapplies bounds after first show', () => {
|
||||
} as never);
|
||||
|
||||
assert.deepEqual(
|
||||
calls.filter((call) => call === 'update-bounds' || call === 'show'),
|
||||
['update-bounds', 'show', 'update-bounds'],
|
||||
calls.filter((call) => call === 'update-bounds' || call === 'show-inactive'),
|
||||
['update-bounds', 'show-inactive', 'update-bounds'],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -185,6 +185,8 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
shouldUseMacOSMousePassthrough ||
|
||||
forceMousePassthrough ||
|
||||
(shouldDefaultToPassthrough && (!isVisibleOverlayFocused || shouldForcePassiveReshow));
|
||||
const isNonNativePassiveOverlay =
|
||||
!args.isWindowsPlatform && !args.isMacOSPlatform && !overlayInteractionActive;
|
||||
const shouldBindTrackedWindowsOverlay = args.isWindowsPlatform && !!args.windowTracker;
|
||||
const shouldKeepTrackedWindowsOverlayTopmost =
|
||||
!args.isWindowsPlatform ||
|
||||
@@ -227,7 +229,10 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
// skip — ready-to-show hasn't fired yet; the onWindowContentReady
|
||||
// callback will trigger another visibility update when the renderer
|
||||
// has painted its first frame.
|
||||
} else if ((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) {
|
||||
} else if (
|
||||
((args.isWindowsPlatform || args.isMacOSPlatform) && shouldIgnoreMouseEvents) ||
|
||||
isNonNativePassiveOverlay
|
||||
) {
|
||||
if (args.isWindowsPlatform) {
|
||||
setOverlayWindowOpacity(mainWindow, 0);
|
||||
}
|
||||
@@ -271,7 +276,12 @@ export function updateVisibleOverlayVisibility(args: {
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
if (!args.isWindowsPlatform && !args.isMacOSPlatform && !forceMousePassthrough) {
|
||||
if (
|
||||
!args.isWindowsPlatform &&
|
||||
!args.isMacOSPlatform &&
|
||||
!forceMousePassthrough &&
|
||||
overlayInteractionActive
|
||||
) {
|
||||
mainWindow.focus();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { BrowserWindow, BrowserWindowConstructorOptions } from 'electron';
|
||||
import type {
|
||||
BrowserWindow,
|
||||
BrowserWindowConstructorOptions,
|
||||
MessageBoxSyncOptions,
|
||||
} from 'electron';
|
||||
import type { WindowGeometry } from '../../types';
|
||||
|
||||
const DEFAULT_STATS_WINDOW_WIDTH = 900;
|
||||
@@ -9,6 +13,15 @@ type StatsWindowLevelController = Pick<BrowserWindow, 'setAlwaysOnTop' | 'moveTo
|
||||
Partial<Pick<BrowserWindow, 'setVisibleOnAllWorkspaces' | 'setFullScreenable'>>;
|
||||
type VisibleStatsWindowLevelController = StatsWindowLevelController &
|
||||
Pick<BrowserWindow, 'isDestroyed' | 'isVisible'>;
|
||||
type VisibleStatsWindowDialogLayerController = Pick<
|
||||
BrowserWindow,
|
||||
'isDestroyed' | 'isVisible' | 'setAlwaysOnTop'
|
||||
>;
|
||||
type StatsNativeConfirmDialogWindow = Pick<BrowserWindow, 'isDestroyed'>;
|
||||
type StatsNativeConfirmDialogPresenter<WindowT> = {
|
||||
showWithParent: (window: WindowT, options: MessageBoxSyncOptions) => number;
|
||||
showWithoutParent: (options: MessageBoxSyncOptions) => number;
|
||||
};
|
||||
|
||||
type StatsWindowBoundsController = Pick<BrowserWindow, 'getBounds' | 'getContentBounds'>;
|
||||
type StatsWindowPresentationController = Pick<BrowserWindow, 'show' | 'focus'> &
|
||||
@@ -124,6 +137,41 @@ export function promoteVisibleStatsWindowAboveOverlay(
|
||||
return true;
|
||||
}
|
||||
|
||||
export function demoteVisibleStatsWindowBelowDialogs(
|
||||
window: VisibleStatsWindowDialogLayerController,
|
||||
): boolean {
|
||||
if (window.isDestroyed() || !window.isVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
window.setAlwaysOnTop(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function buildStatsNativeConfirmDialogOptions(message: string): MessageBoxSyncOptions {
|
||||
return {
|
||||
type: 'warning',
|
||||
message,
|
||||
buttons: ['Delete', 'Cancel'],
|
||||
defaultId: 1,
|
||||
cancelId: 1,
|
||||
noLink: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function showStatsNativeConfirmDialog<WindowT extends StatsNativeConfirmDialogWindow>(
|
||||
window: WindowT | null,
|
||||
message: string,
|
||||
presenter: StatsNativeConfirmDialogPresenter<WindowT>,
|
||||
): boolean {
|
||||
const options = buildStatsNativeConfirmDialogOptions(message);
|
||||
const response =
|
||||
window && !window.isDestroyed()
|
||||
? presenter.showWithParent(window, options)
|
||||
: presenter.showWithoutParent(options);
|
||||
return response === 0;
|
||||
}
|
||||
|
||||
export function presentStatsWindow(
|
||||
window: StatsWindowPresentationController,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
|
||||
@@ -3,10 +3,13 @@ import test from 'node:test';
|
||||
import {
|
||||
buildStatsWindowLoadFileOptions,
|
||||
buildStatsWindowOptions,
|
||||
buildStatsNativeConfirmDialogOptions,
|
||||
demoteVisibleStatsWindowBelowDialogs,
|
||||
presentStatsWindow,
|
||||
promoteVisibleStatsWindowAboveOverlay,
|
||||
promoteStatsWindowLevel,
|
||||
resolveStatsWindowOuterBoundsForContent,
|
||||
showStatsNativeConfirmDialog,
|
||||
shouldHideStatsWindowForInput,
|
||||
} from './stats-window-runtime';
|
||||
|
||||
@@ -274,6 +277,90 @@ test('promoteVisibleStatsWindowAboveOverlay skips hidden stats windows', () => {
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('demoteVisibleStatsWindowBelowDialogs lowers visible stats below native dialogs', () => {
|
||||
const calls: string[] = [];
|
||||
const demoted = demoteVisibleStatsWindowBelowDialogs({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => true,
|
||||
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
|
||||
calls.push(`always-on-top:${flag}:${level ?? 'none'}:${relativeLevel ?? 0}`);
|
||||
},
|
||||
} as never);
|
||||
|
||||
assert.equal(demoted, true);
|
||||
assert.deepEqual(calls, ['always-on-top:false:none:0']);
|
||||
});
|
||||
|
||||
test('demoteVisibleStatsWindowBelowDialogs skips hidden stats windows', () => {
|
||||
const calls: string[] = [];
|
||||
const demoted = demoteVisibleStatsWindowBelowDialogs({
|
||||
isDestroyed: () => false,
|
||||
isVisible: () => false,
|
||||
setAlwaysOnTop: () => calls.push('always-on-top'),
|
||||
} as never);
|
||||
|
||||
assert.equal(demoted, false);
|
||||
assert.deepEqual(calls, []);
|
||||
});
|
||||
|
||||
test('buildStatsNativeConfirmDialogOptions makes delete the explicit destructive action', () => {
|
||||
assert.deepEqual(buildStatsNativeConfirmDialogOptions('Delete this session?'), {
|
||||
type: 'warning',
|
||||
message: 'Delete this session?',
|
||||
buttons: ['Delete', 'Cancel'],
|
||||
defaultId: 1,
|
||||
cancelId: 1,
|
||||
noLink: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('showStatsNativeConfirmDialog parents the native dialog to live stats windows', () => {
|
||||
const calls: string[] = [];
|
||||
const parent = { isDestroyed: () => false };
|
||||
|
||||
const confirmed = showStatsNativeConfirmDialog(parent, 'Delete this session?', {
|
||||
showWithParent: (window, options) => {
|
||||
assert.equal(window, parent);
|
||||
calls.push(`${options.message}:${options.defaultId}:${options.cancelId}`);
|
||||
return 0;
|
||||
},
|
||||
showWithoutParent: () => {
|
||||
calls.push('unparented');
|
||||
return 1;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(confirmed, true);
|
||||
assert.deepEqual(calls, ['Delete this session?:1:1']);
|
||||
});
|
||||
|
||||
test('showStatsNativeConfirmDialog treats cancel as not confirmed', () => {
|
||||
const confirmed = showStatsNativeConfirmDialog({ isDestroyed: () => false }, 'Delete?', {
|
||||
showWithParent: () => 1,
|
||||
showWithoutParent: () => 0,
|
||||
});
|
||||
|
||||
assert.equal(confirmed, false);
|
||||
});
|
||||
|
||||
test('showStatsNativeConfirmDialog falls back to an unparented dialog without a live stats window', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
const confirmed = showStatsNativeConfirmDialog({ isDestroyed: () => true }, 'Delete?', {
|
||||
showWithParent: () => {
|
||||
calls.push('parented');
|
||||
return 0;
|
||||
},
|
||||
showWithoutParent: (options) => {
|
||||
calls.push(options.message);
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(confirmed, true);
|
||||
assert.deepEqual(calls, ['Delete?']);
|
||||
});
|
||||
|
||||
test('presentStatsWindow shows inactive on macOS to stay on the fullscreen mpv Space', () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron';
|
||||
import { BrowserWindow, dialog, ipcMain } from 'electron';
|
||||
import * as path from 'path';
|
||||
import type { WindowGeometry } from '../../types.js';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts.js';
|
||||
import {
|
||||
buildStatsWindowLoadFileOptions,
|
||||
buildStatsWindowOptions,
|
||||
demoteVisibleStatsWindowBelowDialogs,
|
||||
presentStatsWindow,
|
||||
promoteStatsWindowLevel,
|
||||
promoteVisibleStatsWindowAboveOverlay,
|
||||
resolveStatsWindowOuterBoundsForContent,
|
||||
showStatsNativeConfirmDialog,
|
||||
shouldHideStatsWindowForInput,
|
||||
STATS_WINDOW_TITLE,
|
||||
} from './stats-window-runtime.js';
|
||||
@@ -16,6 +18,8 @@ import { ensureHyprlandWindowFloatingByTitle } from './hyprland-window-placement
|
||||
|
||||
let statsWindow: BrowserWindow | null = null;
|
||||
let toggleRegistered = false;
|
||||
let nativeDialogLayerRegistered = false;
|
||||
let nativeDialogLayerSuspensionCount = 0;
|
||||
|
||||
export interface StatsWindowOptions {
|
||||
/** Absolute path to stats/dist/ directory */
|
||||
@@ -63,6 +67,10 @@ function showStatsWindow(window: BrowserWindow, options: StatsWindowOptions): vo
|
||||
}
|
||||
|
||||
export function promoteStatsOverlayAbovePlayback(): boolean {
|
||||
if (nativeDialogLayerSuspensionCount > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!statsWindow) {
|
||||
return false;
|
||||
}
|
||||
@@ -74,6 +82,71 @@ export function promoteStatsOverlayAbovePlayback(): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
export function demoteStatsOverlayBelowDialogs(): boolean {
|
||||
if (!statsWindow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return demoteVisibleStatsWindowBelowDialogs(statsWindow);
|
||||
}
|
||||
|
||||
export function suspendStatsWindowLayerForNativeDialog(): void {
|
||||
nativeDialogLayerSuspensionCount += 1;
|
||||
if (nativeDialogLayerSuspensionCount !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
demoteStatsOverlayBelowDialogs();
|
||||
}
|
||||
|
||||
export function restoreStatsWindowLayerAfterNativeDialog(): void {
|
||||
if (nativeDialogLayerSuspensionCount <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
nativeDialogLayerSuspensionCount -= 1;
|
||||
if (nativeDialogLayerSuspensionCount === 0) {
|
||||
promoteStatsOverlayAbovePlayback();
|
||||
}
|
||||
}
|
||||
|
||||
export async function withStatsWindowLayerSuspendedForNativeDialog<T>(
|
||||
showDialog: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
suspendStatsWindowLayerForNativeDialog();
|
||||
try {
|
||||
return await showDialog();
|
||||
} finally {
|
||||
restoreStatsWindowLayerAfterNativeDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function confirmStatsNativeDialog(message: unknown): boolean {
|
||||
const dialogMessage =
|
||||
typeof message === 'string' && message.trim().length > 0 ? message : 'Confirm deletion?';
|
||||
|
||||
return showStatsNativeConfirmDialog(statsWindow, dialogMessage, {
|
||||
showWithParent: (parentWindow, options) => dialog.showMessageBoxSync(parentWindow, options),
|
||||
showWithoutParent: (options) => dialog.showMessageBoxSync(options),
|
||||
});
|
||||
}
|
||||
|
||||
function registerStatsNativeDialogLayerHandlers(): void {
|
||||
if (nativeDialogLayerRegistered) return;
|
||||
nativeDialogLayerRegistered = true;
|
||||
|
||||
ipcMain.on(IPC_CHANNELS.command.statsNativeConfirmDialog, (event, message) => {
|
||||
event.returnValue = confirmStatsNativeDialog(message);
|
||||
});
|
||||
ipcMain.on(IPC_CHANNELS.command.statsNativeDialogOpened, (event) => {
|
||||
suspendStatsWindowLayerForNativeDialog();
|
||||
event.returnValue = true;
|
||||
});
|
||||
ipcMain.on(IPC_CHANNELS.command.statsNativeDialogClosed, () => {
|
||||
restoreStatsWindowLayerAfterNativeDialog();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the stats overlay window: create on first call, then show/hide.
|
||||
* The React app stays mounted across toggles — state is preserved.
|
||||
@@ -132,6 +205,7 @@ export function toggleStatsOverlay(options: StatsWindowOptions): void {
|
||||
* Call this once during app initialization.
|
||||
*/
|
||||
export function registerStatsOverlayToggle(options: StatsWindowOptions): void {
|
||||
registerStatsNativeDialogLayerHandlers();
|
||||
if (toggleRegistered) return;
|
||||
toggleRegistered = true;
|
||||
ipcMain.on(IPC_CHANNELS.command.toggleStatsOverlay, () => {
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
shouldHandleLaunchMpvAtEntry,
|
||||
shouldHandleStatsDaemonCommandAtEntry,
|
||||
hasTransportedStartupArgs,
|
||||
shouldForwardStartupArgvViaAppControl,
|
||||
applyEarlyLinuxCommandLineSwitches,
|
||||
resolveLinuxPasswordStoreValue,
|
||||
} from './main-entry-runtime';
|
||||
|
||||
test('normalizeStartupArgv defaults no-arg startup to --start --background on non-Windows', () => {
|
||||
@@ -106,6 +109,64 @@ test('hasTransportedStartupArgs detects env-carried app args', () => {
|
||||
assert.equal(hasTransportedStartupArgs({}), false);
|
||||
});
|
||||
|
||||
test('resolveLinuxPasswordStoreValue defaults Linux safeStorage to gnome-libsecret', () => {
|
||||
assert.equal(resolveLinuxPasswordStoreValue(['SubMiner.AppImage'], 'linux'), 'gnome-libsecret');
|
||||
assert.equal(
|
||||
resolveLinuxPasswordStoreValue(['SubMiner.AppImage', '--password-store', 'gnome'], 'linux'),
|
||||
'gnome-libsecret',
|
||||
);
|
||||
assert.equal(resolveLinuxPasswordStoreValue(['SubMiner.exe'], 'win32'), null);
|
||||
});
|
||||
|
||||
test('applyEarlyLinuxCommandLineSwitches appends password store before main startup', () => {
|
||||
const switches: Array<[string, string | undefined]> = [];
|
||||
applyEarlyLinuxCommandLineSwitches(
|
||||
{
|
||||
appendSwitch: (name, value) => {
|
||||
switches.push([name, value]);
|
||||
},
|
||||
},
|
||||
['SubMiner.AppImage', '--password-store=kwallet6'],
|
||||
'linux',
|
||||
);
|
||||
|
||||
assert.deepEqual(switches, [
|
||||
['enable-features', 'GlobalShortcutsPortal'],
|
||||
['password-store', 'kwallet6'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('transported AppImage visibility commands should forward through app control', () => {
|
||||
assert.equal(
|
||||
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {
|
||||
SUBMINER_APP_ARGC: '1',
|
||||
SUBMINER_APP_ARG_0: '--hide-visible-overlay',
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('app control forwarding is only for transported runtime commands', () => {
|
||||
assert.equal(
|
||||
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--hide-visible-overlay'], {}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--app-ping'], {
|
||||
SUBMINER_APP_ARGC: '1',
|
||||
SUBMINER_APP_ARG_0: '--app-ping',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldForwardStartupArgvViaAppControl(['SubMiner.AppImage', '--launch-mpv'], {
|
||||
SUBMINER_APP_ARGC: '1',
|
||||
SUBMINER_APP_ARG_0: '--launch-mpv',
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldHandleHelpOnlyAtEntry detects help-only invocation', () => {
|
||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help'], {}), true);
|
||||
assert.equal(shouldHandleHelpOnlyAtEntry(['--help', '--start'], {}), false);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { CliArgs, parseArgs, shouldStartApp } from './cli/args';
|
||||
import { CliArgs, hasExplicitCommand, parseArgs, shouldStartApp } from './cli/args';
|
||||
import { resolveConfigDir } from './config/path-resolution';
|
||||
|
||||
const BACKGROUND_ARG = '--background';
|
||||
const START_ARG = '--start';
|
||||
const PASSWORD_STORE_ARG = '--password-store';
|
||||
const DEFAULT_LINUX_PASSWORD_STORE = 'gnome-libsecret';
|
||||
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
||||
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
||||
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||
@@ -34,6 +35,10 @@ type EarlyAppLike = {
|
||||
setPath: (name: 'userData', value: string) => void;
|
||||
};
|
||||
|
||||
type CommandLineLike = {
|
||||
appendSwitch: (name: string, value?: string) => void;
|
||||
};
|
||||
|
||||
type EarlyAppPathOptions = {
|
||||
platform?: NodeJS.Platform;
|
||||
appDataDir?: string;
|
||||
@@ -73,6 +78,58 @@ function removePassiveStartupArgs(argv: string[]): string[] {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function getPasswordStoreArg(argv: string[]): string | null {
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg?.startsWith(PASSWORD_STORE_ARG)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === PASSWORD_STORE_ARG) {
|
||||
const value = argv[i + 1];
|
||||
if (value && !value.startsWith('--')) {
|
||||
return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const [prefix, value] = arg.split('=', 2);
|
||||
if (prefix === PASSWORD_STORE_ARG && value && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePasswordStoreArg(value: string): string {
|
||||
const normalized = value.trim();
|
||||
if (normalized.toLowerCase() === 'gnome') {
|
||||
return DEFAULT_LINUX_PASSWORD_STORE;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveLinuxPasswordStoreValue(
|
||||
argv: string[],
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): string | null {
|
||||
if (platform !== 'linux') return null;
|
||||
return normalizePasswordStoreArg(getPasswordStoreArg(argv) ?? DEFAULT_LINUX_PASSWORD_STORE);
|
||||
}
|
||||
|
||||
export function applyEarlyLinuxCommandLineSwitches(
|
||||
commandLine: CommandLineLike,
|
||||
argv: string[],
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): void {
|
||||
if (platform !== 'linux') return;
|
||||
commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
||||
commandLine.appendSwitch(
|
||||
'password-store',
|
||||
resolveLinuxPasswordStoreValue(argv, platform) ?? DEFAULT_LINUX_PASSWORD_STORE,
|
||||
);
|
||||
}
|
||||
|
||||
function consumesLaunchMpvValue(token: string): boolean {
|
||||
return (
|
||||
token.startsWith('--') &&
|
||||
@@ -90,6 +147,20 @@ export function hasTransportedStartupArgs(env: NodeJS.ProcessEnv): boolean {
|
||||
return typeof env[TRANSPORTED_APP_ARGC_ENV] === 'string';
|
||||
}
|
||||
|
||||
export function shouldForwardStartupArgvViaAppControl(
|
||||
argv: string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
): boolean {
|
||||
if (env.ELECTRON_RUN_AS_NODE === '1') return false;
|
||||
if (!hasTransportedStartupArgs(env)) return false;
|
||||
|
||||
const args = parseCliArgs(argv);
|
||||
if (args.help || args.appPing || args.launchMpv) return false;
|
||||
if (resolveStatsDaemonCommandAction(argv) !== null) return false;
|
||||
|
||||
return hasExplicitCommand(args);
|
||||
}
|
||||
|
||||
function readTransportedStartupArgs(env: NodeJS.ProcessEnv): string[] | null {
|
||||
const rawCount = env[TRANSPORTED_APP_ARGC_ENV];
|
||||
if (rawCount === undefined) {
|
||||
|
||||
+51
-12
@@ -9,17 +9,20 @@ import {
|
||||
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,
|
||||
@@ -173,6 +176,7 @@ function readConfiguredWindowsMpvLaunch(configDir: string): {
|
||||
}
|
||||
|
||||
process.argv = normalizeStartupArgv(process.argv, process.env);
|
||||
applyEarlyLinuxCommandLineSwitches(app.commandLine, process.argv);
|
||||
applySanitizedEnv(sanitizeStartupEnv(process.env));
|
||||
const userDataPath = configureEarlyAppPaths(app);
|
||||
const reportFatalError = createFatalErrorReporter({
|
||||
@@ -184,6 +188,44 @@ registerFatalErrorHandlers({
|
||||
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, {
|
||||
@@ -233,17 +275,14 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
|
||||
app.exit(exitCode);
|
||||
});
|
||||
} else {
|
||||
const gotSingleInstanceLock = requestSingleInstanceLockEarly(app);
|
||||
if (!gotSingleInstanceLock) {
|
||||
app.exit(0);
|
||||
}
|
||||
try {
|
||||
require('./main.js');
|
||||
} catch (error) {
|
||||
reportFatalError(error, {
|
||||
title: 'SubMiner startup failed',
|
||||
context: 'SubMiner failed while loading the main process.',
|
||||
void forwardStartupArgvViaAppControlIfAvailable()
|
||||
.then((forwarded) => {
|
||||
if (!forwarded) {
|
||||
startMainProcess();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('SubMiner app-control handoff failed:', error);
|
||||
startMainProcess();
|
||||
});
|
||||
app.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+15
-1
@@ -356,6 +356,7 @@ import {
|
||||
promoteStatsOverlayAbovePlayback,
|
||||
registerStatsOverlayToggle,
|
||||
toggleStatsOverlay as toggleStatsOverlayWindow,
|
||||
withStatsWindowLayerSuspendedForNativeDialog,
|
||||
} from './core/services/stats-window.js';
|
||||
import {
|
||||
createFirstRunSetupService,
|
||||
@@ -5184,6 +5185,8 @@ function getUpdateService() {
|
||||
});
|
||||
app.focus({ steal: true });
|
||||
},
|
||||
withStatsWindowLayerSuspended: (showDialog) =>
|
||||
withStatsWindowLayerSuspendedForNativeDialog(showDialog),
|
||||
showMessageBox: (options) => dialog.showMessageBox(options),
|
||||
});
|
||||
updateService = createUpdateService({
|
||||
@@ -6406,6 +6409,13 @@ function ensureOverlayWindowsReadyForVisibilityActions(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function notifyMpvPluginVisibleOverlayVisibility(visible: boolean): void {
|
||||
sendMpvCommandRuntime(appState.mpvClient, [
|
||||
'script-message',
|
||||
visible ? 'subminer-visible-overlay-shown' : 'subminer-visible-overlay-hidden',
|
||||
]);
|
||||
}
|
||||
|
||||
function setVisibleOverlayVisible(visible: boolean): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
if (!visible) {
|
||||
@@ -6416,18 +6426,21 @@ function setVisibleOverlayVisible(visible: boolean): void {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
setVisibleOverlayVisibleHandler(visible);
|
||||
notifyMpvPluginVisibleOverlayVisibility(visible);
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
|
||||
function toggleVisibleOverlay(): void {
|
||||
ensureOverlayWindowsReadyForVisibilityActions();
|
||||
const nextVisible = !overlayManager.getVisibleOverlayVisible();
|
||||
autoplayReadyGate.markCurrentMediaAutoplayReady();
|
||||
if (overlayManager.getVisibleOverlayVisible()) {
|
||||
if (!nextVisible) {
|
||||
cancelPendingLinuxMpvFullscreenOverlayRefreshBurst();
|
||||
} else {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
toggleVisibleOverlayHandler();
|
||||
notifyMpvPluginVisibleOverlayVisibility(nextVisible);
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
function setOverlayVisible(visible: boolean): void {
|
||||
@@ -6439,6 +6452,7 @@ function setOverlayVisible(visible: boolean): void {
|
||||
void ensureOverlayMpvSubtitlesHidden();
|
||||
}
|
||||
setOverlayVisibleHandler(visible);
|
||||
notifyMpvPluginVisibleOverlayVisibility(visible);
|
||||
syncOverlayMpvSubtitleSuppression();
|
||||
}
|
||||
function handleOverlayModalClosed(modal: OverlayHostedModal): void {
|
||||
|
||||
@@ -73,6 +73,22 @@ test('manual visible overlay toggles suppress current-media autoplay release', (
|
||||
);
|
||||
});
|
||||
|
||||
test('manual visible overlay changes notify mpv plugin visibility state', () => {
|
||||
const source = readMainSource();
|
||||
const setBlock = source.match(
|
||||
/function setVisibleOverlayVisible\(visible: boolean\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
const toggleBlock = source.match(
|
||||
/function toggleVisibleOverlay\(\): void \{(?<body>[\s\S]*?)\n\}/,
|
||||
)?.groups?.body;
|
||||
|
||||
assert.ok(setBlock);
|
||||
assert.ok(toggleBlock);
|
||||
assert.match(setBlock, /notifyMpvPluginVisibleOverlayVisibility\(visible\);/);
|
||||
assert.match(toggleBlock, /const nextVisible = !overlayManager\.getVisibleOverlayVisible\(\);/);
|
||||
assert.match(toggleBlock, /notifyMpvPluginVisibleOverlayVisibility\(nextVisible\);/);
|
||||
});
|
||||
|
||||
test('main process uses one shared mpv plugin runtime config helper', () => {
|
||||
const source = readMainSource();
|
||||
assert.match(source, /function getMpvPluginRuntimeConfig\(\)/);
|
||||
|
||||
@@ -9,6 +9,7 @@ type MockWindow = {
|
||||
ignoreMouseEvents: boolean;
|
||||
forwardedIgnoreMouseEvents: boolean;
|
||||
webContentsFocused: boolean;
|
||||
alwaysOnTopCalls: string[];
|
||||
showCount: number;
|
||||
hideCount: number;
|
||||
sent: unknown[][];
|
||||
@@ -53,6 +54,7 @@ function createMockWindow(): MockWindow & {
|
||||
ignoreMouseEvents: false,
|
||||
forwardedIgnoreMouseEvents: false,
|
||||
webContentsFocused: false,
|
||||
alwaysOnTopCalls: [],
|
||||
showCount: 0,
|
||||
hideCount: 0,
|
||||
sent: [],
|
||||
@@ -72,7 +74,9 @@ function createMockWindow(): MockWindow & {
|
||||
state.ignoreMouseEvents = ignore;
|
||||
state.forwardedIgnoreMouseEvents = options?.forward === true;
|
||||
},
|
||||
setAlwaysOnTop: (_flag: boolean, _level?: string, _relativeLevel?: number) => {},
|
||||
setAlwaysOnTop: (flag: boolean, level?: string, relativeLevel?: number) => {
|
||||
state.alwaysOnTopCalls.push(`top:${flag}:${level ?? ''}:${relativeLevel ?? ''}`);
|
||||
},
|
||||
moveTop: () => {},
|
||||
getShowCount: () => state.showCount,
|
||||
getHideCount: () => state.hideCount,
|
||||
@@ -155,6 +159,13 @@ function createMockWindow(): MockWindow & {
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'alwaysOnTopCalls', {
|
||||
get: () => state.alwaysOnTopCalls,
|
||||
set: (value: string[]) => {
|
||||
state.alwaysOnTopCalls = value;
|
||||
},
|
||||
});
|
||||
|
||||
Object.defineProperty(window, 'url', {
|
||||
get: () => state.url,
|
||||
set: (value: string) => {
|
||||
@@ -219,6 +230,7 @@ test('sendToActiveOverlayWindow targets modal window with full geometry and trac
|
||||
runtime.notifyOverlayModalOpened('runtime-options');
|
||||
assert.equal(window.getShowCount(), 1);
|
||||
assert.equal(window.isFocused(), true);
|
||||
assert.deepEqual(window.alwaysOnTopCalls, ['top:true:screen-saver:3']);
|
||||
assert.deepEqual(window.sent, [['runtime-options:open']]);
|
||||
});
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ export function createOverlayModalRuntimeService(
|
||||
|
||||
const elevateModalWindow = (window: BrowserWindow): void => {
|
||||
if (window.isDestroyed()) return;
|
||||
window.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
window.setAlwaysOnTop(true, 'screen-saver', 3);
|
||||
window.moveTop();
|
||||
};
|
||||
|
||||
|
||||
@@ -61,6 +61,42 @@ test('createReportJellyfinRemoteProgressHandler reports playback progress', asyn
|
||||
assert.equal(lastProgressAtMs, 5000);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler reports while remote websocket is disconnected', async () => {
|
||||
const reportPayloads: Array<{ positionTicks: number; isPaused: boolean }> = [];
|
||||
|
||||
const reportProgress = createReportJellyfinRemoteProgressHandler({
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-1',
|
||||
playMethod: 'DirectPlay',
|
||||
}),
|
||||
clearActivePlayback: () => {},
|
||||
getSession: () => ({
|
||||
isConnected: () => false,
|
||||
reportProgress: async (payload) => {
|
||||
reportPayloads.push({
|
||||
positionTicks: payload.positionTicks,
|
||||
isPaused: payload.isPaused,
|
||||
});
|
||||
},
|
||||
reportStopped: async () => {},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 42,
|
||||
requestProperty: async (name: string) => (name === 'pause' ? false : 42),
|
||||
}),
|
||||
getNow: () => 5000,
|
||||
getLastProgressAtMs: () => 0,
|
||||
setLastProgressAtMs: () => {},
|
||||
progressIntervalMs: 3000,
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportProgress(true);
|
||||
|
||||
assert.deepEqual(reportPayloads, [{ positionTicks: 420_000_000, isPaused: false }]);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteProgressHandler normalizes mpv pause strings', async () => {
|
||||
const reportPayloads: Array<{ isPaused: boolean }> = [];
|
||||
|
||||
@@ -219,6 +255,53 @@ test('createReportJellyfinRemoteStoppedHandler reports stop and clears playback'
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler reports stop while remote websocket is disconnected', async () => {
|
||||
let cleared = false;
|
||||
let stoppedPayload: {
|
||||
itemId: string;
|
||||
positionTicks?: number;
|
||||
failed?: boolean;
|
||||
} | null = null;
|
||||
const reportStopped = createReportJellyfinRemoteStoppedHandler({
|
||||
getActivePlayback: () => ({
|
||||
itemId: 'item-2',
|
||||
mediaSourceId: undefined,
|
||||
playMethod: 'Transcode',
|
||||
audioStreamIndex: null,
|
||||
subtitleStreamIndex: null,
|
||||
loadedMediaPath: 'https://stream.example/video.m3u8',
|
||||
}),
|
||||
clearActivePlayback: () => {
|
||||
cleared = true;
|
||||
},
|
||||
getSession: () => ({
|
||||
isConnected: () => false,
|
||||
reportProgress: async () => {},
|
||||
reportStopped: async (payload) => {
|
||||
stoppedPayload = {
|
||||
itemId: payload.itemId,
|
||||
positionTicks: payload.positionTicks,
|
||||
failed: payload.failed,
|
||||
};
|
||||
},
|
||||
}),
|
||||
getMpvClient: () => ({
|
||||
currentTimePos: 12.5,
|
||||
}),
|
||||
ticksPerSecond: 10_000_000,
|
||||
logDebug: () => {},
|
||||
});
|
||||
|
||||
await reportStopped();
|
||||
|
||||
assert.deepEqual(stoppedPayload, {
|
||||
itemId: 'item-2',
|
||||
positionTicks: 125_000_000,
|
||||
failed: false,
|
||||
});
|
||||
assert.equal(cleared, true);
|
||||
});
|
||||
|
||||
test('createReportJellyfinRemoteStoppedHandler ignores unloaded active playback', async () => {
|
||||
let cleared = false;
|
||||
let stopped = false;
|
||||
|
||||
@@ -108,7 +108,8 @@ export function createReportJellyfinRemoteProgressHandler(
|
||||
const playback = deps.getActivePlayback();
|
||||
if (!playback) return;
|
||||
const session = deps.getSession();
|
||||
if (!session || !session.isConnected()) return;
|
||||
// Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
|
||||
if (!session) return;
|
||||
const now = deps.getNow();
|
||||
try {
|
||||
const mpvClient = deps.getMpvClient();
|
||||
@@ -167,7 +168,8 @@ export function createReportJellyfinRemoteStoppedHandler(deps: JellyfinRemoteSto
|
||||
return;
|
||||
}
|
||||
const session = deps.getSession();
|
||||
if (!session || !session.isConnected()) {
|
||||
// Timeline posts are HTTP requests; keep them flowing while the remote websocket reconnects.
|
||||
if (!session) {
|
||||
deps.clearActivePlayback();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -201,6 +201,94 @@ test('preload jellyfin subtitles waits for delayed external japanese track inste
|
||||
);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles accepts numeric string mpv track ids', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
||||
{ index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
requestProperty: async () => [
|
||||
{
|
||||
type: 'sub',
|
||||
id: ' ',
|
||||
lang: 'jpn',
|
||||
title: 'Invalid empty id',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/invalid.srt',
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: '10',
|
||||
lang: 'jpn',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
|
||||
},
|
||||
{
|
||||
type: 'sub',
|
||||
id: '11',
|
||||
lang: 'eng',
|
||||
title: 'English',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
|
||||
},
|
||||
],
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.deepEqual(
|
||||
commands.filter((command) => command[0] === 'set_property'),
|
||||
[
|
||||
['set_property', 'sid', 10],
|
||||
['set_property', 'secondary-sid', 11],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles retries transient mpv track-list read failures', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let requestCount = 0;
|
||||
const preload = createPreloadJellyfinExternalSubtitlesHandler(
|
||||
makeDeps({
|
||||
listJellyfinSubtitleTracks: async () => [
|
||||
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
|
||||
],
|
||||
getMpvClient: () => ({
|
||||
connected: true,
|
||||
requestProperty: async () => {
|
||||
requestCount += 1;
|
||||
if (requestCount === 1) {
|
||||
throw new Error('MPV request timed out');
|
||||
}
|
||||
return [
|
||||
{
|
||||
type: 'sub',
|
||||
id: 10,
|
||||
lang: 'jpn',
|
||||
title: 'Japanese',
|
||||
external: true,
|
||||
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
|
||||
},
|
||||
];
|
||||
},
|
||||
}),
|
||||
sendMpvCommand: (command) => commands.push(command),
|
||||
}),
|
||||
);
|
||||
|
||||
await preload({ session, clientInfo, itemId: 'item-1' });
|
||||
|
||||
assert.equal(requestCount, 2);
|
||||
assert.deepEqual(commands.at(-1), ['set_property', 'sid', 10]);
|
||||
});
|
||||
|
||||
test('preload jellyfin subtitles does not let later subtitle adds steal japanese primary selection', async () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
let requestCount = 0;
|
||||
|
||||
@@ -151,18 +151,16 @@ function parseMpvSubtitleTracks(trackListRaw: unknown): MpvSubtitleTrack[] {
|
||||
? trackListRaw
|
||||
.filter(
|
||||
(track): track is Record<string, unknown> =>
|
||||
Boolean(track) &&
|
||||
typeof track === 'object' &&
|
||||
track.type === 'sub' &&
|
||||
typeof track.id === 'number',
|
||||
Boolean(track) && typeof track === 'object' && track.type === 'sub',
|
||||
)
|
||||
.map((track) => ({
|
||||
id: track.id as number,
|
||||
id: parseTrackId(track.id),
|
||||
lang: String(track.lang || ''),
|
||||
title: String(track.title || ''),
|
||||
external: track.external === true,
|
||||
externalFilename: String(track['external-filename'] || ''),
|
||||
}))
|
||||
.filter((track): track is MpvSubtitleTrack => track.id !== null)
|
||||
: [];
|
||||
}
|
||||
|
||||
@@ -179,6 +177,15 @@ function hasExpectedExternalSubtitleTracks(
|
||||
return expectedExternalFilenames.every((filePath) => loadedExternalFilenames.has(filePath));
|
||||
}
|
||||
|
||||
function parseTrackId(value: unknown): number | null {
|
||||
if (typeof value === 'string' && value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const numeric =
|
||||
typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN;
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
async function readMpvSubtitleTracks(deps: {
|
||||
getMpvClient: () => MpvClientLike | null;
|
||||
}): Promise<MpvSubtitleTrack[] | null> {
|
||||
@@ -186,7 +193,12 @@ async function readMpvSubtitleTracks(deps: {
|
||||
if (!client || client.connected === false) {
|
||||
return null;
|
||||
}
|
||||
const trackListRaw = await client.requestProperty('track-list');
|
||||
let trackListRaw: unknown;
|
||||
try {
|
||||
trackListRaw = await client.requestProperty('track-list');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return parseMpvSubtitleTracks(trackListRaw);
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ test('overlay modal input state activates modal window interactivity and syncs d
|
||||
assert.deepEqual(modalWindow.calls, [
|
||||
'focusable:true',
|
||||
'ignore:false',
|
||||
'top:true:screen-saver:1',
|
||||
'top:true:screen-saver:3',
|
||||
'focus',
|
||||
'web-focus',
|
||||
]);
|
||||
|
||||
@@ -42,7 +42,7 @@ export function createOverlayModalInputState(deps: OverlayModalInputStateDeps) {
|
||||
setWindowFocusable(modalWindow);
|
||||
requestOverlayApplicationFocus();
|
||||
modalWindow.setIgnoreMouseEvents(false);
|
||||
modalWindow.setAlwaysOnTop(true, 'screen-saver', 1);
|
||||
modalWindow.setAlwaysOnTop(true, 'screen-saver', 3);
|
||||
modalWindow.focus();
|
||||
if (!modalWindow.webContents.isFocused()) {
|
||||
modalWindow.webContents.focus();
|
||||
|
||||
@@ -28,6 +28,57 @@ test('update dialog presenter focuses app and yields the run loop before showing
|
||||
assert.deepEqual(calls, ['focus', 'yield', 'dialog:SubMiner is up to date (v0.14.0)']);
|
||||
});
|
||||
|
||||
test('update dialog presenter suspends stats window layer while showing dialogs', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
calls.push(`dialog:${options.message}`);
|
||||
return { response: 0 };
|
||||
};
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'linux',
|
||||
withStatsWindowLayerSuspended: async (showDialog) => {
|
||||
calls.push('suspend-stats-window');
|
||||
try {
|
||||
return await showDialog();
|
||||
} finally {
|
||||
calls.push('restore-stats-window');
|
||||
}
|
||||
},
|
||||
showMessageBox,
|
||||
});
|
||||
|
||||
await presenter.showNoUpdateDialog('0.14.0');
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
'suspend-stats-window',
|
||||
'dialog:SubMiner is up to date (v0.14.0)',
|
||||
'restore-stats-window',
|
||||
]);
|
||||
});
|
||||
|
||||
test('update dialog presenter restores stats window layer when dialog fails', async () => {
|
||||
const calls: string[] = [];
|
||||
const presenter = createUpdateDialogPresenter({
|
||||
platform: 'linux',
|
||||
withStatsWindowLayerSuspended: async (showDialog) => {
|
||||
calls.push('suspend-stats-window');
|
||||
try {
|
||||
return await showDialog();
|
||||
} finally {
|
||||
calls.push('restore-stats-window');
|
||||
}
|
||||
},
|
||||
showMessageBox: async () => {
|
||||
calls.push('dialog');
|
||||
throw new Error('dialog failed');
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(() => presenter.showNoUpdateDialog('0.14.0'), /dialog failed/);
|
||||
|
||||
assert.deepEqual(calls, ['suspend-stats-window', 'dialog', 'restore-stats-window']);
|
||||
});
|
||||
|
||||
test('update dialog presenter awaits async focusApp before yielding and showing the dialog', async () => {
|
||||
const calls: string[] = [];
|
||||
const showMessageBox: ShowMessageBox = async (options) => {
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface UpdateDialogPresenterDeps {
|
||||
showMessageBox: ShowMessageBox;
|
||||
focusApp?: () => void | Promise<void>;
|
||||
yieldToRunLoop?: () => Promise<void>;
|
||||
withStatsWindowLayerSuspended?: <T>(showDialog: () => Promise<T>) => Promise<T>;
|
||||
platform?: NodeJS.Platform;
|
||||
}
|
||||
|
||||
@@ -46,12 +47,18 @@ async function maybeFocusAppForDialog(deps: UpdateDialogPresenterDeps): Promise<
|
||||
|
||||
export function createUpdateDialogPresenter(deps: UpdateDialogPresenterDeps) {
|
||||
const showFocusedMessageBox: ShowMessageBox = async (options) => {
|
||||
try {
|
||||
await maybeFocusAppForDialog(deps);
|
||||
} catch {
|
||||
// Best-effort focus only; never block the dialog itself.
|
||||
}
|
||||
return deps.showMessageBox(options);
|
||||
const showDialog = async (): Promise<MessageBoxResultLike> => {
|
||||
try {
|
||||
await maybeFocusAppForDialog(deps);
|
||||
} catch {
|
||||
// Best-effort focus only; never block the dialog itself.
|
||||
}
|
||||
return deps.showMessageBox(options);
|
||||
};
|
||||
|
||||
return deps.withStatsWindowLayerSuspended
|
||||
? deps.withStatsWindowLayerSuspended(showDialog)
|
||||
: showDialog();
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -43,6 +43,18 @@ const statsAPI = {
|
||||
hideOverlay: (): void => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.toggleStatsOverlay);
|
||||
},
|
||||
|
||||
confirmNativeDialog: (message: string): boolean => {
|
||||
return ipcRenderer.sendSync(IPC_CHANNELS.command.statsNativeConfirmDialog, message) === true;
|
||||
},
|
||||
|
||||
beginNativeDialog: (): void => {
|
||||
ipcRenderer.sendSync(IPC_CHANNELS.command.statsNativeDialogOpened);
|
||||
},
|
||||
|
||||
endNativeDialog: (): void => {
|
||||
ipcRenderer.send(IPC_CHANNELS.command.statsNativeDialogClosed);
|
||||
},
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', { stats: statsAPI });
|
||||
|
||||
@@ -1008,6 +1008,38 @@ test('visible-layer y-t dispatches mpv plugin toggle while overlay owns focus',
|
||||
}
|
||||
});
|
||||
|
||||
test('visible-layer configured overlay toggle dispatches mpv plugin toggle', async () => {
|
||||
const { handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
try {
|
||||
await handlers.setupMpvInputForwarding();
|
||||
handlers.updateSessionBindings([
|
||||
{
|
||||
sourcePath: 'shortcuts.toggleVisibleOverlayGlobal',
|
||||
originalKey: 'Alt+Shift+O',
|
||||
key: { code: 'KeyO', modifiers: ['alt', 'shift'] },
|
||||
actionType: 'session-action',
|
||||
actionId: 'toggleVisibleOverlay',
|
||||
},
|
||||
] as never);
|
||||
|
||||
testGlobals.dispatchKeydown({ key: 'O', code: 'KeyO', altKey: true, shiftKey: true });
|
||||
|
||||
assert.equal(
|
||||
testGlobals.mpvCommands.some(
|
||||
(command) => command[0] === 'script-message' && command[1] === 'subminer-toggle',
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
testGlobals.sessionActions.some((action) => action.actionId === 'toggleVisibleOverlay'),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
testGlobals.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('refreshConfiguredShortcuts updates hot-reloaded stats and watched keys', async () => {
|
||||
const { ctx, handlers, testGlobals } = createKeyboardHandlerHarness();
|
||||
|
||||
|
||||
@@ -207,6 +207,11 @@ export function createKeyboardHandlers(
|
||||
return;
|
||||
}
|
||||
|
||||
if (binding.actionType === 'session-action' && binding.actionId === 'toggleVisibleOverlay') {
|
||||
window.electronAPI.sendMpvCommand(['script-message', 'subminer-toggle']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (binding.actionType === 'session-action' && binding.actionId === 'openControllerSelect') {
|
||||
options.openControllerSelectModal?.();
|
||||
return;
|
||||
|
||||
@@ -27,6 +27,9 @@ export const IPC_CHANNELS = {
|
||||
toggleDevTools: 'toggle-dev-tools',
|
||||
toggleOverlay: 'toggle-overlay',
|
||||
saveSubtitlePosition: 'save-subtitle-position',
|
||||
statsNativeConfirmDialog: 'stats:native-confirm-dialog',
|
||||
statsNativeDialogOpened: 'stats:native-dialog-opened',
|
||||
statsNativeDialogClosed: 'stats:native-dialog-closed',
|
||||
saveControllerConfig: 'save-controller-config',
|
||||
saveControllerPreference: 'save-controller-preference',
|
||||
setMecabEnabled: 'set-mecab-enabled',
|
||||
|
||||
Reference in New Issue
Block a user