feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped
This commit is contained in:
parent
5cd1de6859
commit
708e20f747
406 changed files with 42464 additions and 6199 deletions
|
|
@ -1,21 +1,61 @@
|
|||
// src/renderer/pages/MeetingModePage.tsx
|
||||
// Phase 14: Meeting Mode — 실시간 녹음 + 메모 + 회의록 UI
|
||||
// High-End Meeting Intelligence Studio: Live Transcribing & On-the-fly Editing + Context Enricher Memos
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Box,
|
||||
LinearProgress,
|
||||
TextField,
|
||||
Snackbar,
|
||||
Alert,
|
||||
Typography,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
Divider,
|
||||
CircularProgress,
|
||||
} from '@mui/material'
|
||||
import { Plus, Square, Circle } from 'lucide-react'
|
||||
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '@d3ro/ui/components/ds'
|
||||
import {
|
||||
Plus,
|
||||
Square,
|
||||
Circle,
|
||||
Users,
|
||||
Clock,
|
||||
FileText,
|
||||
Sparkles,
|
||||
Mic,
|
||||
Activity,
|
||||
Trash2,
|
||||
Tag,
|
||||
CheckCircle2,
|
||||
ListTodo,
|
||||
Lightbulb,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Settings,
|
||||
RefreshCw,
|
||||
Cpu,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
MetalCard,
|
||||
PhosphorText,
|
||||
Led,
|
||||
PhysicalButton,
|
||||
ScreenPanel,
|
||||
DoubleBezelCard,
|
||||
TactileBadge,
|
||||
AudioVisualizerBar,
|
||||
} from '@d3ro/ui/components/ds'
|
||||
import { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs'
|
||||
import { EditableSegment } from '../components/meeting/EditableSegment'
|
||||
import { PageHeader, EmptyStateCard } from '../components/shared'
|
||||
import { isImeComposingEvent } from '../utils/keyboard'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '@d3ro/ui/theme'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type {
|
||||
MeetingSessionSummary,
|
||||
|
|
@ -28,6 +68,23 @@ import type {
|
|||
|
||||
type MeetingView = 'list' | 'recording' | 'detail'
|
||||
|
||||
interface MeetingErrorInfo {
|
||||
type: 'collision' | 'stt' | 'audio' | 'general'
|
||||
title: string
|
||||
code?: number | string
|
||||
message: string
|
||||
reason: string
|
||||
suggestion: string
|
||||
}
|
||||
|
||||
const CONTEXT_TAGS = [
|
||||
{ label: '#결정', icon: <CheckCircle2 size={12} />, color: d3roPalette.tag.green },
|
||||
{ label: '#할일', icon: <ListTodo size={12} />, color: d3roPalette.accent.light },
|
||||
{ label: '#안건', icon: <Tag size={12} />, color: d3roPalette.tag.orange },
|
||||
{ label: '#아이디어', icon: <Lightbulb size={12} />, color: d3roPalette.tag.purple },
|
||||
{ label: '#이슈', icon: <AlertTriangle size={12} />, color: d3roPalette.tag.red },
|
||||
]
|
||||
|
||||
export function MeetingModePage(): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [view, setView] = useState<MeetingView>('list')
|
||||
|
|
@ -37,6 +94,13 @@ export function MeetingModePage(): React.ReactElement {
|
|||
const [page, setPage] = useState(1)
|
||||
const pageSize = 20
|
||||
|
||||
// 사전 점검 및 기동 상태
|
||||
const [isStarting, setIsStarting] = useState(false)
|
||||
const [errorInfo, setErrorInfo] = useState<MeetingErrorInfo | null>(null)
|
||||
const [sttProvider, setSttProvider] = useState<string>('local')
|
||||
const [sttEngineState, setSttEngineState] = useState<string>('ready')
|
||||
const [micDeviceName, setMicDeviceName] = useState<string>('')
|
||||
|
||||
// 녹음 뷰 상태
|
||||
const [stateInfo, setStateInfo] = useState<MeetingModeStateInfo | null>(null)
|
||||
const [segments, setSegments] = useState<CaptionSegment[]>([])
|
||||
|
|
@ -53,6 +117,77 @@ export function MeetingModePage(): React.ReactElement {
|
|||
const [detail, setDetail] = useState<MeetingSessionDetail | null>(null)
|
||||
const [snackbarMsg, setSnackbarMsg] = useState<string | null>(null)
|
||||
|
||||
// ── 사전 환경 점검 로드 ──
|
||||
const checkPreflight = useCallback(async () => {
|
||||
try {
|
||||
const st = await window.electronAPI.stt.getStatus()
|
||||
if (st.success && st.data) {
|
||||
setSttEngineState(st.data.engineState)
|
||||
}
|
||||
const provRes = await window.electronAPI.config.get({ key: 'sttProvider' })
|
||||
if (provRes.success && provRes.data) {
|
||||
setSttProvider(String(provRes.data))
|
||||
}
|
||||
const devsRes = await window.electronAPI.audio.getDevices()
|
||||
if (devsRes.success && devsRes.data && devsRes.data.length > 0) {
|
||||
const def = devsRes.data.find((d) => d.isDefault) || devsRes.data[0]
|
||||
setMicDeviceName(def.label || '기본 마이크')
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
checkPreflight()
|
||||
}, [checkPreflight])
|
||||
|
||||
// ── 에러 파서 ──
|
||||
const parseMeetingError = useCallback((errMessage?: string, errCode?: number | string): MeetingErrorInfo => {
|
||||
const msg = (errMessage ?? '').toLowerCase()
|
||||
const code = errCode
|
||||
|
||||
if (code === 1400 || msg.includes('활성') || msg.includes('자막') || msg.includes('이미') || msg.includes('already') || msg.includes('collision')) {
|
||||
return {
|
||||
type: 'collision',
|
||||
title: '이전 회의 또는 실시간 자막 충돌',
|
||||
code: code ?? 'SESSION_COLLISION',
|
||||
message: errMessage || '이전 회의 세션 또는 자막 모드가 백그라운드에 여전히 활성화되어 있습니다.',
|
||||
reason: '백그라운드에서 동작 중이던 회의/자막 프로세스가 완전히 해제되지 않아 충돌했습니다.',
|
||||
suggestion: '기존 프로세스를 강제 초기화한 후 즉시 새 회의를 시작할 수 있습니다.',
|
||||
}
|
||||
}
|
||||
|
||||
if (code === 101 || (code !== undefined && Number(code) >= 100 && Number(code) <= 120) || msg.includes('stt') || msg.includes('whisper') || msg.includes('sidecar') || msg.includes('python') || msg.includes('model')) {
|
||||
return {
|
||||
type: 'stt',
|
||||
title: '음성 인식(STT) 엔진 준비 실패',
|
||||
code: code ?? 'STT_UNAVAILABLE',
|
||||
message: errMessage || 'STT 음성 인식 엔진 또는 Whisper 모델 로드에 실패했습니다.',
|
||||
reason: '로컬 Whisper 모델 파일이 준비되지 않았거나, 사이드카 프로세스 포트가 점유되었습니다.',
|
||||
suggestion: 'STT 설정에서 모델 다운로드 상태를 확인하거나 강제 초기화 후 다시 시도하세요.',
|
||||
}
|
||||
}
|
||||
|
||||
if ((code !== undefined && Number(code) >= 200 && Number(code) <= 220) || msg.includes('audio') || msg.includes('mic') || msg.includes('장치') || msg.includes('sox')) {
|
||||
return {
|
||||
type: 'audio',
|
||||
title: '마이크 캡처 시작 실패',
|
||||
code: code ?? 'MIC_CAPTURE_FAILED',
|
||||
message: errMessage || '마이크 오디오 스트림을 시작할 수 없습니다.',
|
||||
reason: '기본 마이크 입력 장치를 찾을 수 없거나 Windows 마이크 권한이 비활성화되어 있습니다.',
|
||||
suggestion: '오디오 설정에서 마이크 장치를 선택하거나 기본 장치로 재시도하세요.',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'general',
|
||||
title: '회의를 시작할 수 없습니다',
|
||||
code: code ?? 'MEETING_START_ERROR',
|
||||
message: errMessage || '알 수 없는 오류로 회의 녹음을 시작하지 못했습니다.',
|
||||
reason: '백그라운드 통신 또는 초기화 중 문제가 발생했습니다.',
|
||||
suggestion: '프로세스를 강제 초기화한 후 다시 시작해 보세요.',
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── 세션 목록 로드 ──
|
||||
const loadSessions = useCallback(async () => {
|
||||
setLoading(true)
|
||||
|
|
@ -73,7 +208,6 @@ export function MeetingModePage(): React.ReactElement {
|
|||
const unsubState = window.electronAPI.meetingMode.onStateChanged((info) => {
|
||||
setStateInfo(info)
|
||||
if (info.state === 'idle' && view === 'recording') {
|
||||
// 후처리 완료 또는 에러 → 목록으로 이동 + 새로고침
|
||||
setProgress(null)
|
||||
setView('list')
|
||||
}
|
||||
|
|
@ -81,7 +215,6 @@ export function MeetingModePage(): React.ReactElement {
|
|||
|
||||
const unsubSegment = window.electronAPI.meetingMode.onSegment((seg) => {
|
||||
setSegments((prev) => [...prev, seg])
|
||||
// 자동 스크롤
|
||||
setTimeout(() => {
|
||||
if (transcriptRef.current) {
|
||||
transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight
|
||||
|
|
@ -98,13 +231,15 @@ export function MeetingModePage(): React.ReactElement {
|
|||
setView('list')
|
||||
})
|
||||
|
||||
const unsubError = window.electronAPI.meetingMode.onError(() => {
|
||||
const unsubError = window.electronAPI.meetingMode.onError((e) => {
|
||||
setProgress(null)
|
||||
setView('list')
|
||||
const parsed = parseMeetingError(e.message, e.code)
|
||||
setErrorInfo(parsed)
|
||||
})
|
||||
|
||||
const unsubAudioLevel = window.electronAPI.meetingMode.onAudioLevel((data) => {
|
||||
setAudioLevel(Math.min(data.level * 10, 1)) // RMS 정규화
|
||||
setAudioLevel(Math.min(data.level * 10, 1))
|
||||
})
|
||||
|
||||
return () => {
|
||||
|
|
@ -115,7 +250,7 @@ export function MeetingModePage(): React.ReactElement {
|
|||
unsubError()
|
||||
unsubAudioLevel()
|
||||
}
|
||||
}, [view])
|
||||
}, [view, parseMeetingError])
|
||||
|
||||
// ── 경과 시간 타이머 ──
|
||||
useEffect(() => {
|
||||
|
|
@ -134,32 +269,73 @@ export function MeetingModePage(): React.ReactElement {
|
|||
}, [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'))
|
||||
const handleStartRecording = useCallback(async (force = false) => {
|
||||
setIsStarting(true)
|
||||
setErrorInfo(null)
|
||||
try {
|
||||
const resp = await window.electronAPI.meetingMode.startRecording({ force })
|
||||
if (resp.success) {
|
||||
setSegments([])
|
||||
setMemos([])
|
||||
setElapsedMs(0)
|
||||
setProgress(null)
|
||||
setView('recording')
|
||||
} else {
|
||||
const parsed = parseMeetingError(resp.error?.message, resp.error?.code)
|
||||
setErrorInfo(parsed)
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
const parsed = parseMeetingError(msg)
|
||||
setErrorInfo(parsed)
|
||||
} finally {
|
||||
setIsStarting(false)
|
||||
}
|
||||
}, [t])
|
||||
}, [parseMeetingError])
|
||||
|
||||
const handleStopRecording = useCallback(async () => {
|
||||
await window.electronAPI.meetingMode.stopRecording()
|
||||
}, [])
|
||||
|
||||
// ── 실시간 전사 세그먼트 즉시 수정 핸들러 ──
|
||||
const handleLiveEditSegment = useCallback(
|
||||
async (segmentId: string, newText: string) => {
|
||||
setSegments((prev) =>
|
||||
prev.map((s) => (s.id === segmentId ? { ...s, text: newText } : s)),
|
||||
)
|
||||
if (stateInfo?.sessionId) {
|
||||
await window.electronAPI.meetingMode.editSegment({
|
||||
sessionId: stateInfo.sessionId,
|
||||
segmentId,
|
||||
text: newText,
|
||||
})
|
||||
}
|
||||
},
|
||||
[stateInfo?.sessionId],
|
||||
)
|
||||
|
||||
const isAddingMemoRef = useRef(false)
|
||||
const isViewingDetailRef = useRef(false)
|
||||
|
||||
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('')
|
||||
if (!memoInput.trim() || isAddingMemoRef.current) return
|
||||
isAddingMemoRef.current = true
|
||||
const text = memoInput.trim()
|
||||
setMemoInput('')
|
||||
try {
|
||||
const resp = await window.electronAPI.meetingMode.addMemo({ content: text })
|
||||
if (resp.success) {
|
||||
setMemos((prev) => [...prev, resp.data])
|
||||
}
|
||||
} finally {
|
||||
isAddingMemoRef.current = false
|
||||
}
|
||||
}, [memoInput])
|
||||
|
||||
const handleTagClick = (tag: string) => {
|
||||
setMemoInput((prev) => (prev ? `${tag} ${prev}` : `${tag} `))
|
||||
}
|
||||
|
||||
const handleMemoKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (isImeComposingEvent(e)) return
|
||||
|
|
@ -171,18 +347,25 @@ export function MeetingModePage(): React.ReactElement {
|
|||
[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 handleViewDetail = useCallback(
|
||||
async (sessionId: string) => {
|
||||
if (isViewingDetailRef.current) return
|
||||
isViewingDetailRef.current = true
|
||||
try {
|
||||
const resp = await window.electronAPI.meetingMode.getSession({ sessionId })
|
||||
if (resp.success) {
|
||||
setDetail(resp.data)
|
||||
setView('detail')
|
||||
} else {
|
||||
setSnackbarMsg(t('meeting.viewDetailError'))
|
||||
}
|
||||
} finally {
|
||||
isViewingDetailRef.current = false
|
||||
}
|
||||
},
|
||||
[t],
|
||||
)
|
||||
|
||||
|
||||
// ── 유틸 ──
|
||||
const formatTime = (ms: number): string => {
|
||||
const totalSec = Math.floor(ms / 1000)
|
||||
const min = Math.floor(totalSec / 60)
|
||||
|
|
@ -195,13 +378,18 @@ export function MeetingModePage(): React.ReactElement {
|
|||
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' => {
|
||||
const statusTone = (status: string): 'success' | 'warning' | 'error' | 'accent' => {
|
||||
switch (status) {
|
||||
case 'completed': return 'green'
|
||||
case 'recording': return 'red'
|
||||
case 'processing': return 'amber'
|
||||
case 'error': return 'red'
|
||||
default: return 'blue'
|
||||
case 'completed':
|
||||
return 'success'
|
||||
case 'recording':
|
||||
return 'error'
|
||||
case 'processing':
|
||||
return 'warning'
|
||||
case 'error':
|
||||
return 'error'
|
||||
default:
|
||||
return 'accent'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -218,25 +406,188 @@ export function MeetingModePage(): React.ReactElement {
|
|||
</Snackbar>
|
||||
)
|
||||
|
||||
const errorDialog = (
|
||||
<Dialog
|
||||
open={errorInfo !== null}
|
||||
onClose={() => setErrorInfo(null)}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
PaperProps={{
|
||||
sx: {
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
borderRadius: d3roRadius.doubleBezelOuter,
|
||||
border: `1px solid ${d3roPalette.tag.red}`,
|
||||
boxShadow: d3roShadow.dialog,
|
||||
p: 1.5,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{ display: 'flex', alignItems: 'center', gap: 1.5, pb: 1 }}>
|
||||
<AlertCircle size={22} style={{ color: d3roPalette.tag.red }} />
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontWeight: 700, fontSize: '17px', color: d3roPalette.text.primary }}>
|
||||
{errorInfo?.title}
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: d3roRadius.md,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<TactileBadge tone="error" mono>
|
||||
{errorInfo?.code ? `ERR: ${errorInfo.code}` : 'ERROR'}
|
||||
</TactileBadge>
|
||||
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.disabled, fontFamily: d3roFontMono }}>
|
||||
Meeting Intelligence Diagnostics
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Typography sx={{ fontSize: '13px', color: d3roPalette.text.secondary, fontFamily: d3roFontMono, wordBreak: 'break-all', mb: 1.5 }}>
|
||||
{errorInfo?.message}
|
||||
</Typography>
|
||||
|
||||
<Divider sx={{ my: 1, borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mt: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', gap: 1.2, alignItems: 'flex-start' }}>
|
||||
<Typography sx={{ fontSize: '12px', fontWeight: 700, color: d3roPalette.tag.orange, minWidth: 65 }}>
|
||||
발생 원인
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '12.5px', color: d3roPalette.text.primary, lineHeight: 1.4 }}>
|
||||
{errorInfo?.reason}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1.2, alignItems: 'flex-start' }}>
|
||||
<Typography sx={{ fontSize: '12px', fontWeight: 700, color: d3roPalette.tag.green, minWidth: 65 }}>
|
||||
해결 조치
|
||||
</Typography>
|
||||
<Typography sx={{ fontSize: '12.5px', color: d3roPalette.text.primary, lineHeight: 1.4 }}>
|
||||
{errorInfo?.suggestion}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ p: 2, pt: 1, display: 'flex', gap: 1.5, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setErrorInfo(null)}
|
||||
sx={{
|
||||
color: d3roPalette.text.secondary,
|
||||
borderColor: d3roPalette.border.default,
|
||||
borderRadius: d3roRadius.sm,
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
|
||||
{errorInfo?.type === 'stt' && (
|
||||
<PhysicalButton
|
||||
tone="default"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setErrorInfo(null)
|
||||
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 2 } }))
|
||||
}}
|
||||
leadingIcon={<Settings size={13} />}
|
||||
>
|
||||
STT / 모델 설정 열기
|
||||
</PhysicalButton>
|
||||
)}
|
||||
|
||||
{errorInfo?.type === 'audio' && (
|
||||
<PhysicalButton
|
||||
tone="default"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setErrorInfo(null)
|
||||
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 1 } }))
|
||||
}}
|
||||
leadingIcon={<Mic size={13} />}
|
||||
>
|
||||
마이크 / 오디오 설정 열기
|
||||
</PhysicalButton>
|
||||
)}
|
||||
|
||||
<PhysicalButton
|
||||
tone="accent"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setErrorInfo(null)
|
||||
handleStartRecording(true)
|
||||
}}
|
||||
leadingIcon={<RefreshCw size={13} />}
|
||||
>
|
||||
{errorInfo?.type === 'collision'
|
||||
? '기존 프로세스 초기화 후 즉시 시작'
|
||||
: '강제 초기화 후 다시 시도'}
|
||||
</PhysicalButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
// ── 렌더: 목록 뷰 ──
|
||||
if (view === 'list') {
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ maxWidth: 1060, mx: 'auto', p: { xs: 2.5, md: 4 }, pb: 10 }}>
|
||||
<PageHeader
|
||||
title={t('meeting.title')}
|
||||
count={totalSessions > 0 ? t('meeting.sessions', { count: totalSessions }) : undefined}
|
||||
count={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
{totalSessions > 0 && (
|
||||
<TactileBadge tone="mono" mono size="small">
|
||||
{t('meeting.sessions', { count: totalSessions })}
|
||||
</TactileBadge>
|
||||
)}
|
||||
<TactileBadge tone="default" size="small">
|
||||
<Mic size={10} style={{ marginRight: 3, color: d3roPalette.tag.green }} />
|
||||
{micDeviceName || '기본 마이크'}
|
||||
</TactileBadge>
|
||||
<TactileBadge tone="mono" size="small">
|
||||
<Cpu size={10} style={{ marginRight: 3 }} />
|
||||
{sttProvider === 'local' ? 'Local Whisper' : sttProvider.toUpperCase()}
|
||||
</TactileBadge>
|
||||
</Box>
|
||||
}
|
||||
action={
|
||||
<PhysicalButton size="small" onClick={handleStartRecording}>
|
||||
<Plus size={16} style={{ marginRight: 4 }} />
|
||||
{t('meeting.newMeeting')}
|
||||
<PhysicalButton
|
||||
tone="accent"
|
||||
size="small"
|
||||
onClick={() => handleStartRecording(false)}
|
||||
disabled={isStarting}
|
||||
trailingIcon={isStarting ? <CircularProgress size={13} sx={{ color: 'inherit' }} /> : <Plus size={14} />}
|
||||
>
|
||||
{isStarting ? '회의 준비 중...' : t('meeting.newMeeting')}
|
||||
</PhysicalButton>
|
||||
}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<LinearProgress sx={{ mt: 2 }} />
|
||||
<Box sx={{ py: 6, textAlign: 'center' }}>
|
||||
<PhosphorText variant="dim">{t('common.loading')}</PhosphorText>
|
||||
</Box>
|
||||
) : sessions.length === 0 ? (
|
||||
<EmptyStateCard message={`${t('meeting.noSessions')}\n${t('meeting.noSessionsDesc')}`} />
|
||||
<EmptyStateCard
|
||||
message={`${t('meeting.noSessions')}\n${t('meeting.noSessionsDesc')}`}
|
||||
icon={<Users />}
|
||||
action={
|
||||
<PhysicalButton tone="accent" onClick={() => handleStartRecording(false)} disabled={isStarting}>
|
||||
{isStarting ? (
|
||||
<CircularProgress size={14} sx={{ color: 'inherit', mr: 1 }} />
|
||||
) : (
|
||||
<Plus size={15} style={{ marginRight: 4 }} />
|
||||
)}
|
||||
{isStarting ? '회의 준비 중...' : t('meeting.newMeeting')}
|
||||
</PhysicalButton>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
|
|
@ -244,10 +595,9 @@ export function MeetingModePage(): React.ReactElement {
|
|||
gridTemplateColumns: {
|
||||
xs: '1fr',
|
||||
sm: 'repeat(2, 1fr)',
|
||||
md: 'repeat(3, 1fr)',
|
||||
lg: 'repeat(4, 1fr)',
|
||||
lg: 'repeat(3, 1fr)',
|
||||
},
|
||||
gap: 1.5,
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{sessions.map((session) => (
|
||||
|
|
@ -256,74 +606,88 @@ export function MeetingModePage(): React.ReactElement {
|
|||
onClick={() => handleViewDetail(session.id)}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
|
||||
transition: 'transform 0.2s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
'&:hover': { transform: 'translateY(-2px)' },
|
||||
}}
|
||||
>
|
||||
<MetalCard>
|
||||
{/* 제목 — 여러 줄 허용 */}
|
||||
<PhosphorText
|
||||
variant="heading"
|
||||
sx={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
mb: 1.5,
|
||||
}}
|
||||
>
|
||||
{session.title ?? t('meeting.untitled')}
|
||||
</PhosphorText>
|
||||
<MetalCard sx={{ p: 2.75, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* Status Badge Row */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||
<TactileBadge
|
||||
ledColor={session.status === 'completed' ? 'green' : session.status === 'recording' ? 'red' : 'amber'}
|
||||
tone={statusTone(session.status)}
|
||||
>
|
||||
{session.status === 'completed'
|
||||
? t('meeting.completed')
|
||||
: session.status === 'error'
|
||||
? t('meeting.error')
|
||||
: session.status === 'processing'
|
||||
? t('meeting.processing')
|
||||
: t('meeting.recording')}
|
||||
</TactileBadge>
|
||||
|
||||
{/* 정보 그리드 */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75, mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.meta.size, fontFamily: d3roFontMono }}>
|
||||
<PhosphorText variant="meta" sx={{ color: d3roPalette.text.dimLabel }}>
|
||||
{formatDate(session.startedAt)}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', gap: 1.5 }}>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.meta.size, fontFamily: d3roFontMono }}>
|
||||
|
||||
{/* Title */}
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: '15px',
|
||||
fontWeight: 700,
|
||||
color: d3roPalette.text.primary,
|
||||
lineHeight: 1.35,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
mb: 2,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{session.title ?? t('meeting.untitled')}
|
||||
</Typography>
|
||||
|
||||
{/* Metadata Footer */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1.5,
|
||||
pt: 1.5,
|
||||
borderTop: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
}}
|
||||
>
|
||||
<TactileBadge mono tone="mono">
|
||||
<Clock size={11} style={{ marginRight: 2 }} />
|
||||
{session.durationMs != null
|
||||
? t('meeting.duration', { minutes: Math.round(session.durationMs / 60000) })
|
||||
: '—'}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.meta.size, fontFamily: d3roFontMono }}>
|
||||
{t('meeting.memos')}: {session.memoCount}
|
||||
</PhosphorText>
|
||||
</TactileBadge>
|
||||
<TactileBadge mono tone="default">
|
||||
<FileText size={11} style={{ marginRight: 2 }} />
|
||||
{session.memoCount} Memos
|
||||
</TactileBadge>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 하단 — 상태 표시 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, pt: 1, borderTop: `1px solid ${d3roPalette.border.subtle}` }}>
|
||||
<Led color={statusColor(session.status)} size={6} />
|
||||
<PhosphorText variant="dim" sx={{ fontSize: d3roTypo.label.size, textTransform: 'uppercase', letterSpacing: d3roTypo.label.spacing }}>
|
||||
{session.status === 'completed' ? t('meeting.completed')
|
||||
: session.status === 'error' ? t('meeting.error')
|
||||
: session.status === 'processing' ? t('meeting.processing')
|
||||
: t('meeting.recording')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
|
||||
{/* 프로세싱 진행 바 */}
|
||||
{session.status === 'processing' && progress && progress.sessionId === session.id && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress.percent}
|
||||
sx={{
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.main },
|
||||
}}
|
||||
/>
|
||||
<PhosphorText variant="dim" sx={{ mt: 0.5, fontSize: d3roTypo.nano.size }}>
|
||||
{t(`meeting.processingStep.${progress.step}` as Parameters<typeof t>[0])}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
)}
|
||||
</MetalCard>
|
||||
{/* Processing Progress Bar */}
|
||||
{session.status === 'processing' && progress && progress.sessionId === session.id && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={progress.percent}
|
||||
sx={{
|
||||
height: 4,
|
||||
borderRadius: d3roRadius.pill,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.main },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</MetalCard>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
|
@ -333,58 +697,80 @@ export function MeetingModePage(): React.ReactElement {
|
|||
)
|
||||
}
|
||||
|
||||
// ── 렌더: 녹음 뷰 ──
|
||||
// ── 렌더: 실시간 녹음 & 온더플라이 수정 + 문맥 보강 메모 뷰 ──
|
||||
if (view === 'recording') {
|
||||
const isProcessing = stateInfo?.state === 'processing'
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
{/* 상단 바 */}
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Circle size={16} fill="currentColor" style={{ color: d3roPalette.tag.red, animation: 'pulse 1s infinite' }} />
|
||||
<PhosphorText variant="label" sx={{ fontFamily: d3roFontMono }}>
|
||||
{formatTime(elapsedMs)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="dim">
|
||||
{isProcessing ? t('meeting.processing') : t('meeting.recording')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
{!isProcessing && (
|
||||
<PhysicalButton size="small" color="error" onClick={handleStopRecording}>
|
||||
<Square size={16} style={{ marginRight: 4 }} />
|
||||
{t('meeting.stopRecording')}
|
||||
</PhysicalButton>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ p: { xs: 2.5, md: 4 }, height: '100%', display: 'flex', flexDirection: 'column', pb: 8 }}>
|
||||
{/* Top Recording Cockpit */}
|
||||
<DoubleBezelCard innerPadding={2} sx={{ mb: 2.5 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Circle
|
||||
size={14}
|
||||
fill="currentColor"
|
||||
style={{ color: d3roPalette.tag.red, animation: 'led-pulse 1.2s infinite' }}
|
||||
/>
|
||||
<Typography
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '20px',
|
||||
fontWeight: 700,
|
||||
color: d3roPalette.text.primary,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{formatTime(elapsedMs)}
|
||||
</Typography>
|
||||
<TactileBadge tone={isProcessing ? 'warning' : 'error'} ledColor={isProcessing ? 'amber' : 'red'}>
|
||||
{isProcessing ? t('meeting.processing') : t('meeting.recording')}
|
||||
</TactileBadge>
|
||||
</Box>
|
||||
|
||||
{/* 프로세싱 진행률 */}
|
||||
{!isProcessing && (
|
||||
<PhysicalButton tone="danger" size="small" onClick={handleStopRecording}>
|
||||
<Square size={14} style={{ marginRight: 4 }} />
|
||||
{t('meeting.stopRecording')}
|
||||
</PhysicalButton>
|
||||
)}
|
||||
</Box>
|
||||
</DoubleBezelCard>
|
||||
|
||||
{/* Processing Progress */}
|
||||
{isProcessing && progress && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<MetalCard sx={{ mb: 2, p: 2 }}>
|
||||
<LinearProgress variant="determinate" value={progress.percent} sx={{ height: 6, borderRadius: 3 }} />
|
||||
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
|
||||
<PhosphorText variant="dim" sx={{ mt: 0.75, display: 'block' }}>
|
||||
{t(`meeting.processingStep.${progress.step}` as Parameters<typeof t>[0])} ({progress.percent}%)
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 메인 패널: 전사 + 메모 */}
|
||||
<Box sx={{ flex: 1, display: 'flex', gap: 2, minHeight: 0 }}>
|
||||
{/* 좌: 실시간 전사 */}
|
||||
<Box sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<ScreenPanel>
|
||||
<PhosphorText variant="dim" sx={{ mb: 1, fontSize: 11 }}>
|
||||
{t('meeting.transcript')}
|
||||
</PhosphorText>
|
||||
{/* Main Recording Workspace */}
|
||||
<Box sx={{ flex: 1, display: 'grid', gridTemplateColumns: { xs: '1fr', lg: '1fr 380px' }, gap: 2.5, minHeight: 0 }}>
|
||||
{/* Left: Real-time Interactive Transcript (클릭하여 지난 발화 즉시 수정 가능!) */}
|
||||
<ScreenPanel sx={{ p: 2.5, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<PhosphorText variant="heading">{t('meeting.transcript')}</PhosphorText>
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: d3roPalette.text.dimLabel }}>
|
||||
(클릭하여 지난 발화 즉시 수정 가능)
|
||||
</Typography>
|
||||
</Box>
|
||||
<TactileBadge mono tone="mono">
|
||||
{segments.length} Segments
|
||||
</TactileBadge>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
ref={transcriptRef}
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.body.size,
|
||||
color: d3roPalette.text.primary,
|
||||
lineHeight: 1.8,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1,
|
||||
}}
|
||||
>
|
||||
{segments.map((seg) => (
|
||||
|
|
@ -394,83 +780,95 @@ export function MeetingModePage(): React.ReactElement {
|
|||
timestamp={Math.max(0, seg.timestamp - (recordingStartRef.current || seg.timestamp))}
|
||||
text={seg.text}
|
||||
edited={false}
|
||||
onEdit={() => { /* no-op: readOnly=true */ }}
|
||||
readOnly
|
||||
onEdit={handleLiveEditSegment}
|
||||
readOnly={isProcessing}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</ScreenPanel>
|
||||
</Box>
|
||||
|
||||
{/* 우: 메모 입력 */}
|
||||
<Box sx={{ width: 300, display: 'flex', flexDirection: 'column' }}>
|
||||
<PhosphorText variant="dim" sx={{ mb: 1, fontSize: 11 }}>
|
||||
{t('meeting.memos')}
|
||||
</PhosphorText>
|
||||
<Box sx={{ flex: 1, overflow: 'auto', mb: 1 }}>
|
||||
{/* Right: Live Context Enricher & Quick Tags Memos */}
|
||||
<MetalCard sx={{ p: 2.5, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Sparkles size={16} style={{ color: d3roPalette.accent.light }} />
|
||||
<PhosphorText variant="heading">CONTEXT ENRICHER</PhosphorText>
|
||||
</Box>
|
||||
<TactileBadge mono tone="mono">
|
||||
{memos.length} Memos
|
||||
</TactileBadge>
|
||||
</Box>
|
||||
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11.5px', color: d3roPalette.text.secondary, mb: 1.5, lineHeight: 1.4 }}>
|
||||
실시간 작성된 메모는 AI 회의록 생성 시 최우선 문맥으로 반영됩니다.
|
||||
</Typography>
|
||||
|
||||
{/* Quick Context Category Chips */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, mb: 1.5, flexWrap: 'wrap' }}>
|
||||
{CONTEXT_TAGS.map((ct) => (
|
||||
<TactileBadge
|
||||
key={ct.label}
|
||||
onClick={() => handleTagClick(ct.label)}
|
||||
sx={{ cursor: 'pointer', display: 'inline-flex', alignItems: 'center', gap: 0.5 }}
|
||||
>
|
||||
{ct.icon}
|
||||
{ct.label}
|
||||
</TactileBadge>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Memos List */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto', mb: 2, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{memos.map((memo) => (
|
||||
<Box
|
||||
key={memo.id}
|
||||
sx={{
|
||||
mb: 0.5,
|
||||
p: 1,
|
||||
borderRadius: 1,
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
p: 1.5,
|
||||
borderRadius: d3roRadius.inner,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
}}
|
||||
>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: 10, fontFamily: d3roFontMono }}>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: '10.5px', fontFamily: d3roFontMono, mb: 0.25, display: 'block' }}>
|
||||
{formatTime(memo.timestampMs)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="body" sx={{ fontSize: 12 }}>
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13px', color: d3roPalette.text.primary, lineHeight: 1.5 }}>
|
||||
{memo.content}
|
||||
</PhosphorText>
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* Memo Input */}
|
||||
{!isProcessing && (
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
placeholder={t('meeting.memoPlaceholder')}
|
||||
placeholder="[#결정/할일/안건] 핵심 메모 입력 (Enter)..."
|
||||
value={memoInput}
|
||||
onChange={(e) => setMemoInput(e.target.value)}
|
||||
onKeyDown={handleMemoKeyDown}
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 13,
|
||||
fontFamily: d3roFontSans,
|
||||
fontSize: '13px',
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: d3roRadius.inner,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</MetalCard>
|
||||
</Box>
|
||||
|
||||
{/* 오디오 레벨 미터 */}
|
||||
{/* Live Audio Spectrum Meter */}
|
||||
{!isProcessing && (
|
||||
<Box sx={{ mt: 1.5, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Led color={audioLevel > 0.02 ? 'green' : 'amber'} size={8} />
|
||||
<Box sx={{
|
||||
flex: 1,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<Box sx={{
|
||||
height: '100%',
|
||||
width: `${Math.max(audioLevel * 100, 0)}%`,
|
||||
bgcolor: audioLevel > 0.7 ? d3roPalette.tag.red : audioLevel > 0.3 ? d3roPalette.tag.orange : d3roPalette.tag.green,
|
||||
borderRadius: 3,
|
||||
transition: 'width 0.1s ease-out',
|
||||
}} />
|
||||
</Box>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: 10, fontFamily: d3roFontMono, minWidth: 30 }}>
|
||||
{Math.round(audioLevel * 100)}%
|
||||
</PhosphorText>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<AudioVisualizerBar audioLevel={audioLevel} bars={36} height={44} />
|
||||
</Box>
|
||||
)}
|
||||
{snackbar}
|
||||
{errorDialog}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -478,12 +876,12 @@ export function MeetingModePage(): React.ReactElement {
|
|||
// ── 렌더: 상세 뷰 ──
|
||||
if (view === 'detail' && detail) {
|
||||
return (
|
||||
<MeetingDetailTabs
|
||||
detail={detail}
|
||||
onBack={() => { setDetail(null); setView('list') }}
|
||||
/>
|
||||
<>
|
||||
<MeetingDetailTabs detail={detail} onBack={() => { setDetail(null); setView('list') }} />
|
||||
{errorDialog}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return <Box />
|
||||
return errorDialog
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue