Update MobileCallPage and useVoicePreview to handle call end state

- Introduce a new state variable `callEnded` in `useVoicePreview` to manage call termination more effectively.
- Modify `MobileCallPage` to adjust the `inCall` condition based on `callEnded`, ensuring accurate UI updates during and after calls.
- Update rendering logic to display appropriate messages based on call status, enhancing user feedback during call transitions.
- Refactor button visibility to only show when in an active call, improving user interface clarity.
This commit is contained in:
Xin Wang
2026-07-10 22:48:40 +08:00
parent bb71d28721
commit 80d59fddc6
2 changed files with 52 additions and 27 deletions

View File

@@ -248,6 +248,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
selectOutputDevice, selectOutputDevice,
supportsOutputSelection, supportsOutputSelection,
messages, messages,
callEnded,
networkQuality, networkQuality,
connect, connect,
replaceVideoStream, replaceVideoStream,
@@ -267,7 +268,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const [view, setView] = useState<CallView>("camera"); const [view, setView] = useState<CallView>("camera");
const connecting = status === "connecting"; const connecting = status === "connecting";
const inCall = connecting || status === "connected"; const inCall = !callEnded && (connecting || status === "connected");
useEffect(() => { useEffect(() => {
if (videoRef.current) videoRef.current.srcObject = cameraStream; if (videoRef.current) videoRef.current.srcObject = cameraStream;
@@ -282,6 +283,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
}, [connect, startCamera]); }, [connect, startCamera]);
const endCall = useCallback(() => { const endCall = useCallback(() => {
setView("camera");
disconnect(); disconnect();
stopCamera(); stopCamera();
}, [disconnect, stopCamera]); }, [disconnect, stopCamera]);
@@ -300,11 +302,10 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
}, [startCall]); }, [startCall]);
useEffect(() => { useEffect(() => {
if (status === "idle" || status === "failed") { if (callEnded || status === "idle" || status === "failed") {
stopCamera(); stopCamera();
setView("camera");
} }
}, [status, stopCamera]); }, [callEnded, status, stopCamera]);
const error = previewError || cameraError; const error = previewError || cameraError;
@@ -341,33 +342,39 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
<span <span
className={[ className={[
"h-2 w-2 rounded-full", "h-2 w-2 rounded-full",
status === "connected" !callEnded && status === "connected"
? "animate-pulse bg-emerald-400" ? "animate-pulse bg-emerald-400"
: connecting : !callEnded && connecting
? "animate-pulse bg-amber-300" ? "animate-pulse bg-amber-300"
: "bg-white/40", : "bg-white/40",
].join(" ")} ].join(" ")}
/> />
{status === "connected" {callEnded
? "通话" ? "通话已结束"
: connecting : status === "connected"
? "正在连接…" ? "通话中"
: "通话已结束"} : connecting
? "正在连接…"
: "通话已结束"}
</div> </div>
<button {inCall && (
type="button" <button
onClick={() => setView((current) => current === "camera" ? "chat" : "camera")} type="button"
aria-label={view === "camera" ? "显示聊天记录" : "显示摄像头画面"} onClick={() =>
title={view === "camera" ? "聊天记录" : "摄像头画面"} setView((current) => current === "camera" ? "chat" : "camera")
className="absolute right-4 top-[max(1rem,env(safe-area-inset-top))] z-10 flex size-9 items-center justify-center rounded-full border border-white/15 bg-black/40 text-white shadow-lg backdrop-blur-md transition-colors hover:bg-black/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40" }
> aria-label={view === "camera" ? "显示聊天记录" : "显示摄像头画面"}
{view === "camera" ? ( title={view === "camera" ? "聊天记录" : "摄像头画面"}
<MessageSquareText className="size-[18px]" /> className="absolute right-4 top-[max(1rem,env(safe-area-inset-top))] z-10 flex size-9 items-center justify-center rounded-full border border-white/15 bg-black/40 text-white shadow-lg backdrop-blur-md transition-colors hover:bg-black/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
) : ( >
<Video className="size-[18px]" /> {view === "camera" ? (
)} <MessageSquareText className="size-[18px]" />
</button> ) : (
<Video className="size-[18px]" />
)}
</button>
)}
{!cameraStream && inCall && view === "camera" && ( {!cameraStream && inCall && view === "camera" && (
<div className="absolute inset-0 z-[2] flex items-center justify-center px-8 text-center"> <div className="absolute inset-0 z-[2] flex items-center justify-center px-8 text-center">
@@ -391,7 +398,10 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
<div className="flex max-w-sm flex-col items-center gap-4 text-center"> <div className="flex max-w-sm flex-col items-center gap-4 text-center">
<Button <Button
type="button" type="button"
onClick={() => void startCall()} onClick={() => {
setView("camera");
void startCall();
}}
className="h-12 gap-2 rounded-full bg-white px-6 text-[#07101a] shadow-2xl hover:bg-white/90" className="h-12 gap-2 rounded-full bg-white px-6 text-[#07101a] shadow-2xl hover:bg-white/90"
> >
<Phone size={19} /> <Phone size={19} />

View File

@@ -214,6 +214,9 @@ export function useVoicePreview(
// 远端(助手 TTS)媒体流:除挂到 <audio> 播放外,也暴露给波形可视化 // 远端(助手 TTS)媒体流:除挂到 <audio> 播放外,也暴露给波形可视化
const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null); const [remoteStream, setRemoteStream] = useState<MediaStream | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]); const [messages, setMessages] = useState<ChatMessage[]>([]);
// 后端结束节点/EndFrame 触发的正常结束。与 failed 区分,供独立通话页
// 立即收起媒体画面并展示重新开始入口。
const [callEnded, setCallEnded] = useState(false);
const [networkQuality, setNetworkQuality] = const [networkQuality, setNetworkQuality] =
useState<NetworkQuality>("unknown"); useState<NetworkQuality>("unknown");
// 可选麦克风/扬声器列表与当前选择(空串表示交给浏览器选默认设备) // 可选麦克风/扬声器列表与当前选择(空串表示交给浏览器选默认设备)
@@ -398,6 +401,7 @@ export function useVoicePreview(
setError(null); setError(null);
setMicWarning(null); setMicWarning(null);
setMessages([]); // 新会话清空上一轮聊天记录 setMessages([]); // 新会话清空上一轮聊天记录
setCallEnded(false);
endedByServerRef.current = false; endedByServerRef.current = false;
setStatus("connecting"); setStatus("connecting");
@@ -630,9 +634,12 @@ export function useVoicePreview(
// 工作流:后端报告当前激活节点,交给画布高亮 // 工作流:后端报告当前激活节点,交给画布高亮
onNodeActiveRef.current?.(msg.nodeId); onNodeActiveRef.current?.(msg.nodeId);
} else if (msg?.type === "call-ended") { } else if (msg?.type === "call-ended") {
// 后端走到结束节点、正常收尾:随后的断开按正常结束处理,不报错 // 后端已播完结束节点的收尾语,即将排入 EndFrame。此时主动释放
// 浏览器侧资源,不依赖后续 WebRTC 状态事件是否及时到达。
endedByServerRef.current = true; endedByServerRef.current = true;
setCallEnded(true);
onNodeActiveRef.current?.(null); onNodeActiveRef.current?.(null);
disconnect();
} }
} catch { } catch {
/* 非 JSON / 未知消息,忽略 */ /* 非 JSON / 未知消息,忽略 */
@@ -689,7 +696,14 @@ export function useVoicePreview(
} finally { } finally {
startingRef.current = false; startingRef.current = false;
} }
}, [assistantId, applyOutputDevice, fail, closeOnRemoteEnd, refreshDevices]); }, [
assistantId,
applyOutputDevice,
fail,
closeOnRemoteEnd,
disconnect,
refreshDevices,
]);
useEffect(() => { useEffect(() => {
if (status !== "connected") return; if (status !== "connected") return;
@@ -826,6 +840,7 @@ export function useVoicePreview(
localStream, localStream,
remoteStream, remoteStream,
messages, messages,
callEnded,
networkQuality, networkQuality,
audioInputs, audioInputs,
audioOutputs, audioOutputs,