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 () => {
|
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 () => {
|
||||||
|
|||||||
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,
|
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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,17 +190,26 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
if (!mpvClient?.requestProperty) {
|
if (!mpvClient?.requestProperty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void mpvClient.requestProperty('time-pos').then((timePos) => {
|
void mpvClient
|
||||||
const currentPath = (deps.appState.currentMediaPath ?? '').trim();
|
.requestProperty('time-pos')
|
||||||
if (currentPath.length > 0 && currentPath !== mediaPath) {
|
.then((timePos) => {
|
||||||
return;
|
const currentPath = (deps.appState.currentMediaPath ?? '').trim();
|
||||||
}
|
if (currentPath.length > 0 && currentPath !== mediaPath) {
|
||||||
const resolvedTime = Number(timePos);
|
return;
|
||||||
if (Number.isFinite(currentKnownTime) && Number.isFinite(resolvedTime) && currentKnownTime === resolvedTime) {
|
}
|
||||||
return;
|
const resolvedTime = Number(timePos);
|
||||||
}
|
if (
|
||||||
writePlaybackPositionFromMpv(resolvedTime);
|
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>) =>
|
updateSubtitleRenderMetrics: (patch: Record<string, unknown>) =>
|
||||||
deps.updateSubtitleRenderMetrics(patch),
|
deps.updateSubtitleRenderMetrics(patch),
|
||||||
|
|||||||
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, []);
|
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[] = [];
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
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();
|
schedulePendingCheck();
|
||||||
},
|
},
|
||||||
|
isAppOwnedFlowInFlight: (): boolean => appOwnedFlowInFlight,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user