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).
265 lines
8.6 KiB
JavaScript
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')
|
|
})
|
|
}
|
|
})()
|