mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
feat(electron): audio capture permissions and settings (#11185)
fix AF-2420, AF-2391, AF-2265
This commit is contained in:
@@ -13,8 +13,21 @@ export class LitTranscriptionBlock extends BlockComponent<TranscriptionBlockMode
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
get lastCalloutBlock() {
|
||||
for (const child of this.model.children.toReversed()) {
|
||||
if (child.flavour === 'affine:callout') {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return this.std.host.renderChildren(this.model);
|
||||
return this.std.host.renderChildren(this.model, model => {
|
||||
// if model is the last transcription block, we should render it
|
||||
return model === this.lastCalloutBlock;
|
||||
});
|
||||
}
|
||||
|
||||
@property({ type: String, attribute: 'data-block-id' })
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
import { Button, Tooltip, useConfirmModal } 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 {
|
||||
AnimatedTranscribeIcon,
|
||||
Button,
|
||||
Tooltip,
|
||||
useConfirmModal,
|
||||
} from '@affine/component';
|
||||
import { AudioPlayer } from '@affine/component/ui/audio-player';
|
||||
import { useEnableAI } from '@affine/core/components/hooks/affine/use-enable-ai';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { useSeekTime } from '@affine/core/components/hooks/use-seek-time';
|
||||
import { CurrentServerScopeProvider } from '@affine/core/components/providers/current-server-scope';
|
||||
import { PublicUserLabel } from '@affine/core/modules/cloud/views/public-user';
|
||||
import { GlobalDialogService } from '@affine/core/modules/dialogs';
|
||||
import type { AudioAttachmentBlock } from '@affine/core/modules/media/entities/audio-attachment-block';
|
||||
import { useAttachmentMediaBlock } from '@affine/core/modules/media/views/use-attachment-media';
|
||||
import { AudioAttachmentService } from '@affine/core/modules/media/services/audio-attachment';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { AttachmentViewerProps } from '../types';
|
||||
import * as styles from './audio-block.css';
|
||||
@@ -177,6 +182,31 @@ const AttachmentAudioPlayer = ({ block }: { block: AudioAttachmentBlock }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const useAttachmentMediaBlock = (model: AttachmentBlockModel) => {
|
||||
const audioAttachmentService = useService(AudioAttachmentService);
|
||||
const [audioAttachmentBlock, setAttachmentMedia] = useState<
|
||||
AudioAttachmentBlock | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!model.props.sourceId) {
|
||||
return;
|
||||
}
|
||||
const entity = audioAttachmentService.get(model);
|
||||
if (!entity) {
|
||||
return;
|
||||
}
|
||||
const audioAttachmentBlock = entity.obj;
|
||||
setAttachmentMedia(audioAttachmentBlock);
|
||||
audioAttachmentBlock.mount();
|
||||
return () => {
|
||||
audioAttachmentBlock.unmount();
|
||||
entity.release();
|
||||
};
|
||||
}, [audioAttachmentService, model]);
|
||||
return audioAttachmentBlock;
|
||||
};
|
||||
|
||||
export const AudioBlockEmbedded = (props: AttachmentViewerProps) => {
|
||||
const audioAttachmentBlock = useAttachmentMediaBlock(props.model);
|
||||
const transcriptionBlock = useLiveData(
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
cursor: 'default',
|
||||
width: '100%',
|
||||
backgroundColor: cssVarV2('layer/background/primary'),
|
||||
gap: 12,
|
||||
});
|
||||
|
||||
export const upper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
fontWeight: 500,
|
||||
fontSize: '16px',
|
||||
color: cssVarV2('text/primary'),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: '24px',
|
||||
gap: 12,
|
||||
});
|
||||
|
||||
export const upperLeft = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const upperRight = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
export const upperRow = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
export const nameLabel = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
marginRight: 8,
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
export const spacer = style({
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const sizeInfo = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
|
||||
export const audioIcon = style({
|
||||
height: 40,
|
||||
width: 40,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
});
|
||||
|
||||
export const controlButton = style({
|
||||
height: 40,
|
||||
width: 40,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
color: cssVarV2('text/primary'),
|
||||
});
|
||||
|
||||
export const controls = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
});
|
||||
|
||||
export const button = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
color: cssVarV2('text/primary'),
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '4px',
|
||||
minWidth: '28px',
|
||||
height: '28px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
':hover': {
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
},
|
||||
':disabled': {
|
||||
opacity: 0.5,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
});
|
||||
|
||||
export const progressContainer = style({
|
||||
width: '100%',
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
export const progressBar = style({
|
||||
width: '100%',
|
||||
height: 12,
|
||||
backgroundColor: cssVarV2('layer/background/tertiary'),
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const progressFill = style({
|
||||
height: '100%',
|
||||
backgroundColor: cssVarV2('icon/fileIconColors/red'),
|
||||
transition: 'width 0.1s linear',
|
||||
});
|
||||
|
||||
export const timeDisplay = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
minWidth: 48,
|
||||
':last-of-type': {
|
||||
textAlign: 'right',
|
||||
},
|
||||
});
|
||||
|
||||
export const miniRoot = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
borderRadius: 4,
|
||||
padding: 8,
|
||||
cursor: 'default',
|
||||
width: '100%',
|
||||
backgroundColor: cssVarV2('layer/background/primary'),
|
||||
});
|
||||
|
||||
export const miniNameLabel = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: '20px',
|
||||
marginBottom: 2,
|
||||
});
|
||||
|
||||
export const miniPlayerContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 24,
|
||||
});
|
||||
|
||||
export const miniProgressContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 24,
|
||||
});
|
||||
|
||||
export const miniCloseButton = style({
|
||||
position: 'absolute',
|
||||
right: 8,
|
||||
top: 8,
|
||||
display: 'none',
|
||||
background: cssVarV2('layer/background/secondary'),
|
||||
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
selectors: {
|
||||
[`${miniRoot}:hover &`]: {
|
||||
display: 'block',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,233 +0,0 @@
|
||||
import { IconButton } from '@affine/component';
|
||||
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';
|
||||
import { AudioWaveform } from './audio-waveform';
|
||||
import { AnimatedPlayIcon } from './lottie/animated-play-icon';
|
||||
|
||||
// Format seconds to mm:ss
|
||||
const formatTime = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export interface AudioPlayerProps {
|
||||
// Audio metadata
|
||||
name: string;
|
||||
size: number | ReactNode; // the size entry may be used for drawing error message
|
||||
waveform: number[] | null;
|
||||
// Playback state
|
||||
playbackState: 'idle' | 'playing' | 'paused' | 'stopped';
|
||||
seekTime: number;
|
||||
duration: number;
|
||||
loading?: boolean;
|
||||
|
||||
notesEntry?: ReactNode;
|
||||
|
||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||
|
||||
// Playback controls
|
||||
onPlay: MouseEventHandler;
|
||||
onPause: MouseEventHandler;
|
||||
onStop: MouseEventHandler;
|
||||
onSeek: (newTime: number) => void;
|
||||
}
|
||||
|
||||
export const AudioPlayer = ({
|
||||
name,
|
||||
size,
|
||||
playbackState,
|
||||
seekTime,
|
||||
duration,
|
||||
notesEntry,
|
||||
waveform,
|
||||
loading,
|
||||
onPlay,
|
||||
onPause,
|
||||
onSeek,
|
||||
onClick,
|
||||
}: AudioPlayerProps) => {
|
||||
// Handle progress bar click
|
||||
const handleProgressClick = useCallback(
|
||||
(progress: number) => {
|
||||
const newTime = progress * duration;
|
||||
onSeek(newTime);
|
||||
},
|
||||
[duration, onSeek]
|
||||
);
|
||||
|
||||
const handlePlayToggle = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
if (playbackState === 'playing') {
|
||||
onPause(e);
|
||||
} else {
|
||||
onPlay(e);
|
||||
}
|
||||
},
|
||||
[loading, playbackState, onPause, onPlay]
|
||||
);
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercentage = duration > 0 ? seekTime / duration : 0;
|
||||
const iconState = loading
|
||||
? 'loading'
|
||||
: playbackState === 'playing'
|
||||
? 'pause'
|
||||
: 'play';
|
||||
|
||||
return (
|
||||
<div className={styles.root} onClick={onClick}>
|
||||
<div className={styles.upper}>
|
||||
<div className={styles.upperLeft}>
|
||||
<div className={styles.upperRow}>
|
||||
<VoiceIcon />
|
||||
<div className={styles.nameLabel}>{name}</div>
|
||||
</div>
|
||||
<div className={styles.upperRow}>
|
||||
<div className={styles.sizeInfo}>
|
||||
{typeof size === 'number' ? bytes(size) : size}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.upperRight}>
|
||||
{notesEntry}
|
||||
<AnimatedPlayIcon
|
||||
onClick={handlePlayToggle}
|
||||
className={styles.controlButton}
|
||||
state={iconState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.progressContainer}>
|
||||
<div className={styles.timeDisplay}>{formatTime(seekTime)}</div>
|
||||
<AudioWaveform
|
||||
waveform={waveform || []}
|
||||
progress={progressPercentage}
|
||||
onManualSeek={handleProgressClick}
|
||||
/>
|
||||
<div className={styles.timeDisplay}>{formatTime(duration)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MiniAudioPlayer = ({
|
||||
name,
|
||||
playbackState,
|
||||
seekTime,
|
||||
duration,
|
||||
waveform,
|
||||
onPlay,
|
||||
onPause,
|
||||
onSeek,
|
||||
onClick,
|
||||
onStop,
|
||||
}: AudioPlayerProps) => {
|
||||
// Handle progress bar click
|
||||
const handleProgressClick = useCallback(
|
||||
(progress: number) => {
|
||||
const newTime = progress * duration;
|
||||
onSeek(newTime);
|
||||
},
|
||||
[duration, onSeek]
|
||||
);
|
||||
|
||||
const handlePlayToggle = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (playbackState === 'playing') {
|
||||
onPause(e);
|
||||
} else {
|
||||
onPlay(e);
|
||||
}
|
||||
},
|
||||
[playbackState, onPlay, onPause]
|
||||
);
|
||||
|
||||
const handleRewind = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
onSeek(clamp(seekTime - 15, 0, duration));
|
||||
},
|
||||
[seekTime, duration, onSeek]
|
||||
);
|
||||
|
||||
const handleForward = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
onSeek(clamp(seekTime + 30, 0, duration));
|
||||
},
|
||||
[seekTime, duration, onSeek]
|
||||
);
|
||||
|
||||
const handleClose = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
onStop(e);
|
||||
},
|
||||
[onStop]
|
||||
);
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercentage = duration > 0 ? seekTime / duration : 0;
|
||||
const iconState =
|
||||
playbackState === 'playing'
|
||||
? 'pause'
|
||||
: playbackState === 'paused'
|
||||
? 'play'
|
||||
: 'loading';
|
||||
|
||||
return (
|
||||
<div className={styles.miniRoot} onClick={onClick}>
|
||||
<div className={styles.miniNameLabel}>{name}</div>
|
||||
<div className={styles.miniPlayerContainer}>
|
||||
<IconButton
|
||||
icon={<ReduceFifteenSecondIcon />}
|
||||
size={18}
|
||||
variant="plain"
|
||||
onClick={handleRewind}
|
||||
/>
|
||||
<AnimatedPlayIcon
|
||||
onClick={handlePlayToggle}
|
||||
className={styles.controlButton}
|
||||
state={iconState}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
icon={<AddThirtySecondIcon />}
|
||||
size={18}
|
||||
variant="plain"
|
||||
onClick={handleForward}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
className={styles.miniCloseButton}
|
||||
icon={<CloseIcon />}
|
||||
size={16}
|
||||
variant="plain"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div className={styles.miniProgressContainer}>
|
||||
<AudioWaveform
|
||||
waveform={waveform || []}
|
||||
progress={progressPercentage}
|
||||
onManualSeek={handleProgressClick}
|
||||
mini
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '1px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
maxWidth: 2000, // since we have at least 1000 samples, the max width is 2000
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
import { type AffineThemeKeyV2, cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { clamp } from 'lodash-es';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
import * as styles from './audio-waveform.css';
|
||||
|
||||
// Helper function to get computed CSS variable value
|
||||
const getCSSVarValue = (element: HTMLElement, varName: AffineThemeKeyV2) => {
|
||||
const style = getComputedStyle(element);
|
||||
const varRef = cssVarV2(varName);
|
||||
const varKey = varRef.match(/var\((.*?)\)/)?.[1];
|
||||
return varKey ? style.getPropertyValue(varKey).trim() : '';
|
||||
};
|
||||
|
||||
interface DrawWaveformOptions {
|
||||
canvas: HTMLCanvasElement;
|
||||
container: HTMLElement;
|
||||
waveform: number[];
|
||||
progress: number;
|
||||
mini: boolean;
|
||||
}
|
||||
|
||||
// to avoid the indicator being cut off at the edges
|
||||
const horizontalPadding = 2;
|
||||
|
||||
const drawWaveform = ({
|
||||
canvas,
|
||||
container,
|
||||
waveform,
|
||||
progress,
|
||||
mini,
|
||||
}: DrawWaveformOptions) => {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const rect = container.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
|
||||
const barWidth = mini ? 0.5 : 1;
|
||||
const gap = 1;
|
||||
|
||||
const availableWidth = rect.width - horizontalPadding * 2;
|
||||
const totalBars = Math.floor(availableWidth / (barWidth + gap));
|
||||
|
||||
// Resample waveform data to match number of bars
|
||||
// We have at least 1000 samples. Totalbars should be less than the total number of samples.
|
||||
const step = waveform.length / totalBars;
|
||||
const bars = Array.from({ length: totalBars }, (_, i) => {
|
||||
const startIdx = Math.floor(i * step);
|
||||
const endIdx = Math.floor((i + 1) * step);
|
||||
const slice = waveform.slice(startIdx, endIdx);
|
||||
return Math.max(slice.reduce((a, b) => a + b, 0) / slice.length, 0.1);
|
||||
});
|
||||
|
||||
// Get colors from CSS variables
|
||||
const unplayedColor = getCSSVarValue(container, 'text/placeholder');
|
||||
const playedColor = getCSSVarValue(
|
||||
container,
|
||||
'block/recordBlock/timelineIndeicator'
|
||||
);
|
||||
const progressIndex = Math.floor(progress * bars.length);
|
||||
|
||||
// Draw bars
|
||||
bars.forEach((value, i) => {
|
||||
const x = horizontalPadding + i * (barWidth + gap);
|
||||
const height = value * rect.height;
|
||||
const y = (rect.height - height) / 2;
|
||||
|
||||
ctx.fillStyle =
|
||||
progress > 0 && i <= progressIndex ? playedColor : unplayedColor;
|
||||
|
||||
// Use roundRect for rounded corners
|
||||
if (ctx.roundRect) {
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(x, y, barWidth, height, barWidth / 2);
|
||||
ctx.fill();
|
||||
} else {
|
||||
// Fallback for browsers that don't support roundRect
|
||||
ctx.fillRect(x, y, barWidth, height);
|
||||
}
|
||||
});
|
||||
|
||||
// Draw progress indicator if progress > 0
|
||||
if (progress > 0) {
|
||||
const x = horizontalPadding + progress * availableWidth;
|
||||
ctx.fillStyle = playedColor;
|
||||
|
||||
// Draw the vertical line
|
||||
ctx.fillRect(x - 0.5, 0, 1, rect.height);
|
||||
|
||||
// Draw circles at top and bottom with better positioning
|
||||
const dotRadius = 1.5;
|
||||
ctx.beginPath();
|
||||
// Top dot
|
||||
ctx.arc(x, dotRadius, dotRadius, 0, Math.PI * 2);
|
||||
// Bottom dot
|
||||
ctx.arc(x, rect.height - dotRadius, dotRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
};
|
||||
|
||||
// waveform are the amplitude of the audio that sampled at 1000 points
|
||||
// the value is between 0 and 1
|
||||
export const AudioWaveform = ({
|
||||
waveform,
|
||||
progress,
|
||||
onManualSeek,
|
||||
mini = false, // the bar will be 0.5px instead. by default, the bar is 1px
|
||||
}: {
|
||||
waveform: number[];
|
||||
progress: number;
|
||||
onManualSeek: (progress: number) => void;
|
||||
mini?: boolean;
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// Handle click events for seeking
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const availableWidth = rect.width - horizontalPadding * 2;
|
||||
const newProgress = Math.max(
|
||||
0,
|
||||
Math.min(1, (x - horizontalPadding) / availableWidth)
|
||||
);
|
||||
onManualSeek(newProgress);
|
||||
e.stopPropagation();
|
||||
},
|
||||
[onManualSeek]
|
||||
);
|
||||
|
||||
// Draw on resize
|
||||
useEffect(() => {
|
||||
const draw = () => {
|
||||
const canvas = canvasRef.current;
|
||||
const container = containerRef.current;
|
||||
if (!canvas || !container) return;
|
||||
|
||||
drawWaveform({
|
||||
canvas,
|
||||
container,
|
||||
waveform,
|
||||
progress: clamp(progress, 0, 1),
|
||||
mini,
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
draw();
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
observer.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [mini, progress, waveform]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.root}
|
||||
onClick={handleClick}
|
||||
role="slider"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={progress * 100}
|
||||
>
|
||||
<canvas ref={canvasRef} style={{ width: '100%', height: '100%' }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from './audio-player';
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Loading } from '@affine/component';
|
||||
import clsx from 'clsx';
|
||||
import type { LottieRef } from 'lottie-react';
|
||||
import Lottie from 'lottie-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import pausetoplay from './pausetoplay.json';
|
||||
import playtopause from './playtopause.json';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export interface AnimatedPlayIconProps {
|
||||
state: 'play' | 'pause' | 'loading';
|
||||
className?: string;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
const buildAnimatedLottieIcon = (data: Record<string, unknown>) => {
|
||||
const Component = ({
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
className?: string;
|
||||
}) => {
|
||||
const lottieRef: LottieRef = useRef(null);
|
||||
useEffect(() => {
|
||||
if (lottieRef.current) {
|
||||
const lottie = lottieRef.current;
|
||||
lottie.setSpeed(2);
|
||||
lottie.play();
|
||||
}
|
||||
}, []);
|
||||
return (
|
||||
<Lottie
|
||||
onClick={onClick}
|
||||
lottieRef={lottieRef}
|
||||
className={clsx(styles.root, className)}
|
||||
animationData={data}
|
||||
loop={false}
|
||||
autoplay={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return Component;
|
||||
};
|
||||
|
||||
const PlayIcon = buildAnimatedLottieIcon(playtopause);
|
||||
const PauseIcon = buildAnimatedLottieIcon(pausetoplay);
|
||||
|
||||
export const AnimatedPlayIcon = ({
|
||||
state,
|
||||
className,
|
||||
onClick,
|
||||
}: AnimatedPlayIconProps) => {
|
||||
if (state === 'loading') {
|
||||
return <Loading size={40} />;
|
||||
}
|
||||
const Icon = state === 'play' ? PlayIcon : PauseIcon;
|
||||
return <Icon onClick={onClick} className={className} />;
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
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
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,715 +0,0 @@
|
||||
{
|
||||
"v": "5.12.1",
|
||||
"fr": 60,
|
||||
"ip": 60,
|
||||
"op": 103,
|
||||
"w": 40,
|
||||
"h": 40,
|
||||
"nm": "pause to play",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "Icon (Stroke)",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.48], "y": [1] },
|
||||
"o": { "x": [0.26], "y": [1] },
|
||||
"t": 60,
|
||||
"s": [100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833], "y": [1] },
|
||||
"o": { "x": [0.26], "y": [0] },
|
||||
"t": 90,
|
||||
"s": [0]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833], "y": [1] },
|
||||
"o": { "x": [0.167], "y": [0] },
|
||||
"t": 120,
|
||||
"s": [0]
|
||||
},
|
||||
{ "t": 142, "s": [100] }
|
||||
],
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20, 20, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.48, 0.48, 0.48], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.26, 0.26, 0.26], "y": [1, 1, 0] },
|
||||
"t": 60,
|
||||
"s": [100, 100, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.02] },
|
||||
"o": { "x": [0.26, 0.26, 0.26], "y": [0, 0, 0] },
|
||||
"t": 90,
|
||||
"s": [32, 32, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.64, 0.64, 0.64], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.33, 0.33, 0.33], "y": [0.52, 0.52, 0] },
|
||||
"t": 120,
|
||||
"s": [43, 43, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 143,
|
||||
"s": [115, 115, 100]
|
||||
},
|
||||
{ "t": 159, "s": [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.782, 0.44],
|
||||
[0, 0],
|
||||
[0.504, 0.225],
|
||||
[0.525, -0.059],
|
||||
[0.453, -0.629],
|
||||
[0.051, -0.554],
|
||||
[0, -0.867],
|
||||
[0, 0],
|
||||
[-0.05, -0.55],
|
||||
[-0.309, -0.428],
|
||||
[-0.77, -0.087],
|
||||
[-0.508, 0.227],
|
||||
[-0.756, 0.425],
|
||||
[0, 0],
|
||||
[-0.468, 0.323],
|
||||
[-0.221, 0.491],
|
||||
[0.324, 0.718],
|
||||
[0.47, 0.324]
|
||||
],
|
||||
"o": [
|
||||
[0, 0],
|
||||
[-0.756, -0.425],
|
||||
[-0.508, -0.227],
|
||||
[-0.77, 0.087],
|
||||
[-0.309, 0.428],
|
||||
[-0.05, 0.55],
|
||||
[0, 0],
|
||||
[0, 0.867],
|
||||
[0.051, 0.554],
|
||||
[0.453, 0.629],
|
||||
[0.525, 0.059],
|
||||
[0.504, -0.225],
|
||||
[0, 0],
|
||||
[0.782, -0.44],
|
||||
[0.47, -0.324],
|
||||
[0.324, -0.718],
|
||||
[-0.221, -0.491],
|
||||
[-0.468, -0.323]
|
||||
],
|
||||
"v": [
|
||||
[4.482, -3.424],
|
||||
[-1.857, -6.99],
|
||||
[-3.729, -7.985],
|
||||
[-5.269, -8.313],
|
||||
[-7.19, -7.189],
|
||||
[-7.66, -5.686],
|
||||
[-7.71, -3.566],
|
||||
[-7.71, 3.566],
|
||||
[-7.66, 5.686],
|
||||
[-7.19, 7.189],
|
||||
[-5.269, 8.313],
|
||||
[-3.729, 7.985],
|
||||
[-1.857, 6.99],
|
||||
[4.482, 3.424],
|
||||
[6.365, 2.305],
|
||||
[7.467, 1.13],
|
||||
[7.467, -1.13],
|
||||
[6.365, -2.305]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "路径 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [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": "Icon (Stroke)",
|
||||
"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": "Union",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.833], "y": [0.833] },
|
||||
"o": { "x": [0.167], "y": [0.167] },
|
||||
"t": 60,
|
||||
"s": [0]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833], "y": [0.833] },
|
||||
"o": { "x": [0.167], "y": [0.167] },
|
||||
"t": 83,
|
||||
"s": [100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6], "y": [1] },
|
||||
"o": { "x": [0.32], "y": [0.94] },
|
||||
"t": 120,
|
||||
"s": [100]
|
||||
},
|
||||
{ "t": 150, "s": [0] }
|
||||
],
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20, 20, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.64, 0.64, 0.64], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.33, 0.33, 0.33], "y": [0.52, 0.52, 0] },
|
||||
"t": 60,
|
||||
"s": [43, 43, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 83,
|
||||
"s": [115, 115, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833] },
|
||||
"o": { "x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167] },
|
||||
"t": 99,
|
||||
"s": [100, 100, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 120,
|
||||
"s": [100, 100, 100]
|
||||
},
|
||||
{ "t": 150, "s": [39, 39, 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.849, 0],
|
||||
[0, -0.849],
|
||||
[0, 0],
|
||||
[-0.849, 0],
|
||||
[0, 0.849]
|
||||
],
|
||||
"o": [
|
||||
[0, -0.849],
|
||||
[-0.849, 0],
|
||||
[0, 0],
|
||||
[0, 0.849],
|
||||
[0.849, 0],
|
||||
[0, 0]
|
||||
],
|
||||
"v": [
|
||||
[-2.563, -6.152],
|
||||
[-4.101, -7.69],
|
||||
[-5.639, -6.152],
|
||||
[-5.639, 6.152],
|
||||
[-4.101, 7.69],
|
||||
[-2.563, 6.152]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "路径 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "mm",
|
||||
"mm": 5,
|
||||
"nm": "合并路径 1",
|
||||
"mn": "ADBE Vector Filter - Merge",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ind": 2,
|
||||
"ty": "sh",
|
||||
"ix": 3,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[-0.849, 0],
|
||||
[0, -0.849],
|
||||
[0, 0],
|
||||
[0.849, 0],
|
||||
[0, 0.849],
|
||||
[0, 0]
|
||||
],
|
||||
"o": [
|
||||
[0.849, 0],
|
||||
[0, 0],
|
||||
[0, 0.849],
|
||||
[-0.849, 0],
|
||||
[0, 0],
|
||||
[0, -0.849]
|
||||
],
|
||||
"v": [
|
||||
[4.101, -7.69],
|
||||
[5.639, -6.152],
|
||||
[5.639, 6.152],
|
||||
[4.101, 7.69],
|
||||
[2.563, 6.152],
|
||||
[2.563, -6.152]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "路径 2",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "mm",
|
||||
"mm": 5,
|
||||
"nm": "合并路径 2",
|
||||
"mn": "ADBE Vector Filter - Merge",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [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": "Union",
|
||||
"np": 5,
|
||||
"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": "形状图层 3",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.833], "y": [0.833] },
|
||||
"o": { "x": [0.167], "y": [0.167] },
|
||||
"t": 133,
|
||||
"s": [100]
|
||||
},
|
||||
{ "t": 145, "s": [0] }
|
||||
],
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20.52, 20.457, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 120,
|
||||
"s": [12, 12, 100]
|
||||
},
|
||||
{ "t": 145, "s": [100, 100, 100] }
|
||||
],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"d": 1,
|
||||
"ty": "el",
|
||||
"s": { "a": 0, "k": [40, 40], "ix": 2 },
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||
"nm": "椭圆路径 1",
|
||||
"mn": "ADBE Vector Shape - Ellipse",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 4 },
|
||||
"w": { "a": 0, "k": 0, "ix": 5 },
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "描边 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.936106004902, 0.936106004902, 0.936106004902, 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.52, -0.457], "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": "椭圆 1",
|
||||
"np": 3,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 120,
|
||||
"op": 161,
|
||||
"st": 60,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 4,
|
||||
"ty": 4,
|
||||
"nm": "形状图层 2",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.833], "y": [0.833] },
|
||||
"o": { "x": [0.167], "y": [0.167] },
|
||||
"t": 73,
|
||||
"s": [100]
|
||||
},
|
||||
{ "t": 85, "s": [0] }
|
||||
],
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20.52, 20.457, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 60,
|
||||
"s": [12, 12, 100]
|
||||
},
|
||||
{ "t": 85, "s": [100, 100, 100] }
|
||||
],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"d": 1,
|
||||
"ty": "el",
|
||||
"s": { "a": 0, "k": [40, 40], "ix": 2 },
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||
"nm": "椭圆路径 1",
|
||||
"mn": "ADBE Vector Shape - Ellipse",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 4 },
|
||||
"w": { "a": 0, "k": 0, "ix": 5 },
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "描边 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.936106004902, 0.936106004902, 0.936106004902, 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.52, -0.457], "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": "椭圆 1",
|
||||
"np": 3,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 101,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 5,
|
||||
"ty": 4,
|
||||
"nm": "形状图层 1",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20.52, 20.457, 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": [
|
||||
{
|
||||
"d": 1,
|
||||
"ty": "el",
|
||||
"s": { "a": 0, "k": [40, 40], "ix": 2 },
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||
"nm": "椭圆路径 1",
|
||||
"mn": "ADBE Vector Shape - Ellipse",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 4 },
|
||||
"w": { "a": 0, "k": 0, "ix": 5 },
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "描边 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.960784316063, 0.960784316063, 0.960784316063, 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.52, -0.457], "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": "椭圆 1",
|
||||
"np": 3,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 5400,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"props": {}
|
||||
}
|
||||
@@ -1,715 +0,0 @@
|
||||
{
|
||||
"v": "5.12.1",
|
||||
"fr": 60,
|
||||
"ip": 120,
|
||||
"op": 159,
|
||||
"w": 40,
|
||||
"h": 40,
|
||||
"nm": "playtopause",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "Icon (Stroke)",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.48], "y": [1] },
|
||||
"o": { "x": [0.26], "y": [1] },
|
||||
"t": 60,
|
||||
"s": [100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833], "y": [1] },
|
||||
"o": { "x": [0.26], "y": [0] },
|
||||
"t": 90,
|
||||
"s": [0]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833], "y": [1] },
|
||||
"o": { "x": [0.167], "y": [0] },
|
||||
"t": 120,
|
||||
"s": [0]
|
||||
},
|
||||
{ "t": 142, "s": [100] }
|
||||
],
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20, 20, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.48, 0.48, 0.48], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.26, 0.26, 0.26], "y": [1, 1, 0] },
|
||||
"t": 60,
|
||||
"s": [100, 100, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.02] },
|
||||
"o": { "x": [0.26, 0.26, 0.26], "y": [0, 0, 0] },
|
||||
"t": 90,
|
||||
"s": [32, 32, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.64, 0.64, 0.64], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.33, 0.33, 0.33], "y": [0.52, 0.52, 0] },
|
||||
"t": 120,
|
||||
"s": [43, 43, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 143,
|
||||
"s": [115, 115, 100]
|
||||
},
|
||||
{ "t": 159, "s": [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.782, 0.44],
|
||||
[0, 0],
|
||||
[0.504, 0.225],
|
||||
[0.525, -0.059],
|
||||
[0.453, -0.629],
|
||||
[0.051, -0.554],
|
||||
[0, -0.867],
|
||||
[0, 0],
|
||||
[-0.05, -0.55],
|
||||
[-0.309, -0.428],
|
||||
[-0.77, -0.087],
|
||||
[-0.508, 0.227],
|
||||
[-0.756, 0.425],
|
||||
[0, 0],
|
||||
[-0.468, 0.323],
|
||||
[-0.221, 0.491],
|
||||
[0.324, 0.718],
|
||||
[0.47, 0.324]
|
||||
],
|
||||
"o": [
|
||||
[0, 0],
|
||||
[-0.756, -0.425],
|
||||
[-0.508, -0.227],
|
||||
[-0.77, 0.087],
|
||||
[-0.309, 0.428],
|
||||
[-0.05, 0.55],
|
||||
[0, 0],
|
||||
[0, 0.867],
|
||||
[0.051, 0.554],
|
||||
[0.453, 0.629],
|
||||
[0.525, 0.059],
|
||||
[0.504, -0.225],
|
||||
[0, 0],
|
||||
[0.782, -0.44],
|
||||
[0.47, -0.324],
|
||||
[0.324, -0.718],
|
||||
[-0.221, -0.491],
|
||||
[-0.468, -0.323]
|
||||
],
|
||||
"v": [
|
||||
[4.482, -3.424],
|
||||
[-1.857, -6.99],
|
||||
[-3.729, -7.985],
|
||||
[-5.269, -8.313],
|
||||
[-7.19, -7.189],
|
||||
[-7.66, -5.686],
|
||||
[-7.71, -3.566],
|
||||
[-7.71, 3.566],
|
||||
[-7.66, 5.686],
|
||||
[-7.19, 7.189],
|
||||
[-5.269, 8.313],
|
||||
[-3.729, 7.985],
|
||||
[-1.857, 6.99],
|
||||
[4.482, 3.424],
|
||||
[6.365, 2.305],
|
||||
[7.467, 1.13],
|
||||
[7.467, -1.13],
|
||||
[6.365, -2.305]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "路径 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [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": "Icon (Stroke)",
|
||||
"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": "Union",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.833], "y": [0.833] },
|
||||
"o": { "x": [0.167], "y": [0.167] },
|
||||
"t": 60,
|
||||
"s": [0]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833], "y": [0.833] },
|
||||
"o": { "x": [0.167], "y": [0.167] },
|
||||
"t": 83,
|
||||
"s": [100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6], "y": [1] },
|
||||
"o": { "x": [0.32], "y": [0.94] },
|
||||
"t": 120,
|
||||
"s": [100]
|
||||
},
|
||||
{ "t": 150, "s": [0] }
|
||||
],
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20, 20, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.64, 0.64, 0.64], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.33, 0.33, 0.33], "y": [0.52, 0.52, 0] },
|
||||
"t": 60,
|
||||
"s": [43, 43, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 83,
|
||||
"s": [115, 115, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.833, 0.833, 0.833], "y": [0.833, 0.833, 0.833] },
|
||||
"o": { "x": [0.167, 0.167, 0.167], "y": [0.167, 0.167, 0.167] },
|
||||
"t": 99,
|
||||
"s": [100, 100, 100]
|
||||
},
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 120,
|
||||
"s": [100, 100, 100]
|
||||
},
|
||||
{ "t": 150, "s": [39, 39, 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.849, 0],
|
||||
[0, -0.849],
|
||||
[0, 0],
|
||||
[-0.849, 0],
|
||||
[0, 0.849]
|
||||
],
|
||||
"o": [
|
||||
[0, -0.849],
|
||||
[-0.849, 0],
|
||||
[0, 0],
|
||||
[0, 0.849],
|
||||
[0.849, 0],
|
||||
[0, 0]
|
||||
],
|
||||
"v": [
|
||||
[-2.563, -6.152],
|
||||
[-4.101, -7.69],
|
||||
[-5.639, -6.152],
|
||||
[-5.639, 6.152],
|
||||
[-4.101, 7.69],
|
||||
[-2.563, 6.152]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "路径 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "mm",
|
||||
"mm": 5,
|
||||
"nm": "合并路径 1",
|
||||
"mn": "ADBE Vector Filter - Merge",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ind": 2,
|
||||
"ty": "sh",
|
||||
"ix": 3,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[-0.849, 0],
|
||||
[0, -0.849],
|
||||
[0, 0],
|
||||
[0.849, 0],
|
||||
[0, 0.849],
|
||||
[0, 0]
|
||||
],
|
||||
"o": [
|
||||
[0.849, 0],
|
||||
[0, 0],
|
||||
[0, 0.849],
|
||||
[-0.849, 0],
|
||||
[0, 0],
|
||||
[0, -0.849]
|
||||
],
|
||||
"v": [
|
||||
[4.101, -7.69],
|
||||
[5.639, -6.152],
|
||||
[5.639, 6.152],
|
||||
[4.101, 7.69],
|
||||
[2.563, 6.152],
|
||||
[2.563, -6.152]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "路径 2",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "mm",
|
||||
"mm": 5,
|
||||
"nm": "合并路径 2",
|
||||
"mn": "ADBE Vector Filter - Merge",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [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": "Union",
|
||||
"np": 5,
|
||||
"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": "形状图层 3",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.833], "y": [0.833] },
|
||||
"o": { "x": [0.167], "y": [0.167] },
|
||||
"t": 133,
|
||||
"s": [100]
|
||||
},
|
||||
{ "t": 145, "s": [0] }
|
||||
],
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20.52, 20.457, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 120,
|
||||
"s": [12, 12, 100]
|
||||
},
|
||||
{ "t": 145, "s": [100, 100, 100] }
|
||||
],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"d": 1,
|
||||
"ty": "el",
|
||||
"s": { "a": 0, "k": [40, 40], "ix": 2 },
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||
"nm": "椭圆路径 1",
|
||||
"mn": "ADBE Vector Shape - Ellipse",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 4 },
|
||||
"w": { "a": 0, "k": 0, "ix": 5 },
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "描边 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.936106004902, 0.936106004902, 0.936106004902, 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.52, -0.457], "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": "椭圆 1",
|
||||
"np": 3,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 120,
|
||||
"op": 161,
|
||||
"st": 60,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 4,
|
||||
"ty": 4,
|
||||
"nm": "形状图层 2",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.833], "y": [0.833] },
|
||||
"o": { "x": [0.167], "y": [0.167] },
|
||||
"t": 73,
|
||||
"s": [100]
|
||||
},
|
||||
{ "t": 85, "s": [0] }
|
||||
],
|
||||
"ix": 11
|
||||
},
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20.52, 20.457, 0], "ix": 2, "l": 2 },
|
||||
"a": { "a": 0, "k": [0, 0, 0], "ix": 1, "l": 2 },
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": { "x": [0.6, 0.6, 0.6], "y": [1, 1, 1] },
|
||||
"o": { "x": [0.32, 0.32, 0.32], "y": [0.94, 0.94, 0] },
|
||||
"t": 60,
|
||||
"s": [12, 12, 100]
|
||||
},
|
||||
{ "t": 85, "s": [100, 100, 100] }
|
||||
],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"d": 1,
|
||||
"ty": "el",
|
||||
"s": { "a": 0, "k": [40, 40], "ix": 2 },
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||
"nm": "椭圆路径 1",
|
||||
"mn": "ADBE Vector Shape - Ellipse",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 4 },
|
||||
"w": { "a": 0, "k": 0, "ix": 5 },
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "描边 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.936106004902, 0.936106004902, 0.936106004902, 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.52, -0.457], "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": "椭圆 1",
|
||||
"np": 3,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 101,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 5,
|
||||
"ty": 4,
|
||||
"nm": "形状图层 1",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": { "a": 0, "k": 100, "ix": 11 },
|
||||
"r": { "a": 0, "k": 0, "ix": 10 },
|
||||
"p": { "a": 0, "k": [20.52, 20.457, 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": [
|
||||
{
|
||||
"d": 1,
|
||||
"ty": "el",
|
||||
"s": { "a": 0, "k": [40, 40], "ix": 2 },
|
||||
"p": { "a": 0, "k": [0, 0], "ix": 3 },
|
||||
"nm": "椭圆路径 1",
|
||||
"mn": "ADBE Vector Shape - Ellipse",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.960784316063, 0.960784316063, 0.960784316063, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": { "a": 0, "k": 100, "ix": 4 },
|
||||
"w": { "a": 0, "k": 0, "ix": 5 },
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "描边 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "fl",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.960784316063, 0.960784316063, 0.960784316063, 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.52, -0.457], "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": "椭圆 1",
|
||||
"np": 3,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 5400,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": [],
|
||||
"props": {}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
export const root = style({
|
||||
display: 'inline-flex',
|
||||
});
|
||||
|
||||
// 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
@@ -1,3 +1,4 @@
|
||||
import { MiniAudioPlayer } from '@affine/component/ui/audio-player';
|
||||
import { AudioMediaManagerService } from '@affine/core/modules/media';
|
||||
import type { AudioAttachmentBlock } from '@affine/core/modules/media/entities/audio-attachment-block';
|
||||
import { AudioAttachmentService } from '@affine/core/modules/media/services/audio-attachment';
|
||||
@@ -5,8 +6,7 @@ import { LiveData, useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { combineLatest, debounceTime, map, of } from 'rxjs';
|
||||
|
||||
import { MiniAudioPlayer } from '../audio-player';
|
||||
import { useSeekTime } from '../audio-player/use-seek-time';
|
||||
import { useSeekTime } from '../hooks/use-seek-time';
|
||||
import * as styles from './sidebar-audio-player.css';
|
||||
|
||||
export const SidebarAudioPlayer = () => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FolderIcon,
|
||||
InformationIcon,
|
||||
KeyboardIcon,
|
||||
MeetingIcon,
|
||||
NotificationIcon,
|
||||
PenIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
@@ -23,6 +24,7 @@ import { BillingSettings } from './billing';
|
||||
import { EditorSettings } from './editor';
|
||||
import { ExperimentalFeatures } from './experimental-features';
|
||||
import { PaymentIcon, UpgradeIcon } from './icons';
|
||||
import { MeetingsSettings } from './meetings';
|
||||
import { NotificationSettings } from './notifications';
|
||||
import { AFFiNEPricingPlans } from './plans';
|
||||
import { Shortcuts } from './shortcuts';
|
||||
@@ -46,6 +48,9 @@ export const useGeneralSettingList = (): GeneralSettingList => {
|
||||
const enableEditorSettings = useLiveData(
|
||||
featureFlagService.flags.enable_editor_settings.$
|
||||
);
|
||||
const enableMeetings = useLiveData(
|
||||
featureFlagService.flags.enable_meetings.$
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
userFeatureService.userFeature.revalidate();
|
||||
@@ -83,6 +88,15 @@ export const useGeneralSettingList = (): GeneralSettingList => {
|
||||
});
|
||||
}
|
||||
|
||||
if (enableMeetings) {
|
||||
settings.push({
|
||||
key: 'meetings',
|
||||
title: t['com.affine.settings.meetings'](),
|
||||
icon: <MeetingIcon />,
|
||||
testId: 'meetings-panel-trigger',
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPaymentFeature) {
|
||||
settings.splice(4, 0, {
|
||||
key: 'plans',
|
||||
@@ -147,6 +161,8 @@ export const GeneralSetting = ({
|
||||
return <EditorSettings />;
|
||||
case 'appearance':
|
||||
return <AppearanceSettings />;
|
||||
case 'meetings':
|
||||
return <MeetingsSettings />;
|
||||
case 'about':
|
||||
return <AboutAffine />;
|
||||
case 'plans':
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
IconButton,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
Switch,
|
||||
useConfirmModal,
|
||||
} from '@affine/component';
|
||||
import {
|
||||
SettingHeader,
|
||||
SettingRow,
|
||||
SettingWrapper,
|
||||
} from '@affine/component/setting-components';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { MeetingSettingsService } from '@affine/core/modules/media/services/meeting-settings';
|
||||
import type { MeetingSettingsSchema } from '@affine/electron/main/shared-state-schema';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import {
|
||||
ArrowRightSmallIcon,
|
||||
DoneIcon,
|
||||
InformationFillDuotoneIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const RecordingModes: MeetingSettingsSchema['recordingMode'][] = [
|
||||
'prompt',
|
||||
'auto-start',
|
||||
'none',
|
||||
];
|
||||
|
||||
const RecordingModeMenu = () => {
|
||||
const meetingSettingsService = useService(MeetingSettingsService);
|
||||
const settings = useLiveData(meetingSettingsService.settings$);
|
||||
const t = useI18n();
|
||||
|
||||
const options = useMemo(() => {
|
||||
return RecordingModes.map(mode => ({
|
||||
label: t[`com.affine.settings.meetings.record.recording-mode.${mode}`](),
|
||||
value: mode,
|
||||
}));
|
||||
}, [t]);
|
||||
|
||||
const currentMode = settings.recordingMode;
|
||||
|
||||
const handleRecordingModeChange = useCallback(
|
||||
(mode: MeetingSettingsSchema['recordingMode']) => {
|
||||
meetingSettingsService.setRecordingMode(mode);
|
||||
},
|
||||
[meetingSettingsService]
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
items={options.map(option => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
title={option.label}
|
||||
onSelect={() => handleRecordingModeChange(option.value)}
|
||||
data-selected={currentMode === option.value}
|
||||
>
|
||||
{option.label}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
>
|
||||
<MenuTrigger style={{ fontWeight: 600, width: '250px' }} block={true}>
|
||||
{options.find(option => option.value === currentMode)?.label}
|
||||
</MenuTrigger>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const MeetingsSettings = () => {
|
||||
const t = useI18n();
|
||||
const meetingSettingsService = useService(MeetingSettingsService);
|
||||
const settings = useLiveData(meetingSettingsService.settings$);
|
||||
const [recordingFeatureAvailable, setRecordingFeatureAvailable] =
|
||||
useState(false);
|
||||
|
||||
const [screenRecordingPermission, setScreenRecordingPermission] =
|
||||
useState(false);
|
||||
|
||||
const confirmModal = useConfirmModal();
|
||||
|
||||
useEffect(() => {
|
||||
meetingSettingsService
|
||||
.isRecordingFeatureAvailable()
|
||||
.then(available => {
|
||||
setRecordingFeatureAvailable(available ?? false);
|
||||
})
|
||||
.catch(() => {
|
||||
setRecordingFeatureAvailable(false);
|
||||
});
|
||||
meetingSettingsService
|
||||
.checkScreenRecordingPermission()
|
||||
.then(permission => {
|
||||
setScreenRecordingPermission(permission ?? false);
|
||||
})
|
||||
.catch(err => console.log(err));
|
||||
}, [meetingSettingsService]);
|
||||
|
||||
const handleEnabledChange = useAsyncCallback(
|
||||
async (checked: boolean) => {
|
||||
try {
|
||||
await meetingSettingsService.setEnabled(checked);
|
||||
} catch {
|
||||
confirmModal.openConfirmModal({
|
||||
title:
|
||||
t['com.affine.settings.meetings.record.permission-modal.title'](),
|
||||
description:
|
||||
t[
|
||||
'com.affine.settings.meetings.record.permission-modal.description'
|
||||
](),
|
||||
onConfirm: async () => {
|
||||
await meetingSettingsService.showScreenRecordingPermissionSetting();
|
||||
},
|
||||
cancelText: t['com.affine.recording.dismiss'](),
|
||||
confirmButtonOptions: {
|
||||
variant: 'primary',
|
||||
},
|
||||
confirmText:
|
||||
t[
|
||||
'com.affine.settings.meetings.record.permission-modal.open-setting'
|
||||
](),
|
||||
});
|
||||
}
|
||||
},
|
||||
[confirmModal, meetingSettingsService, t]
|
||||
);
|
||||
|
||||
const handleAutoTranscriptionChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
meetingSettingsService.setAutoTranscription(checked);
|
||||
},
|
||||
[meetingSettingsService]
|
||||
);
|
||||
|
||||
const handleOpenScreenRecordingPermissionSetting =
|
||||
useAsyncCallback(async () => {
|
||||
await meetingSettingsService.showScreenRecordingPermissionSetting();
|
||||
}, [meetingSettingsService]);
|
||||
|
||||
const handleOpenSavedRecordings = useAsyncCallback(async () => {
|
||||
await meetingSettingsService.openSavedRecordings();
|
||||
}, [meetingSettingsService]);
|
||||
|
||||
return (
|
||||
<div className={styles.meetingWrapper}>
|
||||
<SettingHeader title={t['com.affine.settings.meetings']()} />
|
||||
|
||||
<SettingRow
|
||||
name={t['com.affine.settings.meetings.enable.title']()}
|
||||
desc={t['com.affine.settings.meetings.enable.description']()}
|
||||
>
|
||||
<Switch
|
||||
checked={settings.enabled}
|
||||
onChange={handleEnabledChange}
|
||||
data-testid="meetings-enable-switch"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{recordingFeatureAvailable && (
|
||||
<>
|
||||
<SettingWrapper
|
||||
disabled={!settings.enabled}
|
||||
title={t['com.affine.settings.meetings.record.header']()}
|
||||
>
|
||||
<SettingRow
|
||||
name={t['com.affine.settings.meetings.record.recording-mode']()}
|
||||
desc={t[
|
||||
'com.affine.settings.meetings.record.recording-mode.description'
|
||||
]()}
|
||||
>
|
||||
<RecordingModeMenu />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name={t['com.affine.settings.meetings.record.open-saved-file']()}
|
||||
desc={t[
|
||||
'com.affine.settings.meetings.record.open-saved-file.description'
|
||||
]()}
|
||||
>
|
||||
<IconButton
|
||||
icon={<ArrowRightSmallIcon />}
|
||||
onClick={handleOpenSavedRecordings}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingWrapper>
|
||||
<SettingWrapper
|
||||
disabled={!settings.enabled}
|
||||
title={t['com.affine.settings.meetings.transcription.header']()}
|
||||
>
|
||||
<SettingRow
|
||||
name={t[
|
||||
'com.affine.settings.meetings.transcription.auto-transcription'
|
||||
]()}
|
||||
desc={t[
|
||||
'com.affine.settings.meetings.transcription.auto-transcription.description'
|
||||
]()}
|
||||
>
|
||||
<Switch
|
||||
checked={settings.autoTranscription}
|
||||
onChange={handleAutoTranscriptionChange}
|
||||
data-testid="meetings-auto-transcription-switch"
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingWrapper>
|
||||
<SettingWrapper
|
||||
title={t['com.affine.settings.meetings.privacy.header']()}
|
||||
>
|
||||
<SettingRow
|
||||
name={t[
|
||||
'com.affine.settings.meetings.privacy.screen-system-audio-recording'
|
||||
]()}
|
||||
desc={
|
||||
<>
|
||||
{t[
|
||||
'com.affine.settings.meetings.privacy.screen-system-audio-recording.description'
|
||||
]()}
|
||||
{!screenRecordingPermission && (
|
||||
<span
|
||||
onClick={handleOpenScreenRecordingPermissionSetting}
|
||||
className={styles.permissionSetting}
|
||||
>
|
||||
{t[
|
||||
'com.affine.settings.meetings.privacy.screen-system-audio-recording.permission-setting'
|
||||
]()}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
icon={
|
||||
screenRecordingPermission ? (
|
||||
<DoneIcon />
|
||||
) : (
|
||||
<InformationFillDuotoneIcon
|
||||
className={styles.noPermissionIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
onClick={handleOpenScreenRecordingPermissionSetting}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingWrapper>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const meetingWrapper = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
});
|
||||
|
||||
export const permissionSetting = style({
|
||||
color: cssVarV2('text/link'),
|
||||
cursor: 'pointer',
|
||||
marginLeft: 4,
|
||||
});
|
||||
|
||||
export const noPermissionIcon = style({
|
||||
color: cssVarV2('button/error'),
|
||||
});
|
||||
@@ -13,6 +13,7 @@ export type SettingTab =
|
||||
| 'experimental-features'
|
||||
| 'editor'
|
||||
| 'account'
|
||||
| 'meetings'
|
||||
| `workspace:${'preference' | 'properties' | 'members' | 'storage' | 'billing' | 'license' | 'integrations'}`;
|
||||
|
||||
export type GLOBAL_DIALOG_SCHEMA = {
|
||||
|
||||
@@ -225,15 +225,6 @@ export const AFFINE_FLAGS = {
|
||||
configurable: !isMobile,
|
||||
defaultState: false,
|
||||
},
|
||||
enable_audio_block: {
|
||||
category: 'affine',
|
||||
displayName:
|
||||
'com.affine.settings.workspace.experimental-features.enable-audio-block.name',
|
||||
description:
|
||||
'com.affine.settings.workspace.experimental-features.enable-audio-block.description',
|
||||
configurable: !isMobile,
|
||||
defaultState: false,
|
||||
},
|
||||
enable_editor_rtl: {
|
||||
category: 'affine',
|
||||
displayName:
|
||||
@@ -274,6 +265,24 @@ export const AFFINE_FLAGS = {
|
||||
configurable: isCanaryBuild,
|
||||
defaultState: false,
|
||||
},
|
||||
enable_audio_block: {
|
||||
category: 'affine',
|
||||
displayName:
|
||||
'com.affine.settings.workspace.experimental-features.enable-audio-block.name',
|
||||
description:
|
||||
'com.affine.settings.workspace.experimental-features.enable-audio-block.description',
|
||||
configurable: !isMobile,
|
||||
defaultState: false,
|
||||
},
|
||||
enable_meetings: {
|
||||
category: 'affine',
|
||||
displayName:
|
||||
'com.affine.settings.workspace.experimental-features.enable-meetings.name',
|
||||
description:
|
||||
'com.affine.settings.workspace.experimental-features.enable-meetings.description',
|
||||
configurable: !isMobile && environment.isMacOs,
|
||||
defaultState: false,
|
||||
},
|
||||
} satisfies { [key in string]: FlagInfo };
|
||||
|
||||
// oxlint-disable-next-line no-redeclare
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { encodeAudioBlobToOpus } from '@affine/core/utils/webm-encoding';
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { AiJobStatus } from '@affine/graphql';
|
||||
import {
|
||||
@@ -118,7 +119,8 @@ export class AudioAttachmentBlock extends Entity<AttachmentBlockModel> {
|
||||
if (!buffer) {
|
||||
throw new Error('No audio buffer available');
|
||||
}
|
||||
const blob = new Blob([buffer], { type: this.props.props.type });
|
||||
const encodedBuffer = await encodeAudioBlobToOpus(buffer, 64000);
|
||||
const blob = new Blob([encodedBuffer], { type: this.props.props.type });
|
||||
const file = new File([blob], this.props.props.name, {
|
||||
type: this.props.props.type,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { Framework } from '@toeverything/infra';
|
||||
|
||||
import { DefaultServerService, WorkspaceServerService } from '../cloud';
|
||||
import { DesktopApiService } from '../desktop-api';
|
||||
import { GlobalState } from '../storage';
|
||||
import { GlobalState, GlobalStateService } from '../storage';
|
||||
import { WorkbenchService } from '../workbench';
|
||||
import { WorkspaceScope, WorkspaceService } from '../workspace';
|
||||
import { AudioAttachmentBlock } from './entities/audio-attachment-block';
|
||||
@@ -16,9 +15,11 @@ import {
|
||||
} from './providers/global-audio-state';
|
||||
import { AudioAttachmentService } from './services/audio-attachment';
|
||||
import { AudioMediaManagerService } from './services/audio-media-manager';
|
||||
import { MeetingSettingsService } from './services/meeting-settings';
|
||||
|
||||
export function configureMediaModule(framework: Framework) {
|
||||
framework
|
||||
.service(MeetingSettingsService, [GlobalStateService])
|
||||
.scope(WorkspaceScope)
|
||||
.entity(AudioMedia, [WorkspaceService])
|
||||
.entity(AudioAttachmentBlock, [AudioMediaManagerService, WorkspaceService])
|
||||
@@ -31,27 +32,18 @@ export function configureMediaModule(framework: Framework) {
|
||||
WorkspaceServerService,
|
||||
DefaultServerService,
|
||||
])
|
||||
.service(AudioAttachmentService);
|
||||
.service(AudioAttachmentService)
|
||||
.service(AudioMediaManagerService, [
|
||||
GlobalMediaStateProvider,
|
||||
WorkbenchService,
|
||||
]);
|
||||
|
||||
if (BUILD_CONFIG.isElectron) {
|
||||
framework
|
||||
.impl(GlobalMediaStateProvider, ElectronGlobalMediaStateProvider, [
|
||||
GlobalState,
|
||||
])
|
||||
.scope(WorkspaceScope)
|
||||
.service(AudioMediaManagerService, [
|
||||
GlobalMediaStateProvider,
|
||||
WorkbenchService,
|
||||
DesktopApiService,
|
||||
]);
|
||||
framework.impl(GlobalMediaStateProvider, ElectronGlobalMediaStateProvider, [
|
||||
GlobalState,
|
||||
]);
|
||||
} else {
|
||||
framework
|
||||
.impl(GlobalMediaStateProvider, WebGlobalMediaStateProvider)
|
||||
.scope(WorkspaceScope)
|
||||
.service(AudioMediaManagerService, [
|
||||
GlobalMediaStateProvider,
|
||||
WorkbenchService,
|
||||
]);
|
||||
framework.impl(GlobalMediaStateProvider, WebGlobalMediaStateProvider);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { clamp } from 'lodash-es';
|
||||
import { distinctUntilChanged } from 'rxjs';
|
||||
|
||||
import type { DesktopApiService } from '../../desktop-api';
|
||||
import { DesktopApiService } from '../../desktop-api';
|
||||
import type { WorkbenchService } from '../../workbench';
|
||||
import { AudioMedia } from '../entities/audio-media';
|
||||
import type { BaseGlobalMediaStateProvider } from '../providers/global-audio-state';
|
||||
@@ -36,18 +36,13 @@ export class AudioMediaManagerService extends Service {
|
||||
});
|
||||
|
||||
private readonly mediaDisposables = new WeakMap<AudioMedia, (() => void)[]>();
|
||||
private readonly desktopApi = this.framework.getOptional(DesktopApiService);
|
||||
|
||||
constructor(
|
||||
private readonly globalMediaState: BaseGlobalMediaStateProvider,
|
||||
private readonly workbench: WorkbenchService,
|
||||
private readonly desktopApi?: DesktopApiService
|
||||
private readonly workbench: WorkbenchService
|
||||
) {
|
||||
super();
|
||||
|
||||
if (!BUILD_CONFIG.isElectron) {
|
||||
this.desktopApi = undefined;
|
||||
}
|
||||
|
||||
this.disposables.push(() => {
|
||||
this.mediaPool.clear();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type {
|
||||
MeetingSettingsKey,
|
||||
MeetingSettingsSchema,
|
||||
} from '@affine/electron/main/shared-state-schema';
|
||||
import { LiveData, Service } from '@toeverything/infra';
|
||||
import { defaults } from 'lodash-es';
|
||||
|
||||
import { DesktopApiService } from '../../desktop-api';
|
||||
import type { GlobalStateService } from '../../storage';
|
||||
|
||||
const MEETING_SETTINGS_KEY: typeof MeetingSettingsKey = 'meetingSettings';
|
||||
|
||||
const defaultMeetingSettings: MeetingSettingsSchema = {
|
||||
enabled: false,
|
||||
recordingSavingMode: 'new-doc',
|
||||
autoTranscription: true,
|
||||
recordingMode: 'prompt',
|
||||
};
|
||||
|
||||
export class MeetingSettingsService extends Service {
|
||||
constructor(private readonly globalStateService: GlobalStateService) {
|
||||
super();
|
||||
}
|
||||
|
||||
private readonly desktopApiService =
|
||||
this.framework.getOptional(DesktopApiService);
|
||||
|
||||
readonly settings$ = LiveData.computed(get => {
|
||||
const value = get(
|
||||
LiveData.from(
|
||||
this.globalStateService.globalState.watch<MeetingSettingsSchema>(
|
||||
MEETING_SETTINGS_KEY
|
||||
),
|
||||
undefined
|
||||
)
|
||||
);
|
||||
return defaults(value, defaultMeetingSettings);
|
||||
});
|
||||
|
||||
get settings() {
|
||||
return this.settings$.value;
|
||||
}
|
||||
|
||||
// we do not want the caller to directly set the settings,
|
||||
// there could be some side effects when the settings are changed.
|
||||
async setEnabled(enabled: boolean) {
|
||||
const currentEnabled = this.settings.enabled;
|
||||
if (currentEnabled === enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(await this.isRecordingFeatureAvailable())) {
|
||||
return;
|
||||
}
|
||||
|
||||
// when the user enable the recording feature the first time,
|
||||
// the app may prompt the user to allow the recording feature by MacOS.
|
||||
// when the user allows the recording feature, the app may be required to restart.
|
||||
if (enabled) {
|
||||
// if the user already enabled the recording feature, we need to disable it
|
||||
const successful =
|
||||
await this.desktopApiService?.handler.recording.setupRecordingFeature();
|
||||
if (!successful) {
|
||||
throw new Error('Failed to setup recording feature');
|
||||
}
|
||||
} else {
|
||||
// check if there is any ongoing recording
|
||||
const ongoingRecording =
|
||||
await this.desktopApiService?.handler.recording.getCurrentRecording();
|
||||
if (
|
||||
ongoingRecording &&
|
||||
ongoingRecording.status !== 'new' &&
|
||||
ongoingRecording.status !== 'ready'
|
||||
) {
|
||||
throw new Error('There is an ongoing recording, please stop it first');
|
||||
}
|
||||
// if the user disabled the recording feature, we need to setup the recording feature
|
||||
await this.desktopApiService?.handler.recording.disableRecordingFeature();
|
||||
}
|
||||
|
||||
// Only update the state after successful feature setup/disable
|
||||
this.globalStateService.globalState.set(MEETING_SETTINGS_KEY, {
|
||||
...this.settings$.value,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
setRecordingSavingMode(mode: MeetingSettingsSchema['recordingSavingMode']) {
|
||||
this.globalStateService.globalState.set(MEETING_SETTINGS_KEY, {
|
||||
...this.settings$.value,
|
||||
recordingSavingMode: mode,
|
||||
});
|
||||
}
|
||||
|
||||
setAutoTranscription(autoTranscription: boolean) {
|
||||
this.globalStateService.globalState.set(MEETING_SETTINGS_KEY, {
|
||||
...this.settings$.value,
|
||||
autoTranscription,
|
||||
});
|
||||
}
|
||||
|
||||
// this is a desktop-only feature for MacOS version 14.2 and above
|
||||
async isRecordingFeatureAvailable() {
|
||||
return this.desktopApiService?.handler.recording.checkRecordingAvailable();
|
||||
}
|
||||
|
||||
async checkScreenRecordingPermission() {
|
||||
return this.desktopApiService?.handler.recording.checkScreenRecordingPermission();
|
||||
}
|
||||
|
||||
// the following methods are only available on MacOS right?
|
||||
async showScreenRecordingPermissionSetting() {
|
||||
return this.desktopApiService?.handler.recording.showScreenRecordingPermissionSetting();
|
||||
}
|
||||
|
||||
setRecordingMode = (mode: MeetingSettingsSchema['recordingMode']) => {
|
||||
const currentMode = this.settings.recordingMode;
|
||||
|
||||
if (currentMode === mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.globalStateService.globalState.set(MEETING_SETTINGS_KEY, {
|
||||
...this.settings,
|
||||
recordingMode: mode,
|
||||
});
|
||||
};
|
||||
|
||||
async openSavedRecordings() {
|
||||
// todo: open the saved recordings folder
|
||||
await this.desktopApiService?.handler.recording.showSavedRecordings();
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { AttachmentBlockModel } from '@blocksuite/affine/model';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import type { AudioAttachmentBlock } from '../entities/audio-attachment-block';
|
||||
import { AudioAttachmentService } from '../services/audio-attachment';
|
||||
|
||||
export const useAttachmentMediaBlock = (model: AttachmentBlockModel) => {
|
||||
const audioAttachmentService = useService(AudioAttachmentService);
|
||||
const [audioAttachmentBlock, setAttachmentMedia] = useState<
|
||||
AudioAttachmentBlock | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!model.props.sourceId) {
|
||||
return;
|
||||
}
|
||||
const entity = audioAttachmentService.get(model);
|
||||
if (!entity) {
|
||||
return;
|
||||
}
|
||||
const audioAttachmentBlock = entity.obj;
|
||||
setAttachmentMedia(audioAttachmentBlock);
|
||||
audioAttachmentBlock.mount();
|
||||
return () => {
|
||||
audioAttachmentBlock.unmount();
|
||||
entity.release();
|
||||
};
|
||||
}, [audioAttachmentService, model]);
|
||||
return audioAttachmentBlock;
|
||||
};
|
||||
222
packages/frontend/core/src/utils/webm-encoding.ts
Normal file
222
packages/frontend/core/src/utils/webm-encoding.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { ArrayBufferTarget, Muxer } from 'webm-muxer';
|
||||
|
||||
interface AudioEncodingConfig {
|
||||
sampleRate: number;
|
||||
numberOfChannels: number;
|
||||
bitrate?: number;
|
||||
}
|
||||
|
||||
const logger = new DebugLogger('webm-encoding');
|
||||
|
||||
/**
|
||||
* Creates and configures an Opus encoder with the given settings
|
||||
*/
|
||||
async function createOpusEncoder(config: AudioEncodingConfig): Promise<{
|
||||
encoder: AudioEncoder;
|
||||
encodedChunks: EncodedAudioChunk[];
|
||||
}> {
|
||||
const encodedChunks: EncodedAudioChunk[] = [];
|
||||
const encoder = new AudioEncoder({
|
||||
output: chunk => {
|
||||
encodedChunks.push(chunk);
|
||||
},
|
||||
error: err => {
|
||||
throw new Error(`Encoding error: ${err}`);
|
||||
},
|
||||
});
|
||||
|
||||
encoder.configure({
|
||||
codec: 'opus',
|
||||
sampleRate: config.sampleRate,
|
||||
numberOfChannels: config.numberOfChannels,
|
||||
bitrate: config.bitrate ?? 64000,
|
||||
});
|
||||
|
||||
return { encoder, encodedChunks };
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes audio frames using the provided encoder
|
||||
*/
|
||||
async function encodeAudioFrames({
|
||||
audioData,
|
||||
numberOfChannels,
|
||||
sampleRate,
|
||||
encoder,
|
||||
}: {
|
||||
audioData: Float32Array;
|
||||
numberOfChannels: number;
|
||||
sampleRate: number;
|
||||
encoder: AudioEncoder;
|
||||
}): Promise<void> {
|
||||
const CHUNK_SIZE = numberOfChannels * 1024;
|
||||
let offset = 0;
|
||||
|
||||
try {
|
||||
for (let i = 0; i < audioData.length; i += CHUNK_SIZE) {
|
||||
const chunkSize = Math.min(CHUNK_SIZE, audioData.length - i);
|
||||
const chunk = audioData.subarray(i, i + chunkSize);
|
||||
|
||||
const frame = new AudioData({
|
||||
format: 'f32',
|
||||
sampleRate,
|
||||
numberOfFrames: chunk.length / numberOfChannels,
|
||||
numberOfChannels,
|
||||
timestamp: (offset * 1000000) / sampleRate,
|
||||
data: chunk,
|
||||
});
|
||||
|
||||
encoder.encode(frame);
|
||||
frame.close();
|
||||
|
||||
offset += chunk.length / numberOfChannels;
|
||||
}
|
||||
} finally {
|
||||
await encoder.flush();
|
||||
encoder.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a WebM container with the encoded audio chunks
|
||||
*/
|
||||
function muxToWebM(
|
||||
encodedChunks: EncodedAudioChunk[],
|
||||
config: AudioEncodingConfig
|
||||
): Uint8Array {
|
||||
const target = new ArrayBufferTarget();
|
||||
const muxer = new Muxer({
|
||||
target,
|
||||
audio: {
|
||||
codec: 'A_OPUS',
|
||||
sampleRate: config.sampleRate,
|
||||
numberOfChannels: config.numberOfChannels,
|
||||
},
|
||||
});
|
||||
|
||||
for (const chunk of encodedChunks) {
|
||||
muxer.addAudioChunk(chunk, {});
|
||||
}
|
||||
|
||||
muxer.finalize();
|
||||
return new Uint8Array(target.buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes raw audio data to Opus in WebM container.
|
||||
*/
|
||||
export async function encodeRawBufferToOpus({
|
||||
filepath,
|
||||
sampleRate,
|
||||
numberOfChannels,
|
||||
}: {
|
||||
filepath: string;
|
||||
sampleRate: number;
|
||||
numberOfChannels: number;
|
||||
}): Promise<Uint8Array> {
|
||||
logger.debug('Encoding raw buffer to Opus');
|
||||
const response = await fetch(new URL(filepath, location.origin));
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is null');
|
||||
}
|
||||
|
||||
const { encoder, encodedChunks } = await createOpusEncoder({
|
||||
sampleRate,
|
||||
numberOfChannels,
|
||||
});
|
||||
|
||||
// Process the stream
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Float32Array[] = [];
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(new Float32Array(value.buffer));
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
// Combine all chunks into a single Float32Array
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const audioData = new Float32Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
audioData.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
|
||||
await encodeAudioFrames({
|
||||
audioData,
|
||||
numberOfChannels,
|
||||
sampleRate,
|
||||
encoder,
|
||||
});
|
||||
|
||||
const webm = muxToWebM(encodedChunks, { sampleRate, numberOfChannels });
|
||||
logger.debug('Encoded raw buffer to Opus');
|
||||
return webm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes an audio file Blob to Opus in WebM container with specified bitrate.
|
||||
* @param blob Input audio file blob (supports any browser-decodable format)
|
||||
* @param targetBitrate Target bitrate in bits per second (bps)
|
||||
* @returns Promise resolving to encoded WebM data as Uint8Array
|
||||
*/
|
||||
export async function encodeAudioBlobToOpus(
|
||||
blob: Blob | ArrayBuffer | Uint8Array,
|
||||
targetBitrate: number = 64000
|
||||
): Promise<Uint8Array> {
|
||||
const audioContext = new AudioContext();
|
||||
logger.debug('Encoding audio blob to Opus');
|
||||
|
||||
try {
|
||||
let buffer: ArrayBuffer;
|
||||
if (blob instanceof Blob) {
|
||||
buffer = await blob.arrayBuffer();
|
||||
} else if (blob instanceof Uint8Array) {
|
||||
buffer =
|
||||
blob.buffer instanceof ArrayBuffer ? blob.buffer : blob.slice().buffer;
|
||||
} else {
|
||||
buffer = blob;
|
||||
}
|
||||
|
||||
const audioBuffer = await audioContext.decodeAudioData(buffer);
|
||||
|
||||
const config: AudioEncodingConfig = {
|
||||
sampleRate: audioBuffer.sampleRate,
|
||||
numberOfChannels: audioBuffer.numberOfChannels,
|
||||
bitrate: targetBitrate,
|
||||
};
|
||||
|
||||
const { encoder, encodedChunks } = await createOpusEncoder(config);
|
||||
|
||||
// Combine all channels into a single Float32Array
|
||||
const audioData = new Float32Array(
|
||||
audioBuffer.length * config.numberOfChannels
|
||||
);
|
||||
for (let channel = 0; channel < config.numberOfChannels; channel++) {
|
||||
const channelData = audioBuffer.getChannelData(channel);
|
||||
for (let i = 0; i < channelData.length; i++) {
|
||||
audioData[i * config.numberOfChannels + channel] = channelData[i];
|
||||
}
|
||||
}
|
||||
|
||||
await encodeAudioFrames({
|
||||
audioData,
|
||||
numberOfChannels: config.numberOfChannels,
|
||||
sampleRate: config.sampleRate,
|
||||
encoder,
|
||||
});
|
||||
|
||||
const webm = muxToWebM(encodedChunks, config);
|
||||
logger.debug('Encoded audio blob to Opus');
|
||||
return webm;
|
||||
} finally {
|
||||
await audioContext.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user