mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-24 05:07:06 +08:00
feat(electron): audio capture permissions and settings (#11185)
fix AF-2420, AF-2391, AF-2265
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,331 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { AudioPlayer, MiniAudioPlayer } from './audio-player';
|
||||
|
||||
const AudioWrapper = () => {
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||
const [waveform, setWaveform] = useState<number[] | null>(null);
|
||||
const [playbackState, setPlaybackState] = useState<
|
||||
'idle' | 'playing' | 'paused' | 'stopped'
|
||||
>('idle');
|
||||
const [seekTime, setSeekTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const audioUrlRef = useRef<string | null>(null);
|
||||
|
||||
// Generate waveform data from audio file
|
||||
const generateWaveform = async (audioBuffer: AudioBuffer) => {
|
||||
const channelData = audioBuffer.getChannelData(0);
|
||||
const samples = 1000;
|
||||
const blockSize = Math.floor(channelData.length / samples);
|
||||
const waveformData = [];
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const start = i * blockSize;
|
||||
const end = start + blockSize;
|
||||
let sum = 0;
|
||||
for (let j = start; j < end; j++) {
|
||||
sum += Math.abs(channelData[j]);
|
||||
}
|
||||
waveformData.push(sum / blockSize);
|
||||
}
|
||||
|
||||
// Normalize waveform data
|
||||
const max = Math.max(...waveformData);
|
||||
return waveformData.map(val => val / max);
|
||||
};
|
||||
|
||||
const handleFileChange = useCallback(async (file: File) => {
|
||||
setLoading(true);
|
||||
setAudioFile(file);
|
||||
setPlaybackState('idle');
|
||||
setSeekTime(0);
|
||||
setDuration(0);
|
||||
setWaveform(null);
|
||||
|
||||
// Revoke previous URL if exists
|
||||
if (audioUrlRef.current) {
|
||||
URL.revokeObjectURL(audioUrlRef.current);
|
||||
}
|
||||
|
||||
// Create new URL for the audio file
|
||||
const fileUrl = URL.createObjectURL(file);
|
||||
audioUrlRef.current = fileUrl;
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const audioContext = new AudioContext();
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
const waveformData = await generateWaveform(audioBuffer);
|
||||
setWaveform(waveformData);
|
||||
} catch (error) {
|
||||
console.error('Error processing audio file:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cleanup object URL when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (audioUrlRef.current) {
|
||||
URL.revokeObjectURL(audioUrlRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith('audio/')) {
|
||||
handleFileChange(file);
|
||||
}
|
||||
},
|
||||
[handleFileChange]
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleFileChange(file);
|
||||
}
|
||||
},
|
||||
[handleFileChange]
|
||||
);
|
||||
|
||||
const handlePlay = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (audioRef.current) {
|
||||
const playPromise = audioRef.current.play();
|
||||
|
||||
// Handle play promise to catch any errors
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
setPlaybackState('playing');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error playing audio:', error);
|
||||
setPlaybackState('paused');
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePause = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
setPlaybackState('paused');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleStop = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.currentTime = 0;
|
||||
setPlaybackState('stopped');
|
||||
setSeekTime(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSeek = useCallback(
|
||||
(time: number) => {
|
||||
if (audioRef.current) {
|
||||
// Ensure time is within valid range
|
||||
const clampedTime = Math.max(
|
||||
0,
|
||||
Math.min(time, audioRef.current.duration)
|
||||
);
|
||||
audioRef.current.currentTime = clampedTime;
|
||||
if (playbackState === 'stopped') {
|
||||
setPlaybackState('paused');
|
||||
}
|
||||
}
|
||||
},
|
||||
[playbackState]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !audioFile) return;
|
||||
|
||||
const updateTime = () => {
|
||||
setSeekTime(audio.currentTime);
|
||||
};
|
||||
|
||||
const updateDuration = () => {
|
||||
if (!isNaN(audio.duration) && isFinite(audio.duration)) {
|
||||
setDuration(audio.duration);
|
||||
setPlaybackState('paused');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle direct interaction with audio element controls
|
||||
const handleNativeTimeUpdate = () => {
|
||||
setSeekTime(audio.currentTime);
|
||||
};
|
||||
|
||||
const handleNativePlay = () => {
|
||||
setPlaybackState('playing');
|
||||
};
|
||||
|
||||
const handleNativePause = () => {
|
||||
if (audio.currentTime >= audio.duration - 0.1) {
|
||||
setPlaybackState('stopped');
|
||||
setSeekTime(0);
|
||||
} else {
|
||||
setPlaybackState('paused');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
setPlaybackState('stopped');
|
||||
setSeekTime(0);
|
||||
};
|
||||
|
||||
const handlePlaying = () => {
|
||||
setPlaybackState('playing');
|
||||
};
|
||||
|
||||
const handlePaused = () => {
|
||||
if (audio.currentTime === 0) {
|
||||
setPlaybackState('stopped');
|
||||
} else {
|
||||
setPlaybackState('paused');
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
console.error('Audio playback error');
|
||||
setPlaybackState('stopped');
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleWaiting = () => {
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
const handleCanPlay = () => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
// Add all event listeners
|
||||
audio.addEventListener('timeupdate', updateTime);
|
||||
audio.addEventListener('seeking', handleNativeTimeUpdate);
|
||||
audio.addEventListener('seeked', handleNativeTimeUpdate);
|
||||
audio.addEventListener('play', handleNativePlay);
|
||||
audio.addEventListener('pause', handleNativePause);
|
||||
audio.addEventListener('loadedmetadata', updateDuration);
|
||||
audio.addEventListener('durationchange', updateDuration);
|
||||
audio.addEventListener('ended', handleEnded);
|
||||
audio.addEventListener('playing', handlePlaying);
|
||||
audio.addEventListener('pause', handlePaused);
|
||||
audio.addEventListener('error', handleError);
|
||||
audio.addEventListener('waiting', handleWaiting);
|
||||
audio.addEventListener('canplay', handleCanPlay);
|
||||
|
||||
return () => {
|
||||
// Remove all event listeners
|
||||
audio.removeEventListener('timeupdate', updateTime);
|
||||
audio.removeEventListener('seeking', handleNativeTimeUpdate);
|
||||
audio.removeEventListener('seeked', handleNativeTimeUpdate);
|
||||
audio.removeEventListener('play', handleNativePlay);
|
||||
audio.removeEventListener('pause', handleNativePause);
|
||||
audio.removeEventListener('loadedmetadata', updateDuration);
|
||||
audio.removeEventListener('durationchange', updateDuration);
|
||||
audio.removeEventListener('ended', handleEnded);
|
||||
audio.removeEventListener('playing', handlePlaying);
|
||||
audio.removeEventListener('pause', handlePaused);
|
||||
audio.removeEventListener('error', handleError);
|
||||
audio.removeEventListener('waiting', handleWaiting);
|
||||
audio.removeEventListener('canplay', handleCanPlay);
|
||||
};
|
||||
}, [audioFile]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: '200px',
|
||||
border: '2px dashed #ccc',
|
||||
borderRadius: '8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '20px',
|
||||
gap: '20px',
|
||||
}}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={e => e.preventDefault()}
|
||||
>
|
||||
{!audioFile ? (
|
||||
<>
|
||||
<div>Drag & drop an audio file here, or</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*"
|
||||
onChange={handleFileSelect}
|
||||
style={{ maxWidth: '200px' }}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrlRef.current || ''}
|
||||
preload="metadata"
|
||||
controls
|
||||
style={{ width: '100%', maxWidth: '600px' }}
|
||||
/>
|
||||
<MiniAudioPlayer
|
||||
name={audioFile.name}
|
||||
size={audioFile.size}
|
||||
waveform={waveform}
|
||||
playbackState={playbackState}
|
||||
seekTime={seekTime}
|
||||
duration={duration}
|
||||
loading={loading}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onStop={handleStop}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
<AudioPlayer
|
||||
name={audioFile.name}
|
||||
size={audioFile.size}
|
||||
waveform={waveform}
|
||||
playbackState={playbackState}
|
||||
seekTime={seekTime}
|
||||
duration={duration}
|
||||
loading={loading}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onStop={handleStop}
|
||||
onSeek={handleSeek}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const meta: Meta<typeof AudioWrapper> = {
|
||||
title: 'UI/AudioPlayer',
|
||||
component: AudioWrapper,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AudioWrapper>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,233 @@
|
||||
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 { IconButton } from '../button';
|
||||
import { AnimatedPlayIcon } from '../lottie';
|
||||
import * as styles from './audio-player.css';
|
||||
import { AudioWaveform } from './audio-waveform';
|
||||
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
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
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './audio-player';
|
||||
export * from './audio-waveform';
|
||||
Reference in New Issue
Block a user