style: simple audio player

This commit is contained in:
jialin
2025-06-09 18:45:47 +08:00
parent 8070d3f1e6
commit 8383547943
8 changed files with 408 additions and 29 deletions
@@ -11,9 +11,11 @@ const AudioWrapper = styled.div`
const AudioElement: React.FC<any> = (props) => {
return (
<AudioWrapper>
<audio {...props}></audio>
</AudioWrapper>
<div>
<AudioWrapper>
<audio {...props} controls></audio>
</AudioWrapper>
</div>
);
};
@@ -0,0 +1,368 @@
import { formatTime } from '@/utils/index';
import { DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Button, Dropdown, Slider, type MenuProps } from 'antd';
import { createStyles } from 'antd-style';
import { round } from 'lodash';
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo
} from 'react';
import IconFont from '../icon-font';
type ActionItem = 'download' | 'delete' | 'speed';
interface AudioPlayerProps {
autoplay?: boolean;
url: string;
speed?: number;
ref?: any;
name?: string;
height?: number;
width?: number;
duration?: number;
actions?: ActionItem[];
onDelete?: () => void;
}
const useStyles = createStyles(({ css, token }) => {
// @ts-ignore
const isDarkMode = token.darkMode as boolean;
return {
wrapper: css`
position: relative;
min-width: 300px;
height: 54px;
display: flex;
align-items: center;
justify-content: flex-start;
padding: 6px 10px;
background-color: ${isDarkMode
? 'var(--ant-color-fill-secondary)'
: '#F1F3F4'};
border-radius: 28px;
.inner {
display: flex;
align-items: center;
justify-content: flex-start;
flex: 1;
gap: 10px;
.slider {
flex: 1;
&:hover {
.ant-slider-handle {
opacity: 1;
transition: opacity 0.3s ease-in-out;
}
}
&:focus-within {
.ant-slider-handle {
opacity: 1;
}
}
}
.ant-slider-handle {
opacity: 0;
&::before {
background-color: var(--ant-color-bg-spotlight);
border-radius: 50%;
}
&::after {
display: none;
}
}
}
`
};
});
const sliderStyles = {
rail: {
borderRadius: '4px',
backgroundColor: 'var(--ant-color-bg-spotlight)'
}
};
const speedOptions = [
{ label: '1x', value: 1 },
{ label: '2x', value: 2 },
{ label: '3x', value: 3 },
{ label: '4x', value: 4 }
];
const speedConfig = {
min: 0.5,
max: 2,
step: 0.25
};
const AudioPlayer: React.FC<AudioPlayerProps> = forwardRef((props, ref) => {
const intl = useIntl();
const { styles } = useStyles();
const {
autoplay = false,
speed: defaultSpeed = 1,
actions = ['delete'],
onDelete
} = props;
const audioRef = React.useRef<HTMLAudioElement>(null);
const [audioState, setAudioState] = React.useState<{
currentTime: number;
duration: number;
}>({
currentTime: 0,
duration: 0
});
const [playOn, setPlayOn] = React.useState<boolean>(false);
const [speakerOn, setSpeakerOn] = React.useState<boolean>(false);
const [volume, setVolume] = React.useState<number>(1);
const [speed, setSpeed] = React.useState<number>(defaultSpeed);
const timer = React.useRef<any>(null);
useImperativeHandle(ref, () => ({
play: () => {
audioRef.current?.play();
},
pause: () => {
audioRef.current?.pause();
}
}));
const handleShowVolume = useCallback(() => {
setSpeakerOn(!speakerOn);
}, [speakerOn]);
const handleSeepdChange = useCallback((value: number | string) => {
setSpeed(value as number);
audioRef.current!.playbackRate = value as number;
}, []);
const handleAudioOnPlay = useCallback(() => {
timer.current = setInterval(() => {
setAudioState((prestate) => {
return {
currentTime: Math.ceil(audioRef.current?.currentTime || 0),
duration:
prestate.duration || Math.ceil(audioRef.current?.duration || 0)
};
});
if (audioRef.current?.paused || audioRef.current?.ended) {
clearInterval(timer.current);
setPlayOn(false);
setAudioState((prestate: any) => {
return {
currentTime: audioRef.current?.ended ? 0 : prestate.currentTime,
duration: prestate.duration
};
});
}
}, 500);
}, []);
const handlePlay = useCallback(() => {
setPlayOn(!playOn);
if (playOn) {
audioRef.current?.pause();
} else {
audioRef.current?.play();
}
}, [playOn]);
const handleFormatVolume = (val?: number) => {
if (val === undefined) {
return `${round(volume * 100)}%`;
}
return `${round(val * 100)}%`;
};
const handleVolumeChange = useCallback((value: number) => {
audioRef.current!.volume = round(value, 2);
setVolume(round(value, 2));
}, []);
const initPlayerConfig = () => {
if (audioRef.current) {
audioRef.current!.volume = volume;
audioRef.current!.playbackRate = speed;
}
};
const handleLoadedMetadata = useCallback(
(data: any) => {
const duration = Math.ceil(audioRef.current?.duration || 0);
setAudioState({
currentTime: 0,
duration:
duration && duration !== Infinity ? duration : props.duration || 0
});
setPlayOn(autoplay);
},
[autoplay, props.duration]
);
const handleCurrentChange = useCallback((val: number) => {
audioRef.current!.currentTime = val;
setAudioState((prestate) => {
return {
currentTime: val,
duration: prestate.duration
};
});
}, []);
const handleReduceSpeed = () => {
setSpeed((pre) => {
if (pre - speedConfig.step < speedConfig.min) {
return speedConfig.min;
}
const next = pre - speedConfig.step;
audioRef.current!.playbackRate = next;
return next;
});
};
const handleAddSpeed = () => {
setSpeed((pre) => {
if (pre + speedConfig.step > speedConfig.max) {
return speedConfig.max;
}
const next = pre + speedConfig.step;
audioRef.current!.playbackRate = next;
return next;
});
};
const handleOnLoad = (e: any) => {
console.log('onload', e);
};
const onDownload = useCallback(() => {
const url = props.url || '';
const filename = props.name;
const link = document.createElement('a');
link.href = url;
link.download = 'audio.mp3'; // Default filename
document.body.appendChild(link);
link.click();
link.remove();
}, [props.url, props.name]);
const items: MenuProps['items'] = useMemo(() => {
return [
{
key: 'download',
label: intl.formatMessage({ id: 'common.button.download' }),
icon: <DownloadOutlined />,
onClick: onDownload
},
{
key: 'speed',
label: intl.formatMessage({ id: 'playground.params.speed' }),
icon: <IconFont type="icon-play-speed"></IconFont>,
children: speedOptions.map((item) => ({
key: item.value,
label: item.label,
onClick: () => handleSeepdChange(item.value)
}))
},
{
key: 'delete',
label: intl.formatMessage({ id: 'common.button.delete' }),
icon: <DeleteOutlined />,
danger: true,
onClick: () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.src = '';
audioRef.current.load();
}
setAudioState({ currentTime: 0, duration: 0 });
setPlayOn(false);
onDelete?.();
}
}
].filter((item) => actions.includes(item.key as ActionItem));
}, [actions, intl, onDownload, onDelete, handleSeepdChange]);
useEffect(() => {
if (audioRef.current) {
initPlayerConfig();
}
}, [audioRef.current]);
useEffect(() => {
return () => {
clearInterval(timer.current);
};
}, []);
return (
<div
className={styles.wrapper}
style={{ width: props.width || '100%', height: props.height || '54px' }}
>
<div className="inner">
<Button
size="middle"
type="text"
onClick={handlePlay}
shape="circle"
disabled={!audioState?.duration}
icon={
!playOn ? (
<IconFont
type="icon-play"
style={{ fontSize: '22px' }}
></IconFont>
) : (
<IconFont
type="icon-pause"
style={{ fontSize: '22px' }}
></IconFont>
)
}
></Button>
<span className="time current">
{formatTime(audioState.currentTime)} /{' '}
{formatTime(audioState.duration)}
</span>
<div className="slider">
<Slider
tooltip={{ open: false }}
min={0}
step={1}
styles={sliderStyles}
max={audioState.duration}
value={audioState.currentTime}
onChange={handleCurrentChange}
/>
</div>
<Dropdown menu={{ items }} trigger={['click']}>
<Button
icon={<IconFont type="icon-more"></IconFont>}
type="text"
size="middle"
shape="circle"
></Button>
</Dropdown>
</div>
<audio
crossOrigin="anonymous"
autoPlay={autoplay}
src={props.url}
ref={audioRef}
preload="metadata"
style={{ opacity: 0, position: 'absolute', left: '-9999px' }}
onPlay={handleAudioOnPlay}
onLoadedMetadata={handleLoadedMetadata}
></audio>
</div>
);
});
export default React.memo(AudioPlayer);
+2 -2
View File
@@ -1,8 +1,8 @@
import { createFromIconfontCN } from '@ant-design/icons';
import './iconfont/iconfont.js';
// import './iconfont/iconfont.js';
const IconFont = createFromIconfontCN({
scriptUrl: ''
scriptUrl: '//at.alicdn.com/t/c/font_4613488_gxrotg9r0p.js'
});
export default IconFont;
+1
View File
@@ -69,6 +69,7 @@ export default {
}
},
token: {
darkMode: true,
fontFamily:
"Helvetica Neue, -apple-system, BlinkMacSystemFont, Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'",
colorText: '#ccc',
+1
View File
@@ -66,6 +66,7 @@ export default {
}
},
token: {
darkMode: false,
fontFamily:
"Helvetica Neue, -apple-system, BlinkMacSystemFont, Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'",
colorText: 'rgba(0,0,0,1)',
+2 -1
View File
@@ -7,5 +7,6 @@ export async function queryDashboardData() {
}
export async function queryDashboardUsageData() {
return request(`${DASHBOARD_API}/usage`);
// return request(`${DASHBOARD_API}/usage`);
return {};
}
@@ -1,4 +1,4 @@
import AudioElement from '@/components/audio-player/audio-element';
import SimpleAudio from '@/components/audio-player/simple-audio';
import IconFont from '@/components/icon-font';
import UploadAudio from '@/components/upload-audio';
import HotKeys, { KeyMap } from '@/config/hotkeys';
@@ -322,14 +322,10 @@ const MessageInput: React.FC<MessageInputProps> = forwardRef(
});
};
const handleDeleteAudio = (uid: number | string) => {
const list = _.filter(
message.audio,
(item: MessageItem) => item.uid !== uid
);
const handleDeleteAudio = () => {
setMessage({
...message,
audio: list
audio: []
});
};
@@ -517,11 +513,11 @@ const MessageInput: React.FC<MessageInputProps> = forwardRef(
></ThumbImg>
{message.audio && message.audio.length > 0 && (
<AudioWrapper>
<AudioElement
src={message.audio?.[0].data?.url}
<SimpleAudio
url={message.audio?.[0].data?.url}
height={44}
onDelete={handleDeleteAudio}
controls
></AudioElement>
></SimpleAudio>
</AudioWrapper>
)}
</div>
@@ -1,4 +1,4 @@
import AudioElement from '@/components/audio-player/audio-element';
import SimpleAudio from '@/components/audio-player/simple-audio';
import FullMarkdown from '@/components/markdown-viewer/full-markdown';
import { Input } from 'antd';
import classNames from 'classnames';
@@ -17,9 +17,7 @@ import ThinkContent from './think-content';
const AudioWrapper = styled.div`
padding-left: 10px;
audio {
padding-top: 10px;
}
padding-top: 10px;
`;
interface MessageBodyProps {
@@ -132,6 +130,16 @@ const MessageBody: React.FC<MessageBodyProps> = forwardRef(
});
};
const handleDeleteAudio = () => {
updateMessage?.({
role: data.role,
content: data.content,
uid: data.uid,
audio: [],
imgs: data.imgs || []
});
};
const handleMessageChange = (e: any) => {
updateMessage?.({
imgs: data.imgs || [],
@@ -207,10 +215,11 @@ const MessageBody: React.FC<MessageBodyProps> = forwardRef(
/>
{data.audio && data.audio.length > 0 && (
<AudioWrapper>
<AudioElement
src={data.audio?.[0]?.data.url}
controls
></AudioElement>
<SimpleAudio
url={data.audio?.[0]?.data.url}
actions={[]}
height={44}
></SimpleAudio>
</AudioWrapper>
)}
</div>
@@ -236,10 +245,11 @@ const MessageBody: React.FC<MessageBodyProps> = forwardRef(
/>
{data.audio && data.audio.length > 0 && (
<AudioWrapper>
<AudioElement
src={data.audio?.[0]?.data.url}
controls
></AudioElement>
<SimpleAudio
url={data.audio?.[0]?.data.url}
onDelete={handleDeleteAudio}
height={44}
></SimpleAudio>
</AudioWrapper>
)}
</div>