Enhance MobileCallPage with chat transcript feature
- Introduce a new `MobileCallTranscript` component to display chat messages during calls, improving user interaction. - Implement message timestamp formatting and automatic scrolling for better usability. - Add a toggle button to switch between camera view and chat view, enhancing the user experience during calls. - Refactor the layout of the `MobileCallPage` to accommodate the new chat functionality while maintaining existing video call features.
This commit is contained in:
@@ -1,7 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Camera, Headphones, Loader2, Mic, Phone, PhoneOff, Video } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Camera,
|
||||
Headphones,
|
||||
Loader2,
|
||||
MessageSquareText,
|
||||
Mic,
|
||||
Phone,
|
||||
PhoneOff,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -20,7 +29,81 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useCameraPreview } from "@/hooks/use-camera-preview";
|
||||
import { useVoicePreview } from "@/hooks/use-voice-preview";
|
||||
import {
|
||||
useVoicePreview,
|
||||
type ChatMessage,
|
||||
} from "@/hooks/use-voice-preview";
|
||||
|
||||
type CallView = "camera" | "chat";
|
||||
|
||||
function messageTime(timestamp: string): string {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return date.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function MobileCallTranscript({ messages }: { messages: ChatMessage[] }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (container) container.scrollTop = container.scrollHeight;
|
||||
}, [messages]);
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-[1] bg-[#07101a]/95 backdrop-blur-sm">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scrollbar-subtle h-full overflow-y-auto px-4 pb-28 pt-20"
|
||||
>
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 pb-16 text-center">
|
||||
<MessageSquareText className="size-7 text-white/40" />
|
||||
<p className="text-sm font-medium text-white/80">暂无聊天记录</p>
|
||||
<p className="max-w-64 text-xs leading-5 text-white/45">
|
||||
通话中的语音转写和助手回复会实时显示在这里。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{messages.map((message) => {
|
||||
const time = messageTime(message.timestamp);
|
||||
const isAssistant = message.role === "assistant";
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={[
|
||||
"flex max-w-[88%] flex-col gap-1",
|
||||
isAssistant ? "self-start" : "self-end items-end",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className="px-1 text-[11px] text-white/40">
|
||||
{isAssistant ? "助手" : "我"}
|
||||
{time ? ` · ${time}` : ""}
|
||||
</span>
|
||||
<div
|
||||
className={[
|
||||
"whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-sm leading-6 shadow-sm",
|
||||
isAssistant
|
||||
? "rounded-tl-sm bg-white/10 text-white/90"
|
||||
: "rounded-tr-sm bg-white text-[#07101a]",
|
||||
].join(" ")}
|
||||
>
|
||||
{message.content || (message.streaming ? "…" : "")}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CallAudioDeviceSelect({
|
||||
micValue,
|
||||
@@ -163,6 +246,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
||||
selectDevice,
|
||||
selectOutputDevice,
|
||||
supportsOutputSelection,
|
||||
messages,
|
||||
connect,
|
||||
replaceVideoStream,
|
||||
disconnect,
|
||||
@@ -179,6 +263,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
||||
selectCamera: changeCamera,
|
||||
} = camera;
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [view, setView] = useState<CallView>("camera");
|
||||
const connecting = status === "connecting";
|
||||
const inCall = connecting || status === "connected";
|
||||
|
||||
@@ -219,22 +304,28 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
||||
const error = previewError || cameraError;
|
||||
|
||||
return (
|
||||
<main className="relative isolate h-dvh min-h-80 w-full overflow-hidden bg-[#07101a] text-white">
|
||||
<main className="flex h-dvh w-full items-center justify-center overflow-hidden bg-black lg:p-4">
|
||||
<audio ref={audioRef} autoPlay playsInline className="hidden" />
|
||||
<div
|
||||
data-testid="mobile-call-viewport"
|
||||
className="relative isolate h-dvh min-h-80 w-full overflow-hidden bg-[#07101a] text-white lg:h-[calc(100dvh-2rem)] lg:min-h-0 lg:w-auto lg:aspect-[9/16] lg:rounded-[2rem] lg:ring-1 lg:ring-white/15 lg:shadow-2xl"
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className={[
|
||||
"absolute inset-0 h-full w-full -scale-x-100 object-cover transition-opacity duration-300",
|
||||
cameraStream ? "opacity-100" : "opacity-0",
|
||||
"absolute inset-0 h-full w-full -scale-x-100 object-cover transition-opacity duration-300 lg:object-contain",
|
||||
cameraStream && view === "camera" ? "opacity-100" : "opacity-0",
|
||||
].join(" ")}
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/35 via-transparent to-black/65" />
|
||||
|
||||
<div className="absolute left-1/2 top-[max(1rem,env(safe-area-inset-top))] flex -translate-x-1/2 items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-1.5 text-xs text-white/85 backdrop-blur-md">
|
||||
{view === "chat" && <MobileCallTranscript messages={messages} />}
|
||||
|
||||
<div className="absolute left-1/2 top-[max(1rem,env(safe-area-inset-top))] z-10 flex -translate-x-1/2 items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-1.5 text-xs text-white/85 backdrop-blur-md">
|
||||
<span
|
||||
className={[
|
||||
"h-2 w-2 rounded-full",
|
||||
@@ -252,8 +343,22 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
||||
: "通话已结束"}
|
||||
</div>
|
||||
|
||||
{!cameraStream && inCall && (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-8 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView((current) => current === "camera" ? "chat" : "camera")}
|
||||
aria-label={view === "camera" ? "显示聊天记录" : "显示摄像头画面"}
|
||||
title={view === "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"
|
||||
>
|
||||
{view === "camera" ? (
|
||||
<MessageSquareText className="size-[18px]" />
|
||||
) : (
|
||||
<Video className="size-[18px]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{!cameraStream && inCall && view === "camera" && (
|
||||
<div className="absolute inset-0 z-[2] flex items-center justify-center px-8 text-center">
|
||||
<div className="flex max-w-sm flex-col items-center gap-3">
|
||||
{cameraStarting ? (
|
||||
<Loader2 size={34} className="animate-spin text-white/70" />
|
||||
@@ -270,7 +375,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
||||
)}
|
||||
|
||||
{!inCall && (
|
||||
<div className="absolute inset-0 flex items-center justify-center px-8">
|
||||
<div className="absolute inset-0 z-[2] flex items-center justify-center px-8">
|
||||
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -286,7 +391,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
||||
)}
|
||||
|
||||
{inCall && (
|
||||
<div className="absolute bottom-[max(1.25rem,env(safe-area-inset-bottom))] left-1/2 flex -translate-x-1/2 items-center gap-3 min-[380px]:gap-5 landscape:bottom-[max(1rem,env(safe-area-inset-bottom))]">
|
||||
<div className="absolute bottom-[max(1.25rem,env(safe-area-inset-bottom))] left-1/2 z-10 flex -translate-x-1/2 items-center gap-3 min-[380px]:gap-5 landscape:bottom-[max(1rem,env(safe-area-inset-bottom))]">
|
||||
<CallAudioDeviceSelect
|
||||
micValue={selectedDeviceId}
|
||||
micDevices={audioInputs}
|
||||
@@ -314,6 +419,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user