mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-29 08:09:52 +08:00
feat(native): record encoding (#14188)
fix #13784 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Start/stop system or meeting recordings with Ogg/Opus artifacts and native start/stop APIs; workspace backup recovery. * **Refactor** * Simplified recording lifecycle and UI flows; native runtime now orchestrates recording/processing and reporting. * **Bug Fixes** * Stronger path validation, safer import/export dialogs, consistent error handling/logging, and retry-safe recording processing. * **Chores** * Added cross-platform native audio capture and Ogg/Opus encoding support. * **Tests** * New unit, integration, and e2e tests for recording, path guards, dialogs, and workspace recovery. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,28 +1,17 @@
|
||||
import { Button } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { appIconMap } from '@affine/core/utils';
|
||||
import {
|
||||
createStreamEncoder,
|
||||
encodeRawBufferToOpus,
|
||||
type OpusStreamEncoder,
|
||||
} from '@affine/core/utils/opus-encoding';
|
||||
import { apis, events } from '@affine/electron-api';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
type Status = {
|
||||
id: number;
|
||||
status:
|
||||
| 'new'
|
||||
| 'recording'
|
||||
| 'paused'
|
||||
| 'stopped'
|
||||
| 'ready'
|
||||
| 'create-block-success'
|
||||
| 'create-block-failed';
|
||||
status: 'new' | 'recording' | 'processing' | 'ready';
|
||||
blockCreationStatus?: 'success' | 'failed';
|
||||
appName?: string;
|
||||
appGroupId?: number;
|
||||
icon?: Buffer;
|
||||
@@ -58,6 +47,7 @@ const appIcon = appIconMap[BUILD_CONFIG.appBuildType];
|
||||
|
||||
export function Recording() {
|
||||
const status = useRecordingStatus();
|
||||
const trackedNewRecordingIdsRef = useRef<Set<number>>(new Set());
|
||||
|
||||
const t = useI18n();
|
||||
const textElement = useMemo(() => {
|
||||
@@ -66,14 +56,19 @@ export function Recording() {
|
||||
}
|
||||
if (status.status === 'new') {
|
||||
return t['com.affine.recording.new']();
|
||||
} else if (status.status === 'create-block-success') {
|
||||
} else if (
|
||||
status.status === 'ready' &&
|
||||
status.blockCreationStatus === 'success'
|
||||
) {
|
||||
return t['com.affine.recording.success.prompt']();
|
||||
} else if (status.status === 'create-block-failed') {
|
||||
} else if (
|
||||
status.status === 'ready' &&
|
||||
status.blockCreationStatus === 'failed'
|
||||
) {
|
||||
return t['com.affine.recording.failed.prompt']();
|
||||
} else if (
|
||||
status.status === 'recording' ||
|
||||
status.status === 'ready' ||
|
||||
status.status === 'stopped'
|
||||
status.status === 'processing'
|
||||
) {
|
||||
if (status.appName) {
|
||||
return t['com.affine.recording.recording']({
|
||||
@@ -105,106 +100,16 @@ export function Recording() {
|
||||
await apis?.recording?.stopRecording(status.id);
|
||||
}, [status]);
|
||||
|
||||
const handleProcessStoppedRecording = useAsyncCallback(
|
||||
async (currentStreamEncoder?: OpusStreamEncoder) => {
|
||||
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([
|
||||
currentStreamEncoder
|
||||
? currentStreamEncoder.finish()
|
||||
: encodeRawBufferToOpus({
|
||||
filepath,
|
||||
sampleRate,
|
||||
numberOfChannels,
|
||||
}),
|
||||
new Promise<void>(resolve => {
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, 500); // wait at least 500ms 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(() => {
|
||||
let removed = false;
|
||||
let currentStreamEncoder: OpusStreamEncoder | undefined;
|
||||
if (!status || status.status !== 'new') return;
|
||||
if (trackedNewRecordingIdsRef.current.has(status.id)) return;
|
||||
|
||||
apis?.recording
|
||||
.getCurrentRecording()
|
||||
.then(status => {
|
||||
if (status) {
|
||||
return handleRecordingStatusChanged(status);
|
||||
}
|
||||
return;
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
const handleRecordingStatusChanged = async (status: Status) => {
|
||||
if (removed) {
|
||||
return;
|
||||
}
|
||||
if (status?.status === 'new') {
|
||||
track.popup.$.recordingBar.toggleRecordingBar({
|
||||
type: 'Meeting record',
|
||||
appName: status.appName || 'System Audio',
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
status?.status === 'recording' &&
|
||||
status.sampleRate &&
|
||||
status.numberOfChannels &&
|
||||
(!currentStreamEncoder || currentStreamEncoder.id !== status.id)
|
||||
) {
|
||||
currentStreamEncoder?.close();
|
||||
currentStreamEncoder = createStreamEncoder(status.id, {
|
||||
sampleRate: status.sampleRate,
|
||||
numberOfChannels: status.numberOfChannels,
|
||||
});
|
||||
currentStreamEncoder.poll().catch(console.error);
|
||||
}
|
||||
|
||||
if (status?.status === 'stopped') {
|
||||
handleProcessStoppedRecording(currentStreamEncoder);
|
||||
currentStreamEncoder = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
// allow processing stopped event in tray menu as well:
|
||||
const unsubscribe = events?.recording.onRecordingStatusChanged(status => {
|
||||
if (status) {
|
||||
handleRecordingStatusChanged(status).catch(console.error);
|
||||
}
|
||||
trackedNewRecordingIdsRef.current.add(status.id);
|
||||
track.popup.$.recordingBar.toggleRecordingBar({
|
||||
type: 'Meeting record',
|
||||
appName: status.appName || 'System Audio',
|
||||
});
|
||||
|
||||
return () => {
|
||||
removed = true;
|
||||
unsubscribe?.();
|
||||
currentStreamEncoder?.close();
|
||||
};
|
||||
}, [handleProcessStoppedRecording]);
|
||||
}, [status]);
|
||||
|
||||
const handleStartRecording = useAsyncCallback(async () => {
|
||||
if (!status) {
|
||||
@@ -249,7 +154,10 @@ export function Recording() {
|
||||
{t['com.affine.recording.stop']()}
|
||||
</Button>
|
||||
);
|
||||
} else if (status.status === 'stopped' || status.status === 'ready') {
|
||||
} else if (
|
||||
status.status === 'processing' ||
|
||||
(status.status === 'ready' && !status.blockCreationStatus)
|
||||
) {
|
||||
return (
|
||||
<Button
|
||||
variant="error"
|
||||
@@ -258,13 +166,19 @@ export function Recording() {
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
} else if (status.status === 'create-block-success') {
|
||||
} else if (
|
||||
status.status === 'ready' &&
|
||||
status.blockCreationStatus === 'success'
|
||||
) {
|
||||
return (
|
||||
<Button variant="primary" onClick={handleDismiss}>
|
||||
{t['com.affine.recording.success.button']()}
|
||||
</Button>
|
||||
);
|
||||
} else if (status.status === 'create-block-failed') {
|
||||
} else if (
|
||||
status.status === 'ready' &&
|
||||
status.blockCreationStatus === 'failed'
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="plain" onClick={handleDismiss}>
|
||||
|
||||
Reference in New Issue
Block a user