mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
fix(overlay): stabilize macOS focus handoff and sidebar Yomitan pause
- Keep overlay visible during macOS foreground probe after overlay blur - Hold sidebar hover-pause while a Yomitan lookup popup remains open
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Kept playback paused for Yomitan lookup popups opened from the subtitle sidebar when popup auto-pause is enabled.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Kept the macOS visible overlay stable when clicking from the overlay back into mpv.
|
||||||
@@ -1213,6 +1213,54 @@ test('macOS tracked overlay hides when mpv loses foreground', () => {
|
|||||||
assert.ok(!calls.includes('show'));
|
assert.ok(!calls.includes('show'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('macOS keeps visible overlay stable while probing frontmost app after overlay blur', () => {
|
||||||
|
const { window, calls } = createMainWindowRecorder();
|
||||||
|
const tracker: WindowTrackerStub = {
|
||||||
|
isTracking: () => true,
|
||||||
|
getGeometry: () => ({ x: 0, y: 0, width: 1280, height: 720 }),
|
||||||
|
isTargetWindowFocused: () => false,
|
||||||
|
isTargetWindowMinimized: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.show();
|
||||||
|
calls.length = 0;
|
||||||
|
|
||||||
|
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: true,
|
||||||
|
isWindowsPlatform: false,
|
||||||
|
macOSForegroundProbeActive: true,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
assert.ok(calls.includes('update-bounds'));
|
||||||
|
assert.ok(calls.includes('sync-layer'));
|
||||||
|
assert.ok(calls.includes('mouse-ignore:true:forward'));
|
||||||
|
assert.ok(calls.includes('ensure-level'));
|
||||||
|
assert.ok(calls.includes('enforce-order'));
|
||||||
|
assert.ok(calls.includes('sync-shortcuts'));
|
||||||
|
assert.ok(!calls.includes('always-on-top:false'));
|
||||||
|
assert.ok(!calls.includes('hide'));
|
||||||
|
});
|
||||||
|
|
||||||
test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => {
|
test('macOS keeps tracked overlay visible while overlay interaction is active after mpv loses foreground', () => {
|
||||||
const { window, calls, setFocused } = createMainWindowRecorder();
|
const { window, calls, setFocused } = createMainWindowRecorder();
|
||||||
const tracker: WindowTrackerStub = {
|
const tracker: WindowTrackerStub = {
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
lastKnownWindowsForegroundProcessName?: string | null;
|
lastKnownWindowsForegroundProcessName?: string | null;
|
||||||
windowsOverlayProcessName?: string | null;
|
windowsOverlayProcessName?: string | null;
|
||||||
windowsFocusHandoffGraceActive?: boolean;
|
windowsFocusHandoffGraceActive?: boolean;
|
||||||
|
macOSForegroundProbeActive?: boolean;
|
||||||
trackerNotReadyWarningShown: boolean;
|
trackerNotReadyWarningShown: boolean;
|
||||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||||
@@ -115,6 +116,12 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
const isTrackedMacOSTargetMinimized =
|
const isTrackedMacOSTargetMinimized =
|
||||||
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
|
canReportMacOSTargetMinimized && windowTracker?.isTargetWindowMinimized() === true;
|
||||||
const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.();
|
const trackedMacOSTargetFocused = args.windowTracker?.isTargetWindowFocused?.();
|
||||||
|
const shouldPreserveMacOSOverlayDuringForegroundProbe =
|
||||||
|
args.isMacOSPlatform &&
|
||||||
|
args.macOSForegroundProbeActive === true &&
|
||||||
|
!!windowTracker &&
|
||||||
|
!isTrackedMacOSTargetMinimized &&
|
||||||
|
(windowTracker.isTracking() || windowTracker.getGeometry() !== null);
|
||||||
const hasTransientMacOSTrackerLoss =
|
const hasTransientMacOSTrackerLoss =
|
||||||
args.isMacOSPlatform &&
|
args.isMacOSPlatform &&
|
||||||
canReportMacOSTargetMinimized &&
|
canReportMacOSTargetMinimized &&
|
||||||
@@ -124,7 +131,10 @@ export function updateVisibleOverlayVisibility(args: {
|
|||||||
trackedMacOSTargetFocused !== false &&
|
trackedMacOSTargetFocused !== false &&
|
||||||
mainWindow.isVisible();
|
mainWindow.isVisible();
|
||||||
const isTrackedMacOSTargetFocused =
|
const isTrackedMacOSTargetFocused =
|
||||||
hasTransientMacOSTrackerLoss || !args.isMacOSPlatform || !args.windowTracker
|
hasTransientMacOSTrackerLoss ||
|
||||||
|
shouldPreserveMacOSOverlayDuringForegroundProbe ||
|
||||||
|
!args.isMacOSPlatform ||
|
||||||
|
!args.windowTracker
|
||||||
? true
|
? true
|
||||||
: (trackedMacOSTargetFocused ?? true);
|
: (trackedMacOSTargetFocused ?? true);
|
||||||
const shouldReleaseMacOSOverlayLevel =
|
const shouldReleaseMacOSOverlayLevel =
|
||||||
|
|||||||
+49
@@ -2237,6 +2237,7 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
|
|||||||
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
|
getLastKnownWindowsForegroundProcessName: () => lastWindowsVisibleOverlayForegroundProcessName,
|
||||||
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
|
getWindowsOverlayProcessName: () => path.parse(process.execPath).name.toLowerCase(),
|
||||||
getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(),
|
getWindowsFocusHandoffGraceActive: () => hasWindowsVisibleOverlayFocusHandoffGrace(),
|
||||||
|
getMacOSForegroundProbeActive: () => macOSVisibleOverlayForegroundProbeActive,
|
||||||
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
|
getTrackerNotReadyWarningShown: () => appState.trackerNotReadyWarningShown,
|
||||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||||
appState.trackerNotReadyWarningShown = shown;
|
appState.trackerNotReadyWarningShown = shown;
|
||||||
@@ -2280,6 +2281,7 @@ const VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS = [0, 25, 100, 250] as const;
|
|||||||
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
|
const WINDOWS_VISIBLE_OVERLAY_Z_ORDER_RETRY_DELAYS_MS = [0, 48, 120, 240, 480] as const;
|
||||||
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
|
const WINDOWS_VISIBLE_OVERLAY_FOREGROUND_POLL_INTERVAL_MS = 75;
|
||||||
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
|
const WINDOWS_VISIBLE_OVERLAY_FOCUS_HANDOFF_GRACE_MS = 200;
|
||||||
|
const MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS = 1_200;
|
||||||
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
let visibleOverlayBlurRefreshTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
let windowsVisibleOverlayZOrderRetryTimeouts: Array<ReturnType<typeof setTimeout>> = [];
|
||||||
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
let windowsVisibleOverlayZOrderSyncInFlight = false;
|
||||||
@@ -2288,6 +2290,9 @@ let windowsVisibleOverlayForegroundPollInterval: ReturnType<typeof setInterval>
|
|||||||
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
let lastWindowsVisibleOverlayForegroundProcessName: string | null = null;
|
||||||
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
let lastWindowsVisibleOverlayBlurredAtMs = 0;
|
||||||
let visibleOverlayInteractionActive = false;
|
let visibleOverlayInteractionActive = false;
|
||||||
|
let macOSVisibleOverlayForegroundProbeActive = false;
|
||||||
|
let macOSVisibleOverlayForegroundProbeToken = 0;
|
||||||
|
let macOSVisibleOverlayForegroundProbeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
function clearVisibleOverlayBlurRefreshTimeouts(): void {
|
function clearVisibleOverlayBlurRefreshTimeouts(): void {
|
||||||
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
|
for (const timeout of visibleOverlayBlurRefreshTimeouts) {
|
||||||
@@ -2303,6 +2308,49 @@ function clearWindowsVisibleOverlayZOrderRetryTimeouts(): void {
|
|||||||
windowsVisibleOverlayZOrderRetryTimeouts = [];
|
windowsVisibleOverlayZOrderRetryTimeouts = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finishMacOSVisibleOverlayForegroundProbe(token: number): void {
|
||||||
|
if (token !== macOSVisibleOverlayForegroundProbeToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
|
||||||
|
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
|
||||||
|
macOSVisibleOverlayForegroundProbeTimeout = null;
|
||||||
|
}
|
||||||
|
if (!macOSVisibleOverlayForegroundProbeActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
macOSVisibleOverlayForegroundProbeActive = false;
|
||||||
|
overlayVisibilityRuntime.updateVisibleOverlayVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMacOSVisibleOverlayForegroundProbe(): void {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tracker = appState.windowTracker;
|
||||||
|
if (!tracker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
macOSVisibleOverlayForegroundProbeActive = true;
|
||||||
|
const token = ++macOSVisibleOverlayForegroundProbeToken;
|
||||||
|
if (macOSVisibleOverlayForegroundProbeTimeout !== null) {
|
||||||
|
clearTimeout(macOSVisibleOverlayForegroundProbeTimeout);
|
||||||
|
}
|
||||||
|
macOSVisibleOverlayForegroundProbeTimeout = setTimeout(() => {
|
||||||
|
finishMacOSVisibleOverlayForegroundProbe(token);
|
||||||
|
}, MACOS_VISIBLE_OVERLAY_FOREGROUND_PROBE_TIMEOUT_MS);
|
||||||
|
|
||||||
|
void tracker
|
||||||
|
.refreshNow()
|
||||||
|
.catch((error) => {
|
||||||
|
logger.warn('Failed to refresh macOS frontmost app after overlay blur', error);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
finishMacOSVisibleOverlayForegroundProbe(token);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getWindowsNativeWindowHandle(window: BrowserWindow): string {
|
function getWindowsNativeWindowHandle(window: BrowserWindow): string {
|
||||||
const handle = window.getNativeWindowHandle();
|
const handle = window.getNativeWindowHandle();
|
||||||
return handle.length >= 8
|
return handle.length >= 8
|
||||||
@@ -2501,6 +2549,7 @@ function scheduleVisibleOverlayBlurRefresh(): void {
|
|||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
|
lastWindowsVisibleOverlayBlurredAtMs = Date.now();
|
||||||
}
|
}
|
||||||
|
startMacOSVisibleOverlayForegroundProbe();
|
||||||
clearVisibleOverlayBlurRefreshTimeouts();
|
clearVisibleOverlayBlurRefreshTimeouts();
|
||||||
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
|
for (const delayMs of VISIBLE_OVERLAY_BLUR_REFRESH_DELAYS_MS) {
|
||||||
const refreshTimeout = setTimeout(() => {
|
const refreshTimeout = setTimeout(() => {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export interface OverlayVisibilityRuntimeDeps {
|
|||||||
getLastKnownWindowsForegroundProcessName?: () => string | null;
|
getLastKnownWindowsForegroundProcessName?: () => string | null;
|
||||||
getWindowsOverlayProcessName?: () => string | null;
|
getWindowsOverlayProcessName?: () => string | null;
|
||||||
getWindowsFocusHandoffGraceActive?: () => boolean;
|
getWindowsFocusHandoffGraceActive?: () => boolean;
|
||||||
|
getMacOSForegroundProbeActive?: () => boolean;
|
||||||
getTrackerNotReadyWarningShown: () => boolean;
|
getTrackerNotReadyWarningShown: () => boolean;
|
||||||
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
setTrackerNotReadyWarningShown: (shown: boolean) => void;
|
||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) => void;
|
||||||
@@ -56,6 +57,7 @@ export function createOverlayVisibilityRuntimeService(
|
|||||||
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
|
lastKnownWindowsForegroundProcessName: deps.getLastKnownWindowsForegroundProcessName?.(),
|
||||||
windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null,
|
windowsOverlayProcessName: deps.getWindowsOverlayProcessName?.() ?? null,
|
||||||
windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false,
|
windowsFocusHandoffGraceActive: deps.getWindowsFocusHandoffGraceActive?.() ?? false,
|
||||||
|
macOSForegroundProbeActive: deps.getMacOSForegroundProbeActive?.() ?? false,
|
||||||
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
|
trackerNotReadyWarningShown: deps.getTrackerNotReadyWarningShown(),
|
||||||
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
setTrackerNotReadyWarningShown: (shown: boolean) => {
|
||||||
deps.setTrackerNotReadyWarningShown(shown);
|
deps.setTrackerNotReadyWarningShown(shown);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
|||||||
getLastKnownWindowsForegroundProcessName: () => 'mpv',
|
getLastKnownWindowsForegroundProcessName: () => 'mpv',
|
||||||
getWindowsOverlayProcessName: () => 'subminer',
|
getWindowsOverlayProcessName: () => 'subminer',
|
||||||
getWindowsFocusHandoffGraceActive: () => true,
|
getWindowsFocusHandoffGraceActive: () => true,
|
||||||
|
getMacOSForegroundProbeActive: () => true,
|
||||||
getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown,
|
getTrackerNotReadyWarningShown: () => trackerNotReadyWarningShown,
|
||||||
setTrackerNotReadyWarningShown: (shown) => {
|
setTrackerNotReadyWarningShown: (shown) => {
|
||||||
trackerNotReadyWarningShown = shown;
|
trackerNotReadyWarningShown = shown;
|
||||||
@@ -45,6 +46,7 @@ test('overlay visibility runtime main deps builder maps state and geometry callb
|
|||||||
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
|
assert.equal(deps.getLastKnownWindowsForegroundProcessName?.(), 'mpv');
|
||||||
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
|
assert.equal(deps.getWindowsOverlayProcessName?.(), 'subminer');
|
||||||
assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true);
|
assert.equal(deps.getWindowsFocusHandoffGraceActive?.(), true);
|
||||||
|
assert.equal(deps.getMacOSForegroundProbeActive?.(), true);
|
||||||
assert.equal(deps.getTrackerNotReadyWarningShown(), false);
|
assert.equal(deps.getTrackerNotReadyWarningShown(), false);
|
||||||
deps.setTrackerNotReadyWarningShown(true);
|
deps.setTrackerNotReadyWarningShown(true);
|
||||||
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
deps.updateVisibleOverlayBounds({ x: 0, y: 0, width: 10, height: 10 });
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export function createBuildOverlayVisibilityRuntimeMainDepsHandler(
|
|||||||
deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
|
deps.getLastKnownWindowsForegroundProcessName?.() ?? null,
|
||||||
getWindowsOverlayProcessName: () => deps.getWindowsOverlayProcessName?.() ?? null,
|
getWindowsOverlayProcessName: () => deps.getWindowsOverlayProcessName?.() ?? null,
|
||||||
getWindowsFocusHandoffGraceActive: () => deps.getWindowsFocusHandoffGraceActive?.() ?? false,
|
getWindowsFocusHandoffGraceActive: () => deps.getWindowsFocusHandoffGraceActive?.() ?? false,
|
||||||
|
getMacOSForegroundProbeActive: () => deps.getMacOSForegroundProbeActive?.() ?? false,
|
||||||
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
|
getTrackerNotReadyWarningShown: () => deps.getTrackerNotReadyWarningShown(),
|
||||||
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),
|
setTrackerNotReadyWarningShown: (shown: boolean) => deps.setTrackerNotReadyWarningShown(shown),
|
||||||
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
updateVisibleOverlayBounds: (geometry: WindowGeometry) =>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
createSubtitleSidebarModal,
|
createSubtitleSidebarModal,
|
||||||
findActiveSubtitleCueIndex,
|
findActiveSubtitleCueIndex,
|
||||||
} from './subtitle-sidebar.js';
|
} from './subtitle-sidebar.js';
|
||||||
|
import { YOMITAN_POPUP_HIDDEN_EVENT, YOMITAN_POPUP_SHOWN_EVENT } from '../yomitan-popup.js';
|
||||||
|
|
||||||
function createClassList(initialTokens: string[] = []) {
|
function createClassList(initialTokens: string[] = []) {
|
||||||
const tokens = new Set(initialTokens);
|
const tokens = new Set(initialTokens);
|
||||||
@@ -1542,6 +1543,137 @@ test('subtitle sidebar hover pause ignores playback-state IPC failures', async (
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitle sidebar keeps hover pause while a Yomitan lookup popup remains open', async () => {
|
||||||
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||||
|
const previousWindow = globals.window;
|
||||||
|
const previousDocument = globals.document;
|
||||||
|
const mpvCommands: Array<Array<string | number>> = [];
|
||||||
|
const contentListeners = new Map<string, Array<() => Promise<void> | void>>();
|
||||||
|
const windowListeners = new Map<string, Array<() => Promise<void> | void>>();
|
||||||
|
|
||||||
|
const snapshot: SubtitleSidebarSnapshot = {
|
||||||
|
cues: [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||||
|
currentSubtitle: {
|
||||||
|
text: 'first',
|
||||||
|
startTime: 1,
|
||||||
|
endTime: 2,
|
||||||
|
},
|
||||||
|
currentTimeSec: 1.1,
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
autoOpen: false,
|
||||||
|
layout: 'overlay',
|
||||||
|
toggleKey: 'Backslash',
|
||||||
|
pauseVideoOnHover: true,
|
||||||
|
autoScroll: true,
|
||||||
|
maxWidth: 420,
|
||||||
|
opacity: 0.92,
|
||||||
|
backgroundColor: 'rgba(54, 58, 79, 0.88)',
|
||||||
|
textColor: '#cad3f5',
|
||||||
|
fontFamily: '"Iosevka Aile", sans-serif',
|
||||||
|
fontSize: 17,
|
||||||
|
timestampColor: '#a5adcb',
|
||||||
|
activeLineColor: '#f5bde6',
|
||||||
|
activeLineBackgroundColor: 'rgba(138, 173, 244, 0.22)',
|
||||||
|
hoverLineBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.defineProperty(globalThis, 'window', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
addEventListener: (type: string, listener: () => Promise<void> | void) => {
|
||||||
|
const bucket = windowListeners.get(type) ?? [];
|
||||||
|
bucket.push(listener);
|
||||||
|
windowListeners.set(type, bucket);
|
||||||
|
},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
electronAPI: {
|
||||||
|
getSubtitleSidebarSnapshot: async () => snapshot,
|
||||||
|
getPlaybackPaused: async () => false,
|
||||||
|
sendMpvCommand: (command: Array<string | number>) => {
|
||||||
|
mpvCommands.push(command);
|
||||||
|
},
|
||||||
|
} as unknown as ElectronAPI,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
Object.defineProperty(globalThis, 'document', {
|
||||||
|
configurable: true,
|
||||||
|
value: {
|
||||||
|
createElement: () => createCueRow(),
|
||||||
|
body: {
|
||||||
|
classList: createClassList(),
|
||||||
|
},
|
||||||
|
documentElement: {
|
||||||
|
style: {
|
||||||
|
setProperty: () => {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const state = createRendererState();
|
||||||
|
state.autoPauseVideoOnYomitanPopup = true;
|
||||||
|
const ctx = {
|
||||||
|
dom: {
|
||||||
|
overlay: { classList: createClassList() },
|
||||||
|
subtitleSidebarModal: {
|
||||||
|
classList: createClassList(['hidden']),
|
||||||
|
setAttribute: () => {},
|
||||||
|
style: { setProperty: () => {} },
|
||||||
|
addEventListener: () => {},
|
||||||
|
},
|
||||||
|
subtitleSidebarContent: {
|
||||||
|
classList: createClassList(),
|
||||||
|
getBoundingClientRect: () => ({ width: 420 }),
|
||||||
|
addEventListener: (type: string, listener: () => Promise<void> | void) => {
|
||||||
|
const bucket = contentListeners.get(type) ?? [];
|
||||||
|
bucket.push(listener);
|
||||||
|
contentListeners.set(type, bucket);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
subtitleSidebarClose: { addEventListener: () => {} },
|
||||||
|
subtitleSidebarStatus: { textContent: '' },
|
||||||
|
subtitleSidebarList: createListStub(),
|
||||||
|
},
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
|
||||||
|
const modal = createSubtitleSidebarModal(ctx as never, {
|
||||||
|
modalStateReader: { isAnyModalOpen: () => false },
|
||||||
|
});
|
||||||
|
modal.wireDomEvents();
|
||||||
|
|
||||||
|
await modal.openSubtitleSidebarModal();
|
||||||
|
mpvCommands.length = 0;
|
||||||
|
await contentListeners.get('mouseenter')?.[0]?.();
|
||||||
|
|
||||||
|
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
|
||||||
|
|
||||||
|
for (const listener of windowListeners.get(YOMITAN_POPUP_SHOWN_EVENT) ?? []) {
|
||||||
|
await listener();
|
||||||
|
}
|
||||||
|
await contentListeners.get('mouseleave')?.[0]?.();
|
||||||
|
|
||||||
|
assert.deepEqual(mpvCommands, [['set_property', 'pause', 'yes']]);
|
||||||
|
assert.equal(state.subtitleSidebarPausedByHover, true);
|
||||||
|
|
||||||
|
for (const listener of windowListeners.get(YOMITAN_POPUP_HIDDEN_EVENT) ?? []) {
|
||||||
|
await listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.deepEqual(mpvCommands, [
|
||||||
|
['set_property', 'pause', 'yes'],
|
||||||
|
['set_property', 'pause', 'no'],
|
||||||
|
]);
|
||||||
|
assert.equal(state.subtitleSidebarPausedByHover, false);
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||||
|
Object.defineProperty(globalThis, 'document', { configurable: true, value: previousDocument });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitle sidebar embedded layout reserves and releases mpv right margin', async () => {
|
test('subtitle sidebar embedded layout reserves and releases mpv right margin', async () => {
|
||||||
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
const globals = globalThis as typeof globalThis & { window?: unknown; document?: unknown };
|
||||||
const previousWindow = globals.window;
|
const previousWindow = globals.window;
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { SubtitleCue, SubtitleData, SubtitleSidebarSnapshot } from '../../types';
|
import type { SubtitleCue, SubtitleData, SubtitleSidebarSnapshot } from '../../types';
|
||||||
import type { ModalStateReader, RendererContext } from '../context';
|
import type { ModalStateReader, RendererContext } from '../context';
|
||||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||||
|
import {
|
||||||
|
YOMITAN_POPUP_HIDDEN_EVENT,
|
||||||
|
YOMITAN_POPUP_SHOWN_EVENT,
|
||||||
|
isYomitanPopupVisible,
|
||||||
|
} from '../yomitan-popup.js';
|
||||||
|
|
||||||
const MANUAL_SCROLL_HOLD_MS = 1500;
|
const MANUAL_SCROLL_HOLD_MS = 1500;
|
||||||
const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18;
|
const ACTIVE_CUE_LOOKAHEAD_SEC = 0.18;
|
||||||
@@ -194,6 +199,8 @@ export function createSubtitleSidebarModal(
|
|||||||
let disposeDomEvents: (() => void) | null = null;
|
let disposeDomEvents: (() => void) | null = null;
|
||||||
let subtitleSidebarHovered = false;
|
let subtitleSidebarHovered = false;
|
||||||
let subtitleSidebarFocusedWithin = false;
|
let subtitleSidebarFocusedWithin = false;
|
||||||
|
let subtitleSidebarYomitanPopupVisible = false;
|
||||||
|
let subtitleSidebarPauseHeldByYomitanPopup = false;
|
||||||
|
|
||||||
function restoreEmbeddedSidebarPassthrough(): void {
|
function restoreEmbeddedSidebarPassthrough(): void {
|
||||||
syncOverlayMouseIgnoreState(ctx);
|
syncOverlayMouseIgnoreState(ctx);
|
||||||
@@ -323,18 +330,65 @@ export function createSubtitleSidebarModal(
|
|||||||
return `Jump to subtitle at ${formatCueTimestamp(cue.startTime)}`;
|
return `Jump to subtitle at ${formatCueTimestamp(cue.startTime)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isYomitanPopupVisibleForSidebar(): boolean {
|
||||||
|
if (subtitleSidebarYomitanPopupVisible || ctx.state.yomitanPopupVisible) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return isYomitanPopupVisible(document);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldHoldSidebarPauseForYomitanPopup(): boolean {
|
||||||
|
return (
|
||||||
|
ctx.state.autoPauseVideoOnYomitanPopup &&
|
||||||
|
ctx.state.subtitleSidebarPausedByHover &&
|
||||||
|
isYomitanPopupVisibleForSidebar()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function resumeSubtitleSidebarHoverPause(): void {
|
function resumeSubtitleSidebarHoverPause(): void {
|
||||||
subtitleSidebarHoverRequestId += 1;
|
subtitleSidebarHoverRequestId += 1;
|
||||||
if (!ctx.state.subtitleSidebarPausedByHover) {
|
if (!ctx.state.subtitleSidebarPausedByHover) {
|
||||||
|
subtitleSidebarPauseHeldByYomitanPopup = false;
|
||||||
restoreEmbeddedSidebarPassthrough();
|
restoreEmbeddedSidebarPassthrough();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldHoldSidebarPauseForYomitanPopup()) {
|
||||||
|
subtitleSidebarPauseHeldByYomitanPopup = true;
|
||||||
|
restoreEmbeddedSidebarPassthrough();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
subtitleSidebarPauseHeldByYomitanPopup = false;
|
||||||
ctx.state.subtitleSidebarPausedByHover = false;
|
ctx.state.subtitleSidebarPausedByHover = false;
|
||||||
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'no']);
|
window.electronAPI.sendMpvCommand(['set_property', 'pause', 'no']);
|
||||||
restoreEmbeddedSidebarPassthrough();
|
restoreEmbeddedSidebarPassthrough();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleYomitanPopupShown(): void {
|
||||||
|
subtitleSidebarYomitanPopupVisible = true;
|
||||||
|
if (ctx.state.autoPauseVideoOnYomitanPopup && ctx.state.subtitleSidebarPausedByHover) {
|
||||||
|
subtitleSidebarPauseHeldByYomitanPopup = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleYomitanPopupHidden(): void {
|
||||||
|
subtitleSidebarYomitanPopupVisible = false;
|
||||||
|
if (!subtitleSidebarPauseHeldByYomitanPopup) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
subtitleSidebarPauseHeldByYomitanPopup = false;
|
||||||
|
if (ctx.state.isOverSubtitleSidebar) {
|
||||||
|
restoreEmbeddedSidebarPassthrough();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resumeSubtitleSidebarHoverPause();
|
||||||
|
}
|
||||||
|
|
||||||
function maybeAutoScrollActiveCue(
|
function maybeAutoScrollActiveCue(
|
||||||
previousActiveCueIndex: number,
|
previousActiveCueIndex: number,
|
||||||
behavior: ScrollBehavior = 'smooth',
|
behavior: ScrollBehavior = 'smooth',
|
||||||
@@ -660,8 +714,12 @@ export function createSubtitleSidebarModal(
|
|||||||
syncEmbeddedSidebarLayout();
|
syncEmbeddedSidebarLayout();
|
||||||
};
|
};
|
||||||
window.addEventListener('resize', resizeHandler);
|
window.addEventListener('resize', resizeHandler);
|
||||||
|
window.addEventListener(YOMITAN_POPUP_SHOWN_EVENT, handleYomitanPopupShown);
|
||||||
|
window.addEventListener(YOMITAN_POPUP_HIDDEN_EVENT, handleYomitanPopupHidden);
|
||||||
disposeDomEvents = () => {
|
disposeDomEvents = () => {
|
||||||
window.removeEventListener('resize', resizeHandler);
|
window.removeEventListener('resize', resizeHandler);
|
||||||
|
window.removeEventListener(YOMITAN_POPUP_SHOWN_EVENT, handleYomitanPopupShown);
|
||||||
|
window.removeEventListener(YOMITAN_POPUP_HIDDEN_EVENT, handleYomitanPopupHidden);
|
||||||
disposeDomEvents = null;
|
disposeDomEvents = null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ export abstract class BaseWindowTracker {
|
|||||||
abstract start(): void;
|
abstract start(): void;
|
||||||
abstract stop(): void;
|
abstract stop(): void;
|
||||||
|
|
||||||
|
refreshNow(): Promise<void> {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
getGeometry(): WindowGeometry | null {
|
getGeometry(): WindowGeometry | null {
|
||||||
return this.currentGeometry;
|
return this.currentGeometry;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,6 +359,35 @@ test('MacOSWindowTracker marks target unfocused on explicit inactive helper sign
|
|||||||
assert.deepEqual(focusChanges, [true, false]);
|
assert.deepEqual(focusChanges, [true, false]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('MacOSWindowTracker refreshNow immediately samples frontmost mpv state', async () => {
|
||||||
|
let callIndex = 0;
|
||||||
|
const outputs = [
|
||||||
|
{ stdout: '10,20,1280,720,0', stderr: '' },
|
||||||
|
{ stdout: 'active', stderr: '' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tracker = new MacOSWindowTracker('/tmp/mpv.sock', {
|
||||||
|
resolveHelper: () => ({
|
||||||
|
helperPath: 'helper.swift',
|
||||||
|
helperType: 'swift',
|
||||||
|
}),
|
||||||
|
runHelper: async () => outputs[callIndex++] ?? outputs.at(-1)!,
|
||||||
|
});
|
||||||
|
|
||||||
|
await (tracker as unknown as { refreshNow: () => Promise<void> }).refreshNow();
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), false);
|
||||||
|
|
||||||
|
await (tracker as unknown as { refreshNow: () => Promise<void> }).refreshNow();
|
||||||
|
assert.equal(tracker.isTracking(), true);
|
||||||
|
assert.equal(tracker.isTargetWindowFocused(), true);
|
||||||
|
assert.deepEqual(tracker.getGeometry(), {
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('MacOSWindowTracker drops tracking after consecutive helper misses', async () => {
|
test('MacOSWindowTracker drops tracking after consecutive helper misses', async () => {
|
||||||
let callIndex = 0;
|
let callIndex = 0;
|
||||||
const outputs = [
|
const outputs = [
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ export function parseMacOSHelperOutput(result: string): MacOSHelperWindowState |
|
|||||||
|
|
||||||
export class MacOSWindowTracker extends BaseWindowTracker {
|
export class MacOSWindowTracker extends BaseWindowTracker {
|
||||||
private pollTimeout: ReturnType<typeof setTimeout> | null = null;
|
private pollTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
private pollInFlight = false;
|
private pollInFlightPromise: Promise<void> | null = null;
|
||||||
private started = false;
|
private started = false;
|
||||||
private helperPath: string | null = null;
|
private helperPath: string | null = null;
|
||||||
private helperType: 'binary' | 'swift' | null = null;
|
private helperType: 'binary' | 'swift' | null = null;
|
||||||
@@ -357,7 +357,7 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.started = true;
|
this.started = true;
|
||||||
this.pollGeometry();
|
void this.pollGeometry();
|
||||||
}
|
}
|
||||||
|
|
||||||
stop(): void {
|
stop(): void {
|
||||||
@@ -365,6 +365,11 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
this.clearScheduledPoll();
|
this.clearScheduledPoll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override refreshNow(): Promise<void> {
|
||||||
|
this.clearScheduledPoll();
|
||||||
|
return this.pollGeometry();
|
||||||
|
}
|
||||||
|
|
||||||
override isTargetWindowMinimized(): boolean {
|
override isTargetWindowMinimized(): boolean {
|
||||||
return this.targetWindowMinimized;
|
return this.targetWindowMinimized;
|
||||||
}
|
}
|
||||||
@@ -443,13 +448,19 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
}, this.resolveNextPollIntervalMs());
|
}, this.resolveNextPollIntervalMs());
|
||||||
}
|
}
|
||||||
|
|
||||||
private pollGeometry(): void {
|
private pollGeometry(): Promise<void> {
|
||||||
if (this.pollInFlight || !this.helperPath || !this.helperType) {
|
if (this.pollInFlightPromise) {
|
||||||
return;
|
return this.pollInFlightPromise;
|
||||||
|
}
|
||||||
|
if (!this.helperPath || !this.helperType) {
|
||||||
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pollInFlight = true;
|
this.pollInFlightPromise = this.runHelper(
|
||||||
void this.runHelper(this.helperPath, this.helperType, this.targetMpvSocketPath)
|
this.helperPath,
|
||||||
|
this.helperType,
|
||||||
|
this.targetMpvSocketPath,
|
||||||
|
)
|
||||||
.then(({ stdout }) => {
|
.then(({ stdout }) => {
|
||||||
const parsed = parseMacOSHelperOutput(stdout || '');
|
const parsed = parseMacOSHelperOutput(stdout || '');
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
@@ -495,8 +506,9 @@ export class MacOSWindowTracker extends BaseWindowTracker {
|
|||||||
this.registerTrackingMiss();
|
this.registerTrackingMiss();
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.pollInFlight = false;
|
this.pollInFlightPromise = null;
|
||||||
this.scheduleNextPoll();
|
this.scheduleNextPoll();
|
||||||
});
|
});
|
||||||
|
return this.pollInFlightPromise;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user