Fix Windows YouTube playback flow and overlay pointer tracking

This commit is contained in:
2026-03-25 15:25:17 -07:00
committed by sudacode
parent 5ee4617607
commit c95518e94a
26 changed files with 1044 additions and 36 deletions

View File

@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed overlay pointer tracking so Windows click-through toggles immediately when the cursor enters or leaves subtitle regions, without waiting for a later hover resync.

View File

@@ -0,0 +1,5 @@
type: fixed
area: launcher
- Fixed Windows direct `--youtube-play` startup so MPV boots reliably, stays paused until the app-owned subtitle flow is ready, and reuses an already-running SubMiner instance when available.
- Fixed standalone Windows `--youtube-play` sessions so closing MPV fully exits SubMiner instead of leaving hidden overlay windows or a background process behind.

View File

@@ -246,6 +246,23 @@ test('handleCliCommand defaults youtube mode to download when omitted', () => {
]); ]);
}); });
test('handleCliCommand reuses initialized overlay runtime for second-instance youtube playback', () => {
const { deps, calls } = createDeps({
isOverlayRuntimeInitialized: () => true,
runYoutubePlaybackFlow: async (request) => {
calls.push(`youtube:${request.url}:${request.mode}:${request.source}`);
},
});
handleCliCommand(
makeArgs({ youtubePlay: 'https://youtube.com/watch?v=abc', youtubeMode: 'download' }),
'second-instance',
deps,
);
assert.deepEqual(calls, ['youtube:https://youtube.com/watch?v=abc:download:second-instance']);
});
test('handleCliCommand reports youtube playback flow failures to logs and OSD', async () => { test('handleCliCommand reports youtube playback flow failures to logs and OSD', async () => {
const { deps, calls, osd } = createDeps({ const { deps, calls, osd } = createDeps({
runYoutubePlaybackFlow: async () => { runYoutubePlaybackFlow: async () => {

View File

@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { resolveYoutubePlaybackUrl } from './playback-resolve';
async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-youtube-playback-resolve-'));
try {
return await fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function makeFakeYtDlpScript(dir: string, payload: string): void {
const scriptPath = path.join(dir, 'yt-dlp');
const script = `#!/usr/bin/env node
process.stdout.write(${JSON.stringify(payload)});
`;
fs.writeFileSync(scriptPath, script, 'utf8');
if (process.platform !== 'win32') {
fs.chmodSync(scriptPath, 0o755);
}
fs.writeFileSync(scriptPath + '.cmd', `@echo off\r\nnode "${scriptPath}"\r\n`, 'utf8');
}
async function withFakeYtDlp<T>(payload: string, fn: () => Promise<T>): Promise<T> {
return await withTempDir(async (root) => {
const binDir = path.join(root, 'bin');
fs.mkdirSync(binDir, { recursive: true });
makeFakeYtDlpScript(binDir, payload);
const fakeCommandPath =
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
process.env.SUBMINER_YTDLP_BIN = fakeCommandPath;
try {
return await fn();
} finally {
if (originalCommand === undefined) {
delete process.env.SUBMINER_YTDLP_BIN;
} else {
process.env.SUBMINER_YTDLP_BIN = originalCommand;
}
}
});
}
test('resolveYoutubePlaybackUrl returns the first playable URL line', async () => {
await withFakeYtDlp(
'\nhttps://manifest.googlevideo.com/api/manifest/hls_playlist/test\nhttps://ignored.example/video\n',
async () => {
const result = await resolveYoutubePlaybackUrl('https://www.youtube.com/watch?v=abc123');
assert.equal(result, 'https://manifest.googlevideo.com/api/manifest/hls_playlist/test');
},
);
});
test('resolveYoutubePlaybackUrl rejects when yt-dlp returns no URL', async () => {
await withFakeYtDlp('\n', async () => {
await assert.rejects(
resolveYoutubePlaybackUrl('https://www.youtube.com/watch?v=abc123'),
/returned empty output/,
);
});
});

View File

@@ -0,0 +1,63 @@
import { spawn } from 'node:child_process';
const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000;
const DEFAULT_PLAYBACK_FORMAT = 'b';
function runCapture(
command: string,
args: string[],
timeoutMs = YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS,
): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
const timer = setTimeout(() => {
proc.kill();
reject(new Error(`yt-dlp timed out after ${timeoutMs}ms`));
}, timeoutMs);
proc.stdout.setEncoding('utf8');
proc.stderr.setEncoding('utf8');
proc.stdout.on('data', (chunk) => {
stdout += String(chunk);
});
proc.stderr.on('data', (chunk) => {
stderr += String(chunk);
});
proc.once('error', (error) => {
clearTimeout(timer);
reject(error);
});
proc.once('close', (code) => {
clearTimeout(timer);
if (code === 0) {
resolve({ stdout, stderr });
return;
}
reject(new Error(stderr.trim() || `yt-dlp exited with status ${code ?? 'unknown'}`));
});
});
}
export async function resolveYoutubePlaybackUrl(
targetUrl: string,
format = DEFAULT_PLAYBACK_FORMAT,
): Promise<string> {
const ytDlpCommand = process.env.SUBMINER_YTDLP_BIN?.trim() || 'yt-dlp';
const { stdout } = await runCapture(ytDlpCommand, [
'--get-url',
'--no-warnings',
'-f',
format,
targetUrl,
]);
const playbackUrl =
stdout
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0) ?? '';
if (!playbackUrl) {
throw new Error('yt-dlp returned empty output while resolving YouTube playback URL');
}
return playbackUrl;
}

View File

@@ -315,6 +315,7 @@ import {
acquireYoutubeSubtitleTrack, acquireYoutubeSubtitleTrack,
acquireYoutubeSubtitleTracks, acquireYoutubeSubtitleTracks,
} from './core/services/youtube/generate'; } from './core/services/youtube/generate';
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
import { retimeYoutubeSubtitle } from './core/services/youtube/retime'; import { retimeYoutubeSubtitle } from './core/services/youtube/retime';
import { probeYoutubeTracks } from './core/services/youtube/track-probe'; import { probeYoutubeTracks } from './core/services/youtube/track-probe';
import { startStatsServer } from './core/services/stats-server'; import { startStatsServer } from './core/services/stats-server';
@@ -346,6 +347,9 @@ import {
resolveWindowsMpvShortcutPaths, resolveWindowsMpvShortcutPaths,
} from './main/runtime/windows-mpv-shortcuts'; } from './main/runtime/windows-mpv-shortcuts';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch'; import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy';
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup'; import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps'; import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
import { import {
@@ -496,12 +500,17 @@ let anilistUpdateInFlightState = createInitialAnilistUpdateInFlightState();
const anilistAttemptedUpdateKeys = new Set<string>(); const anilistAttemptedUpdateKeys = new Set<string>();
let anilistCachedAccessToken: string | null = null; let anilistCachedAccessToken: string | null = null;
let jellyfinPlayQuitOnDisconnectArmed = false; let jellyfinPlayQuitOnDisconnectArmed = false;
let youtubePlayQuitOnDisconnectArmed = false;
const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US'; const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US';
const JELLYFIN_TICKS_PER_SECOND = 10_000_000; const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000; const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000;
const DISCORD_PRESENCE_APP_ID = '1475264834730856619'; const DISCORD_PRESENCE_APP_ID = '1475264834730856619';
const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000; const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000; const JELLYFIN_MPV_AUTO_LAUNCH_TIMEOUT_MS = 20000;
const YOUTUBE_MPV_CONNECT_TIMEOUT_MS = 3000;
const YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS = 10000;
const YOUTUBE_MPV_YTDL_FORMAT = 'bestvideo*+bestaudio/best';
const YOUTUBE_DIRECT_PLAYBACK_FORMAT = 'b';
const MPV_JELLYFIN_DEFAULT_ARGS = [ const MPV_JELLYFIN_DEFAULT_ARGS = [
'--sub-auto=fuzzy', '--sub-auto=fuzzy',
'--sub-file-paths=.;subs;subtitles', '--sub-file-paths=.;subs;subtitles',
@@ -940,6 +949,28 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
log: (message: string) => logger.info(message), log: (message: string) => logger.info(message),
getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'), getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'),
}); });
const prepareYoutubePlaybackInMpv = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => {
const client = appState.mpvClient;
if (!client) return null;
const value = await client.requestProperty('path').catch(() => null);
return typeof value === 'string' ? value : null;
},
requestProperty: async (name) => {
const client = appState.mpvClient;
if (!client) return null;
return await client.requestProperty(name);
},
sendMpvCommand: (command) => {
sendMpvCommandRuntime(appState.mpvClient, command);
},
wait: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
});
const waitForYoutubeMpvConnected = createWaitForMpvConnectedHandler({
getMpvClient: () => appState.mpvClient,
now: () => Date.now(),
sleep: (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)),
});
async function runYoutubePlaybackFlowMain(request: { async function runYoutubePlaybackFlowMain(request: {
url: string; url: string;
@@ -948,27 +979,66 @@ async function runYoutubePlaybackFlowMain(request: {
}): Promise<void> { }): Promise<void> {
youtubePrimarySubtitleNotificationRuntime.setAppOwnedFlowInFlight(true); youtubePrimarySubtitleNotificationRuntime.setAppOwnedFlowInFlight(true);
try { try {
let playbackUrl = request.url;
let launchedWindowsMpv = false;
if (process.platform === 'win32') {
try {
playbackUrl = await resolveYoutubePlaybackUrl(request.url, YOUTUBE_DIRECT_PLAYBACK_FORMAT);
logger.info('Resolved direct YouTube playback URL for Windows MPV startup.');
} catch (error) {
logger.warn(
`Failed to resolve direct YouTube playback URL; falling back to page URL: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
if (process.platform === 'win32' && !appState.mpvClient?.connected) { if (process.platform === 'win32' && !appState.mpvClient?.connected) {
const launchResult = launchWindowsMpv( const launchResult = launchWindowsMpv(
[request.url], [playbackUrl],
createWindowsMpvLaunchDeps({ createWindowsMpvLaunchDeps({
showError: (title, content) => dialog.showErrorBox(title, content), showError: (title, content) => dialog.showErrorBox(title, content),
}), }),
[ [
'--pause=yes', '--pause=yes',
'--ytdl=yes',
`--ytdl-format=${YOUTUBE_MPV_YTDL_FORMAT}`,
'--sub-auto=no', '--sub-auto=no',
'--sid=no', '--sub-file-paths=.;subs;subtitles',
'--secondary-sid=no', '--sid=auto',
'--script-opts=subminer-auto_start_pause_until_ready=no', '--secondary-sid=auto',
'--secondary-sub-visibility=no',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
`--log-file=${DEFAULT_MPV_LOG_PATH}`,
`--input-ipc-server=${appState.mpvSocketPath}`, `--input-ipc-server=${appState.mpvSocketPath}`,
], ],
); );
launchedWindowsMpv = launchResult.ok;
if (launchResult.ok) {
logger.info(`Bootstrapping Windows mpv for YouTube playback via ${launchResult.mpvPath}`);
}
if (!launchResult.ok) { if (!launchResult.ok) {
logger.warn('Unable to bootstrap Windows mpv for YouTube playback.'); logger.warn('Unable to bootstrap Windows mpv for YouTube playback.');
} }
} }
if (!appState.mpvClient?.connected) { const connected = await waitForYoutubeMpvConnected(
appState.mpvClient?.connect(); launchedWindowsMpv ? YOUTUBE_MPV_AUTO_LAUNCH_TIMEOUT_MS : YOUTUBE_MPV_CONNECT_TIMEOUT_MS,
);
if (!connected) {
throw new Error(
launchedWindowsMpv
? 'MPV not connected after auto-launch. Ensure mpv is installed and can open the requested YouTube URL.'
: 'MPV not connected. Start mpv with the SubMiner profile or retry after mpv finishes starting.',
);
}
youtubePlayQuitOnDisconnectArmed = false;
setTimeout(() => {
youtubePlayQuitOnDisconnectArmed = true;
}, 3000);
const mediaReady = await prepareYoutubePlaybackInMpv({ url: playbackUrl });
if (!mediaReady) {
logger.warn('Timed out waiting for mpv to load requested YouTube URL; continuing anyway.');
} }
await youtubeFlowRuntime.runYoutubePlaybackFlow({ await youtubeFlowRuntime.runYoutubePlaybackFlow({
url: request.url, url: request.url,
@@ -1281,6 +1351,10 @@ function maybeSignalPluginAutoplayReady(
payload: SubtitleData, payload: SubtitleData,
options?: { forceWhilePaused?: boolean }, options?: { forceWhilePaused?: boolean },
): void { ): void {
if (youtubePrimarySubtitleNotificationRuntime.isAppOwnedFlowInFlight()) {
logger.debug('[autoplay-ready] suppressed while app-owned YouTube flow is active');
return;
}
if (!payload.text.trim()) { if (!payload.text.trim()) {
return; return;
} }
@@ -2829,6 +2903,10 @@ const {
annotationSubtitleWsService.stop(); annotationSubtitleWsService.stop();
}, },
stopTexthookerService: () => texthookerService.stop(), stopTexthookerService: () => texthookerService.stop(),
getMainOverlayWindow: () => overlayManager.getMainWindow(),
clearMainOverlayWindow: () => overlayManager.setMainWindow(null),
getModalOverlayWindow: () => overlayManager.getModalWindow(),
clearModalOverlayWindow: () => overlayManager.setModalWindow(null),
getYomitanParserWindow: () => appState.yomitanParserWindow, getYomitanParserWindow: () => appState.yomitanParserWindow,
clearYomitanParserState: () => { clearYomitanParserState: () => {
appState.yomitanParserWindow = null; appState.yomitanParserWindow = null;
@@ -3478,7 +3556,8 @@ function ensureOverlayStartupPrereqs(): void {
const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({ const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
getInitialArgs: () => appState.initialArgs, getInitialArgs: () => appState.initialArgs,
isBackgroundMode: () => appState.backgroundMode, isBackgroundMode: () => appState.backgroundMode,
shouldEnsureTrayOnStartup: () => process.platform === 'win32', shouldEnsureTrayOnStartup: () =>
shouldEnsureTrayOnStartupForInitialArgs(process.platform, appState.initialArgs),
shouldRunHeadlessInitialCommand: (args) => isHeadlessInitialCommand(args), shouldRunHeadlessInitialCommand: (args) => isHeadlessInitialCommand(args),
ensureTray: () => ensureTray(), ensureTray: () => ensureTray(),
isTexthookerOnlyMode: () => appState.texthookerOnlyMode, isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
@@ -3512,7 +3591,8 @@ const {
>({ >({
bindMpvMainEventHandlersMainDeps: { bindMpvMainEventHandlersMainDeps: {
appState, appState,
getQuitOnDisconnectArmed: () => jellyfinPlayQuitOnDisconnectArmed, getQuitOnDisconnectArmed: () =>
jellyfinPlayQuitOnDisconnectArmed || youtubePlayQuitOnDisconnectArmed,
scheduleQuitCheck: (callback) => { scheduleQuitCheck: (callback) => {
setTimeout(callback, 500); setTimeout(callback, 500);
}, },

View File

@@ -16,6 +16,8 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'), stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'), stopTexthookerService: () => calls.push('stop-texthooker'),
destroyMainOverlayWindow: () => calls.push('destroy-main-overlay-window'),
destroyModalOverlayWindow: () => calls.push('destroy-modal-overlay-window'),
destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'), destroyYomitanParserWindow: () => calls.push('destroy-yomitan-window'),
clearYomitanParserState: () => calls.push('clear-yomitan-state'), clearYomitanParserState: () => calls.push('clear-yomitan-state'),
stopWindowTracker: () => calls.push('stop-tracker'), stopWindowTracker: () => calls.push('stop-tracker'),
@@ -38,7 +40,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
}); });
cleanup(); cleanup();
assert.equal(calls.length, 26); assert.equal(calls.length, 28);
assert.equal(calls[0], 'destroy-tray'); assert.equal(calls[0], 'destroy-tray');
assert.equal(calls[calls.length - 1], 'stop-discord-presence'); assert.equal(calls[calls.length - 1], 'stop-discord-presence');
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket')); assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));

View File

@@ -6,6 +6,8 @@ export function createOnWillQuitCleanupHandler(deps: {
unregisterAllGlobalShortcuts: () => void; unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void; stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void; stopTexthookerService: () => void;
destroyMainOverlayWindow: () => void;
destroyModalOverlayWindow: () => void;
destroyYomitanParserWindow: () => void; destroyYomitanParserWindow: () => void;
clearYomitanParserState: () => void; clearYomitanParserState: () => void;
stopWindowTracker: () => void; stopWindowTracker: () => void;
@@ -34,6 +36,8 @@ export function createOnWillQuitCleanupHandler(deps: {
deps.unregisterAllGlobalShortcuts(); deps.unregisterAllGlobalShortcuts();
deps.stopSubtitleWebsocket(); deps.stopSubtitleWebsocket();
deps.stopTexthookerService(); deps.stopTexthookerService();
deps.destroyMainOverlayWindow();
deps.destroyModalOverlayWindow();
deps.destroyYomitanParserWindow(); deps.destroyYomitanParserWindow();
deps.clearYomitanParserState(); deps.clearYomitanParserState();
deps.stopWindowTracker(); deps.stopWindowTracker();

View File

@@ -18,6 +18,16 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'), unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
stopSubtitleWebsocket: () => calls.push('stop-ws'), stopSubtitleWebsocket: () => calls.push('stop-ws'),
stopTexthookerService: () => calls.push('stop-texthooker'), stopTexthookerService: () => calls.push('stop-texthooker'),
getMainOverlayWindow: () => ({
isDestroyed: () => false,
destroy: () => calls.push('destroy-main-overlay-window'),
}),
clearMainOverlayWindow: () => calls.push('clear-main-overlay-window'),
getModalOverlayWindow: () => ({
isDestroyed: () => false,
destroy: () => calls.push('destroy-modal-overlay-window'),
}),
clearModalOverlayWindow: () => calls.push('clear-modal-overlay-window'),
getYomitanParserWindow: () => ({ getYomitanParserWindow: () => ({
isDestroyed: () => false, isDestroyed: () => false,
@@ -61,6 +71,10 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
cleanup(); cleanup();
assert.ok(calls.includes('destroy-tray')); assert.ok(calls.includes('destroy-tray'));
assert.ok(calls.includes('destroy-main-overlay-window'));
assert.ok(calls.includes('clear-main-overlay-window'));
assert.ok(calls.includes('destroy-modal-overlay-window'));
assert.ok(calls.includes('clear-modal-overlay-window'));
assert.ok(calls.includes('destroy-yomitan-window')); assert.ok(calls.includes('destroy-yomitan-window'));
assert.ok(calls.includes('flush-mpv-log')); assert.ok(calls.includes('flush-mpv-log'));
assert.ok(calls.includes('destroy-socket')); assert.ok(calls.includes('destroy-socket'));
@@ -85,6 +99,16 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
unregisterAllGlobalShortcuts: () => {}, unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {}, stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {}, stopTexthookerService: () => {},
getMainOverlayWindow: () => ({
isDestroyed: () => true,
destroy: () => calls.push('destroy-main-overlay-window'),
}),
clearMainOverlayWindow: () => calls.push('clear-main-overlay-window'),
getModalOverlayWindow: () => ({
isDestroyed: () => true,
destroy: () => calls.push('destroy-modal-overlay-window'),
}),
clearModalOverlayWindow: () => calls.push('clear-modal-overlay-window'),
getYomitanParserWindow: () => ({ getYomitanParserWindow: () => ({
isDestroyed: () => true, isDestroyed: () => true,
destroy: () => calls.push('destroy-yomitan-window'), destroy: () => calls.push('destroy-yomitan-window'),

View File

@@ -25,6 +25,10 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
unregisterAllGlobalShortcuts: () => void; unregisterAllGlobalShortcuts: () => void;
stopSubtitleWebsocket: () => void; stopSubtitleWebsocket: () => void;
stopTexthookerService: () => void; stopTexthookerService: () => void;
getMainOverlayWindow: () => DestroyableWindow | null;
clearMainOverlayWindow: () => void;
getModalOverlayWindow: () => DestroyableWindow | null;
clearModalOverlayWindow: () => void;
getYomitanParserWindow: () => DestroyableWindow | null; getYomitanParserWindow: () => DestroyableWindow | null;
clearYomitanParserState: () => void; clearYomitanParserState: () => void;
@@ -60,6 +64,20 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(), unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(), stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
stopTexthookerService: () => deps.stopTexthookerService(), stopTexthookerService: () => deps.stopTexthookerService(),
destroyMainOverlayWindow: () => {
const window = deps.getMainOverlayWindow();
if (!window) return;
if (window.isDestroyed()) return;
window.destroy();
deps.clearMainOverlayWindow();
},
destroyModalOverlayWindow: () => {
const window = deps.getModalOverlayWindow();
if (!window) return;
if (window.isDestroyed()) return;
window.destroy();
deps.clearModalOverlayWindow();
},
destroyYomitanParserWindow: () => { destroyYomitanParserWindow: () => {
const window = deps.getYomitanParserWindow(); const window = deps.getYomitanParserWindow();
if (!window) return; if (!window) return;

View File

@@ -20,6 +20,10 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
unregisterAllGlobalShortcuts: () => {}, unregisterAllGlobalShortcuts: () => {},
stopSubtitleWebsocket: () => {}, stopSubtitleWebsocket: () => {},
stopTexthookerService: () => {}, stopTexthookerService: () => {},
getMainOverlayWindow: () => null,
clearMainOverlayWindow: () => {},
getModalOverlayWindow: () => null,
clearModalOverlayWindow: () => {},
getYomitanParserWindow: () => null, getYomitanParserWindow: () => null,
clearYomitanParserState: () => {}, clearYomitanParserState: () => {},
getWindowTracker: () => null, getWindowTracker: () => null,

View File

@@ -12,8 +12,9 @@ test('mpv connection handler reports stop and quits when disconnect guard passes
reportJellyfinRemoteStopped: () => calls.push('report-stop'), reportJellyfinRemoteStopped: () => calls.push('report-stop'),
refreshDiscordPresence: () => calls.push('presence-refresh'), refreshDiscordPresence: () => calls.push('presence-refresh'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'), syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
hasInitialJellyfinPlayArg: () => true, hasInitialPlaybackQuitOnDisconnectArg: () => true,
isOverlayRuntimeInitialized: () => false, isOverlayRuntimeInitialized: () => false,
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => true, isQuitOnDisconnectArmed: () => true,
scheduleQuitCheck: (callback) => { scheduleQuitCheck: (callback) => {
calls.push('schedule'); calls.push('schedule');
@@ -36,8 +37,9 @@ test('mpv connection handler syncs overlay subtitle suppression on connect', ()
refreshDiscordPresence: () => calls.push('presence-refresh'), refreshDiscordPresence: () => calls.push('presence-refresh'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'), syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
scheduleCharacterDictionarySync: () => calls.push('dict-sync'), scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
hasInitialJellyfinPlayArg: () => true, hasInitialPlaybackQuitOnDisconnectArg: () => true,
isOverlayRuntimeInitialized: () => false, isOverlayRuntimeInitialized: () => false,
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => true, isQuitOnDisconnectArmed: () => true,
scheduleQuitCheck: () => { scheduleQuitCheck: () => {
calls.push('schedule'); calls.push('schedule');
@@ -52,6 +54,28 @@ test('mpv connection handler syncs overlay subtitle suppression on connect', ()
assert.deepEqual(calls, ['presence-refresh', 'sync-overlay-mpv-sub']); assert.deepEqual(calls, ['presence-refresh', 'sync-overlay-mpv-sub']);
}); });
test('mpv connection handler quits standalone youtube playback even after overlay runtime init', () => {
const calls: string[] = [];
const handler = createHandleMpvConnectionChangeHandler({
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
refreshDiscordPresence: () => calls.push('presence-refresh'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
hasInitialPlaybackQuitOnDisconnectArg: () => true,
isOverlayRuntimeInitialized: () => true,
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => true,
isQuitOnDisconnectArmed: () => true,
scheduleQuitCheck: (callback) => {
calls.push('schedule');
callback();
},
isMpvConnected: () => false,
quitApp: () => calls.push('quit'),
});
handler({ connected: false });
assert.deepEqual(calls, ['presence-refresh', 'report-stop', 'schedule', 'quit']);
});
test('mpv subtitle timing handler ignores blank subtitle lines', () => { test('mpv subtitle timing handler ignores blank subtitle lines', () => {
const calls: string[] = []; const calls: string[] = [];
const handler = createHandleMpvSubtitleTimingHandler({ const handler = createHandleMpvSubtitleTimingHandler({

View File

@@ -22,8 +22,9 @@ export function createHandleMpvConnectionChangeHandler(deps: {
reportJellyfinRemoteStopped: () => void; reportJellyfinRemoteStopped: () => void;
refreshDiscordPresence: () => void; refreshDiscordPresence: () => void;
syncOverlayMpvSubtitleSuppression: () => void; syncOverlayMpvSubtitleSuppression: () => void;
hasInitialJellyfinPlayArg: () => boolean; hasInitialPlaybackQuitOnDisconnectArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean; isOverlayRuntimeInitialized: () => boolean;
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean; isQuitOnDisconnectArmed: () => boolean;
scheduleQuitCheck: (callback: () => void) => void; scheduleQuitCheck: (callback: () => void) => void;
isMpvConnected: () => boolean; isMpvConnected: () => boolean;
@@ -36,8 +37,13 @@ export function createHandleMpvConnectionChangeHandler(deps: {
return; return;
} }
deps.reportJellyfinRemoteStopped(); deps.reportJellyfinRemoteStopped();
if (!deps.hasInitialJellyfinPlayArg()) return; if (!deps.hasInitialPlaybackQuitOnDisconnectArg()) return;
if (deps.isOverlayRuntimeInitialized()) return; if (
deps.isOverlayRuntimeInitialized() &&
!deps.shouldQuitOnDisconnectWhenOverlayRuntimeInitialized()
) {
return;
}
if (!deps.isQuitOnDisconnectArmed()) return; if (!deps.isQuitOnDisconnectArmed()) return;
deps.scheduleQuitCheck(() => { deps.scheduleQuitCheck(() => {
if (deps.isMpvConnected()) return; if (deps.isMpvConnected()) return;

View File

@@ -10,8 +10,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'), reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'), syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'), resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
hasInitialJellyfinPlayArg: () => false, hasInitialPlaybackQuitOnDisconnectArg: () => false,
isOverlayRuntimeInitialized: () => false, isOverlayRuntimeInitialized: () => false,
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
isQuitOnDisconnectArmed: () => false, isQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => { scheduleQuitCheck: () => {
calls.push('schedule-quit-check'); calls.push('schedule-quit-check');

View File

@@ -23,8 +23,9 @@ export function createBindMpvMainEventHandlersHandler(deps: {
syncOverlayMpvSubtitleSuppression: () => void; syncOverlayMpvSubtitleSuppression: () => void;
resetSubtitleSidebarEmbeddedLayout: () => void; resetSubtitleSidebarEmbeddedLayout: () => void;
scheduleCharacterDictionarySync?: () => void; scheduleCharacterDictionarySync?: () => void;
hasInitialJellyfinPlayArg: () => boolean; hasInitialPlaybackQuitOnDisconnectArg: () => boolean;
isOverlayRuntimeInitialized: () => boolean; isOverlayRuntimeInitialized: () => boolean;
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => boolean;
isQuitOnDisconnectArmed: () => boolean; isQuitOnDisconnectArmed: () => boolean;
scheduleQuitCheck: (callback: () => void) => void; scheduleQuitCheck: (callback: () => void) => void;
isMpvConnected: () => boolean; isMpvConnected: () => boolean;
@@ -77,8 +78,11 @@ export function createBindMpvMainEventHandlersHandler(deps: {
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
refreshDiscordPresence: () => deps.refreshDiscordPresence(), refreshDiscordPresence: () => deps.refreshDiscordPresence(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(), syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(), hasInitialPlaybackQuitOnDisconnectArg: () =>
deps.hasInitialPlaybackQuitOnDisconnectArg(),
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(), isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () =>
deps.shouldQuitOnDisconnectWhenOverlayRuntimeInitialized(),
isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(), isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(),
scheduleQuitCheck: (callback) => deps.scheduleQuitCheck(callback), scheduleQuitCheck: (callback) => deps.scheduleQuitCheck(callback),
isMpvConnected: () => deps.isMpvConnected(), isMpvConnected: () => deps.isMpvConnected(),

View File

@@ -61,7 +61,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
refreshDiscordPresence: () => calls.push('presence-refresh'), refreshDiscordPresence: () => calls.push('presence-refresh'),
})(); })();
assert.equal(deps.hasInitialJellyfinPlayArg(), true); assert.equal(deps.hasInitialPlaybackQuitOnDisconnectArg(), true);
assert.equal(deps.isOverlayRuntimeInitialized(), true); assert.equal(deps.isOverlayRuntimeInitialized(), true);
assert.equal(deps.isQuitOnDisconnectArmed(), true); assert.equal(deps.isQuitOnDisconnectArmed(), true);
assert.equal(deps.isMpvConnected(), true); assert.equal(deps.isMpvConnected(), true);
@@ -158,3 +158,59 @@ test('mpv main event main deps wire subtitle callbacks without suppression gate'
deps.setCurrentSubText('sub'); deps.setCurrentSubText('sub');
assert.equal(typeof deps.setCurrentSubText, 'function'); assert.equal(typeof deps.setCurrentSubText, 'function');
}); });
test('flushPlaybackPositionOnMediaPathClear ignores disconnected mpv time-pos reads', async () => {
const recorded: number[] = [];
const deps = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: {
connected: false,
currentTimePos: 42,
requestProperty: async () => {
throw new Error('disconnected');
},
},
immersionTracker: {
recordPlaybackPosition: (time: number) => {
recorded.push(time);
},
},
subtitleTimingTracker: null,
currentMediaPath: '',
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
resetSubtitleSidebarEmbeddedLayout: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
deps.flushPlaybackPositionOnMediaPathClear?.('');
await Promise.resolve();
assert.deepEqual(recorded, [42]);
});

View File

@@ -2,7 +2,7 @@ import type { MergedToken, SubtitleData } from '../../types';
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
appState: { appState: {
initialArgs?: { jellyfinPlay?: unknown } | null; initialArgs?: { jellyfinPlay?: unknown; youtubePlay?: unknown } | null;
overlayRuntimeInitialized: boolean; overlayRuntimeInitialized: boolean;
mpvClient: mpvClient:
| { | {
@@ -79,8 +79,11 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
return () => ({ return () => ({
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(), reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(), syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
hasInitialJellyfinPlayArg: () => Boolean(deps.appState.initialArgs?.jellyfinPlay), hasInitialPlaybackQuitOnDisconnectArg: () =>
Boolean(deps.appState.initialArgs?.jellyfinPlay || deps.appState.initialArgs?.youtubePlay),
isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized, isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized,
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () =>
Boolean(deps.appState.initialArgs?.youtubePlay),
isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(), isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(),
scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback), scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback),
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected), isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
@@ -187,16 +190,25 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
if (!mpvClient?.requestProperty) { if (!mpvClient?.requestProperty) {
return; return;
} }
void mpvClient.requestProperty('time-pos').then((timePos) => { void mpvClient
.requestProperty('time-pos')
.then((timePos) => {
const currentPath = (deps.appState.currentMediaPath ?? '').trim(); const currentPath = (deps.appState.currentMediaPath ?? '').trim();
if (currentPath.length > 0 && currentPath !== mediaPath) { if (currentPath.length > 0 && currentPath !== mediaPath) {
return; return;
} }
const resolvedTime = Number(timePos); const resolvedTime = Number(timePos);
if (Number.isFinite(currentKnownTime) && Number.isFinite(resolvedTime) && currentKnownTime === resolvedTime) { if (
Number.isFinite(currentKnownTime) &&
Number.isFinite(resolvedTime) &&
currentKnownTime === resolvedTime
) {
return; return;
} }
writePlaybackPositionFromMpv(resolvedTime); writePlaybackPositionFromMpv(resolvedTime);
})
.catch(() => {
// mpv can disconnect while clearing media; keep the last cached position.
}); });
}, },
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) => updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>

View File

@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { shouldEnsureTrayOnStartupForInitialArgs } from './startup-tray-policy';
test('startup tray policy enables tray on Windows by default', () => {
assert.equal(shouldEnsureTrayOnStartupForInitialArgs('win32', null), true);
});
test('startup tray policy skips tray for direct youtube playback on Windows', () => {
assert.equal(
shouldEnsureTrayOnStartupForInitialArgs('win32', {
youtubePlay: 'https://www.youtube.com/watch?v=abc',
} as never),
false,
);
});
test('startup tray policy skips tray outside Windows', () => {
assert.equal(shouldEnsureTrayOnStartupForInitialArgs('linux', null), false);
});

View File

@@ -0,0 +1,14 @@
import type { CliArgs } from '../../cli/args';
export function shouldEnsureTrayOnStartupForInitialArgs(
platform: NodeJS.Platform,
initialArgs: CliArgs | null,
): boolean {
if (platform !== 'win32') {
return false;
}
if (initialArgs?.youtubePlay) {
return false;
}
return true;
}

View File

@@ -378,6 +378,73 @@ test('youtube flow does not report failure when subtitle track binds before cue
assert.deepEqual(failures, []); assert.deepEqual(failures, []);
}); });
test('youtube flow does not fail when mpv reports sub-text as unavailable after track bind', async () => {
const failures: string[] = [];
const runtime = createYoutubeFlowRuntime({
probeYoutubeTracks: async () => ({
videoId: 'video123',
title: 'Video 123',
tracks: [primaryTrack],
}),
acquireYoutubeSubtitleTracks: async () => new Map(),
acquireYoutubeSubtitleTrack: async () => ({ path: '/tmp/auto-ja-orig.vtt' }),
retimeYoutubePrimaryTrack: async ({ primaryPath }) => primaryPath,
openPicker: async (payload) => {
queueMicrotask(() => {
void runtime.resolveActivePicker({
sessionId: payload.sessionId,
action: 'use-selected',
primaryTrackId: primaryTrack.id,
secondaryTrackId: null,
});
});
return true;
},
pauseMpv: () => {},
resumeMpv: () => {},
sendMpvCommand: () => {},
requestMpvProperty: async (name) => {
if (name === 'sub-text') {
throw new Error("Failed to read MPV property 'sub-text': property unavailable");
}
return [
{
type: 'sub',
id: 5,
lang: 'ja-orig',
title: 'primary',
external: true,
'external-filename': '/tmp/auto-ja-orig.vtt',
},
];
},
refreshCurrentSubtitle: () => {
throw new Error('should not refresh when sub-text is unavailable');
},
startTokenizationWarmups: async () => {},
waitForTokenizationReady: async () => {},
waitForAnkiReady: async () => {},
wait: async () => {},
waitForPlaybackWindowReady: async () => {},
waitForOverlayGeometryReady: async () => {},
focusOverlayWindow: () => {},
showMpvOsd: () => {},
reportSubtitleFailure: (message) => {
failures.push(message);
},
warn: (message) => {
throw new Error(message);
},
log: () => {},
getYoutubeOutputDir: () => '/tmp',
});
await runtime.openManualPicker({ url: 'https://example.com' });
assert.deepEqual(failures, []);
});
test('youtube flow retries secondary subtitle selection until mpv reports the expected secondary sid', async () => { test('youtube flow retries secondary subtitle selection until mpv reports the expected secondary sid', async () => {
const commands: Array<Array<string | number>> = []; const commands: Array<Array<string | number>> = [];
const waits: number[] = []; const waits: number[] = [];

View File

@@ -417,7 +417,7 @@ async function injectDownloadedSubtitles(
return false; return false;
} }
const currentSubText = await deps.requestMpvProperty('sub-text'); const currentSubText = await deps.requestMpvProperty('sub-text').catch(() => null);
if (typeof currentSubText === 'string' && currentSubText.trim().length > 0) { if (typeof currentSubText === 'string' && currentSubText.trim().length > 0) {
deps.refreshCurrentSubtitle(currentSubText); deps.refreshCurrentSubtitle(currentSubText);
} }

View File

@@ -0,0 +1,169 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createPrepareYoutubePlaybackInMpvHandler } from './youtube-playback-launch';
function createWaitStub() {
return async (_ms: number): Promise<void> => {};
}
test('prepare youtube playback skips load when current path already matches exact URL', async () => {
const commands: Array<Array<string>> = [];
const prepare = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => 'https://www.youtube.com/watch?v=abc123',
requestProperty: async () => [{ type: 'video', id: 1 }],
sendMpvCommand: (command) => commands.push(command),
wait: createWaitStub(),
});
const ok = await prepare({ url: 'https://www.youtube.com/watch?v=abc123' });
assert.equal(ok, true);
assert.deepEqual(commands, []);
});
test('prepare youtube playback treats matching video IDs as already loaded', async () => {
const commands: Array<Array<string>> = [];
const prepare = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => 'https://youtu.be/abc123?t=5',
requestProperty: async () => [{ type: 'video', id: 1 }],
sendMpvCommand: (command) => commands.push(command),
wait: createWaitStub(),
});
const ok = await prepare({ url: 'https://www.youtube.com/watch?v=abc123' });
assert.equal(ok, true);
assert.deepEqual(commands, []);
});
test('prepare youtube playback replaces media and waits for path switch', async () => {
const commands: Array<Array<string>> = [];
const observedPaths = [
'/videos/episode01.mkv',
'/videos/episode01.mkv',
'https://www.youtube.com/watch?v=newvid',
];
const observedTrackLists = [null, [], [{ type: 'video', id: 1 }]];
let requestCount = 0;
const prepare = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => {
const value = observedPaths[Math.min(requestCount, observedPaths.length - 1)] ?? null;
requestCount += 1;
return value;
},
requestProperty: async (name) => {
if (name !== 'track-list') return null;
return observedTrackLists[Math.min(requestCount - 1, observedTrackLists.length - 1)] ?? [];
},
sendMpvCommand: (command) => commands.push(command),
wait: createWaitStub(),
});
const ok = await prepare({
url: 'https://www.youtube.com/watch?v=newvid',
timeoutMs: 1500,
pollIntervalMs: 1,
});
assert.equal(ok, true);
assert.deepEqual(commands, [
['set_property', 'pause', 'yes'],
['set_property', 'sub-auto', 'no'],
['set_property', 'sid', 'no'],
['set_property', 'secondary-sid', 'no'],
['loadfile', 'https://www.youtube.com/watch?v=newvid', 'replace'],
]);
});
test('prepare youtube playback returns false after timeout when path never updates', async () => {
const commands: Array<Array<string>> = [];
let nowTick = 0;
const prepare = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => '/videos/episode01.mkv',
requestProperty: async () => [],
sendMpvCommand: (command) => commands.push(command),
wait: createWaitStub(),
now: () => {
nowTick += 100;
return nowTick;
},
});
const ok = await prepare({
url: 'https://www.youtube.com/watch?v=never-switches',
timeoutMs: 350,
pollIntervalMs: 1,
});
assert.equal(ok, false);
assert.deepEqual(commands[4], [
'loadfile',
'https://www.youtube.com/watch?v=never-switches',
'replace',
]);
});
test('prepare youtube playback waits for playable media tracks after youtube path matches', async () => {
const commands: Array<Array<string>> = [];
const observedPaths = [
'/videos/episode01.mkv',
'https://www.youtube.com/watch?v=newvid',
'https://www.youtube.com/watch?v=newvid',
];
const observedTrackLists = [[], [], [{ type: 'audio', id: 1 }]];
let requestCount = 0;
const prepare = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => {
const value = observedPaths[Math.min(requestCount, observedPaths.length - 1)] ?? null;
requestCount += 1;
return value;
},
requestProperty: async (name) => {
if (name !== 'track-list') return null;
return observedTrackLists[Math.min(requestCount - 1, observedTrackLists.length - 1)] ?? [];
},
sendMpvCommand: (command) => commands.push(command),
wait: createWaitStub(),
});
const ok = await prepare({
url: 'https://www.youtube.com/watch?v=newvid',
timeoutMs: 1500,
pollIntervalMs: 1,
});
assert.equal(ok, true);
assert.deepEqual(commands[4], ['loadfile', 'https://www.youtube.com/watch?v=newvid', 'replace']);
});
test('prepare youtube playback accepts a non-youtube resolved path once playable tracks exist', async () => {
const commands: Array<Array<string>> = [];
const observedPaths = [
'/videos/episode01.mkv',
'https://rr16---sn.example.googlevideo.com/videoplayback?id=abc',
];
const observedTrackLists = [[], [{ type: 'video', id: 1 }, { type: 'audio', id: 2 }]];
let requestCount = 0;
const prepare = createPrepareYoutubePlaybackInMpvHandler({
requestPath: async () => {
const value = observedPaths[Math.min(requestCount, observedPaths.length - 1)] ?? null;
requestCount += 1;
return value;
},
requestProperty: async (name) => {
if (name !== 'track-list') return null;
return observedTrackLists[Math.min(requestCount - 1, observedTrackLists.length - 1)] ?? [];
},
sendMpvCommand: (command) => commands.push(command),
wait: createWaitStub(),
});
const ok = await prepare({
url: 'https://www.youtube.com/watch?v=newvid',
timeoutMs: 1500,
pollIntervalMs: 1,
});
assert.equal(ok, true);
assert.deepEqual(commands[4], ['loadfile', 'https://www.youtube.com/watch?v=newvid', 'replace']);
});

View File

@@ -0,0 +1,153 @@
import { isYoutubeMediaPath } from './youtube-playback';
type YoutubePlaybackLaunchInput = {
url: string;
timeoutMs?: number;
pollIntervalMs?: number;
};
type YoutubePlaybackLaunchDeps = {
requestPath: () => Promise<string | null>;
requestProperty?: (name: string) => Promise<unknown>;
sendMpvCommand: (command: Array<string>) => void;
wait: (ms: number) => Promise<void>;
now?: () => number;
};
function normalizePath(value: string | null | undefined): string {
if (typeof value !== 'string') return '';
return value.trim();
}
function extractYoutubeVideoId(url: string): string | null {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return null;
}
const host = parsed.hostname.toLowerCase();
const path = parsed.pathname.replace(/^\/+/, '');
if (host === 'youtu.be' || host.endsWith('.youtu.be')) {
const id = path.split('/')[0]?.trim() || '';
return id || null;
}
const youtubeHost =
host === 'youtube.com' ||
host.endsWith('.youtube.com') ||
host === 'youtube-nocookie.com' ||
host.endsWith('.youtube-nocookie.com');
if (!youtubeHost) {
return null;
}
if (parsed.pathname === '/watch') {
const id = parsed.searchParams.get('v')?.trim() || '';
return id || null;
}
if (path.startsWith('shorts/') || path.startsWith('embed/')) {
const id = path.split('/')[1]?.trim() || '';
return id || null;
}
return null;
}
function targetsSameYoutubeVideo(currentPath: string, targetUrl: string): boolean {
const currentId = extractYoutubeVideoId(currentPath);
const targetId = extractYoutubeVideoId(targetUrl);
if (!currentId || !targetId) return false;
return currentId === targetId;
}
function pathMatchesYoutubeTarget(currentPath: string, targetUrl: string): boolean {
if (!currentPath) return false;
if (currentPath === targetUrl) return true;
return targetsSameYoutubeVideo(currentPath, targetUrl);
}
function hasPlayableMediaTracks(trackListRaw: unknown): boolean {
if (!Array.isArray(trackListRaw)) return false;
return trackListRaw.some((track) => {
if (!track || typeof track !== 'object') return false;
const type = String((track as Record<string, unknown>).type || '').trim().toLowerCase();
return type === 'video' || type === 'audio';
});
}
export function createPrepareYoutubePlaybackInMpvHandler(deps: YoutubePlaybackLaunchDeps) {
const now = deps.now ?? (() => Date.now());
return async (input: YoutubePlaybackLaunchInput): Promise<boolean> => {
const targetUrl = input.url.trim();
if (!targetUrl) return false;
const timeoutMs = Math.max(200, input.timeoutMs ?? 5000);
const pollIntervalMs = Math.max(25, input.pollIntervalMs ?? 100);
let previousPath = '';
try {
previousPath = normalizePath(await deps.requestPath());
} catch {
// Ignore transient path request failures and continue with bootstrap commands.
}
if (pathMatchesYoutubeTarget(previousPath, targetUrl)) {
return true;
}
deps.sendMpvCommand(['set_property', 'pause', 'yes']);
deps.sendMpvCommand(['set_property', 'sub-auto', 'no']);
deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
deps.sendMpvCommand(['loadfile', targetUrl, 'replace']);
const deadline = now() + timeoutMs;
while (now() < deadline) {
await deps.wait(pollIntervalMs);
let currentPath = '';
try {
currentPath = normalizePath(await deps.requestPath());
} catch {
continue;
}
if (!currentPath) continue;
if (pathMatchesYoutubeTarget(currentPath, targetUrl)) {
if (!deps.requestProperty) {
return true;
}
try {
const trackList = await deps.requestProperty('track-list');
if (hasPlayableMediaTracks(trackList)) {
return true;
}
} catch {
// Continue polling until media tracks are actually available.
}
}
if (previousPath && currentPath !== previousPath) {
if (
isYoutubeMediaPath(currentPath) &&
isYoutubeMediaPath(targetUrl)
) {
return true;
}
if (deps.requestProperty) {
try {
const trackList = await deps.requestProperty('track-list');
if (hasPlayableMediaTracks(trackList)) {
return true;
}
} catch {
// Continue polling until media tracks are actually available.
}
}
}
}
return false;
};
}

View File

@@ -181,5 +181,6 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
} }
schedulePendingCheck(); schedulePendingCheck();
}, },
isAppOwnedFlowInFlight: (): boolean => appOwnedFlowInFlight,
}; };
} }

View File

@@ -759,6 +759,76 @@ test('restorePointerInteractionState re-enables subtitle hover when pointer is a
} }
handlers.restorePointerInteractionState(); handlers.restorePointerInteractionState();
assert.equal(ctx.state.isOverSubtitle, true);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [
{ ignore: false, forward: undefined },
{ ignore: false, forward: undefined },
]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
}
});
test('pointer tracking enables overlay interaction as soon as the cursor reaches subtitles', () => {
const ctx = createMouseTestContext();
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
ctx.platform.shouldToggleMouseIgnore = true;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
getComputedStyle: () => ({
visibility: 'hidden',
display: 'none',
opacity: '0',
}),
focus: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
addEventListener: (type: string, listener: (event: unknown) => void) => {
const bucket = documentListeners.get(type) ?? [];
bucket.push(listener);
documentListeners.set(type, bucket);
},
elementFromPoint: () => ctx.dom.subtitleContainer,
querySelectorAll: () => [],
body: {},
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => false,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: () => {},
});
handlers.setupPointerTracking();
for (const listener of documentListeners.get('mousemove') ?? []) {
listener({ clientX: 120, clientY: 240 });
}
assert.equal(ctx.state.isOverSubtitle, true); assert.equal(ctx.state.isOverSubtitle, true);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), true); assert.equal(ctx.dom.overlay.classList.contains('interactive'), true);
assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]); assert.deepEqual(ignoreCalls, [{ ignore: false, forward: undefined }]);
@@ -768,6 +838,82 @@ test('restorePointerInteractionState re-enables subtitle hover when pointer is a
} }
}); });
test('pointer tracking restores click-through after the cursor leaves subtitles', () => {
const ctx = createMouseTestContext();
const originalWindow = globalThis.window;
const originalDocument = globalThis.document;
const ignoreCalls: Array<{ ignore: boolean; forward?: boolean }> = [];
const documentListeners = new Map<string, Array<(event: unknown) => void>>();
let hoveredElement: unknown = ctx.dom.subtitleContainer;
ctx.platform.shouldToggleMouseIgnore = true;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
electronAPI: {
setIgnoreMouseEvents: (ignore: boolean, options?: { forward?: boolean }) => {
ignoreCalls.push({ ignore, forward: options?.forward });
},
},
getComputedStyle: () => ({
visibility: 'hidden',
display: 'none',
opacity: '0',
}),
focus: () => {},
},
});
Object.defineProperty(globalThis, 'document', {
configurable: true,
value: {
addEventListener: (type: string, listener: (event: unknown) => void) => {
const bucket = documentListeners.get(type) ?? [];
bucket.push(listener);
documentListeners.set(type, bucket);
},
elementFromPoint: () => hoveredElement,
querySelectorAll: () => [],
body: {},
},
});
try {
const handlers = createMouseHandlers(ctx as never, {
modalStateReader: {
isAnySettingsModalOpen: () => false,
isAnyModalOpen: () => false,
},
applyYPercent: () => {},
getCurrentYPercent: () => 10,
persistSubtitlePositionPatch: () => {},
getSubtitleHoverAutoPauseEnabled: () => false,
getYomitanPopupAutoPauseEnabled: () => false,
getPlaybackPaused: async () => false,
sendMpvCommand: () => {},
});
handlers.setupPointerTracking();
for (const listener of documentListeners.get('mousemove') ?? []) {
listener({ clientX: 120, clientY: 240 });
}
hoveredElement = null;
for (const listener of documentListeners.get('mousemove') ?? []) {
listener({ clientX: 640, clientY: 360 });
}
assert.equal(ctx.state.isOverSubtitle, false);
assert.equal(ctx.dom.overlay.classList.contains('interactive'), false);
assert.deepEqual(ignoreCalls, [
{ ignore: false, forward: undefined },
{ ignore: true, forward: true },
]);
} finally {
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
Object.defineProperty(globalThis, 'document', { configurable: true, value: originalDocument });
}
});
test('restorePointerInteractionState keeps overlay interactive until first real pointer move can resync hover', () => { test('restorePointerInteractionState keeps overlay interactive until first real pointer move can resync hover', () => {
const ctx = createMouseTestContext(); const ctx = createMouseTestContext();
const originalWindow = globalThis.window; const originalWindow = globalThis.window;

View File

@@ -20,6 +20,12 @@ export function createMouseHandlers(
sendMpvCommand: (command: (string | number)[]) => void; sendMpvCommand: (command: (string | number)[]) => void;
}, },
) { ) {
type HoverPointState = {
overPrimarySubtitle: boolean;
overSecondarySubtitle: boolean;
isOverSubtitle: boolean;
};
let yomitanPopupVisible = false; let yomitanPopupVisible = false;
let hoverPauseRequestId = 0; let hoverPauseRequestId = 0;
let popupPauseRequestId = 0; let popupPauseRequestId = 0;
@@ -45,7 +51,7 @@ export function createMouseHandlers(
}; };
} }
function syncHoverStateFromPoint(clientX: number, clientY: number): boolean { function getHoverStateFromPoint(clientX: number, clientY: number): HoverPointState {
const hoveredElement = const hoveredElement =
typeof document.elementFromPoint === 'function' typeof document.elementFromPoint === 'function'
? document.elementFromPoint(clientX, clientY) ? document.elementFromPoint(clientX, clientY)
@@ -56,13 +62,52 @@ export function createMouseHandlers(
ctx.dom.secondarySubContainer, ctx.dom.secondarySubContainer,
); );
ctx.state.isOverSubtitle = overPrimarySubtitle || overSecondarySubtitle; return {
overPrimarySubtitle,
overSecondarySubtitle,
isOverSubtitle: overPrimarySubtitle || overSecondarySubtitle,
};
}
function syncHoverStateFromPoint(clientX: number, clientY: number): HoverPointState {
const hoverState = getHoverStateFromPoint(clientX, clientY);
ctx.state.isOverSubtitle = hoverState.isOverSubtitle;
ctx.dom.secondarySubContainer.classList.toggle( ctx.dom.secondarySubContainer.classList.toggle(
'secondary-sub-hover-active', 'secondary-sub-hover-active',
overSecondarySubtitle, hoverState.overSecondarySubtitle,
); );
return ctx.state.isOverSubtitle; return hoverState;
}
function syncHoverStateFromTrackedPointer(event: MouseEvent | PointerEvent): void {
if (!ctx.platform.shouldToggleMouseIgnore || ctx.state.isDragging) {
return;
}
const wasOverSubtitle = ctx.state.isOverSubtitle;
const wasOverSecondarySubtitle = ctx.dom.secondarySubContainer.classList.contains(
'secondary-sub-hover-active',
);
const hoverState = syncHoverStateFromPoint(event.clientX, event.clientY);
if (!wasOverSubtitle && hoverState.isOverSubtitle) {
void handleMouseEnter(undefined, hoverState.overSecondarySubtitle);
return;
}
if (wasOverSubtitle && !hoverState.isOverSubtitle) {
void handleMouseLeave(undefined, wasOverSecondarySubtitle);
return;
}
if (
hoverState.isOverSubtitle &&
hoverState.overSecondarySubtitle !== wasOverSecondarySubtitle
) {
syncOverlayMouseIgnoreState(ctx);
}
} }
function restorePointerInteractionState(): void { function restorePointerInteractionState(): void {
@@ -293,10 +338,12 @@ export function createMouseHandlers(
function setupPointerTracking(): void { function setupPointerTracking(): void {
document.addEventListener('mousemove', (event: MouseEvent) => { document.addEventListener('mousemove', (event: MouseEvent) => {
updatePointerPosition(event); updatePointerPosition(event);
syncHoverStateFromTrackedPointer(event);
maybeResyncPointerHoverState(event); maybeResyncPointerHoverState(event);
}); });
document.addEventListener('pointermove', (event: PointerEvent) => { document.addEventListener('pointermove', (event: PointerEvent) => {
updatePointerPosition(event); updatePointerPosition(event);
syncHoverStateFromTrackedPointer(event);
maybeResyncPointerHoverState(event); maybeResyncPointerHoverState(event);
}); });
} }