mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-26 10:45:57 +08:00
feat(electron): recording popups (#11016)
Added a recording popup UI for the audio recording feature in the desktop app, improving the user experience when capturing audio from applications. ### What changed? - Created a new popup window system for displaying recording controls - Added a dedicated recording UI with start/stop controls and status indicators - Moved audio encoding logic from the main app to a dedicated module - Implemented smooth animations for popup appearance/disappearance - Updated the recording workflow to show visual feedback during recording process - Added internationalization support for recording-related text - Modified the recording status flow to include new states: new, recording, stopped, ready fix AF-2340
This commit is contained in:
@@ -1,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 (
|
||||
|
||||
@@ -167,7 +167,8 @@ export const miniNameLabel = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
height: 20,
|
||||
lineHeight: '20px',
|
||||
marginBottom: 2,
|
||||
});
|
||||
|
||||
export const miniPlayerContainer = style({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({});
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user