refactor(service): 修复音频无法播放问题

dev_na
chenhao 2026-07-21 15:11:08 +08:00
parent e3c688b9ce
commit 22aefae6cf
2 changed files with 84 additions and 58 deletions

View File

@ -784,6 +784,7 @@
}
.meeting-detail-page-v2 .transcript-player.transcript-player--embedded {
position: relative;
display: flex;
align-items: center;
gap: 14px;
@ -825,6 +826,14 @@
transform: none;
}
.meeting-detail-page-v2 .meeting-detail-hidden-audio {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.meeting-detail-page-v2 .transcript-player .player-ghost-btn {
height: 36px !important;
align-self: center;

View File

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Alert, Avatar, Button, Card, Col, Divider, Drawer, Empty, Form, Input, List, Modal, Popover, Progress, QRCode, Radio, Row, Select, Skeleton, Space, Switch, Tag, Typography, App, Dropdown } from 'antd';
import {
@ -37,6 +37,7 @@ import {
MeetingTranscriptVO,
MeetingVO,
reSummary,
resolveAudioMimeType,
resolveMeetingPlaybackAudioUrl,
retryMeetingChapter,
retryMeetingSummary,
@ -1251,6 +1252,7 @@ const MeetingDetail: React.FC = () => {
const emptyTranscriptNoticeShownRef = useRef<number | null>(null);
const audioPlaybackErrorShownRef = useRef<string | null>(null);
const loadedAudioUrlRef = useRef<string | null>(null);
const transcriptItemRefs = useRef<Record<number, HTMLDivElement | null>>({});
const pendingTranscriptScrollIdRef = useRef<number | null>(null);
@ -1313,6 +1315,7 @@ const MeetingDetail: React.FC = () => {
);
const previewAccessPassword = useMemo(() => (meeting?.accessPassword || '').trim(), [meeting?.accessPassword]);
const playbackAudioUrl = useMemo(() => resolveMeetingPlaybackAudioUrl(meeting), [meeting]);
const audioMimeType = useMemo(() => resolveAudioMimeType(playbackAudioUrl), [playbackAudioUrl]);
const audioDownloadFileName = useMemo(() => getMeetingAudioDownloadName(meeting), [meeting]);
const audioDownloadFormatLabel = useMemo(
() => resolveAudioExtension(playbackAudioUrl).toUpperCase(),
@ -1760,26 +1763,28 @@ const MeetingDetail: React.FC = () => {
setSummaryVisible(true);
};
const handleAudioPlaybackError = useCallback(() => {
const handleAudioPlaybackError = useCallback((error?: unknown) => {
const currentAudioUrl = playbackAudioUrl || '';
if (!currentAudioUrl || audioPlaybackErrorShownRef.current === currentAudioUrl) {
return;
}
const normalizedUrl = currentAudioUrl.split('#')[0]?.split('?')[0]?.toLowerCase() || '';
const isM4a = normalizedUrl.endsWith('.m4a');
message.warning(
isM4a
? '当前 m4a 文件在本机浏览器中无法直接播放。已确认文件与服务端响应基本正常,更可能是浏览器对该录音参数或容器实现的兼容性问题。建议优先使用 mp3、wav或下载到本地播放。'
: '当前音频文件无法播放,请检查文件是否损坏或格式是否兼容。',
);
const audio = audioRef.current;
console.warn('音频播放失败', {
audioUrl: currentAudioUrl,
error,
mediaError: audio?.error ? {code: audio.error.code, message: audio.error.message} : null,
readyState: audio?.readyState,
networkState: audio?.networkState,
currentSrc: audio?.currentSrc,
});
audioPlaybackErrorShownRef.current = currentAudioUrl;
setAudioPlaying(false);
}, [message, playbackAudioUrl]);
}, [playbackAudioUrl]);
const seekTo = useCallback((timeMs: number) => {
if (!audioRef.current) return;
audioRef.current.currentTime = timeMs / 1000;
audioRef.current.play().catch(() => handleAudioPlaybackError());
audioRef.current.play().catch((error) => handleAudioPlaybackError(error));
}, [handleAudioPlaybackError]);
const handleTranscriptRowPlay = useCallback((timeMs: number) => {
@ -1986,50 +1991,34 @@ const MeetingDetail: React.FC = () => {
transcriptItemRefs.current[transcriptId] = node;
}, []);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return undefined;
const syncAudioMetadata = useCallback((audio: HTMLAudioElement) => {
setAudioDuration(Number.isFinite(audio.duration) ? audio.duration : 0);
setAudioCurrentTime(audio.currentTime || 0);
audio.playbackRate = audioPlaybackRate;
}, [audioPlaybackRate]);
const handleLoadedMetadata = () => {
setAudioDuration(Number.isFinite(audio.duration) ? audio.duration : 0);
setAudioCurrentTime(audio.currentTime || 0);
audio.playbackRate = audioPlaybackRate;
};
const handleTimeUpdate = () => {
setAudioCurrentTime(audio.currentTime || 0);
};
const handlePlay = () => setAudioPlaying(true);
const handlePause = () => setAudioPlaying(false);
const handleEnded = () => setAudioPlaying(false);
const handleError = () => handleAudioPlaybackError();
const handleAudioElement = useCallback((node: HTMLAudioElement | null) => {
if (!node) return;
// @ts-ignore
audioRef.current = node;
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
audio.addEventListener('durationchange', handleLoadedMetadata);
audio.addEventListener('canplay', handleLoadedMetadata);
audio.addEventListener('timeupdate', handleTimeUpdate);
audio.addEventListener('play', handlePlay);
audio.addEventListener('pause', handlePause);
audio.addEventListener('ended', handleEnded);
audio.addEventListener('error', handleError);
handleLoadedMetadata();
return () => {
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
audio.removeEventListener('durationchange', handleLoadedMetadata);
audio.removeEventListener('canplay', handleLoadedMetadata);
audio.removeEventListener('timeupdate', handleTimeUpdate);
audio.removeEventListener('play', handlePlay);
audio.removeEventListener('pause', handlePause);
audio.removeEventListener('ended', handleEnded);
audio.removeEventListener('error', handleError);
};
}, [audioPlaybackRate, meeting?.status, playbackAudioUrl, handleAudioPlaybackError]);
node.playbackRate = audioPlaybackRate;
if (playbackAudioUrl && loadedAudioUrlRef.current !== playbackAudioUrl) {
loadedAudioUrlRef.current = playbackAudioUrl;
node.load();
}
syncAudioMetadata(node);
}, [audioPlaybackRate, playbackAudioUrl, syncAudioMetadata]);
useEffect(() => {
const audio = audioRef.current;
if (!audio) return;
audioPlaybackErrorShownRef.current = null;
if (!audio) {
loadedAudioUrlRef.current = null;
return;
}
loadedAudioUrlRef.current = playbackAudioUrl || null;
audio.pause();
audio.load();
audio.playbackRate = 1;
@ -2039,12 +2028,23 @@ const MeetingDetail: React.FC = () => {
setAudioPlaying(false);
}, [playbackAudioUrl]);
const handleAudioLoadedMetadata = (event: React.SyntheticEvent<HTMLAudioElement>) => {
syncAudioMetadata(event.currentTarget);
};
const handleAudioTimeUpdate = (event: React.SyntheticEvent<HTMLAudioElement>) => {
setAudioCurrentTime(event.currentTarget.currentTime || 0);
};
const toggleAudioPlayback = () => {
if (!audioRef.current) return;
if (audioRef.current.paused) {
audioRef.current.play().catch(() => handleAudioPlaybackError());
const audio = audioRef.current;
if (!audio) return;
if (audio.paused) {
audio.muted = false;
audio.volume = 1;
audio.play().catch((error) => handleAudioPlaybackError(error));
} else {
audioRef.current.pause();
audio.pause();
}
};
@ -2744,10 +2744,6 @@ const MeetingDetail: React.FC = () => {
style={{ height: '100%' }}
styles={{ body: { padding: 0, height: '100%', display: 'flex', flexDirection: 'column', minHeight: 0 } }}
>
{playbackAudioUrl && (
<audio ref={audioRef} src={playbackAudioUrl} style={{ display: 'none' }} preload="auto" />
)}
<div className="transcript-panel-header">
<div className="transcript-stage-tabs" role="tablist" aria-label="内容切换">
{aiCatalogEnabled && (
@ -2871,7 +2867,28 @@ const MeetingDetail: React.FC = () => {
</div>
{playbackAudioUrl && (
<div className="transcript-player transcript-player--embedded">
<button type="button" className="player-main-btn" onClick={toggleAudioPlayback} aria-label="toggle-audio">
<audio
ref={handleAudioElement}
className="meeting-detail-hidden-audio"
preload="metadata"
playsInline
onLoadedMetadata={handleAudioLoadedMetadata}
onDurationChange={handleAudioLoadedMetadata}
onCanPlay={handleAudioLoadedMetadata}
onTimeUpdate={handleAudioTimeUpdate}
onPlay={() => setAudioPlaying(true)}
onPause={() => setAudioPlaying(false)}
onEnded={() => setAudioPlaying(false)}
onError={(event) => handleAudioPlaybackError(event.currentTarget.error)}
>
<source src={playbackAudioUrl} type={audioMimeType}/>
</audio>
<button
type="button"
className="player-main-btn"
onClick={toggleAudioPlayback}
aria-label="toggle-audio"
>
{audioPlaying ? <PauseOutlined /> : <CaretRightFilled />}
</button>
<div className="player-progress-shell">