diff --git a/frontend/src/pages/business/MeetingDetail.css b/frontend/src/pages/business/MeetingDetail.css index b1ac2ce..b8a2c9f 100644 --- a/frontend/src/pages/business/MeetingDetail.css +++ b/frontend/src/pages/business/MeetingDetail.css @@ -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; diff --git a/frontend/src/pages/business/MeetingDetail.tsx b/frontend/src/pages/business/MeetingDetail.tsx index 77319b6..ab2c211 100644 --- a/frontend/src/pages/business/MeetingDetail.tsx +++ b/frontend/src/pages/business/MeetingDetail.tsx @@ -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(null); const audioPlaybackErrorShownRef = useRef(null); + const loadedAudioUrlRef = useRef(null); const transcriptItemRefs = useRef>({}); const pendingTranscriptScrollIdRef = useRef(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) => { + syncAudioMetadata(event.currentTarget); + }; + + const handleAudioTimeUpdate = (event: React.SyntheticEvent) => { + 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 && ( -