d3ro-voice/apps/desktop/src/renderer/popups/caption-overlay/script.js
Yun Chan 2fe20fa7b5
Some checks failed
deploy-site / deploy (push) Failing after 39s
release / release-windows (push) Failing after 3m41s
portable-unsigned / portable-windows (push) Failing after 12m23s
release: ship v1.6.0 with paged suggestions and a cleaner phrase memory
Next-sentence suggestions now arrive one at a time up to twelve, shown three
per page with Ctrl+Alt+Up/Down to move, Left/Right to page, Enter to accept
and Esc to close; old default bindings migrate and the panel guide follows the
live bindings. The overlay is redesigned, stays put while candidates stream
and sits outside the input box when no caret is reported.

The personal phrase memory stops learning from terminals, code editors and
the coding-agent hub, ignores symbol-heavy lines and empty-field placeholders,
and prunes existing entries that break those rules.

Fixes suggestion keys starting dictation, installs stuck on a pre-1.5.0
speech engine without the focus endpoint, Ollama runner windows flashing
while typing, the speech engine starting twice, and cold-model timeouts.
Live captions can be dragged to a remembered position and show a waiting
notice until the first line arrives.

Bumps the product version to 1.6.0 (Android/iOS build 1060000).
2026-09-24 19:56:28 +09:00

265 lines
8.6 KiB
JavaScript

// 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')
})
}
})()