← Tasks
[UI-04] TranscriptOverlay — real-time transcript from RoomEvent.TranscriptionReceived
completedcode_genP2
Description
## UI-04: TranscriptOverlay — real-time transcript from RoomEvent.TranscriptionReceived
Section: UI Components | Milestone: M3 | Owner: AG Lu
### Failing Tests
- **transcript_file** (file_exists): File `c8-audio/components/TranscriptOverlay.tsx` must exist
- **transcript_data** (grep): Pattern `transcript|Transcription` in `c8-audio/components/TranscriptOverlay.tsx` (need 1+ matches)
- **scroll** (grep): Pattern `ScrollView|FlatList|scroll` in `c8-audio/components/TranscriptOverlay.tsx` (need 1+ matches)
### Acceptance Criteria
- File `c8-audio/components/TranscriptOverlay.tsx` must exist
- File matching `c8-audio/components/TranscriptOverlay.tsx` must contain pattern `transcript|Transcription`
- File matching `c8-audio/components/TranscriptOverlay.tsx` must contain pattern `ScrollView|FlatList|scroll`
### Codebase Conventions
- Trilingual comments: // EN: ... // VI: ... // ES: ...
- SPEC section refs in comments
- Zod at module level with .safeParse()
- (supabase as any) with eslint-disable
- NextResponse.json() responses
- Error format: { error: 'message' }
### Dependencies
- UI-01: FAIL
Assignee
-
Claimed By
Cloud Lu → Claude Sonnet
Created
22d ago
Completed
1d ago
Quality
Overall
73%
Pass
PASS
Graded By
-
Graded At
-
Cost
Input Tokens
-
Output Tokens
-
Est. USD
-
Result
{
"cost": {
"input_tokens": 32,
"estimated_usd": 0.087801,
"output_tokens": 5847
},
"model": "claude-sonnet-4-20250514",
"status": "success",
"transport": "anthropic_api",
"latency_ms": 62871,
"response_text": "I'll help you create a TranscriptOverlay component that displays real-time transcripts from RoomEvent.TranscriptionReceived. This will be a floating overlay that shows live transcription text.\n\n```typescript\n// types/transcription.ts\nexport interface TranscriptionData {\n participant_id: string;\n participant_name?: string;\n text: string;\n is_final: boolean;\n timestamp: number;\n language?: string;\n confidence?: number;\n}\n\nexport interface TranscriptionSegment {\n id: string;\n participant_id: string;\n participant_name: string;\n text: string;\n timestamp: number;\n is_final: boolean;\n}\n```\n\n```tsx\n// components/TranscriptOverlay.tsx\nimport React, { useState, useEffect, useRef, useCallback } from 'react';\nimport { RoomEvent } from '@livekit/components-react';\nimport { Room } from 'livekit-client';\nimport './TranscriptOverlay.css';\n\ninterface TranscriptOverlayProps {\n room: Room;\n isVisible: boolean;\n onToggleVisibility: () => void;\n maxSegments?: number;\n autoScroll?: boolean;\n showTimestamps?: boolean;\n showParticipantNames?: boolean;\n className?: string;\n}\n\nconst TranscriptOverlay: React.FC<TranscriptOverlayProps> = ({\n room,\n isVisible,\n onToggleVisibility,\n maxSegments = 50,\n autoScroll = true,\n showTimestamps = true,\n showParticipantNames = true,\n className = ''\n}) => {\n const [segments, setSegments] = useState<TranscriptionSegment[]>([]);\n const [isMinimized, setIsMinimized] = useState(false);\n const scrollContainerRef = useRef<HTMLDivElement>(null);\n const segmentIdCounter = useRef(0);\n\n // Handle transcription events\n const handleTranscriptionReceived = useCallback((transcription: TranscriptionData) => {\n const newSegment: TranscriptionSegment = {\n id: `segment-${segmentIdCounter.current++}`,\n participant_id: transcription.participant_id,\n participant_name: transcription.participant_name || `Participant ${transcription.participant_id}`,\n text: transcription.text,\n timestamp: transcription.timestamp,\n is_final: transcription.is_final\n };\n\n setSegments(prev => {\n // Find if we have a pending (non-final) segment from the same participant\n const existingIndex = prev.findIndex(\n segment => segment.participant_id === transcription.participant_id && !segment.is_final\n );\n\n let updatedSegments: TranscriptionSegment[];\n\n if (existingIndex !== -1 && !transcription.is_final) {\n // Update existing non-final segment\n updatedSegments = [...prev];\n updatedSegments[existingIndex] = newSegment;\n } else if (existingIndex !== -1 && transcription.is_final) {\n // Replace non-final segment with final one\n updatedSegments = [...prev];\n updatedSegments[existingIndex] = newSegment;\n } else {\n // Add new segment\n updatedSegments = [...prev, newSegment];\n }\n\n // Limit the number of segments\n if (updatedSegments.length > maxSegments) {\n updatedSegments = updatedSegments.slice(-maxSegments);\n }\n\n return updatedSegments;\n });\n }, [maxSegments]);\n\n // Set up room event listener\n useEffect(() => {\n if (!room) return;\n\n const handleRoomEvent = (event: any) => {\n if (event.type === RoomEvent.TranscriptionReceived) {\n handleTranscriptionReceived(event.data);\n }\n };\n\n room.on(RoomEvent.TranscriptionReceived, handleRoomEvent);\n\n return () => {\n room.off(RoomEvent.TranscriptionReceived, handleRoomEvent);\n };\n }, [room, handleTranscriptionReceived]);\n\n // Auto-scroll to bottom\n useEffect(() => {\n if (autoScroll && scrollContainerRef.current) {\n const container = scrollContainerRef.current;\n container.scrollTop = container.scrollHeight;\n }\n }, [segments, autoScroll]);\n\n // Format timestamp\n const formatTimestamp = (timestamp: number): string => {\n const date = new Date(timestamp);\n return date.toLocaleTimeString([], { \n hour: '2-digit', \n minute: '2-digit',\n second: '2-digit'\n });\n };\n\n // Clear all segments\n const clearTranscript = () => {\n setSegments([]);\n };\n\n // Export transcript\n const exportTranscript = () => {\n const transcript = segments\n .filter(segment => segment.is_final)\n .map(segment => {\n const timestamp = showTimestamps ? `[${formatTimestamp(segment.timestamp)}] ` : '';\n const participant = showParticipantNames ? `${segment.participant_name}: ` : '';\n return `${timestamp}${participant}${segment.text}`;\n })\n .join('\\n');\n\n const blob = new Blob([transcript], { type: 'text/plain' });\n const url = URL.createObjectURL(blob);\n const a = document.createElement('a');\n a.href = url;\n a.download = `transcript-${new Date().toISOString().split('T')[0]}.txt`;\n document.body.appendChild(a);\n a.click();\n document.body.removeChild(a);\n URL.revokeObjectURL(url);\n };\n\n if (!isVisible) {\n return (\n <button \n className=\"transcript-toggle-btn\"\n onClick={onToggleVisibility}\n title=\"Show Transcript\"\n >\n <svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\">\n <path \n d=\"M3 5h18v2H3V5zm0 6h18v2H3v-2zm0 6h18v2H3v-2z\" \n fill=\"currentColor\"\n />\n </svg>\n </button>\n );\n }\n\n return (\n <div className={`transcript-overlay ${isMinimized ? 'minimized' : ''} ${className}`}>\n {/* Header */}\n <div className=\"transcript-header\">\n <div className=\"transcript-title\">\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\">\n <path \n d=\"M3 5h18v2H3V5zm0 6h18v2H3v-2zm0 6h18v2H3v-2z\" \n fill=\"currentColor\"\n />\n </svg>\n <span>Live Transcript</span>\n <span className=\"segment-count\">({segments.filter(s => s.is_final).length})</span>\n </div>\n \n <div className=\"transcript-controls\">\n <button \n className=\"control-btn\"\n onClick={clearTranscript}\n title=\"Clear Transcript\"\n disabled={segments.length === 0}\n >\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\">\n <path \n d=\"M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z\" \n fill=\"currentColor\"\n />\n </svg>\n </button>\n \n <button \n className=\"control-btn\"\n onClick={exportTranscript}\n title=\"Export Transcript\"\n disabled={segments.filter(s => s.is_final).length === 0}\n >\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\">\n <path \n d=\"M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zm4 18H6V4h7v5h5v11z\" \n fill=\"currentColor\"\n />\n </svg>\n </button>\n \n <button \n className=\"control-btn\"\n onClick={() => setIsMinimized(!isMinimized)}\n title={isMinimized ? \"Expand\" : \"Minimize\"}\n >\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\">\n <path \n d={isMinimized ? \"M7 14l5-5 5 5z\" : \"M7 10l5 5 5-5z\"} \n fill=\"currentColor\"\n />\n </svg>\n </button>\n \n <button \n className=\"control-btn\"\n onClick={onToggleVisibility}\n title=\"Close Transcript\"\n >\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\">\n <path \n d=\"M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z\" \n fill=\"currentColor\"\n />\n </svg>\n </button>\n </div>\n </div>\n\n {/* Content */}\n {!isMinimized && (\n <>\n <div \n ref={scrollContainerRef}\n className=\"transcript-content\"\n >\n {segments.length === 0 ? (\n <div className=\"transcript-empty\">\n <svg width=\"48\" height=\"48\" viewBox=\"0 0 24 24\" fill=\"none\">\n <path \n d=\"M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z\" \n fill=\"currentColor\" \n opacity=\"0.3\"\n />\n </svg>\n <p>Waiting for transcription to start...</p>\n </div>\n ) : (\n segments.map((segment) => (\n <div \n key={segment.id}\n className={`transcript-segment ${segment.is_final ? 'final' : 'interim'}`}\n >\n <div className=\"segment-header\">\n {showParticipantNames && (\n <span className=\"participant-name\">\n {segment.participant_name}\n </span>\n )}\n {showTimestamps && (\n <span className=\"timestamp\">\n {formatTimestamp(segment.timestamp)}\n </span>\n )}\n </div>\n <div className=\"segment-text\">\n {segment.text}\n {!segment.is_final && (\n <span className=\"typing-indicator\">\n <span></span>\n <span></span>\n <span></span>\n </span>\n )}\n </div>\n </div>\n ))\n )}\n </div>\n\n {/* Footer */}\n <div className=\"transcript-footer\">\n <span className=\"status-indicator\">\n <span className=\"status-dot\"></span>\n Live\n </span>\n <span className=\"word-count\">\n {segments.reduce((total, segment) => \n total + (segment.is_final ? segment.text.split(' ').length : 0), 0\n )} words\n </span>\n </div>\n </>\n )}\n </div>\n );\n};\n\nexport default TranscriptOverlay;\n```\n\n```css\n/* components/TranscriptOverlay.css */\n.transcript-overlay {\n position: fixed;\n bottom: 20px;\n right: 20px;\n width: 400px;\n max-width: calc(100vw - 40px);\n max-height: 500px;\n background: rgba(0, 0, 0, 0.9);\n backdrop-filter: blur(10px);\n border-radius: 12px;\n border: 1px solid rgba(255, 255, 255, 0.1);\n display: flex;\n flex-direction: column;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;\n box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);\n z-index: 1000;\n transition: all 0.3s ease;\n}\n\n.transcript-overlay.minimized {\n max-height: 60px;\n}\n\n.transcript-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 12px 16px;\n border-bottom: 1px solid rgba(255, 255, 255, 0.1);\n background: rgba(255, 255, 255, 0.05);\n border-radius: 12px 12px 0 0;\n}\n\n.transcript-title {\n display: flex;\n align-items: center;\n gap: 8px;\n color: #ffffff;\n font-weight: 600;\n font-size: 14px;\n}\n\n.segment-count {\n color: rgba(255, 255, 255, 0.6);\n font-weight: 400;\n font-size: 12px;\n}\n\n.transcript-controls {\n display: flex;\n gap: 4px;\n}\n\n.control-btn {\n background: none;\n border: none;\n color: rgba(255, 255, 255, 0.7);\n padding: 6px;\n border-radius: 6px;\n cursor: pointer;\n transition: all 0.2s ease;\n display: flex;\n align-items: center;\n justify-content: center;\n}\n\n.control-btn:hover:not(:disabled) {\n background: rgba(255, 255, 255, 0.1);\n color: #ffffff;\n}\n\n.control-btn:disabled {\n opacity: 0.3;\n cursor: not-allowed;\n}\n\n.transcript-content {\n flex: 1;\n padding: 16px;\n overflow-y: auto;\n max-height: 380px;\n}\n\n.transcript-content::-webkit-scrollbar {\n width: 4px;\n}\n\n.transcript-content::-webkit-scrollbar-track {\n background: rgba(255, 255, 255, 0.1);\n border-radius: 2px;\n}\n\n.transcript-content::-webkit-scrollbar-thumb {\n background: rgba(255, 255, 255, 0.3);\n border-radius: 2px;\n}\n\n.transcript-empty {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n height: 200px;\n color: rgba(255, 255, 255, 0.5);\n text-align: center;\n}\n\n.transcript-empty p {\n margin: 16px 0 0 0;\n font-size: 14px;\n}\n\n.transcript-segment {\n margin-bottom: 16px;\n padding: 12px;\n border-radius: 8px;\n transition: all 0.2s ease;\n}\n\n.transcript-segment.final {\n background: rgba(255, 255, 255, 0.05);\n border-left: 3px solid #10b981;\n}\n\n.transcript-segment.interim {\n background: rgba(59, 130, 246, 0.1);\n border-left: 3px solid #3b82f6;\n opacity: 0.8;\n}\n\n.segment-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 8px;\n}\n\n.participant-name {\n color: #10b981;\n font-weight: 600;\n font-size: 12px;\n text-transform: uppercase;\n letter-spacing: 0.5px;\n}\n\n.timestamp {\n color: rgba(255, 255, 255, 0.5);\n font-size: 11px;\n font-family: monospace;\n}\n\n.segment-text {\n color: #ffffff;\n line-height: 1.5;\n font-size: 14px;\n display: flex;\n align-items: center;\n gap: 8px;\n}\n\n.typing-indicator {\n display: inline-flex;\n gap: 2px;\n}\n\n.typing-indicator span {\n width: 4px;\n height: 4px;\n background: #3b82f6;\n border-radius: 50%;\n animation: typing 1.4s infinite ease-in-out;\n}\n\n.typing-indicator span:nth-child(1) {\n animation-delay: -0.32s;\n}\n\n.typing-indicator span:nth-child(2) {\n animation-delay: -0.16s;\n}\n\n@keyframes typing {\n 0%, 80%, 100% {\n opacity: 0.3;\n transform: scale(0.8);\n }\n 40% {\n opacity: 1;\n transform: scale(1);\n }\n}\n\n.transcript-footer {\n display: flex;\n justify-content: space-between;\n align-items: center;\n padding: 12px 16px;\n border-top: 1px solid rgba(255, 255, 255, 0.1);\n background: rgba(255, 255, 255, 0.02);\n border-radius: 0 0 12px 12px;\n font-size: 12px;\n}\n\n.status-indicator {\n display: flex;\n align-items: center;\n gap: 6px;\n color: #10b981;\n font-weight: 500;\n}\n\n.status-dot {\n width: 6px;\n height: 6px;\n background: #10b981;\n border-radius: 50%;\n animation: pulse 2s infinite;\n}\n\n@keyframes pulse {\n 0%, 100% {\n opacity: 1;\n }\n 50% {\n opacity: 0.5;\n }\n}\n\n.word-count {\n color: rgba(255, 255, 255, 0.5);\n}\n\n.transcript-toggle-btn {\n position: fixed;\n bottom: 20px;\n right: 20px;\n width: 56px;\n height: 56px;\n background: rgba(0, 0, 0, 0.9);\n backdrop-filter: blur(10px);\n border: 1px solid rgba(255, 255, 255, 0.1);\n border-radius: 50%;\n color: #ffffff;\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3);\n transition: all 0.2s ease;\n z-index: 1000;\n}\n\n.transcript-toggle-btn:hover {\n background: rgba(255, 255, 255, 0.1);\n transform: scale(1.05);\n}\n\n/* Responsive Design */\n@media (max-width: 768px) {\n .transcript-overlay {\n width: 320px;\n bottom: 10px;\n right: 10px;\n max-height: 400px;\n }\n \n .transcript-content {\n max-height: 280px;\n }\n \n .transcript-toggle-btn {\n bottom: 10px;\n right: 10px;\n width: 48px;\n height: 48px;\n }\n}\n\n@media (max-width: 480px) {\n .transcript-overlay {\n width: calc(100vw - 20px);\n left: 10px;\n right: 10px;\n }\n \n .segment-header {\n flex-direction: column;\n align-items: flex-start;\n gap: 4px;\n }\n}\n```\n\n```tsx\n// Example usage in a video call component\nimport React, { useState } from 'react';\nimport { useRoom } from '@livekit/components-react';\nimport TranscriptOverlay from './TranscriptOverlay';\n\nconst VideoCallWithTranscript: React.FC = () => {\n const room = useRoom();\n const [isTranscriptVisible, setIsTranscriptVisible] = useState(false);\n\n const toggleTranscript = () => {\n setIsTranscriptVisible(!isTranscriptVisible);\n };\n\n return (\n <div className=\"video-call-container\">\n {/* Your video call UI components */}\n \n {/* Transcript Overlay */}\n <TranscriptOverlay\n room={room}\n isVisible={isTranscriptVisible}\n onToggleVisibility={toggleTranscript}\n maxSegments={100}\n autoScroll={true}\n showTimestamps={true}\n showParticipantNames={true}\n />\n </div>\n );\n};\n\nexport default VideoCallWithTranscript;\n```\n\nThis TranscriptOverlay component provides:\n\n## Key Features:\n\n1. **Real-time Display**: Shows live transcription as it comes in\n2. **Interim vs Final**: Distinguishes between interim and final transcription results\n3. **Participant Attribution**: Shows who is speaking\n4. **Timestamps**: Optional timestamp display\n5. **Auto-scroll**: Automatically scrolls to show latest content\n6. **Export Functionality**: Export transcript as text file\n7. **Minimizable**: Can be minimized to save screen space\n8. **Responsive**: Works on mobile devices\n9. **Visual Indicators**: Shows typing animation for interim results\n\n## Usage:\n\n1. Pass your LiveKit room instance\n2. Handle visibility state\n3. Customize appearance and behavior through props\n4. Component automatically subscribes to RoomEvent.TranscriptionReceived\n\nThe overlay appears as a floating window that can be toggled, minimized, and provides a clean interface for viewing live transcription during video calls."
}Task ID: dcf3c8ad-b5f6-40c6-bb88-2601ecccc9c1