mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 16:46:06 -08:00
feat(youtube): add YouTube browser window and Whisper subtitle source (#273)
This commit is contained in:
1 parent
c68b73c8c1
commit
07b32626a6
101 files changed
+2269
-726
No files matched your search
@@ -0,0 +1,214 @@
|
||||
import type {
|
||||
BrowserWindow,
|
||||
BrowserWindowConstructorOptions,
|
||||
IpcMain,
|
||||
MenuItemConstructorOptions,
|
||||
Session,
|
||||
} from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
parseYoutubeBrowserVideoRequest,
|
||||
type YoutubeBrowserVideoRequest,
|
||||
type YoutubeBrowserVideoResult,
|
||||
} from './youtube-browser-playback';
|
||||
import { toYoutubeWatchUrl } from './youtube-playback';
|
||||
|
||||
/** Persistent partition: cookies (and so the YouTube login) survive app restarts. */
|
||||
export const YOUTUBE_BROWSER_PARTITION = 'persist:youtube-browser';
|
||||
export const YOUTUBE_BROWSER_HOME_URL = 'https://www.youtube.com/';
|
||||
|
||||
const ALLOWED_PERMISSIONS = new Set(['fullscreen', 'clipboard-sanitized-write']);
|
||||
const STANDARD_UA_PRODUCTS = '(?:Mozilla|AppleWebKit|Chrome|Safari)';
|
||||
const NON_BROWSER_UA_TOKEN = new RegExp(
|
||||
`\\s+(?!${STANDARD_UA_PRODUCTS}/)[A-Za-z][\\w.-]*/\\S+`,
|
||||
'g',
|
||||
);
|
||||
|
||||
/**
|
||||
* Plain Chrome user agent. Google refuses sign-in from agents that advertise Electron or an
|
||||
* embedding app, so every product token other than the standard Chrome ones is dropped.
|
||||
*/
|
||||
export function buildYoutubeBrowserUserAgent(defaultUserAgent: string): string {
|
||||
return defaultUserAgent.replace(NON_BROWSER_UA_TOKEN, '').trim();
|
||||
}
|
||||
|
||||
export type YoutubeBrowserWindowDeps = {
|
||||
createBrowserWindow: (options: BrowserWindowConstructorOptions) => BrowserWindow;
|
||||
preloadPath: string;
|
||||
ipcMain: Pick<IpcMain, 'on'>;
|
||||
showContextMenu: (window: BrowserWindow, template: MenuItemConstructorOptions[]) => void;
|
||||
openExternal: (url: string) => void;
|
||||
openVideo: (request: YoutubeBrowserVideoRequest) => Promise<YoutubeBrowserVideoResult>;
|
||||
logWarn: (message: string, error?: unknown) => void;
|
||||
logDebug: (message: string) => void;
|
||||
onClosed?: () => void;
|
||||
};
|
||||
|
||||
function isHttpUrl(url: string): boolean {
|
||||
return url.startsWith('https://') || url.startsWith('http://');
|
||||
}
|
||||
|
||||
function configureSession(session: Session): void {
|
||||
session.setUserAgent(buildYoutubeBrowserUserAgent(session.getUserAgent()));
|
||||
session.setPermissionRequestHandler((_webContents, permission, callback) => {
|
||||
callback(ALLOWED_PERMISSIONS.has(permission));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A logged-in YouTube window whose video links open in mpv instead of the page player.
|
||||
* The preload intercepts link clicks; navigation hooks here catch anything that slips past it.
|
||||
*/
|
||||
export function createYoutubeBrowserWindowRuntime(deps: YoutubeBrowserWindowDeps) {
|
||||
let browserWindow: BrowserWindow | null = null;
|
||||
let ipcRegistered = false;
|
||||
|
||||
// `trigger` names the path that caught the video, to tell duplicate requests apart in logs.
|
||||
const sendVideo = (request: YoutubeBrowserVideoRequest, trigger: string): void => {
|
||||
deps.logDebug(`YouTube browser ${request.action} via ${trigger}: ${request.url}`);
|
||||
void deps
|
||||
.openVideo(request)
|
||||
.catch((error: unknown): YoutubeBrowserVideoResult => {
|
||||
deps.logWarn('YouTube browser failed to open video in mpv', error);
|
||||
return { ok: false, message: 'Could not open the video in mpv.' };
|
||||
})
|
||||
.then((result) => {
|
||||
if (browserWindow && !browserWindow.isDestroyed()) {
|
||||
browserWindow.webContents.send(IPC_CHANNELS.event.youtubeBrowserToast, result);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const registerIpc = (): void => {
|
||||
if (ipcRegistered) return;
|
||||
ipcRegistered = true;
|
||||
deps.ipcMain.on(IPC_CHANNELS.command.youtubeBrowserOpenVideo, (event, payload: unknown) => {
|
||||
if (!browserWindow || event.sender !== browserWindow.webContents) return;
|
||||
const request = parseYoutubeBrowserVideoRequest(payload);
|
||||
if (request) sendVideo(request, 'click');
|
||||
});
|
||||
};
|
||||
|
||||
const buildContextMenu = (window: BrowserWindow, linkUrl: string) => {
|
||||
const history = window.webContents.navigationHistory;
|
||||
const videoItems: MenuItemConstructorOptions[] = toYoutubeWatchUrl(linkUrl)
|
||||
? [
|
||||
{
|
||||
label: 'Play in mpv',
|
||||
click: () => sendVideo({ action: 'play', url: linkUrl }, 'context-menu'),
|
||||
},
|
||||
{
|
||||
label: 'Queue in mpv',
|
||||
click: () => sendVideo({ action: 'queue', url: linkUrl }, 'context-menu'),
|
||||
},
|
||||
{ type: 'separator' },
|
||||
]
|
||||
: [];
|
||||
return [
|
||||
...videoItems,
|
||||
{ label: 'Back', enabled: history.canGoBack(), click: () => history.goBack() },
|
||||
{ label: 'Forward', enabled: history.canGoForward(), click: () => history.goForward() },
|
||||
{ label: 'Reload', click: () => window.webContents.reload() },
|
||||
] satisfies MenuItemConstructorOptions[];
|
||||
};
|
||||
|
||||
const createWindow = (): BrowserWindow => {
|
||||
const window = deps.createBrowserWindow({
|
||||
width: 1280,
|
||||
height: 860,
|
||||
title: 'SubMiner YouTube',
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#0f0f0f',
|
||||
webPreferences: {
|
||||
partition: YOUTUBE_BROWSER_PARTITION,
|
||||
preload: deps.preloadPath,
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
sandbox: true,
|
||||
},
|
||||
});
|
||||
const { webContents } = window;
|
||||
const { session } = webContents;
|
||||
configureSession(session);
|
||||
|
||||
webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (toYoutubeWatchUrl(url)) {
|
||||
sendVideo({ action: 'queue', url }, 'window-open');
|
||||
} else if (isHttpUrl(url)) {
|
||||
deps.openExternal(url);
|
||||
}
|
||||
return { action: 'deny' };
|
||||
});
|
||||
|
||||
// Full page loads of a video (e.g. from a redirect) never reach the preload's click hook.
|
||||
webContents.on('will-navigate', (event, url) => {
|
||||
if (!toYoutubeWatchUrl(url)) return;
|
||||
event.preventDefault();
|
||||
sendVideo({ action: 'play', url }, 'will-navigate');
|
||||
});
|
||||
|
||||
// In-app navigations the preload missed (keyboard shortcuts, script-driven links): stop the
|
||||
// page player, hand the video to mpv, and return to the page the user came from.
|
||||
webContents.on('did-navigate-in-page', (_event, url, isMainFrame) => {
|
||||
if (!isMainFrame || !toYoutubeWatchUrl(url)) return;
|
||||
void webContents
|
||||
.executeJavaScript("document.querySelectorAll('video').forEach((v) => v.pause())")
|
||||
.catch(() => {});
|
||||
sendVideo({ action: 'play', url }, 'in-page-navigation');
|
||||
if (webContents.navigationHistory.canGoBack()) {
|
||||
webContents.navigationHistory.goBack();
|
||||
} else {
|
||||
void webContents.loadURL(YOUTUBE_BROWSER_HOME_URL).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
webContents.on('context-menu', (_event, params) => {
|
||||
deps.showContextMenu(window, buildContextMenu(window, params.linkURL));
|
||||
});
|
||||
|
||||
webContents.on('before-input-event', (event, input) => {
|
||||
if (input.type !== 'keyDown') return;
|
||||
const history = webContents.navigationHistory;
|
||||
if (input.alt && input.key === 'ArrowLeft' && history.canGoBack()) {
|
||||
event.preventDefault();
|
||||
history.goBack();
|
||||
} else if (input.alt && input.key === 'ArrowRight' && history.canGoForward()) {
|
||||
event.preventDefault();
|
||||
history.goForward();
|
||||
} else if (input.key === 'F5' || ((input.control || input.meta) && input.key === 'r')) {
|
||||
event.preventDefault();
|
||||
webContents.reload();
|
||||
}
|
||||
});
|
||||
|
||||
window.on('closed', () => {
|
||||
browserWindow = null;
|
||||
void session.cookies.flushStore().catch((error: unknown) => {
|
||||
deps.logWarn('Failed to flush YouTube browser cookies', error);
|
||||
});
|
||||
deps.onClosed?.();
|
||||
});
|
||||
|
||||
void window.loadURL(YOUTUBE_BROWSER_HOME_URL).catch((error: unknown) => {
|
||||
// YouTube redirects its landing page (e.g. `?themeRefresh=1`), which aborts the first load.
|
||||
if ((error as { code?: unknown }).code === 'ERR_ABORTED') return;
|
||||
deps.logWarn('Failed to load YouTube in the browser window', error);
|
||||
});
|
||||
return window;
|
||||
};
|
||||
|
||||
const open = (): void => {
|
||||
registerIpc();
|
||||
if (browserWindow && !browserWindow.isDestroyed()) {
|
||||
if (browserWindow.isMinimized()) browserWindow.restore();
|
||||
browserWindow.show();
|
||||
browserWindow.focus();
|
||||
return;
|
||||
}
|
||||
browserWindow = createWindow();
|
||||
};
|
||||
|
||||
const isOpen = (): boolean => Boolean(browserWindow && !browserWindow.isDestroyed());
|
||||
|
||||
return { open, isOpen };
|
||||
}
|
||||
Reference in new issue
Block a user