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
+16
View File
@@ -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\(\)/);
+13 -1
View File
@@ -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']]);
});
+1 -1
View File
@@ -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;
+4 -2
View File
@@ -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;
+18 -6
View File
@@ -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) => {
+13 -6
View File
@@ -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 {