From 739c01940494ab9e2ed3934ae418f2ab49732bd3 Mon Sep 17 00:00:00 2001 From: Xin Wang Date: Thu, 18 Dec 2025 09:22:01 +0800 Subject: [PATCH] set chat message overlay draggable --- src/components/playground/PhoneSimulator.tsx | 186 +++++++++++++++---- 1 file changed, 154 insertions(+), 32 deletions(-) diff --git a/src/components/playground/PhoneSimulator.tsx b/src/components/playground/PhoneSimulator.tsx index 5b35fd4..f8e4932 100644 --- a/src/components/playground/PhoneSimulator.tsx +++ b/src/components/playground/PhoneSimulator.tsx @@ -70,6 +70,11 @@ export function PhoneSimulator({ const pushToTalkButtonRef = useRef(null); const [showChatOverlay, setShowChatOverlay] = useState(false); const [chatOverlayPosition, setChatOverlayPosition] = useState({ x: 0, y: 0 }); // Will be positioned at top-right by ChatOverlay component + const [chatTogglePosition, setChatTogglePosition] = useState({ x: 0, y: 56 }); // Initial position aligned with visualizer + const [isDraggingChatToggle, setIsDraggingChatToggle] = useState(false); + const chatToggleRef = useRef(null); + const chatToggleDragOffset = useRef({ x: 0, y: 0 }); + const chatToggleHasDragged = useRef(false); useEffect(() => { const voiceAttr = config.settings.attributes?.find(a => a.key === "voice"); @@ -79,30 +84,45 @@ export function PhoneSimulator({ }, [config.settings.attributes]); // Set talking_mode attribute when connected or when mode changes + const lastTalkingModeRef = useRef(null); + const configAttributesRef = useRef(config.settings.attributes); + + // Update config attributes ref when it changes + useEffect(() => { + configAttributesRef.current = config.settings.attributes; + }, [config.settings.attributes]); + useEffect(() => { if (roomState === ConnectionState.Connected && localParticipant) { const talkingMode = isPushToTalkMode ? "push_to_talk" : "realtime"; + + // Only update if the mode actually changed + if (lastTalkingModeRef.current === talkingMode) { + return; + } + lastTalkingModeRef.current = talkingMode; + try { - // Get current attributes to preserve them - const currentAttributes: Record = {}; - // Note: LiveKit's setAttributes replaces all attributes, so we need to merge - // with existing ones from config if any - const configAttributes = config.settings.attributes || []; + // Get current attributes from config to preserve them + const attributesToSet: Record = {}; + const configAttributes = configAttributesRef.current || []; configAttributes.forEach(attr => { if (attr.key && attr.value) { - currentAttributes[attr.key] = attr.value; + attributesToSet[attr.key] = attr.value; } }); - // Set talking_mode along with other attributes - localParticipant.setAttributes({ - ...currentAttributes, - talking_mode: talkingMode, - }); + // Add talking_mode + attributesToSet.talking_mode = talkingMode; + + localParticipant.setAttributes(attributesToSet); } catch (error) { console.error("Failed to set talking_mode attribute:", error); } + } else if (roomState === ConnectionState.Disconnected) { + // Reset ref when disconnected + lastTalkingModeRef.current = null; } - }, [roomState, localParticipant, isPushToTalkMode, config.settings.attributes]); + }, [roomState, localParticipant, isPushToTalkMode]); const [currentTime, setCurrentTime] = useState(""); @@ -174,6 +194,100 @@ export function PhoneSimulator({ }; }, [isDragging]); + // Chat toggle button drag handlers + const handleChatToggleDragStart = (e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + e.stopPropagation(); // Prevent triggering the button click + setIsDraggingChatToggle(true); + chatToggleHasDragged.current = false; + if (!phoneContainerRef.current || !chatToggleRef.current) return; + + const containerRect = phoneContainerRef.current.getBoundingClientRect(); + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + + // Calculate offset relative to container + chatToggleDragOffset.current = { + x: clientX - containerRect.left - chatTogglePosition.x, + y: clientY - containerRect.top - chatTogglePosition.y, + }; + }; + + const handleChatToggleDragMove = (e: MouseEvent | TouchEvent) => { + if (!isDraggingChatToggle || !phoneContainerRef.current || !chatToggleRef.current) return; + + e.preventDefault(); + chatToggleHasDragged.current = true; // Mark that we've actually dragged + + const containerRect = phoneContainerRef.current.getBoundingClientRect(); + const buttonRect = chatToggleRef.current.getBoundingClientRect(); + + const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; + const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + + // Calculate new position relative to container + let newX = clientX - containerRect.left - chatToggleDragOffset.current.x; + let newY = clientY - containerRect.top - chatToggleDragOffset.current.y; + + // Constrain within container + const maxX = containerRect.width - buttonRect.width; + const maxY = containerRect.height - buttonRect.height; + // On mobile (width < 768px), status bar is hidden, so allow dragging to top (y=0) + // On desktop, keep status bar height constraint (48px) + const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; + const minY = isMobile ? 0 : 48; // statusBarHeight = 48px + + newX = Math.max(0, Math.min(newX, maxX)); + newY = Math.max(minY, Math.min(newY, maxY)); + + setChatTogglePosition({ + x: newX, + y: newY, + }); + }; + + const handleChatToggleDragEnd = () => { + setIsDraggingChatToggle(false); + // Reset the flag after a short delay to allow onClick to check it + setTimeout(() => { + chatToggleHasDragged.current = false; + }, 100); + }; + + useEffect(() => { + if (isDraggingChatToggle) { + window.addEventListener("mouseup", handleChatToggleDragEnd); + window.addEventListener("mousemove", handleChatToggleDragMove); + window.addEventListener("touchend", handleChatToggleDragEnd); + window.addEventListener("touchmove", handleChatToggleDragMove, { passive: false }); + } + return () => { + window.removeEventListener("mouseup", handleChatToggleDragEnd); + window.removeEventListener("mousemove", handleChatToggleDragMove); + window.removeEventListener("touchend", handleChatToggleDragEnd); + window.removeEventListener("touchmove", handleChatToggleDragMove); + }; + }, [isDraggingChatToggle]); + + // Initialize chat toggle button position when connected and container is available + useEffect(() => { + if (roomState === ConnectionState.Connected && phoneContainerRef.current) { + // Use a small delay to ensure the button is rendered + const timer = setTimeout(() => { + if (phoneContainerRef.current && chatToggleRef.current) { + const containerWidth = phoneContainerRef.current.offsetWidth; + const buttonWidth = chatToggleRef.current.offsetWidth || 44; // Approximate button width + // Position at rightmost border (flush with right edge) + setChatTogglePosition({ + x: containerWidth - buttonWidth - 56, + y: 56, + }); + } + }, 100); + return () => clearTimeout(timer); + } + }, [roomState]); // Initialize when connected + useEffect(() => { if (showCameraMenu) { Room.getLocalDevices("videoinput").then(setCameras); @@ -877,22 +991,30 @@ export function PhoneSimulator({ - {/* Chat Toggle Button - Top Right, aligned with audio visualizer */} + {/* Chat Toggle Button - Top Right, aligned with audio visualizer (Draggable) */} {roomState === ConnectionState.Connected && voiceAssistant.agent && phoneMode !== "important_message" && phoneMode !== "capture" && ( + {/* End Call Button */}