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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { Camera, Headphones, Loader2, Mic, Phone, PhoneOff, Video } from "lucide-react";
|
import {
|
||||||
|
Camera,
|
||||||
|
Headphones,
|
||||||
|
Loader2,
|
||||||
|
MessageSquareText,
|
||||||
|
Mic,
|
||||||
|
Phone,
|
||||||
|
PhoneOff,
|
||||||
|
Video,
|
||||||
|
} from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -20,7 +29,81 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useCameraPreview } from "@/hooks/use-camera-preview";
|
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({
|
function CallAudioDeviceSelect({
|
||||||
micValue,
|
micValue,
|
||||||
@@ -163,6 +246,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
|||||||
selectDevice,
|
selectDevice,
|
||||||
selectOutputDevice,
|
selectOutputDevice,
|
||||||
supportsOutputSelection,
|
supportsOutputSelection,
|
||||||
|
messages,
|
||||||
connect,
|
connect,
|
||||||
replaceVideoStream,
|
replaceVideoStream,
|
||||||
disconnect,
|
disconnect,
|
||||||
@@ -179,6 +263,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
|||||||
selectCamera: changeCamera,
|
selectCamera: changeCamera,
|
||||||
} = camera;
|
} = camera;
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
const [view, setView] = useState<CallView>("camera");
|
||||||
const connecting = status === "connecting";
|
const connecting = status === "connecting";
|
||||||
const inCall = connecting || status === "connected";
|
const inCall = connecting || status === "connected";
|
||||||
|
|
||||||
@@ -219,22 +304,28 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
|||||||
const error = previewError || cameraError;
|
const error = previewError || cameraError;
|
||||||
|
|
||||||
return (
|
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" />
|
<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
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
autoPlay
|
autoPlay
|
||||||
playsInline
|
playsInline
|
||||||
muted
|
muted
|
||||||
className={[
|
className={[
|
||||||
"absolute inset-0 h-full w-full -scale-x-100 object-cover transition-opacity duration-300",
|
"absolute inset-0 h-full w-full -scale-x-100 object-cover transition-opacity duration-300 lg:object-contain",
|
||||||
cameraStream ? "opacity-100" : "opacity-0",
|
cameraStream && view === "camera" ? "opacity-100" : "opacity-0",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-black/35 via-transparent to-black/65" />
|
<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
|
<span
|
||||||
className={[
|
className={[
|
||||||
"h-2 w-2 rounded-full",
|
"h-2 w-2 rounded-full",
|
||||||
@@ -252,8 +343,22 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
|||||||
: "通话已结束"}
|
: "通话已结束"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!cameraStream && inCall && (
|
<button
|
||||||
<div className="absolute inset-0 flex items-center justify-center px-8 text-center">
|
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">
|
<div className="flex max-w-sm flex-col items-center gap-3">
|
||||||
{cameraStarting ? (
|
{cameraStarting ? (
|
||||||
<Loader2 size={34} className="animate-spin text-white/70" />
|
<Loader2 size={34} className="animate-spin text-white/70" />
|
||||||
@@ -270,7 +375,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!inCall && (
|
{!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">
|
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -286,7 +391,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{inCall && (
|
{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
|
<CallAudioDeviceSelect
|
||||||
micValue={selectedDeviceId}
|
micValue={selectedDeviceId}
|
||||||
micDevices={audioInputs}
|
micDevices={audioInputs}
|
||||||
@@ -314,6 +419,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user