fix(meeting): persist transcript segment edits, restore live recording view, and route settings deep links by tab id
This commit is contained in:
parent
a8983c583a
commit
a85b24d385
15 changed files with 974 additions and 69 deletions
|
|
@ -49,6 +49,13 @@ import { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs'
|
|||
import { EditableSegment } from '../components/meeting/EditableSegment'
|
||||
import { PageHeader, EmptyStateCard } from '../components/shared'
|
||||
import { isImeComposingEvent } from '../utils/keyboard'
|
||||
import { openSettings } from '../navigation/settingsTabs'
|
||||
import {
|
||||
isActiveMeetingState,
|
||||
recordingStartFromElapsed,
|
||||
replaceSegmentText,
|
||||
rollbackSegmentText,
|
||||
} from './meeting/live-session'
|
||||
import { d3roPalette, d3roFontSans, d3roFontMono, d3roRadius, d3roShadow } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type {
|
||||
|
|
@ -98,6 +105,10 @@ export function MeetingModePage(): React.ReactElement {
|
|||
// 녹음 뷰 상태
|
||||
const [stateInfo, setStateInfo] = useState<MeetingModeStateInfo | null>(null)
|
||||
const [segments, setSegments] = useState<CaptionSegment[]>([])
|
||||
const segmentsRef = useRef<CaptionSegment[]>(segments)
|
||||
segmentsRef.current = segments
|
||||
/** startRecording 응답의 세션 id — stateChanged 이벤트보다 먼저 편집이 와도 쓸 수 있게 */
|
||||
const [liveSessionId, setLiveSessionId] = useState<string | null>(null)
|
||||
const [memos, setMemos] = useState<MeetingMemo[]>([])
|
||||
const [memoInput, setMemoInput] = useState('')
|
||||
const [elapsedMs, setElapsedMs] = useState(0)
|
||||
|
|
@ -134,6 +145,28 @@ export function MeetingModePage(): React.ReactElement {
|
|||
checkPreflight()
|
||||
}, [checkPreflight])
|
||||
|
||||
// ── 진행 중 세션 복원 ──
|
||||
// 다른 페이지로 갔다 오면 이 페이지는 새로 마운트된다. main 은 녹음을 계속하므로
|
||||
// 상태를 물어 녹음 뷰(정지 버튼·진행률)를 되살린다.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
window.electronAPI.meetingMode
|
||||
.getState()
|
||||
.then((resp) => {
|
||||
if (cancelled || !resp.success || !isActiveMeetingState(resp.data.state)) return
|
||||
const info = resp.data
|
||||
recordingStartRef.current = recordingStartFromElapsed(info.elapsedMs, Date.now())
|
||||
setStateInfo(info)
|
||||
setLiveSessionId(info.sessionId)
|
||||
setElapsedMs(info.elapsedMs)
|
||||
setView('recording')
|
||||
})
|
||||
.catch(() => { /* 복원 실패 시 목록 뷰 유지 */ })
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
// ── 에러 파서 ──
|
||||
const parseMeetingError = useCallback((errMessage?: string, errCode?: number | string): MeetingErrorInfo => {
|
||||
const msg = (errMessage ?? '').toLowerCase()
|
||||
|
|
@ -268,7 +301,8 @@ export function MeetingModePage(): React.ReactElement {
|
|||
// ── 경과 시간 타이머 ──
|
||||
useEffect(() => {
|
||||
if (view === 'recording') {
|
||||
recordingStartRef.current = Date.now()
|
||||
// 시작 시각은 뷰로 들어오는 쪽(새 녹음 / 세션 복원)이 정한다
|
||||
if (!recordingStartRef.current) recordingStartRef.current = Date.now()
|
||||
elapsedTimerRef.current = setInterval(() => {
|
||||
setElapsedMs(Date.now() - recordingStartRef.current)
|
||||
}, 1000)
|
||||
|
|
@ -288,6 +322,8 @@ export function MeetingModePage(): React.ReactElement {
|
|||
try {
|
||||
const resp = await window.electronAPI.meetingMode.startRecording({ force })
|
||||
if (resp.success) {
|
||||
recordingStartRef.current = Date.now()
|
||||
setLiveSessionId(resp.data.sessionId)
|
||||
setSegments([])
|
||||
setMemos([])
|
||||
setElapsedMs(0)
|
||||
|
|
@ -311,20 +347,33 @@ export function MeetingModePage(): React.ReactElement {
|
|||
}, [])
|
||||
|
||||
// ── 실시간 전사 세그먼트 즉시 수정 핸들러 ──
|
||||
// 화면에 먼저 반영하고, main 이 거부하거나 호출이 실패하면 되돌린 뒤 알린다.
|
||||
const liveSessionIdForEdit = stateInfo?.sessionId ?? liveSessionId
|
||||
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,
|
||||
const previousText = segmentsRef.current.find((s) => s.id === segmentId)?.text
|
||||
if (previousText === undefined || previousText === newText) return
|
||||
setSegments((prev) => replaceSegmentText(prev, segmentId, newText))
|
||||
const rollback = (message: string): void => {
|
||||
setSegments((prev) => rollbackSegmentText(prev, segmentId, newText, previousText))
|
||||
setSnackbarMsg(message)
|
||||
}
|
||||
if (!liveSessionIdForEdit) {
|
||||
rollback(t('meeting.error'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const resp = await window.electronAPI.meetingMode.editSegment({
|
||||
sessionId: liveSessionIdForEdit,
|
||||
segmentId,
|
||||
text: newText,
|
||||
})
|
||||
if (!resp.success) rollback(resp.error.message)
|
||||
} catch (err: unknown) {
|
||||
rollback(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
},
|
||||
[stateInfo?.sessionId],
|
||||
[liveSessionIdForEdit, t],
|
||||
)
|
||||
|
||||
const isAddingMemoRef = useRef(false)
|
||||
|
|
@ -507,7 +556,7 @@ export function MeetingModePage(): React.ReactElement {
|
|||
size="small"
|
||||
onClick={() => {
|
||||
setErrorInfo(null)
|
||||
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 2 } }))
|
||||
openSettings('stt')
|
||||
}}
|
||||
leadingIcon={<Settings size={13} />}
|
||||
>
|
||||
|
|
@ -521,7 +570,7 @@ export function MeetingModePage(): React.ReactElement {
|
|||
size="small"
|
||||
onClick={() => {
|
||||
setErrorInfo(null)
|
||||
window.dispatchEvent(new CustomEvent('d3ro:open-settings', { detail: { tab: 1 } }))
|
||||
openSettings('audio')
|
||||
}}
|
||||
leadingIcon={<Mic size={13} />}
|
||||
>
|
||||
|
|
@ -706,6 +755,7 @@ export function MeetingModePage(): React.ReactElement {
|
|||
</Box>
|
||||
)}
|
||||
{snackbar}
|
||||
{errorDialog}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
44
apps/desktop/src/renderer/pages/meeting/live-session.ts
Normal file
44
apps/desktop/src/renderer/pages/meeting/live-session.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// src/renderer/pages/meeting/live-session.ts
|
||||
// 회의 녹음 뷰의 순수 정책 — 페이지가 다시 마운트될 때 진행 중 세션 복원, 실시간 세그먼트 편집.
|
||||
|
||||
import type { CaptionSegment, MeetingModeState } from '@d3ro/core/types'
|
||||
|
||||
/** main 에서 녹음 또는 후처리가 진행 중이면 녹음 뷰를 보여 줘야 한다(정지·진행률 표시). */
|
||||
export function isActiveMeetingState(state: MeetingModeState | null | undefined): boolean {
|
||||
return state === 'recording' || state === 'processing'
|
||||
}
|
||||
|
||||
/** main 이 알려 준 경과 시간으로 녹음 시작 시각을 되짚는다(경과 타이머·세그먼트 상대 시각 기준). */
|
||||
export function recordingStartFromElapsed(elapsedMs: number, now: number): number {
|
||||
return now - Math.max(0, Number.isFinite(elapsedMs) ? elapsedMs : 0)
|
||||
}
|
||||
|
||||
/** 세그먼트 하나의 텍스트를 바꾼 새 배열. 해당 세그먼트가 없으면 원래 배열 그대로. */
|
||||
export function replaceSegmentText(
|
||||
segments: readonly CaptionSegment[],
|
||||
segmentId: string,
|
||||
text: string,
|
||||
): CaptionSegment[] {
|
||||
let changed = false
|
||||
const next = segments.map((s) => {
|
||||
if (s.id !== segmentId || s.text === text) return s
|
||||
changed = true
|
||||
return { ...s, text }
|
||||
})
|
||||
return changed ? next : (segments as CaptionSegment[])
|
||||
}
|
||||
|
||||
/**
|
||||
* 낙관적 편집을 되돌린다 — 그 사이 같은 세그먼트를 다시 고쳤다면(현재 텍스트가 실패한 편집과 다르면)
|
||||
* 최신 편집을 덮어쓰지 않는다.
|
||||
*/
|
||||
export function rollbackSegmentText(
|
||||
segments: readonly CaptionSegment[],
|
||||
segmentId: string,
|
||||
failedText: string,
|
||||
previousText: string,
|
||||
): CaptionSegment[] {
|
||||
const current = segments.find((s) => s.id === segmentId)
|
||||
if (!current || current.text !== failedText) return segments as CaptionSegment[]
|
||||
return replaceSegmentText(segments, segmentId, previousText)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue