// Caption Overlay 팝업 스크립트 // Phase 10.1: Live Caption — 실시간 자막 오버레이 // Vanilla JS (다른 팝업과 동일한 패턴) ;(function () { 'use strict' // ── 설정 (기본값, IPC로 업데이트) ───────────────────── var config = { fontSize: 18, opacity: 0.85, maxLines: 3, autoClearMs: 5000 } // ── DOM 참조 ───────────────────────────────────────── var linesContainer = document.getElementById('lines') var container = document.getElementById('container') // ── i18n ────────────────────────────────────────────── var i18nStrings = {} // ── 상태 ────────────────────────────────────────────── /** @type {Array<{el: HTMLElement, timer: number|null, id: string}>} */ var lines = [] /** @type {HTMLElement|null} */ var deltaLine = null // ── 자막 줄 추가 ────────────────────────────────────── /** * 확정된 자막 세그먼트를 추가한다. * @param {{id: string, text: string, timestamp: number, isFinal: boolean}} segment */ function addSegment(segment) { // 첫 자막이 오면 준비 안내를 걷는다 (그 전까지는 계속 보여 빈 화면을 만들지 않는다). removeStatusLine() // delta 줄이 있으면 제거 (확정 줄로 교체) removeDeltaLine() var el = document.createElement('div') el.className = 'caption-line' el.textContent = segment.text el.style.fontSize = config.fontSize + 'px' linesContainer.appendChild(el) // auto-clear 타이머 설정 var timer = setTimeout(function () { fadeAndRemoveLine(entry) }, config.autoClearMs) var entry = { el: el, timer: timer, id: segment.id } lines.push(entry) // maxLines 초과 시 가장 오래된 줄 제거 while (lines.length > config.maxLines) { var oldest = lines.shift() if (oldest) { if (oldest.timer) clearTimeout(oldest.timer) if (oldest.el.parentNode) { oldest.el.parentNode.removeChild(oldest.el) } } } } /** * 중간(delta) 자막을 업데이트한다 (아직 확정되지 않은 줄). * @param {{text: string, isFinal: boolean}} data */ function updateDelta(data) { if (data.isFinal) { // isFinal이면 segment로 처리 removeDeltaLine() return } if (!deltaLine) { deltaLine = document.createElement('div') deltaLine.className = 'caption-line delta' deltaLine.style.fontSize = config.fontSize + 'px' linesContainer.appendChild(deltaLine) } deltaLine.textContent = data.text } /** * delta 줄을 제거한다. */ function removeDeltaLine() { if (deltaLine && deltaLine.parentNode) { deltaLine.parentNode.removeChild(deltaLine) } deltaLine = null } /** * 줄을 페이드 아웃 후 제거한다. * @param {{el: HTMLElement, timer: number|null, id: string}} entry */ function fadeAndRemoveLine(entry) { entry.el.classList.add('fading') setTimeout(function () { if (entry.el.parentNode) { entry.el.parentNode.removeChild(entry.el) } var idx = lines.indexOf(entry) if (idx !== -1) { lines.splice(idx, 1) } }, 500) // CSS transition 시간과 일치 } /** * 모든 줄을 제거한다. */ function clearAllLines() { for (var i = 0; i < lines.length; i++) { if (lines[i].timer) clearTimeout(lines[i].timer) if (lines[i].el.parentNode) { lines[i].el.parentNode.removeChild(lines[i].el) } } lines = [] removeDeltaLine() } /** * 설정을 적용한다. * @param {object} newConfig */ function applyConfig(newConfig) { if (newConfig.fontSize !== undefined) config.fontSize = newConfig.fontSize if (newConfig.opacity !== undefined) config.opacity = newConfig.opacity if (newConfig.maxLines !== undefined) config.maxLines = newConfig.maxLines if (newConfig.autoClearMs !== undefined) config.autoClearMs = newConfig.autoClearMs // 기존 줄에 폰트 크기 반영 for (var i = 0; i < lines.length; i++) { lines[i].el.style.fontSize = config.fontSize + 'px' } if (deltaLine) { deltaLine.style.fontSize = config.fontSize + 'px' } // 컨테이너 투명도 if (container) { container.style.opacity = String(config.opacity) } } // ── IPC 리스너 ──────────────────────────────────────── if (window.popupAPI) { // 확정된 자막 세그먼트 window.popupAPI.on('caption:segment', function (segment) { addSegment(segment) }) // 중간 전사 결과 (delta) window.popupAPI.on('caption:delta', function (data) { updateDelta(data) }) // 설정 업데이트 window.popupAPI.on('caption:config', function (newConfig) { applyConfig(newConfig) }) // 숨기기 (세션 종료) window.popupAPI.on('caption:hide', function () { clearAllLines() }) // 상태 변경 // starting: 음성 모델을 준비하는 중 // active : 듣고 있지만 첫 자막은 아직 — 소리를 모아 첫 인식을 마칠 때까지 몇 초 걸린다. // active 가 되자마자 안내를 지우면 그 몇 초가 빈 화면이라 "고장" 처럼 보였다. window.popupAPI.on('caption:stateChanged', function (data) { if (data._i18n) { i18nStrings = data._i18n if (handleHint) handleHint.textContent = i18nStrings.captionDragHint || '' } if (data.state === 'starting') { clearAllLines() showStatusLine(i18nStrings.captionLoading) } else if (data.state === 'active') { if (lines.length === 0) showStatusLine(i18nStrings.captionWaiting) } else if (data.state === 'inactive' || data.state === 'stopping') { clearAllLines() } }) } // ── 준비 안내 줄 ────────────────────────────────────── function showStatusLine(text) { if (!text) return var el = document.getElementById('caption-loading') if (!el) { el = document.createElement('div') el.className = 'caption-line loading' el.id = 'caption-loading' linesContainer.appendChild(el) } el.style.fontSize = config.fontSize + 'px' el.textContent = text } function removeStatusLine() { var el = document.getElementById('caption-loading') if (el && el.parentNode) el.parentNode.removeChild(el) } // ── 끌어서 옮기기 ────────────────────────────────────── // 창은 클릭 통과라 평소엔 아래 앱을 가리지 않는다. 마우스 이동은 전달받으므로 // 올라오면 손잡이를 보여 주고, 손잡이 위에서만 마우스를 받는다. var root = document.getElementById('root') var handle = document.getElementById('handle') var handleHint = document.getElementById('handleHint') var hoverTimer = null var dragging = false function send(channel, value) { if (window.popupAPI) window.popupAPI.send(channel, value) } document.addEventListener('mousemove', function () { if (!root) return root.classList.add('hovering') if (hoverTimer) clearTimeout(hoverTimer) hoverTimer = setTimeout(function () { if (!dragging) root.classList.remove('hovering') }, 1500) }) if (handle) { handle.addEventListener('mouseenter', function () { send('captionPopup:setInteractive', true) }) handle.addEventListener('mouseleave', function () { if (!dragging) send('captionPopup:setInteractive', false) }) handle.addEventListener('pointerdown', function (event) { if (event.button !== 0) return dragging = true handle.setPointerCapture(event.pointerId) if (root) root.classList.add('dragging') send('captionPopup:dragStart') }) var finishDrag = function () { if (!dragging) return dragging = false if (root) root.classList.remove('dragging') send('captionPopup:dragEnd') } handle.addEventListener('pointerup', finishDrag) handle.addEventListener('lostpointercapture', finishDrag) handle.addEventListener('dblclick', function () { send('captionPopup:resetPosition') }) } })()