mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 00: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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user