mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-25 00:11:26 -07:00
fix: align youtube playback with shared overlay startup
This commit is contained in:
241
src/main.ts
241
src/main.ts
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user