mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-18 18:41:52 +08:00
feat(electron): recording popups (#11016)
Added a recording popup UI for the audio recording feature in the desktop app, improving the user experience when capturing audio from applications. ### What changed? - Created a new popup window system for displaying recording controls - Added a dedicated recording UI with start/stop controls and status indicators - Moved audio encoding logic from the main app to a dedicated module - Implemented smooth animations for popup appearance/disappearance - Updated the recording workflow to show visual feedback during recording process - Added internationalization support for recording-related text - Modified the recording status flow to include new states: new, recording, stopped, ready fix AF-2340
This commit is contained in:
@@ -1,18 +1,12 @@
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { Display } from 'electron';
|
||||
import { BrowserWindow, screen } from 'electron';
|
||||
|
||||
import { isMacOS } from '../../shared/utils';
|
||||
import { isDev } from '../config';
|
||||
import { onboardingViewUrl } from '../constants';
|
||||
// import { getExposedMeta } from './exposed';
|
||||
import { logger } from '../logger';
|
||||
|
||||
const getScreenSize = (display: Display) => {
|
||||
const { width, height } = isMacOS() ? display.bounds : display.workArea;
|
||||
return { width, height };
|
||||
};
|
||||
import { fullscreenAndCenter, getScreenSize } from './utils';
|
||||
|
||||
// todo: not all window need all of the exposed meta
|
||||
const getWindowAdditionalArguments = async () => {
|
||||
@@ -24,19 +18,6 @@ const getWindowAdditionalArguments = async () => {
|
||||
];
|
||||
};
|
||||
|
||||
function fullscreenAndCenter(browserWindow: BrowserWindow) {
|
||||
const position = browserWindow.getPosition();
|
||||
const size = browserWindow.getSize();
|
||||
const currentDisplay = screen.getDisplayNearestPoint({
|
||||
x: position[0] + size[0] / 2,
|
||||
y: position[1] + size[1] / 2,
|
||||
});
|
||||
if (!currentDisplay) return;
|
||||
const { width, height } = getScreenSize(currentDisplay);
|
||||
browserWindow.setSize(width, height);
|
||||
browserWindow.center();
|
||||
}
|
||||
|
||||
async function createOnboardingWindow(additionalArguments: string[]) {
|
||||
logger.info('creating onboarding window');
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { join } from 'node:path';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
|
||||
import { BrowserWindow, type BrowserWindowConstructorOptions } from 'electron';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { popupViewUrl } from '../constants';
|
||||
import { logger } from '../logger';
|
||||
import type { MainEventRegister, NamespaceHandlers } from '../type';
|
||||
import { getCurrentDisplay } from './utils';
|
||||
|
||||
type PopupWindowType = 'notification' | 'recording';
|
||||
|
||||
async function getAdditionalArguments(name: string) {
|
||||
const { getExposedMeta } = await import('../exposed');
|
||||
const mainExposedMeta = getExposedMeta();
|
||||
return [
|
||||
`--main-exposed-meta=` + JSON.stringify(mainExposedMeta),
|
||||
`--window-name=${name}`,
|
||||
];
|
||||
}
|
||||
|
||||
const POPUP_PADDING = 20; // padding between the popup and the edge of the screen
|
||||
const NOTIFICATION_SIZE = [300, 128];
|
||||
const RECORDING_SIZE = [300, 36];
|
||||
|
||||
async function animate(
|
||||
current: number,
|
||||
target: number,
|
||||
setter: (val: number) => void,
|
||||
duration = 200,
|
||||
delay = 0
|
||||
): Promise<void> {
|
||||
const fps = 60;
|
||||
const steps = duration / (1000 / fps);
|
||||
const delta = target - current;
|
||||
const easing = (t: number) => -(Math.cos(Math.PI * t) - 1) / 2;
|
||||
|
||||
if (delay > 0) {
|
||||
await setTimeout(delay);
|
||||
}
|
||||
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const progress = easing(i / steps);
|
||||
setter(current + delta * progress);
|
||||
await setTimeout(1000 / fps);
|
||||
}
|
||||
|
||||
// Ensure we hit the target exactly
|
||||
setter(target);
|
||||
}
|
||||
|
||||
abstract class PopupWindow {
|
||||
abstract readonly type: PopupWindowType;
|
||||
abstract readonly name: string;
|
||||
browserWindow: BrowserWindow | undefined;
|
||||
|
||||
abstract windowOptions: Partial<BrowserWindowConstructorOptions>;
|
||||
|
||||
resolveReady: () => void = () => {};
|
||||
ready = new Promise<void>(resolve => {
|
||||
this.resolveReady = resolve;
|
||||
});
|
||||
|
||||
private readonly showing$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
get showing() {
|
||||
return this.showing$.value;
|
||||
}
|
||||
|
||||
async build(): Promise<BrowserWindow> {
|
||||
const browserWindow = new BrowserWindow({
|
||||
...this.windowOptions,
|
||||
resizable: false,
|
||||
minimizable: false,
|
||||
maximizable: false,
|
||||
closable: false,
|
||||
alwaysOnTop: true,
|
||||
focusable: false,
|
||||
hiddenInMissionControl: true,
|
||||
movable: false,
|
||||
titleBarStyle: 'hidden',
|
||||
show: false, // hide by default,
|
||||
backgroundColor: 'transparent',
|
||||
visualEffectState: 'active',
|
||||
vibrancy: 'under-window',
|
||||
webPreferences: {
|
||||
...this.windowOptions.webPreferences,
|
||||
webgl: true,
|
||||
contextIsolation: true,
|
||||
sandbox: false,
|
||||
transparent: true,
|
||||
spellcheck: false,
|
||||
preload: join(__dirname, './preload.js'), // this points to the bundled preload module
|
||||
// serialize exposed meta that to be used in preload
|
||||
additionalArguments: await getAdditionalArguments(this.name),
|
||||
},
|
||||
});
|
||||
|
||||
// required to make the window transparent
|
||||
browserWindow.setBackgroundColor('#00000000');
|
||||
|
||||
browserWindow.loadURL(popupViewUrl).catch(err => logger.error(err));
|
||||
browserWindow.on('ready-to-show', () => {
|
||||
browserWindow.webContents.on('did-finish-load', () => {
|
||||
this.resolveReady();
|
||||
});
|
||||
});
|
||||
return browserWindow;
|
||||
}
|
||||
|
||||
async show() {
|
||||
if (!this.browserWindow) {
|
||||
this.browserWindow = await this.build();
|
||||
}
|
||||
const browserWindow = this.browserWindow;
|
||||
const workArea = getCurrentDisplay(browserWindow).workArea;
|
||||
const popupSize = browserWindow.getSize();
|
||||
|
||||
await this.ready;
|
||||
|
||||
this.showing$.next(true);
|
||||
|
||||
browserWindow.showInactive(); // focus the notification is too distracting right?
|
||||
browserWindow.setOpacity(0);
|
||||
|
||||
// Calculate start and end positions for x coordinate
|
||||
const startX = workArea.x + workArea.width + popupSize[0] + POPUP_PADDING;
|
||||
const endX = workArea.x + workArea.width - popupSize[0] - POPUP_PADDING;
|
||||
const y = workArea.y + POPUP_PADDING;
|
||||
|
||||
// Set initial position
|
||||
browserWindow.setPosition(startX, y);
|
||||
|
||||
// First fade in, then slide
|
||||
await Promise.all([
|
||||
// Slide in animation
|
||||
animate(
|
||||
startX,
|
||||
endX,
|
||||
x => {
|
||||
browserWindow.setPosition(Math.round(x), y);
|
||||
},
|
||||
300
|
||||
),
|
||||
// Fade in animation
|
||||
animate(
|
||||
0,
|
||||
1,
|
||||
opacity => {
|
||||
this.browserWindow?.setOpacity(opacity);
|
||||
},
|
||||
100,
|
||||
100
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
async hide() {
|
||||
if (!this.browserWindow) {
|
||||
return;
|
||||
}
|
||||
this.showing$.next(false);
|
||||
await animate(this.browserWindow.getOpacity(), 0, opacity => {
|
||||
this.browserWindow?.setOpacity(opacity);
|
||||
});
|
||||
this.browserWindow?.hide();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.browserWindow?.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// leave for future use
|
||||
type ElectronNotification = null;
|
||||
|
||||
class NotificationPopupWindow extends PopupWindow {
|
||||
readonly type = 'notification' as const;
|
||||
readonly name = `${this.type}`;
|
||||
|
||||
notification$ = new BehaviorSubject<ElectronNotification | null>(null);
|
||||
|
||||
windowOptions: Partial<BrowserWindowConstructorOptions> = {
|
||||
width: NOTIFICATION_SIZE[0],
|
||||
height: NOTIFICATION_SIZE[1],
|
||||
};
|
||||
|
||||
async notify(notification: ElectronNotification) {
|
||||
this.notification$.next(notification);
|
||||
await super.show();
|
||||
}
|
||||
}
|
||||
|
||||
// recording popup window is singleton across the app
|
||||
class RecordingPopupWindow extends PopupWindow {
|
||||
readonly type = 'recording' as const;
|
||||
readonly name = `${this.type}`;
|
||||
windowOptions: Partial<BrowserWindowConstructorOptions> = {
|
||||
width: RECORDING_SIZE[0],
|
||||
height: RECORDING_SIZE[1],
|
||||
};
|
||||
}
|
||||
|
||||
// Type mapping from PopupWindowType to specific window class
|
||||
type PopupWindowTypeMap = {
|
||||
notification: NotificationPopupWindow;
|
||||
recording: RecordingPopupWindow;
|
||||
};
|
||||
|
||||
export class PopupManager {
|
||||
static readonly instance = new PopupManager();
|
||||
// there could be a single instance of each type of popup window
|
||||
readonly popupWindows$ = new BehaviorSubject<Map<string, PopupWindow>>(
|
||||
new Map()
|
||||
);
|
||||
|
||||
get<T extends PopupWindowType>(type: T): PopupWindowTypeMap[T] {
|
||||
// Check if popup of this type already exists
|
||||
const existingPopup = Array.from(this.popupWindows$.value.values()).find(
|
||||
popup => popup.type === type
|
||||
) as PopupWindowTypeMap[T] | undefined;
|
||||
|
||||
// If exists, return it
|
||||
if (existingPopup) {
|
||||
return existingPopup;
|
||||
}
|
||||
|
||||
// Otherwise create a new one
|
||||
const popupWindow = (() => {
|
||||
switch (type) {
|
||||
case 'notification':
|
||||
return new NotificationPopupWindow() as PopupWindowTypeMap[T];
|
||||
case 'recording':
|
||||
return new RecordingPopupWindow() as PopupWindowTypeMap[T];
|
||||
}
|
||||
})();
|
||||
|
||||
this.popupWindows$.next(
|
||||
new Map(this.popupWindows$.value).set(popupWindow.type, popupWindow)
|
||||
);
|
||||
return popupWindow;
|
||||
}
|
||||
}
|
||||
|
||||
export const popupManager = PopupManager.instance;
|
||||
|
||||
// recording popup window events/handlers are in ../recording/index.ts
|
||||
export const popupHandlers = {
|
||||
getCurrentNotification: async () => {
|
||||
const notification = popupManager.get('notification').notification$.value;
|
||||
if (!notification) {
|
||||
return null;
|
||||
}
|
||||
return notification;
|
||||
},
|
||||
dismissCurrentNotification: async () => {
|
||||
return popupManager.get('notification').hide();
|
||||
},
|
||||
dismissCurrentRecording: async () => {
|
||||
return popupManager.get('recording').hide();
|
||||
},
|
||||
} satisfies NamespaceHandlers;
|
||||
|
||||
export const popupEvents = {
|
||||
onNotificationChanged: (
|
||||
callback: (notification: ElectronNotification | null) => void
|
||||
) => {
|
||||
const notification = popupManager.get('notification');
|
||||
const sub = notification.notification$.subscribe(notification => {
|
||||
callback(notification);
|
||||
});
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
},
|
||||
} satisfies Record<string, MainEventRegister>;
|
||||
@@ -745,7 +745,8 @@ export class WebContentViewsManager {
|
||||
const focusActiveView = () => {
|
||||
if (
|
||||
!this.activeWorkbenchView ||
|
||||
this.activeWorkbenchView.webContents.isFocused()
|
||||
this.activeWorkbenchView.webContents.isFocused() ||
|
||||
this.activeWorkbenchView.webContents.isDevToolsFocused()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { BrowserWindow, type Display, type Rectangle, screen } from 'electron';
|
||||
|
||||
import { isMacOS } from '../../shared/utils';
|
||||
|
||||
export const getCurrentDisplay = (browserWindow: BrowserWindow) => {
|
||||
const position = browserWindow.getPosition();
|
||||
const size = browserWindow.getSize();
|
||||
const currentDisplay = screen.getDisplayNearestPoint({
|
||||
x: position[0] + size[0] / 2,
|
||||
y: position[1] + size[1] / 2,
|
||||
});
|
||||
return currentDisplay;
|
||||
};
|
||||
|
||||
export const getScreenSize = (display: Display | BrowserWindow): Rectangle => {
|
||||
if (display instanceof BrowserWindow) {
|
||||
return getScreenSize(getCurrentDisplay(display));
|
||||
}
|
||||
return isMacOS() ? display.bounds : display.workArea;
|
||||
};
|
||||
|
||||
export const fullscreenAndCenter = (browserWindow: BrowserWindow) => {
|
||||
const currentDisplay = getCurrentDisplay(browserWindow);
|
||||
const { width, height } = getScreenSize(currentDisplay);
|
||||
browserWindow.setSize(width, height);
|
||||
browserWindow.center();
|
||||
};
|
||||
Reference in New Issue
Block a user