@alicloud/appflow-chat 0.0.4-alpha.1 → 0.0.4-alpha.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,246 @@
1
+ /**
2
+ * AudioPlayer - 语音消息播放器组件
3
+ * 用于在消息气泡中展示语音消息,支持播放/暂停、进度条、波形动画、时长显示
4
+ */
5
+
6
+ import React, { useRef, useState, useCallback, useEffect } from 'react';
7
+ import styled, { keyframes, css } from 'styled-components';
8
+ import {
9
+ PlayCircleOutlined,
10
+ PauseCircleOutlined,
11
+ } from '@ant-design/icons';
12
+
13
+ // ==================== 类型定义 ====================
14
+
15
+ export interface AudioPlayerProps {
16
+ /** 音频文件URL */
17
+ src: string;
18
+ /** 消息角色,影响配色 */
19
+ role?: 'user' | 'bot';
20
+ }
21
+
22
+ // ==================== 样式组件 ====================
23
+
24
+ const waveAnimation = keyframes`
25
+ 0%, 100% { height: 4px; }
26
+ 50% { height: 16px; }
27
+ `;
28
+
29
+ const AudioCard = styled.div<{ $role: 'user' | 'bot' }>`
30
+ display: flex;
31
+ align-items: center;
32
+ gap: 10px;
33
+ background: ${props => props.$role === 'user' ? '#e5effe' : '#e8f4fd'};
34
+ border-radius: 12px;
35
+ padding: 10px 14px;
36
+ max-width: 100%;
37
+ box-sizing: border-box;
38
+ min-width: 200px;
39
+ `;
40
+
41
+ const PlayButton = styled.span<{ $role: 'user' | 'bot' }>`
42
+ font-size: 28px;
43
+ cursor: pointer;
44
+ flex-shrink: 0;
45
+ color: ${props => props.$role === 'user' ? '#667eea' : '#1677ff'};
46
+ display: flex;
47
+ align-items: center;
48
+ transition: opacity 0.2s;
49
+
50
+ &:hover {
51
+ opacity: 0.8;
52
+ }
53
+ `;
54
+
55
+ const AudioBody = styled.div`
56
+ flex: 1;
57
+ display: flex;
58
+ flex-direction: column;
59
+ gap: 6px;
60
+ min-width: 0;
61
+ `;
62
+
63
+ const WaveformContainer = styled.div`
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 2px;
67
+ height: 20px;
68
+ `;
69
+
70
+ const WaveBar = styled.div<{ $active: boolean; $playing: boolean; $delay: number }>`
71
+ width: 3px;
72
+ border-radius: 2px;
73
+ background: ${props => props.$active ? '#667eea' : '#c0c8d8'};
74
+ transition: background 0.15s;
75
+
76
+ ${props => props.$playing && props.$active ? css`
77
+ animation: ${waveAnimation} 0.6s ease-in-out infinite;
78
+ animation-delay: ${props.$delay}s;
79
+ ` : css`
80
+ height: ${props.$active ? '12px' : `${4 + Math.random() * 10}px`};
81
+ `}
82
+ `;
83
+
84
+ const ProgressBar = styled.div`
85
+ position: relative;
86
+ height: 3px;
87
+ background: rgba(0, 0, 0, 0.08);
88
+ border-radius: 2px;
89
+ cursor: pointer;
90
+ overflow: visible;
91
+ `;
92
+
93
+ const ProgressFill = styled.div<{ $progress: number }>`
94
+ height: 100%;
95
+ background: #667eea;
96
+ border-radius: 2px;
97
+ width: ${props => props.$progress}%;
98
+ transition: width 0.1s linear;
99
+ position: relative;
100
+
101
+ &::after {
102
+ content: '';
103
+ position: absolute;
104
+ right: -4px;
105
+ top: 50%;
106
+ transform: translateY(-50%);
107
+ width: 8px;
108
+ height: 8px;
109
+ border-radius: 50%;
110
+ background: #667eea;
111
+ opacity: 0;
112
+ transition: opacity 0.2s;
113
+ }
114
+
115
+ ${ProgressBar}:hover &::after {
116
+ opacity: 1;
117
+ }
118
+ `;
119
+
120
+ const AudioDuration = styled.span`
121
+ font-size: 12px;
122
+ color: #888;
123
+ flex-shrink: 0;
124
+ min-width: 32px;
125
+ text-align: right;
126
+ font-variant-numeric: tabular-nums;
127
+ `;
128
+
129
+ // ==================== 工具函数 ====================
130
+
131
+ /** 格式化秒数为 m:ss */
132
+ function formatDuration(seconds: number): string {
133
+ if (!seconds || !isFinite(seconds)) return '0:00';
134
+ const m = Math.floor(seconds / 60);
135
+ const s = Math.floor(seconds % 60);
136
+ return `${m}:${s.toString().padStart(2, '0')}`;
137
+ }
138
+
139
+ /** 生成固定的波形条高度 */
140
+ const WAVE_BARS = Array.from({ length: 20 }, (_, i) => {
141
+ // 用正弦函数生成自然的波形高度
142
+ const base = Math.sin((i / 20) * Math.PI) * 12 + 4;
143
+ return Math.max(4, Math.min(18, base + (Math.random() * 4 - 2)));
144
+ });
145
+
146
+ // ==================== 组件实现 ====================
147
+
148
+ /**
149
+ * AudioPlayer - 语音消息播放器
150
+ *
151
+ * 紧凑的播放器组件,包含播放/暂停按钮 + 波形条 + 进度条 + 时长显示。
152
+ * 风格类似钉钉/微信的聊天语音消息,可独立使用无需外层气泡包裹。
153
+ *
154
+ * @example
155
+ * ```tsx
156
+ * <AudioPlayer src="https://example.com/audio.webm" role="user" />
157
+ * ```
158
+ */
159
+ export const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, role = 'user' }) => {
160
+ const audioRef = useRef<HTMLAudioElement>(null);
161
+ const progressRef = useRef<HTMLDivElement>(null);
162
+ const [isPlaying, setIsPlaying] = useState(false);
163
+ const [currentTime, setCurrentTime] = useState(0);
164
+ const [duration, setDuration] = useState(0);
165
+
166
+ const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
167
+
168
+ useEffect(() => {
169
+ const audio = audioRef.current;
170
+ if (!audio) return;
171
+
172
+ const onLoadedMetadata = () => setDuration(audio.duration);
173
+ const onTimeUpdate = () => setCurrentTime(audio.currentTime);
174
+ const onEnded = () => {
175
+ setIsPlaying(false);
176
+ setCurrentTime(0);
177
+ };
178
+
179
+ audio.addEventListener('loadedmetadata', onLoadedMetadata);
180
+ audio.addEventListener('timeupdate', onTimeUpdate);
181
+ audio.addEventListener('ended', onEnded);
182
+
183
+ return () => {
184
+ audio.removeEventListener('loadedmetadata', onLoadedMetadata);
185
+ audio.removeEventListener('timeupdate', onTimeUpdate);
186
+ audio.removeEventListener('ended', onEnded);
187
+ };
188
+ }, []);
189
+
190
+ const togglePlay = useCallback(() => {
191
+ const audio = audioRef.current;
192
+ if (!audio) return;
193
+
194
+ if (isPlaying) {
195
+ audio.pause();
196
+ setIsPlaying(false);
197
+ } else {
198
+ audio.play().then(() => setIsPlaying(true)).catch(() => {});
199
+ }
200
+ }, [isPlaying]);
201
+
202
+ const handleProgressClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
203
+ const audio = audioRef.current;
204
+ const bar = progressRef.current;
205
+ if (!audio || !bar || !duration) return;
206
+
207
+ const rect = bar.getBoundingClientRect();
208
+ const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
209
+ audio.currentTime = ratio * duration;
210
+ setCurrentTime(audio.currentTime);
211
+ }, [duration]);
212
+
213
+ return (
214
+ <AudioCard $role={role}>
215
+ <audio ref={audioRef} src={src} preload="metadata" />
216
+ <PlayButton $role={role} onClick={togglePlay}>
217
+ {isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
218
+ </PlayButton>
219
+ <AudioBody>
220
+ <WaveformContainer>
221
+ {WAVE_BARS.map((h, i) => {
222
+ const barProgress = (i / WAVE_BARS.length) * 100;
223
+ const isActive = barProgress <= progress;
224
+ return (
225
+ <WaveBar
226
+ key={i}
227
+ $active={isActive}
228
+ $playing={isPlaying}
229
+ $delay={i * 0.05}
230
+ style={!isPlaying || !isActive ? { height: `${h}px` } : undefined}
231
+ />
232
+ );
233
+ })}
234
+ </WaveformContainer>
235
+ <ProgressBar ref={progressRef} onClick={handleProgressClick}>
236
+ <ProgressFill $progress={progress} />
237
+ </ProgressBar>
238
+ </AudioBody>
239
+ <AudioDuration>
240
+ {isPlaying || currentTime > 0 ? formatDuration(currentTime) : formatDuration(duration)}
241
+ </AudioDuration>
242
+ </AudioCard>
243
+ );
244
+ };
245
+
246
+ export default AudioPlayer;