fix: align youtube playback with shared overlay startup

This commit is contained in:
2026-03-22 18:34:25 -07:00
parent 7666a094f4
commit e7242d006f
31 changed files with 3545 additions and 60 deletions

View File

@@ -113,6 +113,7 @@ import {
} from './cli/args';
import type { CliArgs, CliCommandSource } from './cli/args';
import { printHelp } from './cli/help';
import { IPC_CHANNELS } from './shared/ipc/contracts';
import {
buildConfigParseErrorDetails,
buildConfigWarningDialogDetails,
@@ -279,6 +280,7 @@ import {
handleMultiCopyDigit as handleMultiCopyDigitCore,
hasMpvWebsocketPlugin,
importYomitanDictionaryFromZip,
initializeOverlayAnkiIntegration as initializeOverlayAnkiIntegrationCore,
initializeOverlayRuntime as initializeOverlayRuntimeCore,
jellyfinTicksToSecondsRuntime,
listJellyfinItemsRuntime,
@@ -309,12 +311,19 @@ import {
upsertYomitanDictionarySettings,
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
} from './core/services';
import {
acquireYoutubeSubtitleTrack,
acquireYoutubeSubtitleTracks,
} from './core/services/youtube/generate';
import { retimeYoutubeSubtitle } from './core/services/youtube/retime';
import { probeYoutubeTracks } from './core/services/youtube/track-probe';
import { startStatsServer } from './core/services/stats-server';
import { registerStatsOverlayToggle, destroyStatsWindow } from './core/services/stats-window.js';
import {
createFirstRunSetupService,
shouldAutoOpenFirstRunSetup,
} from './main/runtime/first-run-setup-service';
import { createYoutubeFlowRuntime } from './main/runtime/youtube-flow';
import { resolveAutoplayReadyMaxReleaseAttempts } from './main/runtime/startup-autoplay-release-policy';
import {
buildFirstRunSetupHtml,
@@ -332,6 +341,7 @@ import {
detectWindowsMpvShortcuts,
resolveWindowsMpvShortcutPaths,
} from './main/runtime/windows-mpv-shortcuts';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
import {
@@ -442,7 +452,7 @@ import {
resolveSubtitleSourcePath,
} from './main/runtime/subtitle-prefetch-source';
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
import { codecToExtension } from './subsync/utils';
import { codecToExtension, getSubsyncConfig } from './subsync/utils';
if (process.platform === 'linux') {
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
@@ -787,6 +797,185 @@ const appState = createAppState({
mpvSocketPath: getDefaultSocketPath(),
texthookerPort: DEFAULT_TEXTHOOKER_PORT,
});
const startBackgroundWarmupsIfAllowed = (): void => {
if (appState.youtubePlaybackFlowPending) {
return;
}
startBackgroundWarmups();
};
const youtubeFlowRuntime = createYoutubeFlowRuntime({
probeYoutubeTracks: (url: string) => probeYoutubeTracks(url),
acquireYoutubeSubtitleTrack: (input) => acquireYoutubeSubtitleTrack(input),
acquireYoutubeSubtitleTracks: (input) => acquireYoutubeSubtitleTracks(input),
retimeYoutubePrimaryTrack: async ({ primaryTrack, primaryPath, secondaryTrack, secondaryPath }) => {
if (primaryTrack.kind !== 'auto') {
return primaryPath;
}
const result = await retimeYoutubeSubtitle({
primaryPath,
secondaryPath: secondaryTrack ? secondaryPath : null,
});
logger.info(`Using YouTube subtitle path: ${result.path} (${result.strategy})`);
return result.path;
},
openPicker: async (payload) => {
const preferDedicatedModalWindow = false;
const sendPickerOpen = (preferModalWindow: boolean): boolean =>
overlayModalRuntime.sendToActiveOverlayWindow('youtube:picker-open', payload, {
restoreOnModalClose: 'youtube-track-picker',
preferModalWindow,
});
if (!sendPickerOpen(preferDedicatedModalWindow)) {
return false;
}
if (await overlayModalRuntime.waitForModalOpen('youtube-track-picker', 1500)) {
return true;
}
logger.warn(
'YouTube subtitle picker did not acknowledge modal open on first attempt; retrying on visible overlay.',
);
if (!sendPickerOpen(!preferDedicatedModalWindow)) {
return false;
}
return await overlayModalRuntime.waitForModalOpen('youtube-track-picker', 1500);
},
pauseMpv: () => {
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'yes']);
},
resumeMpv: () => {
sendMpvCommandRuntime(appState.mpvClient, ['set_property', 'pause', 'no']);
},
sendMpvCommand: (command) => {
sendMpvCommandRuntime(appState.mpvClient, command);
},
requestMpvProperty: async (name: string) => {
const client = appState.mpvClient;
if (!client) return null;
return await client.requestProperty(name);
},
refreshCurrentSubtitle: (text: string) => {
subtitleProcessingController.refreshCurrentSubtitle(text);
},
startTokenizationWarmups: async () => {
await startTokenizationWarmups();
},
waitForTokenizationReady: async () => {
await currentMediaTokenizationGate.waitUntilReady(
appState.currentMediaPath?.trim() || appState.mpvClient?.currentVideoPath?.trim() || null,
);
},
waitForAnkiReady: async () => {
const integration = appState.ankiIntegration;
if (!integration) {
return;
}
try {
await Promise.race([
integration.waitUntilReady(),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Timed out waiting for AnkiConnect integration')), 2500);
}),
]);
} catch (error) {
logger.warn(
'Continuing YouTube playback before AnkiConnect integration reported ready:',
error instanceof Error ? error.message : String(error),
);
}
},
wait: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
waitForPlaybackWindowReady: async () => {
const deadline = Date.now() + 4000;
while (Date.now() < deadline) {
const tracker = appState.windowTracker;
if (tracker && tracker.isTracking() && tracker.getGeometry()) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
logger.warn('Timed out waiting for tracked playback window before opening YouTube subtitle picker.');
},
waitForOverlayGeometryReady: async () => {
const deadline = Date.now() + 4000;
while (Date.now() < deadline) {
const tracker = appState.windowTracker;
const trackerGeometry = tracker?.getGeometry() ?? null;
if (trackerGeometry && geometryMatches(lastOverlayWindowGeometry, trackerGeometry)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 50));
}
logger.warn('Timed out waiting for overlay geometry to match tracked playback window.');
},
focusOverlayWindow: () => {
const mainWindow = overlayManager.getMainWindow();
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
mainWindow.setIgnoreMouseEvents(false);
if (!mainWindow.isFocused()) {
mainWindow.focus();
}
if (!mainWindow.webContents.isFocused()) {
mainWindow.webContents.focus();
}
},
showMpvOsd: (text: string) => showMpvOsd(text),
warn: (message: string) => logger.warn(message),
log: (message: string) => logger.info(message),
getYoutubeOutputDir: () => path.join(os.homedir(), '.cache', 'subminer', 'youtube-subs'),
});
async function runYoutubePlaybackFlowMain(request: {
url: string;
mode: 'download' | 'generate';
source: CliCommandSource;
}): Promise<void> {
const shouldResumeWarmupsAfterFlow = appState.youtubePlaybackFlowPending;
if (process.platform === 'win32' && !appState.mpvClient?.connected) {
const launchResult = launchWindowsMpv(
[request.url],
createWindowsMpvLaunchDeps({
showError: (title, content) => dialog.showErrorBox(title, content),
}),
[
'--pause=yes',
'--sub-auto=no',
'--sid=no',
'--secondary-sid=no',
'--script-opts=subminer-auto_start_pause_until_ready=no',
`--input-ipc-server=${appState.mpvSocketPath}`,
],
);
if (!launchResult.ok) {
logger.warn('Unable to bootstrap Windows mpv for YouTube playback.');
}
}
if (!appState.mpvClient?.connected) {
appState.mpvClient?.connect();
}
await ensureOverlayRuntimeReady();
try {
await youtubeFlowRuntime.runYoutubePlaybackFlow({
url: request.url,
mode: request.mode,
});
logger.info(`YouTube playback flow completed from ${request.source}.`);
} finally {
if (shouldResumeWarmupsAfterFlow) {
appState.youtubePlaybackFlowPending = false;
startBackgroundWarmupsIfAllowed();
}
}
}
async function ensureOverlayRuntimeReady(): Promise<void> {
await ensureYomitanExtensionLoaded();
initializeOverlayRuntime();
}
let firstRunSetupMessage: string | null = null;
const resolveWindowsMpvShortcutRuntimePaths = () =>
resolveWindowsMpvShortcutPaths({
@@ -1045,6 +1234,9 @@ function maybeSignalPluginAutoplayReady(
payload: SubtitleData,
options?: { forceWhilePaused?: boolean },
): void {
if (appState.youtubePlaybackFlowPending) {
return;
}
if (!payload.text.trim()) {
return;
}
@@ -3064,7 +3256,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
await prewarmSubtitleDictionaries();
},
startBackgroundWarmups: () => {
startBackgroundWarmups();
startBackgroundWarmupsIfAllowed();
},
texthookerOnlyMode: appState.texthookerOnlyMode,
shouldAutoInitializeOverlayRuntimeFromConfig: () =>
@@ -3242,6 +3434,7 @@ const {
createMecabTokenizerAndCheck,
prewarmSubtitleDictionaries,
startBackgroundWarmups,
startTokenizationWarmups,
isTokenizationWarmupReady,
} = composeMpvRuntimeHandlers<
MpvIpcClient,
@@ -3312,6 +3505,9 @@ const {
immersionMediaRuntime.syncFromCurrentMediaState();
},
signalAutoplayReadyIfWarm: () => {
if (appState.youtubePlaybackFlowPending) {
return;
}
if (!isTokenizationWarmupReady()) {
return;
}
@@ -3513,7 +3709,19 @@ const {
tokenizeSubtitleDeferred = tokenizeSubtitle;
function createMpvClientRuntimeService(): MpvIpcClient {
return createMpvClientRuntimeServiceHandler() as MpvIpcClient;
const client = createMpvClientRuntimeServiceHandler() as MpvIpcClient;
client.on('connection-change', ({ connected }) => {
if (connected) {
return;
}
if (!youtubeFlowRuntime.hasActiveSession()) {
return;
}
youtubeFlowRuntime.cancelActivePicker();
broadcastToOverlayWindows(IPC_CHANNELS.event.youtubePickerCancel, null);
overlayModalRuntime.handleOverlayModalClosed('youtube-track-picker');
});
return client;
}
function resetSubtitleSidebarEmbeddedLayoutRuntime(): void {
@@ -3546,6 +3754,11 @@ function getCurrentOverlayGeometry(): WindowGeometry {
return getOverlayGeometryFallback();
}
function geometryMatches(a: WindowGeometry | null, b: WindowGeometry | null): boolean {
if (!a || !b) return false;
return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height;
}
function applyOverlayRegions(geometry: WindowGeometry): void {
lastOverlayWindowGeometry = geometry;
overlayManager.setOverlayWindowBounds(geometry);
@@ -3690,6 +3903,21 @@ function destroyTray(): void {
function initializeOverlayRuntime(): void {
initializeOverlayRuntimeHandler();
initializeOverlayAnkiIntegrationCore({
getResolvedConfig: () => getResolvedConfig(),
getSubtitleTimingTracker: () => appState.subtitleTimingTracker,
getMpvClient: () => appState.mpvClient,
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
getAnkiIntegration: () => appState.ankiIntegration,
setAnkiIntegration: (integration) => {
appState.ankiIntegration = integration as AnkiIntegration | null;
},
showDesktopNotification,
createFieldGroupingCallback: () => createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
shouldStartAnkiIntegration: () =>
!(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
});
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
syncOverlayMpvSubtitleSuppression();
}
@@ -4189,6 +4417,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
onOverlayModalOpened: (modal) => {
overlayModalRuntime.notifyOverlayModalOpened(modal);
},
onYoutubePickerResolve: (request) => youtubeFlowRuntime.resolveActivePicker(request),
openYomitanSettings: () => openYomitanSettings(),
quitApp: () => requestAppQuit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
@@ -4403,6 +4632,7 @@ const createCliCommandContextHandler = createCliCommandContextFactory({
runJellyfinCommand: (argsFromCommand: CliArgs) => runJellyfinCommand(argsFromCommand),
runStatsCommand: (argsFromCommand: CliArgs, source: CliCommandSource) =>
runStatsCliCommand(argsFromCommand, source),
runYoutubePlaybackFlow: (request) => runYoutubePlaybackFlowMain(request),
openYomitanSettings: () => openYomitanSettings(),
cycleSecondarySubMode: () => handleCycleSecondarySubMode(),
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
@@ -4569,7 +4799,10 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
appState.overlayRuntimeInitialized = initialized;
},
startBackgroundWarmups: () => {
if (appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) {
if (
(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)) ||
appState.youtubePlaybackFlowPending
) {
return;
}
startBackgroundWarmups();