diff --git a/changes/fix-overlay-pointer-tracking.md b/changes/fix-overlay-pointer-tracking.md new file mode 100644 index 0000000..467d21c --- /dev/null +++ b/changes/fix-overlay-pointer-tracking.md @@ -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. diff --git a/changes/fix-windows-youtube-playback.md b/changes/fix-windows-youtube-playback.md new file mode 100644 index 0000000..ad13320 --- /dev/null +++ b/changes/fix-windows-youtube-playback.md @@ -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. diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 0e1bf40..2020bc9 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -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 () => { diff --git a/src/core/services/youtube/playback-resolve.test.ts b/src/core/services/youtube/playback-resolve.test.ts new file mode 100644 index 0000000..ae1b1f8 --- /dev/null +++ b/src/core/services/youtube/playback-resolve.test.ts @@ -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(fn: (dir: string) => Promise): Promise { + 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(payload: string, fn: () => Promise): Promise { + 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/, + ); + }); +}); diff --git a/src/core/services/youtube/playback-resolve.ts b/src/core/services/youtube/playback-resolve.ts new file mode 100644 index 0000000..cf88b11 --- /dev/null +++ b/src/core/services/youtube/playback-resolve.ts @@ -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 { + 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; +} diff --git a/src/main.ts b/src/main.ts index 157b855..527ed2a 100644 --- a/src/main.ts +++ b/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(); 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 { 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); }, diff --git a/src/main/runtime/app-lifecycle-actions.test.ts b/src/main/runtime/app-lifecycle-actions.test.ts index 0a97ac9..4c08ead 100644 --- a/src/main/runtime/app-lifecycle-actions.test.ts +++ b/src/main/runtime/app-lifecycle-actions.test.ts @@ -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')); diff --git a/src/main/runtime/app-lifecycle-actions.ts b/src/main/runtime/app-lifecycle-actions.ts index f064807..4dd08be 100644 --- a/src/main/runtime/app-lifecycle-actions.ts +++ b/src/main/runtime/app-lifecycle-actions.ts @@ -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(); diff --git a/src/main/runtime/app-lifecycle-main-cleanup.test.ts b/src/main/runtime/app-lifecycle-main-cleanup.test.ts index b04cd4f..d681438 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.test.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.test.ts @@ -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'), diff --git a/src/main/runtime/app-lifecycle-main-cleanup.ts b/src/main/runtime/app-lifecycle-main-cleanup.ts index f2d5c8e..803a0a1 100644 --- a/src/main/runtime/app-lifecycle-main-cleanup.ts +++ b/src/main/runtime/app-lifecycle-main-cleanup.ts @@ -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; diff --git a/src/main/runtime/composers/startup-lifecycle-composer.test.ts b/src/main/runtime/composers/startup-lifecycle-composer.test.ts index bd04f71..60eb4a7 100644 --- a/src/main/runtime/composers/startup-lifecycle-composer.test.ts +++ b/src/main/runtime/composers/startup-lifecycle-composer.test.ts @@ -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, diff --git a/src/main/runtime/mpv-client-event-bindings.test.ts b/src/main/runtime/mpv-client-event-bindings.test.ts index 3c474d0..173365f 100644 --- a/src/main/runtime/mpv-client-event-bindings.test.ts +++ b/src/main/runtime/mpv-client-event-bindings.test.ts @@ -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({ diff --git a/src/main/runtime/mpv-client-event-bindings.ts b/src/main/runtime/mpv-client-event-bindings.ts index 8520509..3ffa425 100644 --- a/src/main/runtime/mpv-client-event-bindings.ts +++ b/src/main/runtime/mpv-client-event-bindings.ts @@ -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; diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts index e7bcd4a..ef06556 100644 --- a/src/main/runtime/mpv-main-event-bindings.test.ts +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -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'); diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index 7890818..a3de05e 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -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(), diff --git a/src/main/runtime/mpv-main-event-main-deps.test.ts b/src/main/runtime/mpv-main-event-main-deps.test.ts index 845b2fd..9e52f67 100644 --- a/src/main/runtime/mpv-main-event-main-deps.test.ts +++ b/src/main/runtime/mpv-main-event-main-deps.test.ts @@ -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]); +}); diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 2523861..9a74abe 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -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,17 +190,26 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { if (!mpvClient?.requestProperty) { return; } - 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) { - return; - } - writePlaybackPositionFromMpv(resolvedTime); - }); + 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 + ) { + return; + } + writePlaybackPositionFromMpv(resolvedTime); + }) + .catch(() => { + // mpv can disconnect while clearing media; keep the last cached position. + }); }, updateSubtitleRenderMetrics: (patch: Record) => deps.updateSubtitleRenderMetrics(patch), diff --git a/src/main/runtime/startup-tray-policy.test.ts b/src/main/runtime/startup-tray-policy.test.ts new file mode 100644 index 0000000..69ac976 --- /dev/null +++ b/src/main/runtime/startup-tray-policy.test.ts @@ -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); +}); diff --git a/src/main/runtime/startup-tray-policy.ts b/src/main/runtime/startup-tray-policy.ts new file mode 100644 index 0000000..2ee18a9 --- /dev/null +++ b/src/main/runtime/startup-tray-policy.ts @@ -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; +} diff --git a/src/main/runtime/youtube-flow.test.ts b/src/main/runtime/youtube-flow.test.ts index ccc6fe1..b9e2b84 100644 --- a/src/main/runtime/youtube-flow.test.ts +++ b/src/main/runtime/youtube-flow.test.ts @@ -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> = []; const waits: number[] = []; diff --git a/src/main/runtime/youtube-flow.ts b/src/main/runtime/youtube-flow.ts index ac0ea80..aff758f 100644 --- a/src/main/runtime/youtube-flow.ts +++ b/src/main/runtime/youtube-flow.ts @@ -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); } diff --git a/src/main/runtime/youtube-playback-launch.test.ts b/src/main/runtime/youtube-playback-launch.test.ts new file mode 100644 index 0000000..de54263 --- /dev/null +++ b/src/main/runtime/youtube-playback-launch.test.ts @@ -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 => {}; +} + +test('prepare youtube playback skips load when current path already matches exact URL', async () => { + const commands: Array> = []; + 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> = []; + 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> = []; + 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> = []; + 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> = []; + 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> = []; + 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']); +}); diff --git a/src/main/runtime/youtube-playback-launch.ts b/src/main/runtime/youtube-playback-launch.ts new file mode 100644 index 0000000..e390601 --- /dev/null +++ b/src/main/runtime/youtube-playback-launch.ts @@ -0,0 +1,153 @@ +import { isYoutubeMediaPath } from './youtube-playback'; + +type YoutubePlaybackLaunchInput = { + url: string; + timeoutMs?: number; + pollIntervalMs?: number; +}; + +type YoutubePlaybackLaunchDeps = { + requestPath: () => Promise; + requestProperty?: (name: string) => Promise; + sendMpvCommand: (command: Array) => void; + wait: (ms: number) => Promise; + 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).type || '').trim().toLowerCase(); + return type === 'video' || type === 'audio'; + }); +} + +export function createPrepareYoutubePlaybackInMpvHandler(deps: YoutubePlaybackLaunchDeps) { + const now = deps.now ?? (() => Date.now()); + return async (input: YoutubePlaybackLaunchInput): Promise => { + 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; + }; +} diff --git a/src/main/runtime/youtube-primary-subtitle-notification.ts b/src/main/runtime/youtube-primary-subtitle-notification.ts index 99ff8ba..c52804b 100644 --- a/src/main/runtime/youtube-primary-subtitle-notification.ts +++ b/src/main/runtime/youtube-primary-subtitle-notification.ts @@ -181,5 +181,6 @@ export function createYoutubePrimarySubtitleNotificationRuntime(deps: { } schedulePendingCheck(); }, + isAppOwnedFlowInFlight: (): boolean => appOwnedFlowInFlight, }; } diff --git a/src/renderer/handlers/mouse.test.ts b/src/renderer/handlers/mouse.test.ts index f297200..5077ed2 100644 --- a/src/renderer/handlers/mouse.test.ts +++ b/src/renderer/handlers/mouse.test.ts @@ -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 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 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; diff --git a/src/renderer/handlers/mouse.ts b/src/renderer/handlers/mouse.ts index b26ec33..52ef5ed 100644 --- a/src/renderer/handlers/mouse.ts +++ b/src/renderer/handlers/mouse.ts @@ -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); }); }