mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-26 00:26:05 -07:00
Fix Windows YouTube playback flow and overlay pointer tracking
This commit is contained in:
4
changes/fix-overlay-pointer-tracking.md
Normal file
4
changes/fix-overlay-pointer-tracking.md
Normal 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.
|
||||
5
changes/fix-windows-youtube-playback.md
Normal file
5
changes/fix-windows-youtube-playback.md
Normal 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.
|
||||
@@ -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 () => {
|
||||
const { deps, calls, osd } = createDeps({
|
||||
runYoutubePlaybackFlow: async () => {
|
||||
|
||||
67
src/core/services/youtube/playback-resolve.test.ts
Normal file
67
src/core/services/youtube/playback-resolve.test.ts
Normal 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/,
|
||||
);
|
||||
});
|
||||
});
|
||||
63
src/core/services/youtube/playback-resolve.ts
Normal file
63
src/core/services/youtube/playback-resolve.ts
Normal 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;
|
||||
}
|
||||
96
src/main.ts
96
src/main.ts
@@ -315,6 +315,7 @@ import {
|
||||
acquireYoutubeSubtitleTrack,
|
||||
acquireYoutubeSubtitleTracks,
|
||||
} from './core/services/youtube/generate';
|
||||
import { resolveYoutubePlaybackUrl } from './core/services/youtube/playback-resolve';
|
||||
import { retimeYoutubeSubtitle } from './core/services/youtube/retime';
|
||||
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
|
||||
import { startStatsServer } from './core/services/stats-server';
|
||||
@@ -346,6 +347,9 @@ import {
|
||||
resolveWindowsMpvShortcutPaths,
|
||||
} from './main/runtime/windows-mpv-shortcuts';
|
||||
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 { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
|
||||
import {
|
||||
@@ -496,12 +500,17 @@ let anilistUpdateInFlightState = createInitialAnilistUpdateInFlightState();
|
||||
const anilistAttemptedUpdateKeys = new Set<string>();
|
||||
let anilistCachedAccessToken: string | null = null;
|
||||
let jellyfinPlayQuitOnDisconnectArmed = false;
|
||||
let youtubePlayQuitOnDisconnectArmed = false;
|
||||
const JELLYFIN_LANG_PREF = 'ja,jp,jpn,japanese,en,eng,english,enUS,en-US';
|
||||
const JELLYFIN_TICKS_PER_SECOND = 10_000_000;
|
||||
const JELLYFIN_REMOTE_PROGRESS_INTERVAL_MS = 3000;
|
||||
const DISCORD_PRESENCE_APP_ID = '1475264834730856619';
|
||||
const JELLYFIN_MPV_CONNECT_TIMEOUT_MS = 3000;
|
||||
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 = [
|
||||
'--sub-auto=fuzzy',
|
||||
'--sub-file-paths=.;subs;subtitles',
|
||||
@@ -940,6 +949,28 @@ const youtubeFlowRuntime = createYoutubeFlowRuntime({
|
||||
log: (message: string) => logger.info(message),
|
||||
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: {
|
||||
url: string;
|
||||
@@ -948,27 +979,66 @@ async function runYoutubePlaybackFlowMain(request: {
|
||||
}): Promise<void> {
|
||||
youtubePrimarySubtitleNotificationRuntime.setAppOwnedFlowInFlight(true);
|
||||
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) {
|
||||
const launchResult = launchWindowsMpv(
|
||||
[request.url],
|
||||
[playbackUrl],
|
||||
createWindowsMpvLaunchDeps({
|
||||
showError: (title, content) => dialog.showErrorBox(title, content),
|
||||
}),
|
||||
[
|
||||
'--pause=yes',
|
||||
'--ytdl=yes',
|
||||
`--ytdl-format=${YOUTUBE_MPV_YTDL_FORMAT}`,
|
||||
'--sub-auto=no',
|
||||
'--sid=no',
|
||||
'--secondary-sid=no',
|
||||
'--script-opts=subminer-auto_start_pause_until_ready=no',
|
||||
'--sub-file-paths=.;subs;subtitles',
|
||||
'--sid=auto',
|
||||
'--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}`,
|
||||
],
|
||||
);
|
||||
launchedWindowsMpv = launchResult.ok;
|
||||
if (launchResult.ok) {
|
||||
logger.info(`Bootstrapping Windows mpv for YouTube playback via ${launchResult.mpvPath}`);
|
||||
}
|
||||
if (!launchResult.ok) {
|
||||
logger.warn('Unable to bootstrap Windows mpv for YouTube playback.');
|
||||
}
|
||||
}
|
||||
if (!appState.mpvClient?.connected) {
|
||||
appState.mpvClient?.connect();
|
||||
const connected = await waitForYoutubeMpvConnected(
|
||||
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({
|
||||
url: request.url,
|
||||
@@ -1281,6 +1351,10 @@ function maybeSignalPluginAutoplayReady(
|
||||
payload: SubtitleData,
|
||||
options?: { forceWhilePaused?: boolean },
|
||||
): void {
|
||||
if (youtubePrimarySubtitleNotificationRuntime.isAppOwnedFlowInFlight()) {
|
||||
logger.debug('[autoplay-ready] suppressed while app-owned YouTube flow is active');
|
||||
return;
|
||||
}
|
||||
if (!payload.text.trim()) {
|
||||
return;
|
||||
}
|
||||
@@ -2829,6 +2903,10 @@ const {
|
||||
annotationSubtitleWsService.stop();
|
||||
},
|
||||
stopTexthookerService: () => texthookerService.stop(),
|
||||
getMainOverlayWindow: () => overlayManager.getMainWindow(),
|
||||
clearMainOverlayWindow: () => overlayManager.setMainWindow(null),
|
||||
getModalOverlayWindow: () => overlayManager.getModalWindow(),
|
||||
clearModalOverlayWindow: () => overlayManager.setModalWindow(null),
|
||||
getYomitanParserWindow: () => appState.yomitanParserWindow,
|
||||
clearYomitanParserState: () => {
|
||||
appState.yomitanParserWindow = null;
|
||||
@@ -3478,7 +3556,8 @@ function ensureOverlayStartupPrereqs(): void {
|
||||
const handleInitialArgsRuntimeHandler = createInitialArgsRuntimeHandler({
|
||||
getInitialArgs: () => appState.initialArgs,
|
||||
isBackgroundMode: () => appState.backgroundMode,
|
||||
shouldEnsureTrayOnStartup: () => process.platform === 'win32',
|
||||
shouldEnsureTrayOnStartup: () =>
|
||||
shouldEnsureTrayOnStartupForInitialArgs(process.platform, appState.initialArgs),
|
||||
shouldRunHeadlessInitialCommand: (args) => isHeadlessInitialCommand(args),
|
||||
ensureTray: () => ensureTray(),
|
||||
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
|
||||
@@ -3512,7 +3591,8 @@ const {
|
||||
>({
|
||||
bindMpvMainEventHandlersMainDeps: {
|
||||
appState,
|
||||
getQuitOnDisconnectArmed: () => jellyfinPlayQuitOnDisconnectArmed,
|
||||
getQuitOnDisconnectArmed: () =>
|
||||
jellyfinPlayQuitOnDisconnectArmed || youtubePlayQuitOnDisconnectArmed,
|
||||
scheduleQuitCheck: (callback) => {
|
||||
setTimeout(callback, 500);
|
||||
},
|
||||
|
||||
@@ -16,6 +16,8 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
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'),
|
||||
clearYomitanParserState: () => calls.push('clear-yomitan-state'),
|
||||
stopWindowTracker: () => calls.push('stop-tracker'),
|
||||
@@ -38,7 +40,7 @@ test('on will quit cleanup handler runs all cleanup steps', () => {
|
||||
});
|
||||
|
||||
cleanup();
|
||||
assert.equal(calls.length, 26);
|
||||
assert.equal(calls.length, 28);
|
||||
assert.equal(calls[0], 'destroy-tray');
|
||||
assert.equal(calls[calls.length - 1], 'stop-discord-presence');
|
||||
assert.ok(calls.indexOf('flush-mpv-log') < calls.indexOf('destroy-socket'));
|
||||
|
||||
@@ -6,6 +6,8 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
destroyMainOverlayWindow: () => void;
|
||||
destroyModalOverlayWindow: () => void;
|
||||
destroyYomitanParserWindow: () => void;
|
||||
clearYomitanParserState: () => void;
|
||||
stopWindowTracker: () => void;
|
||||
@@ -34,6 +36,8 @@ export function createOnWillQuitCleanupHandler(deps: {
|
||||
deps.unregisterAllGlobalShortcuts();
|
||||
deps.stopSubtitleWebsocket();
|
||||
deps.stopTexthookerService();
|
||||
deps.destroyMainOverlayWindow();
|
||||
deps.destroyModalOverlayWindow();
|
||||
deps.destroyYomitanParserWindow();
|
||||
deps.clearYomitanParserState();
|
||||
deps.stopWindowTracker();
|
||||
|
||||
@@ -18,6 +18,16 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
unregisterAllGlobalShortcuts: () => calls.push('unregister-shortcuts'),
|
||||
stopSubtitleWebsocket: () => calls.push('stop-ws'),
|
||||
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: () => ({
|
||||
isDestroyed: () => false,
|
||||
@@ -61,6 +71,10 @@ test('cleanup deps builder returns handlers that guard optional runtime objects'
|
||||
cleanup();
|
||||
|
||||
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('flush-mpv-log'));
|
||||
assert.ok(calls.includes('destroy-socket'));
|
||||
@@ -85,6 +99,16 @@ test('cleanup deps builder skips destroyed yomitan window', () => {
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
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: () => ({
|
||||
isDestroyed: () => true,
|
||||
destroy: () => calls.push('destroy-yomitan-window'),
|
||||
|
||||
@@ -25,6 +25,10 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
unregisterAllGlobalShortcuts: () => void;
|
||||
stopSubtitleWebsocket: () => void;
|
||||
stopTexthookerService: () => void;
|
||||
getMainOverlayWindow: () => DestroyableWindow | null;
|
||||
clearMainOverlayWindow: () => void;
|
||||
getModalOverlayWindow: () => DestroyableWindow | null;
|
||||
clearModalOverlayWindow: () => void;
|
||||
|
||||
getYomitanParserWindow: () => DestroyableWindow | null;
|
||||
clearYomitanParserState: () => void;
|
||||
@@ -60,6 +64,20 @@ export function createBuildOnWillQuitCleanupDepsHandler(deps: {
|
||||
unregisterAllGlobalShortcuts: () => deps.unregisterAllGlobalShortcuts(),
|
||||
stopSubtitleWebsocket: () => deps.stopSubtitleWebsocket(),
|
||||
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: () => {
|
||||
const window = deps.getYomitanParserWindow();
|
||||
if (!window) return;
|
||||
|
||||
@@ -20,6 +20,10 @@ test('composeStartupLifecycleHandlers returns callable startup lifecycle handler
|
||||
unregisterAllGlobalShortcuts: () => {},
|
||||
stopSubtitleWebsocket: () => {},
|
||||
stopTexthookerService: () => {},
|
||||
getMainOverlayWindow: () => null,
|
||||
clearMainOverlayWindow: () => {},
|
||||
getModalOverlayWindow: () => null,
|
||||
clearModalOverlayWindow: () => {},
|
||||
getYomitanParserWindow: () => null,
|
||||
clearYomitanParserState: () => {},
|
||||
getWindowTracker: () => null,
|
||||
|
||||
@@ -12,8 +12,9 @@ test('mpv connection handler reports stop and quits when disconnect guard passes
|
||||
reportJellyfinRemoteStopped: () => calls.push('report-stop'),
|
||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||
hasInitialJellyfinPlayArg: () => true,
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => true,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
|
||||
isQuitOnDisconnectArmed: () => true,
|
||||
scheduleQuitCheck: (callback) => {
|
||||
calls.push('schedule');
|
||||
@@ -36,8 +37,9 @@ test('mpv connection handler syncs overlay subtitle suppression on connect', ()
|
||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||
scheduleCharacterDictionarySync: () => calls.push('dict-sync'),
|
||||
hasInitialJellyfinPlayArg: () => true,
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => true,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
|
||||
isQuitOnDisconnectArmed: () => true,
|
||||
scheduleQuitCheck: () => {
|
||||
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']);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const calls: string[] = [];
|
||||
const handler = createHandleMpvSubtitleTimingHandler({
|
||||
|
||||
@@ -22,8 +22,9 @@ export function createHandleMpvConnectionChangeHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => void;
|
||||
refreshDiscordPresence: () => void;
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
hasInitialJellyfinPlayArg: () => boolean;
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => boolean;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => boolean;
|
||||
isQuitOnDisconnectArmed: () => boolean;
|
||||
scheduleQuitCheck: (callback: () => void) => void;
|
||||
isMpvConnected: () => boolean;
|
||||
@@ -36,8 +37,13 @@ export function createHandleMpvConnectionChangeHandler(deps: {
|
||||
return;
|
||||
}
|
||||
deps.reportJellyfinRemoteStopped();
|
||||
if (!deps.hasInitialJellyfinPlayArg()) return;
|
||||
if (deps.isOverlayRuntimeInitialized()) return;
|
||||
if (!deps.hasInitialPlaybackQuitOnDisconnectArg()) return;
|
||||
if (
|
||||
deps.isOverlayRuntimeInitialized() &&
|
||||
!deps.shouldQuitOnDisconnectWhenOverlayRuntimeInitialized()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!deps.isQuitOnDisconnectArmed()) return;
|
||||
deps.scheduleQuitCheck(() => {
|
||||
if (deps.isMpvConnected()) return;
|
||||
|
||||
@@ -10,8 +10,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
|
||||
reportJellyfinRemoteStopped: () => calls.push('remote-stopped'),
|
||||
syncOverlayMpvSubtitleSuppression: () => calls.push('sync-overlay-mpv-sub'),
|
||||
resetSubtitleSidebarEmbeddedLayout: () => calls.push('reset-sidebar-layout'),
|
||||
hasInitialJellyfinPlayArg: () => false,
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => false,
|
||||
isOverlayRuntimeInitialized: () => false,
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => false,
|
||||
isQuitOnDisconnectArmed: () => false,
|
||||
scheduleQuitCheck: () => {
|
||||
calls.push('schedule-quit-check');
|
||||
|
||||
@@ -23,8 +23,9 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
syncOverlayMpvSubtitleSuppression: () => void;
|
||||
resetSubtitleSidebarEmbeddedLayout: () => void;
|
||||
scheduleCharacterDictionarySync?: () => void;
|
||||
hasInitialJellyfinPlayArg: () => boolean;
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () => boolean;
|
||||
isOverlayRuntimeInitialized: () => boolean;
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () => boolean;
|
||||
isQuitOnDisconnectArmed: () => boolean;
|
||||
scheduleQuitCheck: (callback: () => void) => void;
|
||||
isMpvConnected: () => boolean;
|
||||
@@ -77,8 +78,11 @@ export function createBindMpvMainEventHandlersHandler(deps: {
|
||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||
refreshDiscordPresence: () => deps.refreshDiscordPresence(),
|
||||
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
||||
hasInitialJellyfinPlayArg: () => deps.hasInitialJellyfinPlayArg(),
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () =>
|
||||
deps.hasInitialPlaybackQuitOnDisconnectArg(),
|
||||
isOverlayRuntimeInitialized: () => deps.isOverlayRuntimeInitialized(),
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () =>
|
||||
deps.shouldQuitOnDisconnectWhenOverlayRuntimeInitialized(),
|
||||
isQuitOnDisconnectArmed: () => deps.isQuitOnDisconnectArmed(),
|
||||
scheduleQuitCheck: (callback) => deps.scheduleQuitCheck(callback),
|
||||
isMpvConnected: () => deps.isMpvConnected(),
|
||||
|
||||
@@ -61,7 +61,7 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
|
||||
refreshDiscordPresence: () => calls.push('presence-refresh'),
|
||||
})();
|
||||
|
||||
assert.equal(deps.hasInitialJellyfinPlayArg(), true);
|
||||
assert.equal(deps.hasInitialPlaybackQuitOnDisconnectArg(), true);
|
||||
assert.equal(deps.isOverlayRuntimeInitialized(), true);
|
||||
assert.equal(deps.isQuitOnDisconnectArmed(), 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');
|
||||
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]);
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { MergedToken, SubtitleData } from '../../types';
|
||||
|
||||
export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
appState: {
|
||||
initialArgs?: { jellyfinPlay?: unknown } | null;
|
||||
initialArgs?: { jellyfinPlay?: unknown; youtubePlay?: unknown } | null;
|
||||
overlayRuntimeInitialized: boolean;
|
||||
mpvClient:
|
||||
| {
|
||||
@@ -79,8 +79,11 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
return () => ({
|
||||
reportJellyfinRemoteStopped: () => deps.reportJellyfinRemoteStopped(),
|
||||
syncOverlayMpvSubtitleSuppression: () => deps.syncOverlayMpvSubtitleSuppression(),
|
||||
hasInitialJellyfinPlayArg: () => Boolean(deps.appState.initialArgs?.jellyfinPlay),
|
||||
hasInitialPlaybackQuitOnDisconnectArg: () =>
|
||||
Boolean(deps.appState.initialArgs?.jellyfinPlay || deps.appState.initialArgs?.youtubePlay),
|
||||
isOverlayRuntimeInitialized: () => deps.appState.overlayRuntimeInitialized,
|
||||
shouldQuitOnDisconnectWhenOverlayRuntimeInitialized: () =>
|
||||
Boolean(deps.appState.initialArgs?.youtubePlay),
|
||||
isQuitOnDisconnectArmed: () => deps.getQuitOnDisconnectArmed(),
|
||||
scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback),
|
||||
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
|
||||
@@ -187,16 +190,25 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
if (!mpvClient?.requestProperty) {
|
||||
return;
|
||||
}
|
||||
void mpvClient.requestProperty('time-pos').then((timePos) => {
|
||||
void mpvClient
|
||||
.requestProperty('time-pos')
|
||||
.then((timePos) => {
|
||||
const currentPath = (deps.appState.currentMediaPath ?? '').trim();
|
||||
if (currentPath.length > 0 && currentPath !== mediaPath) {
|
||||
return;
|
||||
}
|
||||
const resolvedTime = Number(timePos);
|
||||
if (Number.isFinite(currentKnownTime) && Number.isFinite(resolvedTime) && currentKnownTime === resolvedTime) {
|
||||
if (
|
||||
Number.isFinite(currentKnownTime) &&
|
||||
Number.isFinite(resolvedTime) &&
|
||||
currentKnownTime === resolvedTime
|
||||
) {
|
||||
return;
|
||||
}
|
||||
writePlaybackPositionFromMpv(resolvedTime);
|
||||
})
|
||||
.catch(() => {
|
||||
// mpv can disconnect while clearing media; keep the last cached position.
|
||||
});
|
||||
},
|
||||
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>
|
||||
|
||||
20
src/main/runtime/startup-tray-policy.test.ts
Normal file
20
src/main/runtime/startup-tray-policy.test.ts
Normal 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);
|
||||
});
|
||||
14
src/main/runtime/startup-tray-policy.ts
Normal file
14
src/main/runtime/startup-tray-policy.ts
Normal 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;
|
||||
}
|
||||
@@ -378,6 +378,73 @@ test('youtube flow does not report failure when subtitle track binds before cue
|
||||
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 () => {
|
||||
const commands: Array<Array<string | number>> = [];
|
||||
const waits: number[] = [];
|
||||
|
||||
@@ -417,7 +417,7 @@ async function injectDownloadedSubtitles(
|
||||
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) {
|
||||
deps.refreshCurrentSubtitle(currentSubText);
|
||||
}
|
||||
|
||||
169
src/main/runtime/youtube-playback-launch.test.ts
Normal file
169
src/main/runtime/youtube-playback-launch.test.ts
Normal 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']);
|
||||
});
|
||||
153
src/main/runtime/youtube-playback-launch.ts
Normal file
153
src/main/runtime/youtube-playback-launch.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -181,5 +181,6 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: {
|
||||
}
|
||||
schedulePendingCheck();
|
||||
},
|
||||
isAppOwnedFlowInFlight: (): boolean => appOwnedFlowInFlight,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -759,6 +759,76 @@ test('restorePointerInteractionState re-enables subtitle hover when pointer is a
|
||||
}
|
||||
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.dom.overlay.classList.contains('interactive'), true);
|
||||
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', () => {
|
||||
const ctx = createMouseTestContext();
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
@@ -20,6 +20,12 @@ export function createMouseHandlers(
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
},
|
||||
) {
|
||||
type HoverPointState = {
|
||||
overPrimarySubtitle: boolean;
|
||||
overSecondarySubtitle: boolean;
|
||||
isOverSubtitle: boolean;
|
||||
};
|
||||
|
||||
let yomitanPopupVisible = false;
|
||||
let hoverPauseRequestId = 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 =
|
||||
typeof document.elementFromPoint === 'function'
|
||||
? document.elementFromPoint(clientX, clientY)
|
||||
@@ -56,13 +62,52 @@ export function createMouseHandlers(
|
||||
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(
|
||||
'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 {
|
||||
@@ -293,10 +338,12 @@ export function createMouseHandlers(
|
||||
function setupPointerTracking(): void {
|
||||
document.addEventListener('mousemove', (event: MouseEvent) => {
|
||||
updatePointerPosition(event);
|
||||
syncHoverStateFromTrackedPointer(event);
|
||||
maybeResyncPointerHoverState(event);
|
||||
});
|
||||
document.addEventListener('pointermove', (event: PointerEvent) => {
|
||||
updatePointerPosition(event);
|
||||
syncHoverStateFromTrackedPointer(event);
|
||||
maybeResyncPointerHoverState(event);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user