feat(electron): recording popups (#11016)

Added a recording popup UI for the audio recording feature in the desktop app, improving the user experience when capturing audio from applications.

### What changed?

- Created a new popup window system for displaying recording controls
- Added a dedicated recording UI with start/stop controls and status indicators
- Moved audio encoding logic from the main app to a dedicated module
- Implemented smooth animations for popup appearance/disappearance
- Updated the recording workflow to show visual feedback during recording process
- Added internationalization support for recording-related text
- Modified the recording status flow to include new states: new, recording, stopped, ready

fix AF-2340
This commit is contained in:
pengx17
2025-03-26 04:53:43 +00:00
parent 96e83a2141
commit 61c0d01da3
38 changed files with 3611 additions and 383 deletions

View File

@@ -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 = (
<Button
variant="plain"
prefix={<TranscriptWithAiIcon />}
loading={transcribing}
prefix={
<AnimatedTranscribeIcon
state={transcribing ? 'transcribing' : 'idle'}
/>
}
disabled={transcribing}
size="large"
prefixClassName={styles.notesButtonIcon}
@@ -71,11 +74,19 @@ const AttachmentAudioPlayer = ({ block }: { block: AudioAttachmentBlock }) => {
}
}}
>
{transcribing
? t['com.affine.attachmentViewer.audio.transcribing']()
: t['com.affine.attachmentViewer.audio.notes']()}
{t['com.affine.attachmentViewer.audio.notes']()}
</Button>
);
if (transcribing) {
return (
<Tooltip
content={t['com.affine.attachmentViewer.audio.transcribing']()}
>
{inner}
</Tooltip>
);
}
return inner;
}, [enableAi, transcribing, t, transcribed, block, expanded]);
return (

View File

@@ -167,7 +167,8 @@ export const miniNameLabel = style({
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
height: 20,
lineHeight: '20px',
marginBottom: 2,
});
export const miniPlayerContainer = style({

View File

@@ -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<HTMLDivElement>) => {
(e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onSeek(seekTime - 15);
onSeek(clamp(seekTime - 15, 0, duration));
},
[seekTime, onSeek]
[seekTime, duration, onSeek]
);
const handleForward = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
(e: React.MouseEvent<HTMLButtonElement>) => {
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 = ({
<div className={styles.miniRoot} onClick={onClick}>
<div className={styles.miniNameLabel}>{name}</div>
<div className={styles.miniPlayerContainer}>
<div onClick={handleRewind}>-15s</div>
<IconButton
icon={<ReduceFifteenSecondIcon />}
size={18}
variant="plain"
onClick={handleRewind}
/>
<AnimatedPlayIcon
onClick={handlePlayToggle}
className={styles.controlButton}
state={iconState}
/>
<div onClick={handleForward}>+15s</div>
<IconButton
icon={<AddThirtySecondIcon />}
size={18}
variant="plain"
onClick={handleForward}
/>
</div>
<IconButton
className={styles.miniCloseButton}

View File

@@ -1,3 +0,0 @@
import { style } from '@vanilla-extract/css';
export const root = style({});

View File

@@ -4,9 +4,9 @@ import type { LottieRef } from 'lottie-react';
import Lottie from 'lottie-react';
import { useEffect, useRef } from 'react';
import * as styles from './animated-play-icon.css';
import pausetoplay from './pausetoplay.json';
import playtopause from './playtopause.json';
import * as styles from './styles.css';
export interface AnimatedPlayIconProps {
state: 'play' | 'pause' | 'loading';

View File

@@ -0,0 +1,81 @@
import clsx from 'clsx';
import type { LottieRef } from 'lottie-react';
import Lottie from 'lottie-react';
import { useEffect, useRef } from 'react';
import * as styles from './styles.css';
import lottieData from './transcribe.json';
export interface AnimatedTranscribeIconProps {
state: 'idle' | 'transcribing';
className?: string;
onClick?: (e: React.MouseEvent) => 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 (
<Lottie
onClick={onClick}
lottieRef={lottieRef}
className={clsx(styles.root, className)}
animationData={lottieData}
loop={false} // We're handling the loop manually
autoplay={false} // We're controlling playback manually
/>
);
};

View File

@@ -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'),
}
);

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}