feat(音频消息): 增强音频消息组件功能并改进错误处理
- 支持JSON格式音频URL解析,可获取时长和文本信息 - 添加音频URL可用性检查机制 - 实现更完善的错误处理和重试逻辑 - 增加音频文本显示和错误提示样式 - 优化音频播放控制逻辑,确保跨域访问兼容性 - 将msgId类型改为string以避免潜在类型问题
This commit is contained in:
@@ -1,11 +1,22 @@
|
||||
// 消息气泡样式
|
||||
.messageBubble {
|
||||
word-wrap: break-word;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
// 音频错误提示样式
|
||||
.audioError {
|
||||
color: #ff4d4f;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
padding: 4px 8px;
|
||||
background: #fff2f0;
|
||||
border: 1px solid #ffccc7;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
// 语音消息容器
|
||||
.audioMessage {
|
||||
min-width: 200px;
|
||||
@@ -88,6 +99,17 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
// 音频文本显示
|
||||
.audioText {
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
// 进度条容器
|
||||
.audioProgress {
|
||||
margin-top: 8px;
|
||||
|
||||
@@ -4,27 +4,119 @@ import styles from "./AudioMessage.module.scss";
|
||||
|
||||
interface AudioMessageProps {
|
||||
audioUrl: string;
|
||||
msgId: number;
|
||||
msgId: string;
|
||||
}
|
||||
|
||||
interface AudioData {
|
||||
durationMs?: number;
|
||||
url: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
// 解析音频URL,支持两种格式:纯URL字符串和JSON字符串
|
||||
const parseAudioUrl = (audioUrl: string): AudioData => {
|
||||
try {
|
||||
// 尝试解析为JSON
|
||||
const parsed = JSON.parse(audioUrl);
|
||||
if (parsed.url) {
|
||||
return {
|
||||
durationMs: parsed.durationMs,
|
||||
url: parsed.url,
|
||||
text: parsed.text,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// 如果解析失败,说明是纯URL字符串
|
||||
console.log("音频URL为纯字符串格式");
|
||||
}
|
||||
|
||||
// 返回纯URL格式
|
||||
return {
|
||||
url: audioUrl,
|
||||
};
|
||||
};
|
||||
|
||||
// 测试音频URL是否可访问
|
||||
const testAudioUrl = async (url: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(url, { method: "HEAD" });
|
||||
console.log("音频URL测试结果:", response.status, response.statusText);
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("音频URL测试失败:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const AudioMessage: React.FC<AudioMessageProps> = ({ audioUrl, msgId }) => {
|
||||
const [playingAudioId, setPlayingAudioId] = useState<string | null>(null);
|
||||
const [audioProgress, setAudioProgress] = useState<Record<string, number>>(
|
||||
{},
|
||||
);
|
||||
const [audioError, setAudioError] = useState<string | null>(null);
|
||||
const audioRefs = useRef<Record<string, HTMLAudioElement>>({});
|
||||
|
||||
// 解析音频数据
|
||||
const audioData = parseAudioUrl(audioUrl);
|
||||
const actualAudioUrl = audioData.url;
|
||||
const audioDuration = audioData.durationMs;
|
||||
const audioText = audioData.text;
|
||||
|
||||
const audioId = `audio_${msgId}_${Date.now()}`;
|
||||
const isPlaying = playingAudioId === audioId;
|
||||
const progress = audioProgress[audioId] || 0;
|
||||
|
||||
// 格式化时长显示
|
||||
const formatDuration = (ms?: number): string => {
|
||||
if (!ms) return "语音";
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
// 播放/暂停音频
|
||||
const handleAudioToggle = () => {
|
||||
const handleAudioToggle = async () => {
|
||||
const audio = audioRefs.current[audioId];
|
||||
if (!audio) {
|
||||
const newAudio = new Audio(audioUrl);
|
||||
console.log("创建新音频实例:", actualAudioUrl);
|
||||
console.log("音频数据:", audioData);
|
||||
|
||||
// 先测试URL是否可访问
|
||||
const isUrlAccessible = await testAudioUrl(actualAudioUrl);
|
||||
if (!isUrlAccessible) {
|
||||
console.error("音频URL不可访问,无法播放");
|
||||
setAudioError("音频文件无法访问,请检查网络连接");
|
||||
return;
|
||||
}
|
||||
|
||||
// 清除之前的错误状态
|
||||
setAudioError(null);
|
||||
|
||||
const newAudio = new Audio();
|
||||
|
||||
// 设置跨域属性
|
||||
newAudio.crossOrigin = "anonymous";
|
||||
newAudio.preload = "metadata";
|
||||
|
||||
audioRefs.current[audioId] = newAudio;
|
||||
|
||||
newAudio.addEventListener("loadstart", () => {
|
||||
console.log("音频开始加载");
|
||||
});
|
||||
|
||||
newAudio.addEventListener("loadedmetadata", () => {
|
||||
console.log("音频元数据加载完成,时长:", newAudio.duration);
|
||||
});
|
||||
|
||||
newAudio.addEventListener("canplay", () => {
|
||||
console.log("音频可以播放");
|
||||
});
|
||||
|
||||
newAudio.addEventListener("canplaythrough", () => {
|
||||
console.log("音频可以完整播放");
|
||||
});
|
||||
|
||||
newAudio.addEventListener("timeupdate", () => {
|
||||
const currentProgress =
|
||||
(newAudio.currentTime / newAudio.duration) * 100;
|
||||
@@ -35,17 +127,74 @@ const AudioMessage: React.FC<AudioMessageProps> = ({ audioUrl, msgId }) => {
|
||||
});
|
||||
|
||||
newAudio.addEventListener("ended", () => {
|
||||
console.log("音频播放结束");
|
||||
setPlayingAudioId(null);
|
||||
setAudioProgress(prev => ({ ...prev, [audioId]: 0 }));
|
||||
});
|
||||
|
||||
newAudio.addEventListener("error", () => {
|
||||
console.error("音频播放失败");
|
||||
newAudio.addEventListener("error", e => {
|
||||
console.error("音频播放失败:", e);
|
||||
console.error("音频URL:", actualAudioUrl);
|
||||
console.error("错误详情:", newAudio.error);
|
||||
if (newAudio.error) {
|
||||
console.error("错误代码:", newAudio.error.code);
|
||||
console.error("错误消息:", newAudio.error.message);
|
||||
}
|
||||
setPlayingAudioId(null);
|
||||
setAudioError("音频播放失败,请稍后重试");
|
||||
});
|
||||
|
||||
newAudio.play();
|
||||
setPlayingAudioId(audioId);
|
||||
// 设置音频源并尝试播放
|
||||
newAudio.src = actualAudioUrl;
|
||||
console.log("设置音频源:", actualAudioUrl);
|
||||
|
||||
try {
|
||||
console.log("尝试播放音频...");
|
||||
await newAudio.play();
|
||||
console.log("音频播放成功");
|
||||
setPlayingAudioId(audioId);
|
||||
} catch (error) {
|
||||
console.error("播放失败:", error);
|
||||
console.error("错误名称:", error.name);
|
||||
console.error("错误消息:", error.message);
|
||||
|
||||
// 尝试备用方案:不设置crossOrigin
|
||||
console.log("尝试备用播放方案...");
|
||||
try {
|
||||
const fallbackAudio = new Audio(actualAudioUrl);
|
||||
audioRefs.current[audioId] = fallbackAudio;
|
||||
|
||||
// 重新绑定事件监听器
|
||||
fallbackAudio.addEventListener("timeupdate", () => {
|
||||
const currentProgress =
|
||||
(fallbackAudio.currentTime / fallbackAudio.duration) * 100;
|
||||
setAudioProgress(prev => ({
|
||||
...prev,
|
||||
[audioId]: currentProgress,
|
||||
}));
|
||||
});
|
||||
|
||||
fallbackAudio.addEventListener("ended", () => {
|
||||
console.log("备用音频播放结束");
|
||||
setPlayingAudioId(null);
|
||||
setAudioProgress(prev => ({ ...prev, [audioId]: 0 }));
|
||||
});
|
||||
|
||||
fallbackAudio.addEventListener("error", e => {
|
||||
console.error("备用音频播放也失败:", e);
|
||||
setPlayingAudioId(null);
|
||||
setAudioError("音频播放失败,请稍后重试");
|
||||
});
|
||||
|
||||
await fallbackAudio.play();
|
||||
console.log("备用方案播放成功");
|
||||
setPlayingAudioId(audioId);
|
||||
} catch (fallbackError) {
|
||||
console.error("备用方案也失败:", fallbackError);
|
||||
setPlayingAudioId(null);
|
||||
setAudioError("音频播放失败,请检查音频格式或网络连接");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
@@ -55,53 +204,85 @@ const AudioMessage: React.FC<AudioMessageProps> = ({ audioUrl, msgId }) => {
|
||||
Object.values(audioRefs.current).forEach(a => a.pause());
|
||||
setPlayingAudioId(null);
|
||||
|
||||
audio.play();
|
||||
setPlayingAudioId(audioId);
|
||||
try {
|
||||
await audio.play();
|
||||
setPlayingAudioId(audioId);
|
||||
} catch (error) {
|
||||
console.error("重新播放失败:", error);
|
||||
setPlayingAudioId(null);
|
||||
setAudioError("音频播放失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.messageBubble}>
|
||||
<div className={styles.audioMessage}>
|
||||
<div className={styles.audioContainer} onClick={handleAudioToggle}>
|
||||
<div className={styles.audioIcon}>
|
||||
{isPlaying ? (
|
||||
<PauseCircleFilled
|
||||
style={{ fontSize: "20px", color: "#1890ff" }}
|
||||
/>
|
||||
) : (
|
||||
<SoundOutlined style={{ fontSize: "20px", color: "#666" }} />
|
||||
)}
|
||||
<>
|
||||
<div className={styles.messageBubble}>
|
||||
{audioError && (
|
||||
<div
|
||||
className={styles.audioError}
|
||||
onClick={() => {
|
||||
setAudioError(null);
|
||||
handleAudioToggle();
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
title="点击重试"
|
||||
>
|
||||
{audioError} (点击重试)
|
||||
</div>
|
||||
<div className={styles.audioContent}>
|
||||
<div className={styles.audioWaveform}>
|
||||
{/* 音频波形效果 */}
|
||||
{Array.from({ length: 20 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.waveBar} ${isPlaying ? styles.playing : ""}`}
|
||||
style={{
|
||||
height: `${Math.random() * 20 + 10}px`,
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
backgroundColor: progress > i * 5 ? "#1890ff" : "#d9d9d9",
|
||||
}}
|
||||
)}
|
||||
<div className={styles.audioMessage}>
|
||||
<div className={styles.audioContainer} onClick={handleAudioToggle}>
|
||||
<div className={styles.audioIcon}>
|
||||
{isPlaying ? (
|
||||
<PauseCircleFilled
|
||||
style={{ fontSize: "20px", color: "#1890ff" }}
|
||||
/>
|
||||
))}
|
||||
) : (
|
||||
<SoundOutlined style={{ fontSize: "20px", color: "#666" }} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.audioContent}>
|
||||
<div className={styles.audioWaveform}>
|
||||
{/* 音频波形效果 */}
|
||||
{Array.from({ length: 20 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${styles.waveBar} ${isPlaying ? styles.playing : ""}`}
|
||||
style={{
|
||||
height: `${Math.random() * 20 + 10}px`,
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
backgroundColor: progress > i * 5 ? "#1890ff" : "#d9d9d9",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.audioDuration}>
|
||||
{formatDuration(audioDuration)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.audioDuration}>语音</div>
|
||||
</div>
|
||||
{progress > 0 && (
|
||||
<div className={styles.audioProgress}>
|
||||
<div
|
||||
className={styles.audioProgressBar}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{progress > 0 && (
|
||||
<div className={styles.audioProgress}>
|
||||
<div
|
||||
className={styles.audioProgressBar}
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{audioText && (
|
||||
<div className={styles.audioText} title={audioText}>
|
||||
{audioText.length > 10
|
||||
? `${audioText.substring(0, 10)}...`
|
||||
: audioText}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -391,7 +391,7 @@ const MessageRecord: React.FC<MessageRecordProps> = ({ contract }) => {
|
||||
}
|
||||
|
||||
// content直接是音频URL字符串
|
||||
return <AudioMessage audioUrl={content} msgId={msg.id} />;
|
||||
return <AudioMessage audioUrl={content} msgId={String(msg.id)} />;
|
||||
|
||||
case 49: // 小程序/文章/其他:图文、文件
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
|
||||
Reference in New Issue
Block a user