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:
2026-05-23 01:45:09 -07:00
parent 49a94579b6
commit afe1731514
46 changed files with 1472 additions and 79 deletions
+43 -5
View File
@@ -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'],
);
});
+12 -2
View File
@@ -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();
}
+49 -1
View File
@@ -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,
+87
View File
@@ -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[] = [];
+75 -1
View File
@@ -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, () => {