// src/renderer/pages/MeetingModePage.tsx // Phase 14: Meeting Mode — 실시간 녹음 + 메모 + 회의록 UI import { useState, useEffect, useCallback, useRef } from 'react' import { Box, LinearProgress, TextField, Snackbar, Alert, } from '@mui/material' import AddIcon from '@mui/icons-material/Add' import StopIcon from '@mui/icons-material/Stop' import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord' import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '@d3ro/ui/components/ds' import { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs' import { EditableSegment } from '../components/meeting/EditableSegment' import { PageHeader, EmptyStateCard } from '../components/shared' import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme' import { useI18n } from '../i18n' import type { MeetingSessionSummary, MeetingSessionDetail, MeetingModeStateInfo, MeetingProcessingProgress, CaptionSegment, MeetingMemo, } from '@d3ro/core/types' type MeetingView = 'list' | 'recording' | 'detail' export function MeetingModePage(): React.ReactElement { const { t } = useI18n() const [view, setView] = useState('list') const [sessions, setSessions] = useState([]) const [totalSessions, setTotalSessions] = useState(0) const [loading, setLoading] = useState(true) const [page, setPage] = useState(1) const pageSize = 20 // 녹음 뷰 상태 const [stateInfo, setStateInfo] = useState(null) const [segments, setSegments] = useState([]) const [memos, setMemos] = useState([]) const [memoInput, setMemoInput] = useState('') const [elapsedMs, setElapsedMs] = useState(0) const [progress, setProgress] = useState(null) const [audioLevel, setAudioLevel] = useState(0) const transcriptRef = useRef(null) const elapsedTimerRef = useRef | null>(null) const recordingStartRef = useRef(0) // 상세 뷰 상태 const [detail, setDetail] = useState(null) const [snackbarMsg, setSnackbarMsg] = useState(null) // ── 세션 목록 로드 ── const loadSessions = useCallback(async () => { setLoading(true) const resp = await window.electronAPI.meetingMode.getSessions({ page, pageSize }) if (resp.success) { setSessions(resp.data.sessions) setTotalSessions(resp.data.total) } setLoading(false) }, [page]) useEffect(() => { if (view === 'list') loadSessions() }, [view, loadSessions]) // ── IPC 이벤트 구독 ── useEffect(() => { const unsubState = window.electronAPI.meetingMode.onStateChanged((info) => { setStateInfo(info) if (info.state === 'idle' && view === 'recording') { // 후처리 완료 또는 에러 → 목록으로 이동 + 새로고침 setProgress(null) setView('list') } }) const unsubSegment = window.electronAPI.meetingMode.onSegment((seg) => { setSegments((prev) => [...prev, seg]) // 자동 스크롤 setTimeout(() => { if (transcriptRef.current) { transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight } }, 50) }) const unsubProgress = window.electronAPI.meetingMode.onProcessingProgress((p) => { setProgress(p) }) const unsubCompleted = window.electronAPI.meetingMode.onSessionCompleted(() => { setProgress(null) setView('list') }) const unsubError = window.electronAPI.meetingMode.onError(() => { setProgress(null) setView('list') }) const unsubAudioLevel = window.electronAPI.meetingMode.onAudioLevel((data) => { setAudioLevel(Math.min(data.level * 10, 1)) // RMS 정규화 }) return () => { unsubState() unsubSegment() unsubProgress() unsubCompleted() unsubError() unsubAudioLevel() } }, [view]) // ── 경과 시간 타이머 ── useEffect(() => { if (view === 'recording') { recordingStartRef.current = Date.now() elapsedTimerRef.current = setInterval(() => { setElapsedMs(Date.now() - recordingStartRef.current) }, 1000) } return () => { if (elapsedTimerRef.current) { clearInterval(elapsedTimerRef.current) elapsedTimerRef.current = null } } }, [view]) // ── 핸들러 ── const handleStartRecording = useCallback(async () => { const resp = await window.electronAPI.meetingMode.startRecording() if (resp.success) { setSegments([]) setMemos([]) setElapsedMs(0) setProgress(null) setView('recording') } else { setSnackbarMsg(t('meeting.startRecordingError')) } }, [t]) const handleStopRecording = useCallback(async () => { await window.electronAPI.meetingMode.stopRecording() }, []) const handleAddMemo = useCallback(async () => { if (!memoInput.trim()) return const resp = await window.electronAPI.meetingMode.addMemo({ content: memoInput.trim() }) if (resp.success) { setMemos((prev) => [...prev, resp.data]) setMemoInput('') } }, [memoInput]) const handleMemoKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() handleAddMemo() } }, [handleAddMemo], ) const handleViewDetail = useCallback(async (sessionId: string) => { const resp = await window.electronAPI.meetingMode.getSession({ sessionId }) if (resp.success) { setDetail(resp.data) setView('detail') } else { setSnackbarMsg(t('meeting.viewDetailError')) } }, [t]) // ── 유틸 ── const formatTime = (ms: number): string => { const totalSec = Math.floor(ms / 1000) const min = Math.floor(totalSec / 60) const sec = totalSec % 60 return `${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}` } const formatDate = (ts: number): string => { const d = new Date(ts) return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` } const statusColor = (status: string): 'green' | 'amber' | 'red' | 'blue' => { switch (status) { case 'completed': return 'green' case 'recording': return 'red' case 'processing': return 'amber' case 'error': return 'red' default: return 'blue' } } const snackbar = ( setSnackbarMsg(null)} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > setSnackbarMsg(null)} sx={{ width: '100%' }}> {snackbarMsg} ) // ── 렌더: 목록 뷰 ── if (view === 'list') { return ( 0 ? t('meeting.sessions', { count: totalSessions }) : undefined} action={ {t('meeting.newMeeting')} } /> {loading ? ( ) : sessions.length === 0 ? ( ) : ( {sessions.map((session) => ( handleViewDetail(session.id)} sx={{ cursor: 'pointer', transition: 'transform 0.15s ease, box-shadow 0.15s ease', '&:hover': { transform: 'translateY(-2px)' }, }} > {/* 제목 — 여러 줄 허용 */} {session.title ?? t('meeting.untitled')} {/* 정보 그리드 */} {formatDate(session.startedAt)} {session.durationMs != null ? t('meeting.duration', { minutes: Math.round(session.durationMs / 60000) }) : '—'} {t('meeting.memos')}: {session.memoCount} {/* 하단 — 상태 표시 */} {session.status === 'completed' ? t('meeting.completed') : session.status === 'error' ? t('meeting.error') : session.status === 'processing' ? t('meeting.processing') : t('meeting.recording')} {/* 프로세싱 진행 바 */} {session.status === 'processing' && progress && progress.sessionId === session.id && ( {t(`meeting.processingStep.${progress.step}` as Parameters[0])} )} ))} )} {snackbar} ) } // ── 렌더: 녹음 뷰 ── if (view === 'recording') { const isProcessing = stateInfo?.state === 'processing' return ( {/* 상단 바 */} {formatTime(elapsedMs)} {isProcessing ? t('meeting.processing') : t('meeting.recording')} {!isProcessing && ( {t('meeting.stopRecording')} )} {/* 프로세싱 진행률 */} {isProcessing && progress && ( {t(`meeting.processingStep.${progress.step}` as Parameters[0])} ({progress.percent}%) )} {/* 메인 패널: 전사 + 메모 */} {/* 좌: 실시간 전사 */} {t('meeting.transcript')} {segments.map((seg) => ( { /* no-op: readOnly=true */ }} readOnly /> ))} {/* 우: 메모 입력 */} {t('meeting.memos')} {memos.map((memo) => ( {formatTime(memo.timestampMs)} {memo.content} ))} {!isProcessing && ( setMemoInput(e.target.value)} onKeyDown={handleMemoKeyDown} sx={{ '& .MuiInputBase-root': { fontFamily: d3roFontMono, fontSize: 13, }, }} /> )} {/* 오디오 레벨 미터 */} {!isProcessing && ( 0.02 ? 'green' : 'amber'} size={8} /> 0.7 ? d3roPalette.tag.red : audioLevel > 0.3 ? d3roPalette.tag.orange : d3roPalette.tag.green, borderRadius: 3, transition: 'width 0.1s ease-out', }} /> {Math.round(audioLevel * 100)}% )} {snackbar} ) } // ── 렌더: 상세 뷰 ── if (view === 'detail' && detail) { return ( { setDetail(null); setView('list') }} /> ) } return }