From 61c0d01da3e0d2dc9137d739281965851db6f807 Mon Sep 17 00:00:00 2001 From: pengx17 Date: Wed, 26 Mar 2025 04:53:43 +0000 Subject: [PATCH] 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 --- .../src/app/effects/recording.ts | 153 +- .../electron-renderer/src/popup/app.css.ts | 16 + .../apps/electron-renderer/src/popup/app.tsx | 37 + .../electron-renderer/src/popup/index.tsx | 24 + .../src/popup/recording/encode.ts | 100 + .../src/popup/recording/index.tsx | 183 ++ .../src/popup/recording/styles.css.ts | 38 + .../apps/electron-renderer/src/popup/setup.ts | 2 + .../apps/electron-renderer/src/popup/utils.ts | 18 + .../apps/electron-renderer/webpack.config.ts | 1 + .../src/main/application-menu/create.ts | 65 +- .../apps/electron/src/main/constants.ts | 1 + .../frontend/apps/electron/src/main/events.ts | 2 + .../apps/electron/src/main/handlers.ts | 2 + .../apps/electron/src/main/protocol.ts | 6 +- .../apps/electron/src/main/recording/index.ts | 370 ++-- .../src/main/recording/state-machine.ts | 269 +++ .../src/main/recording/state-transitions.md | 88 + .../apps/electron/src/main/recording/types.ts | 13 +- .../apps/electron/src/main/tray/index.ts | 49 +- .../src/main/windows-manager/onboarding.ts | 21 +- .../src/main/windows-manager/popup.ts | 277 +++ .../src/main/windows-manager/tab-views.ts | 3 +- .../src/main/windows-manager/utils.ts | 27 + .../apps/electron/src/shared/utils.ts | 4 +- .../attachment-viewer/audio/audio-block.tsx | 27 +- .../audio-player/audio-player.css.ts | 3 +- .../components/audio-player/audio-player.tsx | 35 +- .../lottie/animated-play-icon.css.ts | 3 - .../lottie/animated-play-icon.tsx | 2 +- .../lottie/animated-transcribe-icon.tsx | 81 + .../audio-player/lottie/styles.css.ts | 59 + .../audio-player/lottie/transcribe.json | 1903 +++++++++++++++++ .../frontend/core/src/modules/theme/index.ts | 4 + packages/frontend/i18n/src/i18n.gen.ts | 72 +- packages/frontend/i18n/src/resources/en.json | 21 +- tools/cli/src/webpack/html-plugin.ts | 13 + tools/cli/src/webpack/index.ts | 2 + 38 files changed, 3611 insertions(+), 383 deletions(-) create mode 100644 packages/frontend/apps/electron-renderer/src/popup/app.css.ts create mode 100644 packages/frontend/apps/electron-renderer/src/popup/app.tsx create mode 100644 packages/frontend/apps/electron-renderer/src/popup/index.tsx create mode 100644 packages/frontend/apps/electron-renderer/src/popup/recording/encode.ts create mode 100644 packages/frontend/apps/electron-renderer/src/popup/recording/index.tsx create mode 100644 packages/frontend/apps/electron-renderer/src/popup/recording/styles.css.ts create mode 100644 packages/frontend/apps/electron-renderer/src/popup/setup.ts create mode 100644 packages/frontend/apps/electron-renderer/src/popup/utils.ts create mode 100644 packages/frontend/apps/electron/src/main/recording/state-machine.ts create mode 100644 packages/frontend/apps/electron/src/main/recording/state-transitions.md create mode 100644 packages/frontend/apps/electron/src/main/windows-manager/popup.ts create mode 100644 packages/frontend/apps/electron/src/main/windows-manager/utils.ts delete mode 100644 packages/frontend/core/src/components/audio-player/lottie/animated-play-icon.css.ts create mode 100644 packages/frontend/core/src/components/audio-player/lottie/animated-transcribe-icon.tsx create mode 100644 packages/frontend/core/src/components/audio-player/lottie/styles.css.ts create mode 100644 packages/frontend/core/src/components/audio-player/lottie/transcribe.json diff --git a/packages/frontend/apps/electron-renderer/src/app/effects/recording.ts b/packages/frontend/apps/electron-renderer/src/app/effects/recording.ts index b15222d51a..827485f64c 100644 --- a/packages/frontend/apps/electron-renderer/src/app/effects/recording.ts +++ b/packages/frontend/apps/electron-renderer/src/app/effects/recording.ts @@ -5,134 +5,21 @@ import { AudioAttachmentService } from '@affine/core/modules/media/services/audi import { WorkbenchService } from '@affine/core/modules/workbench'; import { DebugLogger } from '@affine/debug'; import { apis, events } from '@affine/electron-api'; +import { i18nTime } from '@affine/i18n'; import type { AttachmentBlockModel } from '@blocksuite/affine/model'; import { Text } from '@blocksuite/affine/store'; import type { BlobEngine } from '@blocksuite/affine/sync'; import type { FrameworkProvider } from '@toeverything/infra'; -import { ArrayBufferTarget, Muxer } from 'webm-muxer'; import { getCurrentWorkspace } from './utils'; const logger = new DebugLogger('electron-renderer:recording'); -/** - * Encodes raw audio data to Opus in WebM container. - */ -async function encodeRawBufferToOpus({ - filepath, - sampleRate, - numberOfChannels, -}: { - filepath: string; - sampleRate: number; - numberOfChannels: number; -}): Promise { - // Use streams to process audio data incrementally - const response = await fetch(new URL(filepath, location.origin)); - if (!response.body) { - throw new Error('Response body is null'); - } - - // Setup Opus encoder - const encodedChunks: EncodedAudioChunk[] = []; - const encoder = new AudioEncoder({ - output: chunk => { - encodedChunks.push(chunk); - }, - error: err => { - throw new Error(`Encoding error: ${err}`); - }, - }); - - // Configure Opus encoder - encoder.configure({ - codec: 'opus', - sampleRate: sampleRate, - numberOfChannels: numberOfChannels, - bitrate: 96000, // 96 kbps is good for stereo audio - }); - - // Process the stream - const reader = response.body.getReader(); - let offset = 0; - const CHUNK_SIZE = numberOfChannels * 1024; // Process 1024 samples per channel at a time - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - // Convert the chunk to Float32Array - const float32Data = new Float32Array(value.buffer); - - // Process in smaller chunks to avoid large frames - for (let i = 0; i < float32Data.length; i += CHUNK_SIZE) { - const chunkSize = Math.min(CHUNK_SIZE, float32Data.length - i); - const chunk = float32Data.subarray(i, i + chunkSize); - - // Create and encode frame - const frame = new AudioData({ - format: 'f32', - sampleRate: sampleRate, - numberOfFrames: chunk.length / numberOfChannels, - numberOfChannels: numberOfChannels, - timestamp: (offset * 1000000) / sampleRate, // timestamp in microseconds - data: chunk, - }); - - encoder.encode(frame); - frame.close(); - - offset += chunk.length / numberOfChannels; - } - } - } finally { - await encoder.flush(); - encoder.close(); - } - - if (encodedChunks.length === 0) { - throw new Error('No chunks were produced during encoding'); - } - - // Initialize WebM muxer - const target = new ArrayBufferTarget(); - const muxer = new Muxer({ - target, - audio: { - codec: 'A_OPUS', - sampleRate: sampleRate, - numberOfChannels: numberOfChannels, - }, - }); - - // Add all chunks to the muxer - for (const chunk of encodedChunks) { - muxer.addAudioChunk(chunk, {}); - } - - // Finalize and get WebM container - muxer.finalize(); - const { buffer: webmBuffer } = target; - - return new Uint8Array(webmBuffer); -} - -async function saveRecordingBlob( - blobEngine: BlobEngine, - recording: { - id: number; - filepath: string; - sampleRate: number; - numberOfChannels: number; - } -) { - logger.debug('Saving recording', recording.id); - const opusBuffer = await encodeRawBufferToOpus({ - filepath: recording.filepath, - sampleRate: recording.sampleRate, - numberOfChannels: recording.numberOfChannels, - }); +async function saveRecordingBlob(blobEngine: BlobEngine, filepath: string) { + logger.debug('Saving recording', filepath); + const opusBuffer = await fetch(new URL(filepath, location.origin)).then(res => + res.arrayBuffer() + ); const blob = new Blob([opusBuffer], { type: 'audio/webm', }); @@ -144,7 +31,7 @@ async function saveRecordingBlob( export function setupRecordingEvents(frameworkProvider: FrameworkProvider) { events?.recording.onRecordingStatusChanged(status => { (async () => { - if ((await apis?.ui.isActiveTab()) && status?.status === 'stopped') { + if ((await apis?.ui.isActiveTab()) && status?.status === 'ready') { using currentWorkspace = getCurrentWorkspace(frameworkProvider); if (!currentWorkspace) { return; @@ -155,30 +42,28 @@ export function setupRecordingEvents(frameworkProvider: FrameworkProvider) { const docsService = workspace.scope.get(DocsService); const editorSetting = editorSettingService.editorSetting; + const timestamp = i18nTime(status.startTime, { + absolute: { + accuracy: 'minute', + noYear: true, + }, + }); + const docProps: DocProps = { note: editorSetting.get('affine:note'), page: { title: new Text( 'Recording ' + - (status.appGroup?.name ?? 'System Audio') + + (status.appName ?? 'System Audio') + ' ' + - new Date(status.startTime).toISOString() + timestamp ), }, onStoreLoad: (doc, { noteId }) => { (async () => { - const recording = await apis?.recording.getRecording(status.id); - if (!recording) { - logger.error('Failed to save recording'); - return; - } - // name + timestamp(readable) + extension const attachmentName = - (status.appGroup?.name ?? 'System Audio') + - ' ' + - new Date(status.startTime).toISOString() + - '.webm'; + (status.appName ?? 'System Audio') + ' ' + timestamp + '.webm'; // add size and sourceId to the attachment later const attachmentId = doc.addBlock( @@ -193,11 +78,11 @@ export function setupRecordingEvents(frameworkProvider: FrameworkProvider) { const model = doc.getBlock(attachmentId) ?.model as AttachmentBlockModel; - if (model) { + if (model && status.filepath) { // it takes a while to save the blob, so we show the attachment first const { blobId, blob } = await saveRecordingBlob( doc.workspace.blobSync, - recording + status.filepath ); model.props.size = blob.size; diff --git a/packages/frontend/apps/electron-renderer/src/popup/app.css.ts b/packages/frontend/apps/electron-renderer/src/popup/app.css.ts new file mode 100644 index 0000000000..6e281654cc --- /dev/null +++ b/packages/frontend/apps/electron-renderer/src/popup/app.css.ts @@ -0,0 +1,16 @@ +import { globalStyle, style } from '@vanilla-extract/css'; + +globalStyle('html', { + backgroundColor: 'transparent', + userSelect: 'none', +}); + +globalStyle('body', { + backgroundColor: 'transparent', +}); + +export const root = style({ + backgroundColor: 'transparent', + width: '100%', + height: '100%', +}); diff --git a/packages/frontend/apps/electron-renderer/src/popup/app.tsx b/packages/frontend/apps/electron-renderer/src/popup/app.tsx new file mode 100644 index 0000000000..677e422d23 --- /dev/null +++ b/packages/frontend/apps/electron-renderer/src/popup/app.tsx @@ -0,0 +1,37 @@ +import { ThemeProvider } from '@affine/core/components/theme-provider'; +import { configureDesktopApiModule } from '@affine/core/modules/desktop-api'; +import { configureI18nModule, I18nProvider } from '@affine/core/modules/i18n'; +import { + configureElectronStateStorageImpls, + configureStorageModule, +} from '@affine/core/modules/storage'; +import { configureEssentialThemeModule } from '@affine/core/modules/theme'; +import { appInfo } from '@affine/electron-api'; +import { Framework, FrameworkRoot } from '@toeverything/infra'; + +import * as styles from './app.css'; +import { Recording } from './recording'; + +const framework = new Framework(); +configureI18nModule(framework); +configureEssentialThemeModule(framework); +configureStorageModule(framework); +configureElectronStateStorageImpls(framework); +configureDesktopApiModule(framework); +const frameworkProvider = framework.provider(); + +const mode = appInfo?.windowName as 'notification' | 'recording'; + +export function App() { + return ( + + + +
+ {mode === 'recording' && } +
+
+
+
+ ); +} diff --git a/packages/frontend/apps/electron-renderer/src/popup/index.tsx b/packages/frontend/apps/electron-renderer/src/popup/index.tsx new file mode 100644 index 0000000000..d1dd414090 --- /dev/null +++ b/packages/frontend/apps/electron-renderer/src/popup/index.tsx @@ -0,0 +1,24 @@ +import './setup'; + +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { App } from './app'; + +function main() { + mountApp(); +} + +function mountApp() { + const root = document.getElementById('app'); + if (!root) { + throw new Error('Root element not found'); + } + createRoot(root).render( + + + + ); +} + +main(); diff --git a/packages/frontend/apps/electron-renderer/src/popup/recording/encode.ts b/packages/frontend/apps/electron-renderer/src/popup/recording/encode.ts new file mode 100644 index 0000000000..a0a10a82eb --- /dev/null +++ b/packages/frontend/apps/electron-renderer/src/popup/recording/encode.ts @@ -0,0 +1,100 @@ +import { ArrayBufferTarget, Muxer } from 'webm-muxer'; + +/** + * Encodes raw audio data to Opus in WebM container. + */ +export async function encodeRawBufferToOpus({ + filepath, + sampleRate, + numberOfChannels, +}: { + filepath: string; + sampleRate: number; + numberOfChannels: number; +}): Promise { + // Use streams to process audio data incrementally + const response = await fetch(new URL(filepath, location.origin)); + if (!response.body) { + throw new Error('Response body is null'); + } + + // Setup Opus encoder + const encodedChunks: EncodedAudioChunk[] = []; + const encoder = new AudioEncoder({ + output: chunk => { + encodedChunks.push(chunk); + }, + error: err => { + throw new Error(`Encoding error: ${err}`); + }, + }); + + // Configure Opus encoder + encoder.configure({ + codec: 'opus', + sampleRate: sampleRate, + numberOfChannels: numberOfChannels, + bitrate: 128000, + }); + + // Process the stream + const reader = response.body.getReader(); + let offset = 0; + const CHUNK_SIZE = numberOfChannels * 1024; // Process 1024 samples per channel at a time + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + // Convert the chunk to Float32Array + const float32Data = new Float32Array(value.buffer); + + // Process in smaller chunks to avoid large frames + for (let i = 0; i < float32Data.length; i += CHUNK_SIZE) { + const chunkSize = Math.min(CHUNK_SIZE, float32Data.length - i); + const chunk = float32Data.subarray(i, i + chunkSize); + + // Create and encode frame + const frame = new AudioData({ + format: 'f32', + sampleRate: sampleRate, + numberOfFrames: chunk.length / numberOfChannels, + numberOfChannels: numberOfChannels, + timestamp: (offset * 1000000) / sampleRate, // timestamp in microseconds + data: chunk, + }); + + encoder.encode(frame); + frame.close(); + + offset += chunk.length / numberOfChannels; + } + } + } finally { + await encoder.flush(); + encoder.close(); + } + + // Initialize WebM muxer + const target = new ArrayBufferTarget(); + const muxer = new Muxer({ + target, + audio: { + codec: 'A_OPUS', + sampleRate: sampleRate, + numberOfChannels: numberOfChannels, + }, + }); + + // Add all chunks to the muxer + for (const chunk of encodedChunks) { + muxer.addAudioChunk(chunk, {}); + } + + // Finalize and get WebM container + muxer.finalize(); + const { buffer: webmBuffer } = target; + + return new Uint8Array(webmBuffer); +} diff --git a/packages/frontend/apps/electron-renderer/src/popup/recording/index.tsx b/packages/frontend/apps/electron-renderer/src/popup/recording/index.tsx new file mode 100644 index 0000000000..1eba093bb5 --- /dev/null +++ b/packages/frontend/apps/electron-renderer/src/popup/recording/index.tsx @@ -0,0 +1,183 @@ +import { Button } from '@affine/component'; +import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; +import { appIconMap } from '@affine/core/utils'; +import { apis, events } from '@affine/electron-api'; +import { useI18n } from '@affine/i18n'; +import { useEffect, useMemo, useState } from 'react'; + +import { encodeRawBufferToOpus } from './encode'; +import * as styles from './styles.css'; + +type Status = { + id: number; + status: 'new' | 'recording' | 'paused' | 'stopped' | 'ready'; + appName?: string; + appGroupId?: number; + icon?: Buffer; +}; + +export const useRecordingStatus = () => { + const [status, setStatus] = useState(null); + + useEffect(() => { + // Get initial status + apis?.recording + .getCurrentRecording() + .then(status => setStatus(status as Status)) + .catch(console.error); + + // Subscribe to status changes + const unsubscribe = events?.recording.onRecordingStatusChanged(status => + setStatus(status as Status) + ); + + return () => { + unsubscribe?.(); + }; + }, []); + + return status; +}; + +const appIcon = appIconMap[BUILD_CONFIG.appBuildType]; + +export function Recording() { + const status = useRecordingStatus(); + + const t = useI18n(); + const textElement = useMemo(() => { + if (!status) { + return null; + } + if (status.status === 'new') { + return t['com.affine.recording.new'](); + } else if (status.status === 'ready') { + return t['com.affine.recording.ready'](); + } else if (status.appName) { + return t['com.affine.recording.recording']({ + appName: status.appName, + }); + } else { + return t['com.affine.recording.recording.unnamed'](); + } + }, [status, t]); + + const handleDismiss = useAsyncCallback(async () => { + await apis?.popup?.dismissCurrentRecording(); + }, []); + + const handleStopRecording = useAsyncCallback(async () => { + if (!status) { + return; + } + await apis?.recording?.stopRecording(status.id); + }, [status]); + + const handleProcessStoppedRecording = useAsyncCallback(async () => { + let id: number | undefined; + try { + const result = await apis?.recording?.getCurrentRecording(); + if (!result) { + return; + } + + id = result.id; + + const { filepath, sampleRate, numberOfChannels } = result; + if (!filepath || !sampleRate || !numberOfChannels) { + return; + } + const [buffer] = await Promise.all([ + encodeRawBufferToOpus({ + filepath, + sampleRate, + numberOfChannels, + }), + new Promise(resolve => { + setTimeout(() => { + resolve(); + }, 1000); // wait at least 1 second for better user experience + }), + ]); + await apis?.recording.readyRecording(result.id, buffer); + } catch (error) { + console.error('Failed to stop recording', error); + await apis?.popup?.dismissCurrentRecording(); + if (id) { + await apis?.recording.removeRecording(id); + } + } + }, []); + + useEffect(() => { + // allow processing stopped event in tray menu as well: + return events?.recording.onRecordingStatusChanged(status => { + if (status?.status === 'stopped') { + handleProcessStoppedRecording(); + } + }); + }, [handleProcessStoppedRecording]); + + const handleStartRecording = useAsyncCallback(async () => { + if (!status) { + return; + } + await apis?.recording?.startRecording(status.appGroupId); + }, [status]); + + const controlsElement = useMemo(() => { + if (!status) { + return null; + } + if (status.status === 'new') { + return ( + <> + + + + ); + } else if (status.status === 'recording') { + return ( + + ); + } else if (status.status === 'stopped') { + return ( + + ); + } + return null; + }, [handleDismiss, handleStartRecording, handleStopRecording, status, t]); + + if (!status) { + return null; + } + + return ( +
+ AFFiNE +
{textElement}
+
{controlsElement}
+
+ ); +} diff --git a/packages/frontend/apps/electron-renderer/src/popup/recording/styles.css.ts b/packages/frontend/apps/electron-renderer/src/popup/recording/styles.css.ts new file mode 100644 index 0000000000..1315337a73 --- /dev/null +++ b/packages/frontend/apps/electron-renderer/src/popup/recording/styles.css.ts @@ -0,0 +1,38 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const root = style({ + width: '100%', + height: '100%', + padding: 4, + display: 'flex', + gap: 4, + alignItems: 'center', +}); + +export const affineIcon = style({ + width: 28, + height: 28, +}); + +export const recordingIcon = style({ + width: 8, + height: 8, + borderRadius: '50%', + backgroundColor: cssVarV2('layer/pureWhite'), +}); + +export const text = style({ + fontSize: cssVar('fontSm'), + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontWeight: 600, + flex: 1, +}); + +export const controls = style({ + display: 'flex', + gap: 2, +}); diff --git a/packages/frontend/apps/electron-renderer/src/popup/setup.ts b/packages/frontend/apps/electron-renderer/src/popup/setup.ts new file mode 100644 index 0000000000..d163517211 --- /dev/null +++ b/packages/frontend/apps/electron-renderer/src/popup/setup.ts @@ -0,0 +1,2 @@ +import '@affine/core/bootstrap/electron'; +import '@affine/component/theme'; diff --git a/packages/frontend/apps/electron-renderer/src/popup/utils.ts b/packages/frontend/apps/electron-renderer/src/popup/utils.ts new file mode 100644 index 0000000000..5db59e421b --- /dev/null +++ b/packages/frontend/apps/electron-renderer/src/popup/utils.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react'; + +export const useBlobUrl = (buffer?: Buffer) => { + const [blobUrl, setBlobUrl] = useState(null); + + useEffect(() => { + if (!buffer) { + return; + } + const url = URL.createObjectURL(new Blob([buffer])); + setBlobUrl(url); + return () => { + URL.revokeObjectURL(url); + }; + }, [buffer]); + + return blobUrl; +}; diff --git a/packages/frontend/apps/electron-renderer/webpack.config.ts b/packages/frontend/apps/electron-renderer/webpack.config.ts index 6d42f48ac4..4a1b947a98 100644 --- a/packages/frontend/apps/electron-renderer/webpack.config.ts +++ b/packages/frontend/apps/electron-renderer/webpack.config.ts @@ -3,5 +3,6 @@ export const config = { app: './src/app/index.tsx', shell: './src/shell/index.tsx', backgroundWorker: './src/background-worker/index.ts', + popup: './src/popup/index.tsx', }, }; diff --git a/packages/frontend/apps/electron/src/main/application-menu/create.ts b/packages/frontend/apps/electron/src/main/application-menu/create.ts index 20b30ea9d7..4260def2bb 100644 --- a/packages/frontend/apps/electron/src/main/application-menu/create.ts +++ b/packages/frontend/apps/electron/src/main/application-menu/create.ts @@ -8,13 +8,14 @@ import { addTab, initAndShowMainWindow, reloadView, - showDevTools, showMainWindow, switchTab, switchToNextTab, switchToPreviousTab, undoCloseTab, + WebContentViewsManager, } from '../windows-manager'; +import { popupManager } from '../windows-manager/popup'; import { WorkerManager } from '../worker/pool'; import { applicationMenuSubjects } from './subject'; @@ -111,21 +112,55 @@ export function createApplicationMenu() { label: 'Open devtools', accelerator: isMac ? 'Cmd+Option+I' : 'Ctrl+Shift+I', click: () => { - showDevTools(); - }, - }, - { - label: 'Open worker devtools', - click: () => { + const workerContents = Array.from( + WorkerManager.instance.workers.values() + ).map( + worker => [worker.key, worker.browserWindow.webContents] as const + ); + + const tabs = Array.from( + WebContentViewsManager.instance.tabViewsMap + ).map(view => { + const isActive = WebContentViewsManager.instance.isActiveTab( + view[0] + ); + return [ + view[0] + (isActive ? ' (active)' : ''), + view[1].webContents, + ] as const; + }); + + const popups = Array.from(popupManager.popupWindows$.value.values()) + .filter(popup => popup.browserWindow) + .map(popup => { + // oxlint-disable-next-line no-non-null-assertion + return [popup.type, popup.browserWindow!.webContents] as const; + }); + + const allWebContents = [ + ['tabs', tabs], + ['workers', workerContents], + ['popups', popups], + ] as const; + Menu.buildFromTemplate( - Array.from(WorkerManager.instance.workers.values()).map(item => ({ - label: `${item.key}`, - click: () => { - item.browserWindow.webContents.openDevTools({ - mode: 'undocked', - }); - }, - })) + allWebContents.flatMap(([type, contents]) => { + return [ + { + label: type, + enabled: false, + }, + ...contents.map(([id, webContents]) => ({ + label: id, + click: () => { + webContents.openDevTools({ + mode: 'undocked', + }); + }, + })), + { type: 'separator' }, + ]; + }) ).popup(); }, }, diff --git a/packages/frontend/apps/electron/src/main/constants.ts b/packages/frontend/apps/electron/src/main/constants.ts index d14d55cbef..d27e44fc1f 100644 --- a/packages/frontend/apps/electron/src/main/constants.ts +++ b/packages/frontend/apps/electron/src/main/constants.ts @@ -3,3 +3,4 @@ export const onboardingViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith export const shellViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}shell.html`; export const backgroundWorkerViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}background-worker.html`; export const customThemeViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}theme-editor`; +export const popupViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}popup.html`; diff --git a/packages/frontend/apps/electron/src/main/events.ts b/packages/frontend/apps/electron/src/main/events.ts index fec2f1dc78..f2f35a7051 100644 --- a/packages/frontend/apps/electron/src/main/events.ts +++ b/packages/frontend/apps/electron/src/main/events.ts @@ -8,6 +8,7 @@ import { recordingEvents } from './recording'; import { sharedStorageEvents } from './shared-storage'; import { uiEvents } from './ui/events'; import { updaterEvents } from './updater/event'; +import { popupEvents } from './windows-manager/popup'; export const allEvents = { applicationMenu: applicationMenuEvents, @@ -15,6 +16,7 @@ export const allEvents = { ui: uiEvents, sharedStorage: sharedStorageEvents, recording: recordingEvents, + popup: popupEvents, }; function getActiveWindows() { diff --git a/packages/frontend/apps/electron/src/main/handlers.ts b/packages/frontend/apps/electron/src/main/handlers.ts index 70d0fcf40a..08a337862f 100644 --- a/packages/frontend/apps/electron/src/main/handlers.ts +++ b/packages/frontend/apps/electron/src/main/handlers.ts @@ -9,6 +9,7 @@ import { recordingHandlers } from './recording'; import { sharedStorageHandlers } from './shared-storage'; import { uiHandlers } from './ui/handlers'; import { updaterHandlers } from './updater'; +import { popupHandlers } from './windows-manager/popup'; import { workerHandlers } from './worker/handlers'; export const debugHandlers = { @@ -31,6 +32,7 @@ export const allHandlers = { sharedStorage: sharedStorageHandlers, worker: workerHandlers, recording: recordingHandlers, + popup: popupHandlers, }; export const registerHandlers = () => { diff --git a/packages/frontend/apps/electron/src/main/protocol.ts b/packages/frontend/apps/electron/src/main/protocol.ts index df7427d3ed..c8745dde7b 100644 --- a/packages/frontend/apps/electron/src/main/protocol.ts +++ b/packages/frontend/apps/electron/src/main/protocol.ts @@ -79,7 +79,11 @@ async function handleFileRequest(request: Request) { filepath = decodeURIComponent(urlObject.pathname); // security check if the filepath is within app.getPath('sessionData') const sessionDataPath = app.getPath('sessionData'); - if (!filepath.startsWith(sessionDataPath)) { + const tempPath = app.getPath('temp'); + if ( + !filepath.startsWith(sessionDataPath) && + !filepath.startsWith(tempPath) + ) { throw new Error('Invalid filepath'); } } diff --git a/packages/frontend/apps/electron/src/main/recording/index.ts b/packages/frontend/apps/electron/src/main/recording/index.ts index 65295a42f7..434cdf4f76 100644 --- a/packages/frontend/apps/electron/src/main/recording/index.ts +++ b/packages/frontend/apps/electron/src/main/recording/index.ts @@ -1,16 +1,25 @@ import path from 'node:path'; import { ShareableContent } from '@affine/native'; -import { app, nativeImage, Notification } from 'electron'; +import { app } from 'electron'; import fs from 'fs-extra'; -import { debounce } from 'lodash-es'; -import { BehaviorSubject, distinctUntilChanged, groupBy, mergeMap } from 'rxjs'; +import { + BehaviorSubject, + distinctUntilChanged, + groupBy, + interval, + mergeMap, + Subject, + throttleTime, +} from 'rxjs'; -import { isMacOS } from '../../shared/utils'; +import { isMacOS, shallowEqual } from '../../shared/utils'; import { beforeAppQuit } from '../cleanup'; import { logger } from '../logger'; import type { NamespaceHandlers } from '../type'; import { getMainWindow } from '../windows-manager'; +import { popupManager } from '../windows-manager/popup'; +import { recordingStateMachine } from './state-machine'; import type { AppGroupInfo, Recording, @@ -20,9 +29,10 @@ import type { const subscribers: Subscriber[] = []; +// adhoc recordings are saved in the temp directory const SAVED_RECORDINGS_DIR = path.join( - app.getPath('sessionData'), - 'recordings' + app.getPath('temp'), + 'affine-recordings' ); beforeAppQuit(() => { @@ -40,14 +50,15 @@ let shareableContent: ShareableContent | null = null; export const applications$ = new BehaviorSubject([]); export const appGroups$ = new BehaviorSubject([]); +export const updateApplicationsPing$ = new Subject(); + // recording id -> recording // recordings will be saved in memory before consumed and created as an audio block to user's doc const recordings = new Map(); // there should be only one active recording at a time -export const recordingStatus$ = new BehaviorSubject( - null -); +// We'll now use recordingStateMachine.status$ instead of our own BehaviorSubject +export const recordingStatus$ = recordingStateMachine.status$; function createAppGroup(processGroupId: number): AppGroupInfo | undefined { const groupProcess = @@ -113,37 +124,45 @@ function setupNewRunningAppGroup() { ) ) ); + + appGroups$.value.forEach(group => { + const recordingStatus = recordingStatus$.value; + if ( + group.isRunning && + (!recordingStatus || recordingStatus.status === 'new') + ) { + newRecording(group); + } + }); + subscribers.push( appGroupRunningChanged$.subscribe(currentGroup => { + logger.info( + 'appGroupRunningChanged', + currentGroup.bundleIdentifier, + currentGroup.isRunning + ); + const recordingStatus = recordingStatus$.value; + if (currentGroup.isRunning) { - // TODO(@pengx17): stub impl. will be replaced with a real one later - const notification = new Notification({ - icon: currentGroup.icon - ? nativeImage.createFromBuffer(currentGroup.icon) - : undefined, - title: 'Recording Meeting', - body: `Recording meeting with ${currentGroup.name}`, - actions: [ - { - type: 'button', - text: 'Start', - }, - ], - }); - notification.on('action', () => { - startRecording(currentGroup); - }); - notification.show(); - } else { - // if the group is not running, we should stop the recording (if it is recording) + // when the app is running and there is no active recording popup + // we should show a new recording popup if ( - recordingStatus$.value?.status === 'recording' && - recordingStatus$.value?.appGroup?.processGroupId === - currentGroup.processGroupId + !recordingStatus || + recordingStatus.status === 'new' || + recordingStatus.status === 'ready' ) { - stopRecording().catch(err => { - logger.error('failed to stop recording', err); - }); + newRecording(currentGroup); + } + } else { + // when displaying in "new" state but the app is not running any more + // we should remove the recording + if ( + recordingStatus?.status === 'new' && + currentGroup.bundleIdentifier === + recordingStatus.appGroup?.bundleIdentifier + ) { + removeRecording(recordingStatus.id); } } }) @@ -212,23 +231,60 @@ export async function getRecording(id: number) { }; } +// recording popup status +// new: recording is started, popup is shown +// recording: recording is started, popup is shown +// stopped: recording is stopped, popup showing processing status +// ready: recording is ready, show "open app" button +// null: hide popup function setupRecordingListeners() { subscribers.push( - recordingStatus$.pipe(distinctUntilChanged()).subscribe(status => { - if (status?.status === 'recording') { - let recording = recordings.get(status.id); - // create a recording if not exists - if (!recording) { - recording = createRecording(status); - recordings.set(status.id, recording); + recordingStatus$ + .pipe(distinctUntilChanged(shallowEqual)) + .subscribe(status => { + const popup = popupManager.get('recording'); + + if (status && !popup.showing) { + popup.show().catch(err => { + logger.error('failed to show recording popup', err); + }); } - } else if (status?.status === 'stopped') { - const recording = recordings.get(status.id); - if (recording) { - recording.stream.stop(); + + if (status?.status === 'recording') { + let recording = recordings.get(status.id); + // create a recording if not exists + if (!recording) { + recording = createRecording(status); + recordings.set(status.id, recording); + } + } else if (status?.status === 'stopped') { + const recording = recordings.get(status.id); + if (recording) { + recording.stream.stop(); + } + } else if (status?.status === 'ready') { + // show the popup for 10s + setTimeout(() => { + // check again if current status is still ready + if ( + recordingStatus$.value?.status === 'ready' && + recordingStatus$.value.id === status.id + ) { + popup.hide().catch(err => { + logger.error('failed to hide recording popup', err); + }); + } + }, 10_000); + } else if (!status) { + // status is removed, we should hide the popup + popupManager + .get('recording') + .hide() + .catch(err => { + logger.error('failed to hide recording popup', err); + }); } - } - }) + }) ); } @@ -236,7 +292,6 @@ function getAllApps(): TappableAppInfo[] { if (!shareableContent) { return []; } - const apps = shareableContent.applications().map(app => { try { return { @@ -259,7 +314,6 @@ function getAllApps(): TappableAppInfo[] { !v.bundleIdentifier.startsWith('com.apple') && v.processId !== process.pid ); - return filteredApps; } @@ -270,9 +324,17 @@ type Subscriber = { function setupMediaListeners() { applications$.next(getAllApps()); subscribers.push( + interval(3000).subscribe(() => { + updateApplicationsPing$.next(Date.now()); + }), ShareableContent.onApplicationListChanged(() => { - applications$.next(getAllApps()); - }) + updateApplicationsPing$.next(Date.now()); + }), + updateApplicationsPing$ + .pipe(distinctUntilChanged(), throttleTime(3000)) + .subscribe(() => { + applications$.next(getAllApps()); + }) ); let appStateSubscribers: Subscriber[] = []; @@ -291,12 +353,9 @@ function setupMediaListeners() { apps.forEach(app => { try { const tappableApp = app.rawInstance; - const debouncedAppStateChanged = debounce(() => { - applications$.next(getAllApps()); - }, 100); _appStateSubscribers.push( ShareableContent.onAppStateChanged(tappableApp, () => { - debouncedAppStateChanged(); + updateApplicationsPing$.next(Date.now()); }) ); } catch (error) { @@ -339,82 +398,104 @@ export function setupRecording() { setupRecordingListeners(); } -let recordingId = 0; +function normalizeAppGroupInfo( + appGroup?: AppGroupInfo | number +): AppGroupInfo | undefined { + return typeof appGroup === 'number' + ? appGroups$.value.find(group => group.processGroupId === appGroup) + : appGroup; +} + +export function newRecording( + appGroup?: AppGroupInfo | number +): RecordingStatus | null { + if (!shareableContent) { + return null; // likely called on unsupported platform + } + + return recordingStateMachine.dispatch({ + type: 'NEW_RECORDING', + appGroup: normalizeAppGroupInfo(appGroup), + }); +} export function startRecording( - appGroup?: AppGroupInfo -): RecordingStatus | undefined { - if (!shareableContent) { - return; // likely called on unsupported platform - } - - // hmm, is it possible that there are multiple apps running (listening) in the same group? - const appInfo = appGroup?.apps.find(app => app.isRunning); - - const recordingStatus: RecordingStatus = { - id: recordingId++, - status: 'recording', - startTime: Date.now(), - app: appInfo, - appGroup, - }; - - recordingStatus$.next(recordingStatus); - - return recordingStatus; -} - -export function pauseRecording() { - const recordingStatus = recordingStatus$.value; - if (!recordingStatus) { - return; - } - - recordingStatus$.next({ - ...recordingStatus, - status: 'paused', + appGroup?: AppGroupInfo | number +): RecordingStatus | null { + return recordingStateMachine.dispatch({ + type: 'START_RECORDING', + appGroup: normalizeAppGroupInfo(appGroup), }); } -export function resumeRecording() { - const recordingStatus = recordingStatus$.value; - if (!recordingStatus) { +export function pauseRecording(id: number) { + return recordingStateMachine.dispatch({ type: 'PAUSE_RECORDING', id }); +} + +export function resumeRecording(id: number) { + return recordingStateMachine.dispatch({ type: 'RESUME_RECORDING', id }); +} + +export async function stopRecording(id: number) { + const recording = recordings.get(id); + if (!recording) { + logger.error(`Recording ${id} not found`); return; } - recordingStatus$.next({ - ...recordingStatus, - status: 'recording', - }); -} + if (!recording.file.path) { + logger.error(`Recording ${id} has no file path`); + return; + } + + const recordingStatus = recordingStateMachine.dispatch({ + type: 'STOP_RECORDING', + id, + filepath: String(recording.file.path), + sampleRate: recording.stream.sampleRate, + numberOfChannels: recording.stream.channels, + }); -export async function stopRecording() { - const recordingStatus = recordingStatus$.value; if (!recordingStatus) { logger.error('No recording status to stop'); return; } - const recording = recordings.get(recordingStatus?.id); - if (!recording) { - logger.error(`Recording ${recordingStatus?.id} not found`); - return; - } - - // do not remove the last recordingStatus from recordingStatus$ - recordingStatus$.next({ - ...recordingStatus, - status: 'stopped', - }); const { file } = recording; file.end(); + // Wait for file to finish writing await new Promise(resolve => { file.on('finish', () => { resolve(); }); }); + return serializeRecordingStatus(recordingStatus); +} + +export async function readyRecording(id: number, buffer: Buffer) { + const recordingStatus = recordingStatus$.value; + const recording = recordings.get(id); + if (!recordingStatus || recordingStatus.id !== id || !recording) { + logger.error(`Recording ${id} not found`); + return; + } + + const filepath = path.join( + SAVED_RECORDINGS_DIR, + `${recordingStatus.appGroup?.bundleIdentifier ?? 'unknown'}-${recordingStatus.id}-${recordingStatus.startTime}.webm` + ); + + await fs.writeFile(filepath, buffer); + + // Update the status through the state machine + recordingStateMachine.dispatch({ + type: 'SAVE_RECORDING', + id, + filepath, + }); + // bring up the window getMainWindow() .then(mainWindow => { @@ -427,24 +508,75 @@ export async function stopRecording() { }); } +function removeRecording(id: number) { + recordings.delete(id); + recordingStateMachine.dispatch({ type: 'REMOVE_RECORDING', id }); +} + +export interface SerializedRecordingStatus { + id: number; + status: RecordingStatus['status']; + appName?: string; + // if there is no app group, it means the recording is for system audio + appGroupId?: number; + icon?: Buffer; + startTime: number; + filepath?: string; + sampleRate?: number; + numberOfChannels?: number; +} + +function serializeRecordingStatus( + status: RecordingStatus +): SerializedRecordingStatus { + return { + id: status.id, + status: status.status, + appName: status.appGroup?.name, + appGroupId: status.appGroup?.processGroupId, + icon: status.appGroup?.icon, + startTime: status.startTime, + filepath: status.filepath, + sampleRate: status.sampleRate, + numberOfChannels: status.numberOfChannels, + }; +} + export const recordingHandlers = { getRecording: async (_, id: number) => { return getRecording(id); }, - deleteCachedRecording: async (_, id: number) => { - const recording = recordings.get(id); - if (recording) { - recording.stream.stop(); - recordings.delete(id); - await fs.unlink(recording.file.path); - } - return true; + getCurrentRecording: async () => { + // not all properties are serializable, so we need to return a subset of the status + return recordingStatus$.value + ? serializeRecordingStatus(recordingStatus$.value) + : null; + }, + startRecording: async (_, appGroup?: AppGroupInfo | number) => { + return startRecording(appGroup); + }, + pauseRecording: async (_, id: number) => { + return pauseRecording(id); + }, + stopRecording: async (_, id: number) => { + return stopRecording(id); + }, + // save the encoded recording buffer to the file system + readyRecording: async (_, id: number, buffer: Uint8Array) => { + return readyRecording(id, Buffer.from(buffer)); + }, + removeRecording: async (_, id: number) => { + return removeRecording(id); }, } satisfies NamespaceHandlers; export const recordingEvents = { - onRecordingStatusChanged: (fn: (status: RecordingStatus | null) => void) => { - const sub = recordingStatus$.subscribe(fn); + onRecordingStatusChanged: ( + fn: (status: SerializedRecordingStatus | null) => void + ) => { + const sub = recordingStatus$.subscribe(status => { + fn(status ? serializeRecordingStatus(status) : null); + }); return () => { try { sub.unsubscribe(); diff --git a/packages/frontend/apps/electron/src/main/recording/state-machine.ts b/packages/frontend/apps/electron/src/main/recording/state-machine.ts new file mode 100644 index 0000000000..5c5dda9023 --- /dev/null +++ b/packages/frontend/apps/electron/src/main/recording/state-machine.ts @@ -0,0 +1,269 @@ +import { BehaviorSubject } from 'rxjs'; + +import { shallowEqual } from '../../shared/utils'; +import { logger } from '../logger'; +import type { AppGroupInfo, RecordingStatus } from './types'; + +/** + * Possible states for a recording + */ +export type RecordingState = + | 'new' + | 'recording' + | 'paused' + | 'stopped' + | 'ready' + | 'inactive'; + +/** + * Recording state machine events + */ +export type RecordingEvent = + | { type: 'NEW_RECORDING'; appGroup?: AppGroupInfo } + | { type: 'START_RECORDING'; appGroup?: AppGroupInfo } + | { type: 'PAUSE_RECORDING'; id: number } + | { type: 'RESUME_RECORDING'; id: number } + | { + type: 'STOP_RECORDING'; + id: number; + filepath: string; + sampleRate: number; + numberOfChannels: number; + } + | { + type: 'SAVE_RECORDING'; + id: number; + filepath: string; + } + | { type: 'REMOVE_RECORDING'; id: number }; + +/** + * Recording State Machine + * Handles state transitions for the recording process + */ +export class RecordingStateMachine { + private recordingId = 0; + private readonly recordingStatus$ = + new BehaviorSubject(null); + + /** + * Get the current recording status + */ + get status(): RecordingStatus | null { + return this.recordingStatus$.value; + } + + /** + * Get the BehaviorSubject for recording status + */ + get status$(): BehaviorSubject { + return this.recordingStatus$; + } + + /** + * Dispatch an event to the state machine + * @param event The event to dispatch + * @returns The new recording status after the event is processed + */ + dispatch(event: RecordingEvent): RecordingStatus | null { + const currentStatus = this.recordingStatus$.value; + let newStatus: RecordingStatus | null = null; + + switch (event.type) { + case 'NEW_RECORDING': + newStatus = this.handleNewRecording(event.appGroup); + break; + case 'START_RECORDING': + newStatus = this.handleStartRecording(event.appGroup); + break; + case 'PAUSE_RECORDING': + newStatus = this.handlePauseRecording(); + break; + case 'RESUME_RECORDING': + newStatus = this.handleResumeRecording(); + break; + case 'STOP_RECORDING': + newStatus = this.handleStopRecording( + event.id, + event.filepath, + event.sampleRate, + event.numberOfChannels + ); + break; + case 'SAVE_RECORDING': + newStatus = this.handleSaveRecording(event.id, event.filepath); + break; + case 'REMOVE_RECORDING': + this.handleRemoveRecording(event.id); + newStatus = currentStatus?.id === event.id ? null : currentStatus; + break; + default: + logger.error('Unknown recording event type'); + return currentStatus; + } + + if (shallowEqual(newStatus, currentStatus)) { + return currentStatus; + } + + this.recordingStatus$.next(newStatus); + + return newStatus; + } + + /** + * Handle the NEW_RECORDING event + */ + private handleNewRecording(appGroup?: AppGroupInfo): RecordingStatus { + const recordingStatus: RecordingStatus = { + id: this.recordingId++, + status: 'new', + startTime: Date.now(), + app: appGroup?.apps.find(app => app.isRunning), + appGroup, + }; + return recordingStatus; + } + + /** + * Handle the START_RECORDING event + */ + private handleStartRecording(appGroup?: AppGroupInfo): RecordingStatus { + const currentStatus = this.recordingStatus$.value; + if ( + currentStatus?.status === 'recording' || + currentStatus?.status === 'stopped' + ) { + logger.error( + 'Cannot start a new recording if there is already a recording' + ); + return currentStatus; + } + + if ( + appGroup && + currentStatus?.appGroup?.processGroupId === appGroup.processGroupId + ) { + return { + ...currentStatus, + status: 'recording', + }; + } else { + const newStatus = this.handleNewRecording(appGroup); + return { + ...newStatus, + status: 'recording', + }; + } + } + + /** + * Handle the PAUSE_RECORDING event + */ + private handlePauseRecording(): RecordingStatus | null { + const currentStatus = this.recordingStatus$.value; + + if (!currentStatus) { + logger.error('No active recording to pause'); + return null; + } + + if (currentStatus.status !== 'recording') { + logger.error(`Cannot pause recording in ${currentStatus.status} state`); + return currentStatus; + } + + return { + ...currentStatus, + status: 'paused', + }; + } + + /** + * Handle the RESUME_RECORDING event + */ + private handleResumeRecording(): RecordingStatus | null { + const currentStatus = this.recordingStatus$.value; + + if (!currentStatus) { + logger.error('No active recording to resume'); + return null; + } + + if (currentStatus.status !== 'paused') { + logger.error(`Cannot resume recording in ${currentStatus.status} state`); + return currentStatus; + } + + return { + ...currentStatus, + status: 'recording', + }; + } + + /** + * Handle the STOP_RECORDING event + */ + private handleStopRecording( + id: number, + filepath: string, + sampleRate: number, + numberOfChannels: number + ): RecordingStatus | null { + const currentStatus = this.recordingStatus$.value; + + if (!currentStatus || currentStatus.id !== id) { + logger.error(`Recording ${id} not found for stopping`); + return currentStatus; + } + + if ( + currentStatus.status !== 'recording' && + currentStatus.status !== 'paused' + ) { + logger.error(`Cannot stop recording in ${currentStatus.status} state`); + return currentStatus; + } + + return { + ...currentStatus, + status: 'stopped', + filepath, + sampleRate, + numberOfChannels, + }; + } + + /** + * Handle the SAVE_RECORDING event + */ + private handleSaveRecording( + id: number, + filepath: string + ): RecordingStatus | null { + const currentStatus = this.recordingStatus$.value; + + if (!currentStatus || currentStatus.id !== id) { + logger.error(`Recording ${id} not found for saving`); + return currentStatus; + } + + return { + ...currentStatus, + status: 'ready', + filepath, + }; + } + + /** + * Handle the REMOVE_RECORDING event + */ + private handleRemoveRecording(id: number): void { + // Actual recording removal logic would be handled by the caller + // This just ensures the state is updated correctly + logger.info(`Recording ${id} removed from state machine`); + } +} + +// Create and export a singleton instance +export const recordingStateMachine = new RecordingStateMachine(); diff --git a/packages/frontend/apps/electron/src/main/recording/state-transitions.md b/packages/frontend/apps/electron/src/main/recording/state-transitions.md new file mode 100644 index 0000000000..7ec0f83f5c --- /dev/null +++ b/packages/frontend/apps/electron/src/main/recording/state-transitions.md @@ -0,0 +1,88 @@ +# Recording State Transitions + +This document visualizes the possible state transitions in the recording system. + +## States + +The recording system has the following states: + +- **inactive**: No active recording (null state) +- **new**: A new recording has been detected but not yet started +- **recording**: Audio is being recorded +- **paused**: Recording is temporarily paused +- **stopped**: Recording has been stopped and is processing +- **ready**: Recording is processed and ready for use + +## Transitions + +``` +┌───────────┐ ┌───────┐ +│ │ │ │ +│ inactive │◀───────────────│ ready │ +│ │ │ │ +└─────┬─────┘ └───┬───┘ + │ │ + │ NEW_RECORDING │ + ▼ │ +┌───────────┐ │ +│ │ │ +│ new │ │ +│ │ │ +└─────┬─────┘ │ + │ │ + │ START_RECORDING │ + ▼ │ +┌───────────┐ │ +│ │ STOP_RECORDING│ +│ recording │─────────────────┐ │ +│ │◀────────────┐ │ │ +└─────┬─────┘ │ │ │ + │ │ │ │ + │ PAUSE_RECORDING │ │ │ + ▼ │ │ │ +┌───────────┐ │ │ │ +│ │ │ │ │ +│ paused │ │ │ │ +│ │ │ │ │ +└─────┬─────┘ │ │ │ + │ │ │ │ + │ RESUME_RECORDING │ │ │ + └───────────────────┘ │ │ + │ │ + ▼ │ + ┌───────────┐ + │ │ + │ stopped │ + │ │ + └─────┬─────┘ + │ + │ SAVE_RECORDING + ▼ + ┌───────────┐ + │ │ + │ ready │ + │ │ + └───────────┘ +``` + +## Events + +The following events trigger state transitions: + +- `NEW_RECORDING`: Create a new recording when an app starts or is detected +- `START_RECORDING`: Start recording audio +- `PAUSE_RECORDING`: Pause the current recording +- `RESUME_RECORDING`: Resume a paused recording +- `STOP_RECORDING`: Stop the current recording +- `SAVE_RECORDING`: Save and finalize a recording +- `REMOVE_RECORDING`: Delete a recording + +## Error Handling + +Invalid state transitions are logged and prevented. For example: + +- Cannot start a new recording when one is already in progress +- Cannot pause a recording that is not in the 'recording' state +- Cannot resume a recording that is not in the 'paused' state + +Each transition function in the state machine validates the current state before allowing a transition. diff --git a/packages/frontend/apps/electron/src/main/recording/types.ts b/packages/frontend/apps/electron/src/main/recording/types.ts index 33f0ada7ea..2ac4f727b8 100644 --- a/packages/frontend/apps/electron/src/main/recording/types.ts +++ b/packages/frontend/apps/electron/src/main/recording/types.ts @@ -33,8 +33,17 @@ export interface Recording { export interface RecordingStatus { id: number; // corresponds to the recording id - status: 'recording' | 'paused' | 'stopped'; + // the status of the recording in a linear state machine + // new: an new app group is listening. note, if there are any active recording, the current recording will not change + // recording: the recording is ongoing + // paused: the recording is paused + // stopped: the recording is stopped (processing audio file for use in the editor) + // ready: the recording is ready to be used + status: 'new' | 'recording' | 'paused' | 'stopped' | 'ready'; app?: TappableAppInfo; appGroup?: AppGroupInfo; - startTime: number; + startTime: number; // 0 means not started yet + filepath?: string; // the filepath of the recording (only available when status is ready) + sampleRate?: number; + numberOfChannels?: number; } diff --git a/packages/frontend/apps/electron/src/main/tray/index.ts b/packages/frontend/apps/electron/src/main/tray/index.ts index 26db9b0722..e90aa79911 100644 --- a/packages/frontend/apps/electron/src/main/tray/index.ts +++ b/packages/frontend/apps/electron/src/main/tray/index.ts @@ -14,11 +14,10 @@ import { beforeAppQuit } from '../cleanup'; import { logger } from '../logger'; import { appGroups$, - pauseRecording, recordingStatus$, - resumeRecording, startRecording, stopRecording, + updateApplicationsPing$, } from '../recording'; import { getMainWindow } from '../windows-manager'; import { icons } from './icons'; @@ -132,7 +131,21 @@ class TrayState { const recordingStatus = recordingStatus$.value; - if (!recordingStatus || recordingStatus?.status === 'stopped') { + if ( + !recordingStatus || + (recordingStatus?.status !== 'paused' && + recordingStatus?.status !== 'recording') + ) { + const appMenuItems = runningAppGroups.map(appGroup => ({ + label: appGroup.name, + icon: appGroup.icon || undefined, + click: () => { + logger.info( + `User action: Start Recording Meeting (${appGroup.name})` + ); + startRecording(appGroup); + }, + })); return { key: 'recording', getConfig: () => [ @@ -150,18 +163,10 @@ class TrayState { startRecording(); }, }, - ...runningAppGroups.map(appGroup => ({ - label: appGroup.name, - icon: appGroup.icon || undefined, - click: () => { - logger.info( - `User action: Start Recording Meeting (${appGroup.name})` - ); - startRecording(appGroup); - }, - })), + ...appMenuItems, ], }, + ...appMenuItems, ], }; } @@ -179,26 +184,11 @@ class TrayState { icon: icons.recording, disabled: true, }, - recordingStatus.status === 'paused' - ? { - label: 'Resume', - click: () => { - logger.info('User action: Resume Recording'); - resumeRecording(); - }, - } - : { - label: 'Pause', - click: () => { - logger.info('User action: Pause Recording'); - pauseRecording(); - }, - }, { label: 'Stop', click: () => { logger.info('User action: Stop Recording'); - stopRecording().catch(err => { + stopRecording(recordingStatus.id).catch(err => { logger.error('Failed to stop recording:', err); }); }, @@ -260,6 +250,7 @@ class TrayState { if (!isMacOS()) { this.tray?.popUpContextMenu(); } + updateApplicationsPing$.next(Date.now()); }; this.tray.on('click', clickHandler); const appGroupsSubscription = appGroups$.subscribe(() => { diff --git a/packages/frontend/apps/electron/src/main/windows-manager/onboarding.ts b/packages/frontend/apps/electron/src/main/windows-manager/onboarding.ts index d340c17a39..ff79c2f3c3 100644 --- a/packages/frontend/apps/electron/src/main/windows-manager/onboarding.ts +++ b/packages/frontend/apps/electron/src/main/windows-manager/onboarding.ts @@ -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'); diff --git a/packages/frontend/apps/electron/src/main/windows-manager/popup.ts b/packages/frontend/apps/electron/src/main/windows-manager/popup.ts new file mode 100644 index 0000000000..b026588bfe --- /dev/null +++ b/packages/frontend/apps/electron/src/main/windows-manager/popup.ts @@ -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 { + 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; + + resolveReady: () => void = () => {}; + ready = new Promise(resolve => { + this.resolveReady = resolve; + }); + + private readonly showing$ = new BehaviorSubject(false); + + get showing() { + return this.showing$.value; + } + + async build(): Promise { + 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(null); + + windowOptions: Partial = { + 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 = { + 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>( + new Map() + ); + + get(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; diff --git a/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts b/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts index 652ad39cca..8015e11e43 100644 --- a/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts +++ b/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts @@ -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; } diff --git a/packages/frontend/apps/electron/src/main/windows-manager/utils.ts b/packages/frontend/apps/electron/src/main/windows-manager/utils.ts new file mode 100644 index 0000000000..c4fb1e7b00 --- /dev/null +++ b/packages/frontend/apps/electron/src/main/windows-manager/utils.ts @@ -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(); +}; diff --git a/packages/frontend/apps/electron/src/shared/utils.ts b/packages/frontend/apps/electron/src/shared/utils.ts index 4535258f23..8cd6156be5 100644 --- a/packages/frontend/apps/electron/src/shared/utils.ts +++ b/packages/frontend/apps/electron/src/shared/utils.ts @@ -48,7 +48,7 @@ export class MessageEventChannel implements EventBasedChannel { export const resourcesPath = join(__dirname, `../resources`); // credit: https://github.com/facebook/fbjs/blob/main/packages/fbjs/src/core/shallowEqual.js -export function shallowEqual(objA: any, objB: any) { +export function shallowEqual(objA: T, objB: T) { if (Object.is(objA, objB)) { return true; } @@ -73,7 +73,7 @@ export function shallowEqual(objA: any, objB: any) { for (const key of keysA) { if ( !Object.prototype.hasOwnProperty.call(objB, key) || - !Object.is(objA[key], objB[key]) + !Object.is(objA[key as keyof T], objB[key as keyof T]) ) { return false; } diff --git a/packages/frontend/core/src/blocksuite/attachment-viewer/audio/audio-block.tsx b/packages/frontend/core/src/blocksuite/attachment-viewer/audio/audio-block.tsx index b9dd1c7c77..895bd6f87f 100644 --- a/packages/frontend/core/src/blocksuite/attachment-viewer/audio/audio-block.tsx +++ b/packages/frontend/core/src/blocksuite/attachment-viewer/audio/audio-block.tsx @@ -1,11 +1,11 @@ -import { Button } from '@affine/component'; +import { Button, Tooltip } from '@affine/component'; import { AudioPlayer } from '@affine/core/components/audio-player'; +import { AnimatedTranscribeIcon } from '@affine/core/components/audio-player/lottie/animated-transcribe-icon'; import { useSeekTime } from '@affine/core/components/audio-player/use-seek-time'; import { useEnableAI } from '@affine/core/components/hooks/affine/use-enable-ai'; import type { AudioAttachmentBlock } from '@affine/core/modules/media/entities/audio-attachment-block'; import { useAttachmentMediaBlock } from '@affine/core/modules/media/views/use-attachment-media'; import { useI18n } from '@affine/i18n'; -import { TranscriptWithAiIcon } from '@blocksuite/icons/rc'; import { useLiveData } from '@toeverything/infra'; import { useCallback, useMemo } from 'react'; @@ -54,11 +54,14 @@ const AttachmentAudioPlayer = ({ block }: { block: AudioAttachmentBlock }) => { if (!enableAi) { return null; } - return ( + const inner = ( ); + if (transcribing) { + return ( + + {inner} + + ); + } + return inner; }, [enableAi, transcribing, t, transcribed, block, expanded]); return ( diff --git a/packages/frontend/core/src/components/audio-player/audio-player.css.ts b/packages/frontend/core/src/components/audio-player/audio-player.css.ts index 7b69ccec28..a8912ab34f 100644 --- a/packages/frontend/core/src/components/audio-player/audio-player.css.ts +++ b/packages/frontend/core/src/components/audio-player/audio-player.css.ts @@ -167,7 +167,8 @@ export const miniNameLabel = style({ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', - height: 20, + lineHeight: '20px', + marginBottom: 2, }); export const miniPlayerContainer = style({ diff --git a/packages/frontend/core/src/components/audio-player/audio-player.tsx b/packages/frontend/core/src/components/audio-player/audio-player.tsx index 8c4ad6e74a..193053df5b 100644 --- a/packages/frontend/core/src/components/audio-player/audio-player.tsx +++ b/packages/frontend/core/src/components/audio-player/audio-player.tsx @@ -1,6 +1,12 @@ import { IconButton } from '@affine/component'; -import { CloseIcon, VoiceIcon } from '@blocksuite/icons/rc'; +import { + AddThirtySecondIcon, + CloseIcon, + ReduceFifteenSecondIcon, + VoiceIcon, +} from '@blocksuite/icons/rc'; import bytes from 'bytes'; +import { clamp } from 'lodash-es'; import { type MouseEventHandler, type ReactNode, useCallback } from 'react'; import * as styles from './audio-player.css'; @@ -150,19 +156,19 @@ export const MiniAudioPlayer = ({ ); const handleRewind = useCallback( - (e: React.MouseEvent) => { + (e: React.MouseEvent) => { e.stopPropagation(); - onSeek(seekTime - 15); + onSeek(clamp(seekTime - 15, 0, duration)); }, - [seekTime, onSeek] + [seekTime, duration, onSeek] ); const handleForward = useCallback( - (e: React.MouseEvent) => { + (e: React.MouseEvent) => { e.stopPropagation(); - onSeek(seekTime + 15); + onSeek(clamp(seekTime + 30, 0, duration)); }, - [seekTime, onSeek] + [seekTime, duration, onSeek] ); const handleClose = useCallback( @@ -186,13 +192,24 @@ export const MiniAudioPlayer = ({
{name}
-
-15s
+ } + size={18} + variant="plain" + onClick={handleRewind} + /> -
+15s
+ + } + size={18} + variant="plain" + onClick={handleForward} + />
void; +} + +export const AnimatedTranscribeIcon = ({ + state, + className, + onClick, +}: AnimatedTranscribeIconProps) => { + const lottieRef: LottieRef = useRef(null); + + useEffect(() => { + if (!lottieRef.current) return; + + let loopInterval: NodeJS.Timeout | null = null; + + // Cleanup function to clear any existing intervals + const cleanup = () => { + const animating = !!loopInterval; + if (loopInterval) { + clearInterval(loopInterval); + loopInterval = null; + } + const lottie = lottieRef.current; + // Play the final segment when stopped + if (lottie && animating) { + lottie.goToAndPlay(lottie.animationItem?.currentFrame || 0, true); + } + }; + + if (state === 'transcribing') { + // First play the transition to playing state (0-35) + lottieRef.current.playSegments([0, 35], true); + + // After transition, start the main loop + const startMainLoop = () => { + // Play the main animation segment (35-64) + lottieRef.current?.playSegments([35, 64], true); + + // Set up interval to continue looping + loopInterval = setInterval( + () => { + if (loopInterval) { + lottieRef.current?.playSegments([35, 64], true); + } + }, + (64 - 35) * (1000 / 60) + ); // 60fps + }; + + // Start the main loop after the transition + setTimeout(startMainLoop, 10 * (1000 / 60)); // Wait for transition to complete + } else { + cleanup(); + } + + // Cleanup on unmount or when state changes + return cleanup; + }, [state]); + + return ( + + ); +}; diff --git a/packages/frontend/core/src/components/audio-player/lottie/styles.css.ts b/packages/frontend/core/src/components/audio-player/lottie/styles.css.ts new file mode 100644 index 0000000000..ccaf638058 --- /dev/null +++ b/packages/frontend/core/src/components/audio-player/lottie/styles.css.ts @@ -0,0 +1,59 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { globalStyle, style } from '@vanilla-extract/css'; +export const root = style({}); + +// replace primary colors to cssVarV2('icon/primary') +const iconPrimaryColors = [ + // legacy "--affine-icon-color" + 'rgb(119,117,125)', + // --affine-v2-icon-primary + 'rgb(122,122,122)', +]; + +// todo: may need to replace secondary colors & background colors as well? + +const backgroundPrimaryColors = [ + // --affine-v2-background-primary + 'rgb(255,255,255)', + '#ffffff', +]; + +const backgroundSecondaryColors = [ + // --affine-v2-background-secondary + 'rgb(245,245,245)', +]; + +globalStyle( + `${root} :is(${iconPrimaryColors.map(color => `path[fill="${color}"]`).join(',')})`, + { + fill: cssVarV2('icon/primary'), + } +); + +globalStyle( + `${root} :is(${iconPrimaryColors.map(color => `path[stroke="${color}"]`).join(',')})`, + { + stroke: cssVarV2('icon/primary'), + } +); + +globalStyle( + `${root} :is(${backgroundPrimaryColors.map(color => `rect[fill="${color}"]`).join(',')})`, + { + fill: 'transparent', + } +); + +globalStyle( + `${root} :is(${backgroundPrimaryColors.map(color => `path[fill="${color}"]`).join(',')})`, + { + fill: 'transparent', + } +); + +globalStyle( + `${root} :is(${backgroundSecondaryColors.map(color => `path[fill="${color}"]`).join(',')})`, + { + fill: cssVarV2('layer/background/secondary'), + } +); diff --git a/packages/frontend/core/src/components/audio-player/lottie/transcribe.json b/packages/frontend/core/src/components/audio-player/lottie/transcribe.json new file mode 100644 index 0000000000..f487e910ff --- /dev/null +++ b/packages/frontend/core/src/components/audio-player/lottie/transcribe.json @@ -0,0 +1,1903 @@ +{ + "v": "5.12.1", + "fr": 60, + "ip": 0, + "op": 106, + "w": 24, + "h": 24, + "nm": "light", + "ddd": 0, + "assets": [], + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Vector", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { "a": 0, "k": [11.25, 11.75, 0], "ix": 2, "l": 2 }, + "a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [0, 0], + [0, -1.519], + [0, 0], + [-1.519, 0], + [0, 0], + [0, 0.414], + [0.414, 0], + [0, 0], + [0, 0.69], + [0, 0], + [-0.69, 0], + [0, 0], + [0, -0.69], + [0, 0], + [-0.414, 0], + [0, 0.414], + [0, 0], + [1.519, 0] + ], + "o": [ + [-1.519, 0], + [0, 0], + [0, 1.519], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0], + [-0.69, 0], + [0, 0], + [0, -0.69], + [0, 0], + [0.69, 0], + [0, 0], + [0, 0.414], + [0.414, 0], + [0, 0], + [0, -1.519], + [0, 0] + ], + "v": [ + [-5.5, -6.75], + [-8.25, -4], + [-8.25, 4], + [-5.5, 6.75], + [1.5, 6.75], + [2.25, 6], + [1.5, 5.25], + [-5.5, 5.25], + [-6.75, 4], + [-6.75, -4], + [-5.5, -5.25], + [5.5, -5.25], + [6.75, -4], + [6.75, -2], + [7.5, -1.25], + [8.25, -2], + [8.25, -4], + [5.5, -6.75] + ], + "c": true + }, + "ix": 2 + }, + "nm": "路径 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "t": 5, + "s": [0.478431373835, 0.478431373835, 0.478431373835, 1] + }, + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "t": 10, + "s": [0.117647059262, 0.588235318661, 0.921568632126, 1] + }, + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "t": 95, + "s": [0.117647059262, 0.588235318661, 0.921568632126, 1] + }, + { + "t": 101, + "s": [0.478431373835, 0.478431373835, 0.478431373835, 1] + } + ], + "ix": 4 + }, + "o": { "a": 0, "k": 100, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "填充 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "变换" + } + ], + "nm": "Vector", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 5400, + "st": 0, + "ct": 1, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "Vector 2", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { "a": 0, "k": [17.25, 15.25, 0], "ix": 2, "l": 2 }, + "a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [-0.107, 0.924], + [-0.275, 0], + [-0.031, -0.274], + [-0.413, -0.42], + [-0.911, -0.081], + [0, -0.281], + [0.28, -0.024], + [0.421, -0.421], + [0.077, -0.895], + [0.281, 0], + [0.025, 0.28], + [0.417, 0.409], + [0.908, 0.103], + [0, 0.275], + [-0.274, 0.032], + [-0.409, 0.409] + ], + "o": [ + [0.032, -0.274], + [0.275, 0], + [0.103, 0.908], + [0.409, 0.417], + [0.28, 0.025], + [0, 0.281], + [-0.895, 0.077], + [-0.421, 0.421], + [-0.024, 0.28], + [-0.281, 0], + [-0.081, -0.911], + [-0.42, -0.413], + [-0.274, -0.031], + [0, -0.275], + [0.924, -0.107], + [0.409, -0.409] + ], + "v": [ + [-0.538, -3.27], + [0.001, -3.75], + [0.538, -3.269], + [1.326, -1.314], + [3.256, -0.54], + [3.75, 0.001], + [3.255, 0.54], + [1.314, 1.314], + [0.54, 3.255], + [0.001, 3.75], + [-0.54, 3.256], + [-1.314, 1.326], + [-3.269, 0.538], + [-3.75, 0.001], + [-3.27, -0.538], + [-1.325, -1.325] + ], + "c": true + }, + "ix": 2 + }, + "nm": "路径 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "t": 5, + "s": [0.478431373835, 0.478431373835, 0.478431373835, 1] + }, + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "t": 10, + "s": [0.117647059262, 0.588235318661, 0.921568632126, 1] + }, + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "t": 95, + "s": [0.117647059262, 0.588235318661, 0.921568632126, 1] + }, + { + "t": 101, + "s": [0.478431373835, 0.478431373835, 0.478431373835, 1] + } + ], + "ix": 4 + }, + "o": { "a": 0, "k": 100, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "填充 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "变换" + } + ], + "nm": "Vector", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 5400, + "st": 0, + "ct": 1, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 4, + "nm": "Vector 10", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 77, + "s": [21.156, 12.75, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { "t": 93, "s": [9.25, 12.75, 0] } + ], + "ix": 2, + "l": 2 + }, + "a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 75, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-2, -0.75], + [-2.75, 0], + [-2, 0.75], + [-2.5, 0.75], + [-1.75, 0], + [-2.5, -0.75] + ], + "c": true + } + ] + }, + { + "t": 85, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-2, -0.75], + [-2.75, 0], + [-2, 0.75], + [2, 0.75], + [2.75, 0], + [2, -0.75] + ], + "c": true + } + ] + } + ], + "ix": 2 + }, + "nm": "路径 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "t": 95, + "s": [0.117647059262, 0.588235318661, 0.921568632126, 1] + }, + { + "t": 101, + "s": [0.478431373835, 0.478431373835, 0.478431373835, 1] + } + ], + "ix": 4 + }, + "o": { "a": 0, "k": 100, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "填充 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "变换" + } + ], + "nm": "Vector", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 75, + "op": 5475, + "st": 75, + "ct": 1, + "bm": 0 + }, + { + "ddd": 0, + "ind": 4, + "ty": 4, + "nm": "Vector 9", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 71, + "s": [22.538, 9.75, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { "t": 87, "s": [11, 9.75, 0] } + ], + "ix": 2, + "l": 2 + }, + "a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 71, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-3.75, -0.75], + [-4.5, 0], + [-3.75, 0.75], + [-3.781, 0.75], + [-3.031, 0], + [-3.781, -0.75] + ], + "c": true + } + ] + }, + { + "t": 82, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-3.75, -0.75], + [-4.5, 0], + [-3.75, 0.75], + [3.75, 0.75], + [4.5, 0], + [3.75, -0.75] + ], + "c": true + } + ] + } + ], + "ix": 2 + }, + "nm": "路径 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "t": 95, + "s": [0.117647059262, 0.588235318661, 0.921568632126, 1] + }, + { + "t": 101, + "s": [0.478431373835, 0.478431373835, 0.478431373835, 1] + } + ], + "ix": 4 + }, + "o": { "a": 0, "k": 100, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "填充 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "变换" + } + ], + "nm": "Vector", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 71, + "op": 5471, + "st": 71, + "ct": 1, + "bm": 0 + }, + { + "ddd": 0, + "ind": 5, + "ty": 4, + "nm": "Vector 6", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 47, + "s": [22.538, 9.75, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 63, + "s": [11, 9.75, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { "t": 88, "s": [-0.601, 9.75, 0] } + ], + "ix": 2, + "l": 2 + }, + "a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 47, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-3.75, -0.75], + [-4.5, 0], + [-3.75, 0.75], + [-3.781, 0.75], + [-3.031, 0], + [-3.781, -0.75] + ], + "c": true + } + ] + }, + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 58, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-3.75, -0.75], + [-4.5, 0], + [-3.75, 0.75], + [3.75, 0.75], + [4.5, 0], + [3.75, -0.75] + ], + "c": true + } + ] + }, + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 69, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-3.75, -0.75], + [-4.5, 0], + [-3.75, 0.75], + [3.75, 0.75], + [4.5, 0], + [3.75, -0.75] + ], + "c": true + } + ] + }, + { + "t": 87, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [4, -0.75], + [3.25, 0], + [4, 0.75], + [3.75, 0.75], + [4.5, 0], + [3.75, -0.75] + ], + "c": true + } + ] + } + ], + "ix": 2 + }, + "nm": "路径 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": { + "a": 0, + "k": [0.117647058824, 0.588235294118, 0.921568627451, 1], + "ix": 4 + }, + "o": { "a": 0, "k": 100, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "填充 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "变换" + } + ], + "nm": "Vector", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 47, + "op": 88, + "st": 47, + "ct": 1, + "bm": 0 + }, + { + "ddd": 0, + "ind": 6, + "ty": 4, + "nm": "Vector 8", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 50, + "s": [21.156, 12.75, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 66, + "s": [9.25, 12.75, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { "t": 86, "s": [-2.007, 12.75, 0] } + ], + "ix": 2, + "l": 2 + }, + "a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 48, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-2, -0.75], + [-2.75, 0], + [-2, 0.75], + [-2.5, 0.75], + [-1.75, 0], + [-2.5, -0.75] + ], + "c": true + } + ] + }, + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 58, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-2, -0.75], + [-2.75, 0], + [-2, 0.75], + [2, 0.75], + [2.75, 0], + [2, -0.75] + ], + "c": true + } + ] + }, + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 71, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-2, -0.75], + [-2.75, 0], + [-2, 0.75], + [2, 0.75], + [2.75, 0], + [2, -0.75] + ], + "c": true + } + ] + }, + { + "t": 80, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [2.438, -0.75], + [1.688, 0], + [2.438, 0.75], + [2, 0.75], + [2.75, 0], + [2, -0.75] + ], + "c": true + } + ] + } + ], + "ix": 2 + }, + "nm": "路径 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": { + "a": 0, + "k": [0.117647058824, 0.588235294118, 0.921568627451, 1], + "ix": 4 + }, + "o": { "a": 0, "k": 100, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "填充 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "变换" + } + ], + "nm": "Vector", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 48, + "op": 81, + "st": 48, + "ct": 1, + "bm": 0 + }, + { + "ddd": 0, + "ind": 7, + "ty": 4, + "nm": "Vector 5", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 18, + "s": [22.538, 9.75, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 34, + "s": [11, 9.75, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { "t": 59, "s": [-0.601, 9.75, 0] } + ], + "ix": 2, + "l": 2 + }, + "a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 18, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-3.75, -0.75], + [-4.5, 0], + [-3.75, 0.75], + [-3.781, 0.75], + [-3.031, 0], + [-3.781, -0.75] + ], + "c": true + } + ] + }, + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 29, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-3.75, -0.75], + [-4.5, 0], + [-3.75, 0.75], + [3.75, 0.75], + [4.5, 0], + [3.75, -0.75] + ], + "c": true + } + ] + }, + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 40, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-3.75, -0.75], + [-4.5, 0], + [-3.75, 0.75], + [3.75, 0.75], + [4.5, 0], + [3.75, -0.75] + ], + "c": true + } + ] + }, + { + "t": 58, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [4, -0.75], + [3.25, 0], + [4, 0.75], + [3.75, 0.75], + [4.5, 0], + [3.75, -0.75] + ], + "c": true + } + ] + } + ], + "ix": 2 + }, + "nm": "路径 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": { + "a": 0, + "k": [0.117647058824, 0.588235294118, 0.921568627451, 1], + "ix": 4 + }, + "o": { "a": 0, "k": 100, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "填充 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "变换" + } + ], + "nm": "Vector", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 18, + "op": 59, + "st": 18, + "ct": 1, + "bm": 0 + }, + { + "ddd": 0, + "ind": 8, + "ty": 4, + "nm": "Vector 7", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 21, + "s": [21.156, 12.75, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 37, + "s": [9.25, 12.75, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { "t": 57, "s": [-2.007, 12.75, 0] } + ], + "ix": 2, + "l": 2 + }, + "a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 19, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-2, -0.75], + [-2.75, 0], + [-2, 0.75], + [-2.5, 0.75], + [-1.75, 0], + [-2.5, -0.75] + ], + "c": true + } + ] + }, + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 29, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-2, -0.75], + [-2.75, 0], + [-2, 0.75], + [2, 0.75], + [2.75, 0], + [2, -0.75] + ], + "c": true + } + ] + }, + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 42, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-2, -0.75], + [-2.75, 0], + [-2, 0.75], + [2, 0.75], + [2.75, 0], + [2, -0.75] + ], + "c": true + } + ] + }, + { + "t": 51, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [2.5, -0.75], + [1.75, 0], + [2.5, 0.75], + [2, 0.75], + [2.75, 0], + [2, -0.75] + ], + "c": true + } + ] + } + ], + "ix": 2 + }, + "nm": "路径 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": { + "a": 0, + "k": [0.117647058824, 0.588235294118, 0.921568627451, 1], + "ix": 4 + }, + "o": { "a": 0, "k": 100, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "填充 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "变换" + } + ], + "nm": "Vector", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 19, + "op": 52, + "st": 19, + "ct": 1, + "bm": 0 + }, + { + "ddd": 0, + "ind": 9, + "ty": 4, + "nm": "Vector 4", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 18, + "s": [9.25, 12.75, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { "t": 38, "s": [-2.007, 12.75, 0] } + ], + "ix": 2, + "l": 2 + }, + "a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 23, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-2, -0.75], + [-2.75, 0], + [-2, 0.75], + [2, 0.75], + [2.75, 0], + [2, -0.75] + ], + "c": true + } + ] + }, + { + "t": 32, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [2.5, -0.75], + [1.75, 0], + [2.5, 0.75], + [2, 0.75], + [2.75, 0], + [2, -0.75] + ], + "c": true + } + ] + } + ], + "ix": 2 + }, + "nm": "路径 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "t": 5, + "s": [0.478431373835, 0.478431373835, 0.478431373835, 1] + }, + { + "t": 10, + "s": [0.117647059262, 0.588235318661, 0.921568632126, 1] + } + ], + "ix": 4 + }, + "o": { "a": 0, "k": 100, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "填充 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "变换" + } + ], + "nm": "Vector", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 33, + "st": 0, + "ct": 1, + "bm": 0 + }, + { + "ddd": 0, + "ind": 10, + "ty": 4, + "nm": "Vector 3", + "sr": 1, + "ks": { + "o": { "a": 0, "k": 100, "ix": 11 }, + "r": { "a": 0, "k": 0, "ix": 10 }, + "p": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 5, + "s": [11, 9.75, 0], + "to": [0, 0, 0], + "ti": [0, 0, 0] + }, + { "t": 30, "s": [-0.601, 9.75, 0] } + ], + "ix": 2, + "l": 2 + }, + "a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 }, + "s": { "a": 0, "k": [100, 100, 100], "ix": 6, "l": 2 } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 1, + "k": [ + { + "i": { "x": 0.833, "y": 0.833 }, + "o": { "x": 0.167, "y": 0.167 }, + "t": 11, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [-3.75, -0.75], + [-4.5, 0], + [-3.75, 0.75], + [3.75, 0.75], + [4.5, 0], + [3.75, -0.75] + ], + "c": true + } + ] + }, + { + "t": 30, + "s": [ + { + "i": [ + [0, 0], + [0, -0.414], + [-0.414, 0], + [0, 0], + [0, 0.414], + [0.414, 0] + ], + "o": [ + [-0.414, 0], + [0, 0.414], + [0, 0], + [0.414, 0], + [0, -0.414], + [0, 0] + ], + "v": [ + [4.438, -0.75], + [3.688, 0], + [4.438, 0.75], + [3.75, 0.75], + [4.5, 0], + [3.75, -0.75] + ], + "c": true + } + ] + } + ], + "ix": 2 + }, + "nm": "路径 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "fl", + "c": { + "a": 1, + "k": [ + { + "i": { "x": [0.833], "y": [0.833] }, + "o": { "x": [0.167], "y": [0.167] }, + "t": 5, + "s": [0.478431373835, 0.478431373835, 0.478431373835, 1] + }, + { + "t": 10, + "s": [0.117647059262, 0.588235318661, 0.921568632126, 1] + } + ], + "ix": 4 + }, + "o": { "a": 0, "k": 100, "ix": 5 }, + "r": 1, + "bm": 0, + "nm": "填充 1", + "mn": "ADBE Vector Graphic - Fill", + "hd": false + }, + { + "ty": "tr", + "p": { "a": 0, "k": [0, 0], "ix": 2 }, + "a": { "a": 0, "k": [0, 0], "ix": 1 }, + "s": { "a": 0, "k": [100, 100], "ix": 3 }, + "r": { "a": 0, "k": 0, "ix": 6 }, + "o": { "a": 0, "k": 100, "ix": 7 }, + "sk": { "a": 0, "k": 0, "ix": 4 }, + "sa": { "a": 0, "k": 0, "ix": 5 }, + "nm": "变换" + } + ], + "nm": "Vector", + "np": 2, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 0, + "op": 33, + "st": 0, + "ct": 1, + "bm": 0 + } + ], + "markers": [], + "props": {} +} diff --git a/packages/frontend/core/src/modules/theme/index.ts b/packages/frontend/core/src/modules/theme/index.ts index 027454eb9f..0d0b2466ec 100644 --- a/packages/frontend/core/src/modules/theme/index.ts +++ b/packages/frontend/core/src/modules/theme/index.ts @@ -15,3 +15,7 @@ export function configureAppThemeModule(framework: Framework) { .scope(WorkspaceScope) .service(EdgelessThemeService, [AppThemeService, EditorSettingService]); } + +export function configureEssentialThemeModule(framework: Framework) { + framework.service(AppThemeService).entity(AppTheme); +} diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 0467031b8f..7aac877679 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -1114,10 +1114,6 @@ export function useAFFiNEI18N(): { ["com.affine.auth.sign.auth.code.resend.hint"](options: { readonly second: string; }): string; - /** - * `Sent` - */ - ["com.affine.auth.sent"](): string; /** * `The verification link failed to be sent, please try again later.` */ @@ -1707,6 +1703,10 @@ export function useAFFiNEI18N(): { * `Cancel` */ ["com.affine.confirmModal.button.cancel"](): string; + /** + * `Ok` + */ + ["com.affine.confirmModal.button.ok"](): string; /** * `Current year` */ @@ -6928,7 +6928,7 @@ export function useAFFiNEI18N(): { */ ["com.affine.editor.at-menu.date-picker"](): string; /** - * `Mention Members` + * `Mention Member` */ ["com.affine.editor.at-menu.mention-member"](): string; /** @@ -7428,15 +7428,45 @@ export function useAFFiNEI18N(): { /** * `Notes` */ - ["com.affine.attachmentViewer.audio.notes"](): string; + ["com.affine.audio.notes"](): string; /** * `Transcribing` */ - ["com.affine.attachmentViewer.audio.transcribing"](): string; + ["com.affine.audio.transcribing"](): string; /** - * `Accept & Join` + * `Unable to retrieve AI results for others` */ - ["com.affine.notification.invitation.accept"](): string; + ["com.affine.audio.transcribe.non-owner.confirm.title"](): string; + /** + * `Audio activity` + */ + ["com.affine.recording.new"](): string; + /** + * `Open app` + */ + ["com.affine.recording.ready"](): string; + /** + * `{{appName}}'s audio` + */ + ["com.affine.recording.recording"](options: { + readonly appName: string; + }): string; + /** + * `Audio recording` + */ + ["com.affine.recording.recording.unnamed"](): string; + /** + * `Start` + */ + ["com.affine.recording.start"](): string; + /** + * `Dismiss` + */ + ["com.affine.recording.dismiss"](): string; + /** + * `Stop` + */ + ["com.affine.recording.stop"](): string; /** * `An internal error occurred.` */ @@ -8494,29 +8524,11 @@ export const TypedTrans: { a: JSX.Element; }>>; /** - * `<1>{{username}} has accept your invitation` + * `Please contact <1>{{user}} to upgrade AI rights or resend the attachment.` */ - ["com.affine.notification.invitation-accepted"]: ComponentType>; - /** - * `There is an issue regarding your invitation to <1>{{workspaceName}} ` - */ - ["com.affine.notification.invitation-blocked"]: ComponentType>; - /** - * `<1>{{username}} invited you to join <2>{{workspaceName}}` - */ - ["com.affine.notification.invitation"]: ComponentType, { - ["1"]: JSX.Element; - ["2"]: JSX.Element; - }>>; } = /*#__PURE__*/ createProxy(createComponent); diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 8d2d0b8192..203dad498b 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -270,7 +270,6 @@ "com.affine.auth.sign.auth.code.continue": "Continue with code", "com.affine.auth.sign.auth.code.resend": "Resend code", "com.affine.auth.sign.auth.code.resend.hint": "Resend in {{second}}s", - "com.affine.auth.sent": "Sent", "com.affine.auth.sent.change.email.fail": "The verification link failed to be sent, please try again later.", "com.affine.auth.sent.change.email.hint": "Verification link has been sent.", "com.affine.auth.sent.change.password.hint": "Reset password link has been sent.", @@ -422,6 +421,7 @@ "com.affine.copy.asImage.success": "Image copied", "com.affine.copy.asImage.failed": "Image copy failed", "com.affine.confirmModal.button.cancel": "Cancel", + "com.affine.confirmModal.button.ok": "Ok", "com.affine.currentYear": "Current year", "com.affine.delete-tags.confirm.description": "Deleting <1>{{tag}} cannot be undone, please proceed with caution.", "com.affine.delete-tags.confirm.multi-tag-description": "Deleting {{count}} tags cannot be undone, please proceed with caution.", @@ -1720,7 +1720,7 @@ "com.affine.editor.at-menu.more-docs-hint": "{{count}} more docs", "com.affine.editor.at-menu.journal": "Journal", "com.affine.editor.at-menu.date-picker": "Select a specific date", - "com.affine.editor.at-menu.mention-member": "Mention Members", + "com.affine.editor.at-menu.mention-member": "Mention Member", "com.affine.editor.at-menu.mention-success": "Successfully mentioned the member", "com.affine.editor.bi-directional-link-panel.show": "Show", "com.affine.editor.bi-directional-link-panel.hide": "Hide", @@ -1851,12 +1851,17 @@ "com.affine.integration.readwise-prop.created": "Created", "com.affine.integration.readwise-prop.updated": "Updated", "com.affine.integration.properties": "Integration properties", - "com.affine.attachmentViewer.audio.notes": "Notes", - "com.affine.attachmentViewer.audio.transcribing": "Transcribing", - "com.affine.notification.invitation-accepted": "<1>{{username}} has accept your invitation", - "com.affine.notification.invitation-blocked": "There is an issue regarding your invitation to <1>{{workspaceName}} ", - "com.affine.notification.invitation": "<1>{{username}} invited you to join <2>{{workspaceName}}", - "com.affine.notification.invitation.accept": "Accept & Join", + "com.affine.audio.notes": "Notes", + "com.affine.audio.transcribing": "Transcribing", + "com.affine.audio.transcribe.non-owner.confirm.title": "Unable to retrieve AI results for others", + "com.affine.audio.transcribe.non-owner.confirm.message": "Please contact <1>{{user}} to upgrade AI rights or resend the attachment.", + "com.affine.recording.new": "Audio activity", + "com.affine.recording.ready": "Open app", + "com.affine.recording.recording": "{{appName}}'s audio", + "com.affine.recording.recording.unnamed": "Audio recording", + "com.affine.recording.start": "Start", + "com.affine.recording.dismiss": "Dismiss", + "com.affine.recording.stop": "Stop", "error.INTERNAL_SERVER_ERROR": "An internal error occurred.", "error.NETWORK_ERROR": "Network error.", "error.TOO_MANY_REQUEST": "Too many requests.", diff --git a/tools/cli/src/webpack/html-plugin.ts b/tools/cli/src/webpack/html-plugin.ts index f47090432b..8ac216f852 100644 --- a/tools/cli/src/webpack/html-plugin.ts +++ b/tools/cli/src/webpack/html-plugin.ts @@ -112,6 +112,19 @@ export function createBackgroundWorkerHTMLPlugin( }); } +export function createPopupHTMLPlugin( + flags: BuildFlags, + BUILD_CONFIG: BUILD_CONFIG_TYPE +) { + const htmlPluginOptions = getHTMLPluginOptions(flags, BUILD_CONFIG); + + return new HTMLPlugin({ + ...htmlPluginOptions, + chunks: ['popup'], + filename: `popup.html`, + }); +} + export function createHTMLPlugins( flags: BuildFlags, BUILD_CONFIG: BUILD_CONFIG_TYPE diff --git a/tools/cli/src/webpack/index.ts b/tools/cli/src/webpack/index.ts index 56c5c4180d..80ed22a932 100644 --- a/tools/cli/src/webpack/index.ts +++ b/tools/cli/src/webpack/index.ts @@ -17,6 +17,7 @@ import { productionCacheGroups } from './cache-group.js'; import { createBackgroundWorkerHTMLPlugin, createHTMLPlugins, + createPopupHTMLPlugin, createShellHTMLPlugin, } from './html-plugin.js'; import { WebpackS3Plugin } from './s3-plugin.js'; @@ -425,6 +426,7 @@ export function createWebpackConfig( if (buildConfig.isElectron) { config.plugins.push(createShellHTMLPlugin(flags, buildConfig)); config.plugins.push(createBackgroundWorkerHTMLPlugin(flags, buildConfig)); + config.plugins.push(createPopupHTMLPlugin(flags, buildConfig)); // sourcemap url like # sourceMappingURL=76-6370cd185962bc89.js.map wont load in electron // this is because the default file:// protocol will be ignored by Chromium