feat: LiveSub 2차 — 자막 on/off, 모니터·위치 선택, GPU 저부하, 게임 용어집
이름을 Hearo → LiveSub 로 변경. 말장난보다 하는 일이 바로 보이는 쪽이 낫다. 자막 on/off - 전역 단축키 Ctrl+Alt+S (자막) / Ctrl+Alt+D (번역) — Windows RegisterHotKey + 네이티브 이벤트 필터라 게임 창이 떠 있어도 동작. 다른 OS 에서는 no-op - 단축키·체크박스·우클릭 메뉴가 모두 같은 경로를 타도록 통합 모니터 선택 + 디스코드식 배치 - placement.py: Qt 비의존 배치 계산 (모니터 목록 → 9분할 좌표) - AnchorGrid 위젯으로 3x3 위치 선택, 드래그하면 자유 배치로 전환 - 모니터 구성이 바뀌어도 자막이 화면 밖으로 사라지지 않도록 클램프 GPU 저부하 모드 (기본 켜짐) - 연산 정밀도 int8 강등, VRAM 상한 35%, 추론 후 60ms 양보, 300초 무음 시 모델 언로드, 중간 결과 비활성 - 기본 티어를 균형(6GB) → 신속(4GB) 으로 하향 - 8GB GPU 기준 자막 2.8GB / 게임 5.2GB 게임 용어집 번들 (304개) - 롤 74 · 발로란트 59 · 오버워치2 49 · FPS공통 46 · 마크 38 · 방송 38 - 체크박스로 켜고 끄며, 사용자가 직접 등록한 항목이 항상 우선 검증: pytest 91개 통과 (배치 14 + 팩 12 + UI 6 신규), ruff clean Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
327
src/livesub/ui/main_window.py
Normal file
327
src/livesub/ui/main_window.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""메인 컨트롤 창 — 사이드바 + 페이지 스택.
|
||||
|
||||
디자인 의도: 자막은 게임 위 오버레이가 전담하고, 이 창은 '설정과 상태'만
|
||||
담당한다. 그래서 두 창이 서로 방해하지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QCloseEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QStackedWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..config import AppConfig
|
||||
from ..constants import APP_NAME, APP_SLOGAN, APP_VERSION, user_data_dir
|
||||
from ..core import EngineState, EngineStatus, TranslationEngine, TranslationLine
|
||||
from .hotkeys import GlobalHotkeys
|
||||
from .overlay import SubtitleOverlay
|
||||
from .pages import GlossaryPage, HomePage, ModelsPage, SettingsPage, SubtitlePage
|
||||
from .theme import SPACING, palette, stylesheet
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
NAV = [
|
||||
("홈", "실시간 번역"),
|
||||
("모델", "품질 티어"),
|
||||
("자막", "표시 설정"),
|
||||
("용어집", "고유명사 고정"),
|
||||
("설정", "세부 조정"),
|
||||
]
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
#: 엔진 콜백은 워커 스레드에서 오므로 시그널로 GUI 스레드에 넘긴다.
|
||||
line_received = Signal(object)
|
||||
status_received = Signal(object)
|
||||
|
||||
def __init__(self, config: AppConfig) -> None:
|
||||
super().__init__()
|
||||
self.config = config
|
||||
self.setWindowTitle(f"{APP_NAME} · {APP_SLOGAN}")
|
||||
self.resize(1060, 820)
|
||||
|
||||
self.engine = TranslationEngine(
|
||||
config,
|
||||
on_line=self.line_received.emit,
|
||||
on_status=self.status_received.emit,
|
||||
)
|
||||
self.overlay = SubtitleOverlay(config.subtitle, config.overlay)
|
||||
self.hotkeys = GlobalHotkeys(self)
|
||||
self._transcript_file = None
|
||||
|
||||
self._build_ui()
|
||||
self._connect()
|
||||
self._install_hotkeys()
|
||||
|
||||
self._save_timer = QTimer(self)
|
||||
self._save_timer.setSingleShot(True)
|
||||
self._save_timer.setInterval(800) # 입력 중 계속 쓰지 않도록 묶어서 저장
|
||||
self._save_timer.timeout.connect(lambda: self.config.save())
|
||||
|
||||
# --- UI -------------------------------------------------------------
|
||||
def _build_ui(self) -> None:
|
||||
self.setStyleSheet(stylesheet(self.config.theme))
|
||||
|
||||
central = QWidget()
|
||||
layout = QHBoxLayout(central)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
layout.addWidget(self._build_sidebar())
|
||||
|
||||
self.stack = QStackedWidget()
|
||||
self.home_page = HomePage(self.config)
|
||||
self.models_page = ModelsPage(self.config)
|
||||
self.subtitle_page = SubtitlePage(self.config)
|
||||
self.glossary_page = GlossaryPage(self.config)
|
||||
self.settings_page = SettingsPage(self.config)
|
||||
for page in (
|
||||
self.home_page,
|
||||
self.models_page,
|
||||
self.subtitle_page,
|
||||
self.glossary_page,
|
||||
self.settings_page,
|
||||
):
|
||||
self.stack.addWidget(_scrollable(page))
|
||||
layout.addWidget(self.stack, 1)
|
||||
self.setCentralWidget(central)
|
||||
|
||||
def _build_sidebar(self) -> QFrame:
|
||||
p = palette(self.config.theme)
|
||||
bar = QFrame()
|
||||
bar.setObjectName("Sidebar")
|
||||
bar.setFixedWidth(212)
|
||||
|
||||
layout = QVBoxLayout(bar)
|
||||
layout.setContentsMargins(14, 22, 14, 18)
|
||||
layout.setSpacing(6)
|
||||
|
||||
brand = QLabel(APP_NAME)
|
||||
brand.setStyleSheet(
|
||||
f"font-size: 24px; font-weight: 800; color: {p.accent}; padding-left: 6px;"
|
||||
)
|
||||
layout.addWidget(brand)
|
||||
tag = QLabel(APP_SLOGAN)
|
||||
tag.setObjectName("Hint")
|
||||
tag.setStyleSheet(f"color: {p.text_dim}; padding-left: 7px;")
|
||||
layout.addWidget(tag)
|
||||
layout.addSpacing(SPACING + 8)
|
||||
|
||||
self._nav_group = QButtonGroup(self)
|
||||
self._nav_group.setExclusive(True)
|
||||
for index, (title, hint) in enumerate(NAV):
|
||||
button = QPushButton(title)
|
||||
button.setObjectName("NavButton")
|
||||
button.setCheckable(True)
|
||||
button.setToolTip(hint)
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self._nav_group.addButton(button, index)
|
||||
layout.addWidget(button)
|
||||
self._nav_group.button(0).setChecked(True)
|
||||
self._nav_group.idClicked.connect(self._goto_page)
|
||||
|
||||
layout.addStretch(1)
|
||||
version = QLabel(f"v{APP_VERSION}")
|
||||
version.setObjectName("Hint")
|
||||
version.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
layout.addWidget(version)
|
||||
return bar
|
||||
|
||||
def _connect(self) -> None:
|
||||
self.line_received.connect(self._on_line)
|
||||
self.status_received.connect(self._on_status)
|
||||
|
||||
self.home_page.start_requested.connect(self.start_engine)
|
||||
self.home_page.stop_requested.connect(self.stop_engine)
|
||||
self.home_page.config_changed.connect(self._schedule_save)
|
||||
|
||||
self.models_page.tier_changed.connect(self._on_tier_changed)
|
||||
self.models_page.config_changed.connect(self._schedule_save)
|
||||
|
||||
self.subtitle_page.style_changed.connect(self._on_style_changed)
|
||||
self.subtitle_page.overlay_toggled.connect(self._on_overlay_toggled)
|
||||
self.subtitle_page.placement_changed.connect(self._on_placement_changed)
|
||||
|
||||
self.glossary_page.glossary_changed.connect(self._on_glossary_changed)
|
||||
|
||||
self.settings_page.config_changed.connect(self._schedule_save)
|
||||
self.settings_page.restart_needed.connect(self._on_restart_needed)
|
||||
self.settings_page.hotkeys_changed.connect(self._install_hotkeys)
|
||||
|
||||
self.overlay.geometry_changed.connect(self._on_overlay_moved)
|
||||
self.overlay.visibility_changed.connect(self.subtitle_page.overlay_check.setChecked)
|
||||
self.overlay.closed.connect(
|
||||
lambda: self.subtitle_page.overlay_check.setChecked(False)
|
||||
)
|
||||
|
||||
# --- 전역 단축키 ------------------------------------------------------
|
||||
def _install_hotkeys(self) -> None:
|
||||
"""설정에 적힌 단축키를 OS에 등록한다. 변경될 때마다 다시 등록한다."""
|
||||
self.hotkeys.unregister_all()
|
||||
keys = self.config.hotkeys
|
||||
if not keys.enabled:
|
||||
self.settings_page.set_hotkey_status("전역 단축키를 끈 상태입니다.")
|
||||
return
|
||||
if not self.hotkeys.supported:
|
||||
self.settings_page.set_hotkey_status(
|
||||
"전역 단축키는 Windows에서만 동작합니다. "
|
||||
"이 창이 떠 있을 때는 자막 화면에서 직접 끄고 켤 수 있습니다.",
|
||||
ok=False,
|
||||
)
|
||||
return
|
||||
|
||||
registered, failed = [], []
|
||||
for label, sequence, callback in (
|
||||
("자막", keys.toggle_subtitle, self.toggle_subtitle),
|
||||
("번역", keys.toggle_engine, self.toggle_engine),
|
||||
):
|
||||
if not sequence:
|
||||
continue
|
||||
if self.hotkeys.register(sequence, callback):
|
||||
registered.append(f"{label} {sequence}")
|
||||
else:
|
||||
failed.append(f"{label} {sequence}")
|
||||
|
||||
if failed:
|
||||
self.settings_page.set_hotkey_status(
|
||||
f"등록 실패: {', '.join(failed)} — {self.hotkeys.last_error}", ok=False
|
||||
)
|
||||
else:
|
||||
self.settings_page.set_hotkey_status(
|
||||
f"등록됨: {' · '.join(registered)}" if registered else "등록된 단축키가 없습니다."
|
||||
)
|
||||
|
||||
def toggle_subtitle(self) -> None:
|
||||
"""자막 on/off. 단축키와 체크박스가 모두 여기로 들어온다."""
|
||||
visible = self.overlay.toggle_subtitle()
|
||||
self._schedule_save()
|
||||
log.info("자막 %s", "켬" if visible else "끔")
|
||||
|
||||
def toggle_engine(self) -> None:
|
||||
if self.engine.running:
|
||||
self.stop_engine()
|
||||
else:
|
||||
self.start_engine()
|
||||
|
||||
# --- 엔진 -----------------------------------------------------------
|
||||
def start_engine(self) -> None:
|
||||
if self.config.overlay.visible:
|
||||
self.overlay.show()
|
||||
self.config.save()
|
||||
self.engine.start()
|
||||
|
||||
def stop_engine(self) -> None:
|
||||
self.engine.stop()
|
||||
self.overlay.clear()
|
||||
self._close_transcript()
|
||||
|
||||
def _on_tier_changed(self, tier_key: str) -> None:
|
||||
was_running = self.engine.running
|
||||
if was_running:
|
||||
self.engine.stop()
|
||||
self.engine.models.switch_tier(tier_key)
|
||||
if was_running:
|
||||
self.engine.start()
|
||||
|
||||
def _on_restart_needed(self) -> None:
|
||||
if self.engine.running:
|
||||
self.engine.stop()
|
||||
self.engine.start()
|
||||
|
||||
# --- 이벤트 ---------------------------------------------------------
|
||||
def _on_line(self, line: TranslationLine) -> None:
|
||||
self.overlay.set_line(line.translated_text, line.source_text, line.is_final)
|
||||
self.home_page.on_line(line)
|
||||
if line.is_final and self.config.log_transcripts:
|
||||
self._write_transcript(line)
|
||||
|
||||
def _on_status(self, status: EngineStatus) -> None:
|
||||
self.home_page.on_status(status)
|
||||
if status.state is EngineState.ERROR:
|
||||
QMessageBox.warning(self, "오류", status.message)
|
||||
|
||||
def _on_style_changed(self) -> None:
|
||||
self.overlay.update()
|
||||
self._schedule_save()
|
||||
|
||||
def _on_overlay_toggled(self, visible: bool) -> None:
|
||||
if visible == self.config.overlay.visible:
|
||||
return # 단축키로 바뀐 것을 체크박스가 되돌려 보내는 루프 차단
|
||||
self.overlay.set_subtitle_visible(visible)
|
||||
self._schedule_save()
|
||||
|
||||
def _on_placement_changed(self) -> None:
|
||||
"""모니터·9분할 위치·여백·클릭통과가 바뀌면 자막 창에 바로 반영."""
|
||||
self.overlay.apply_config()
|
||||
self.overlay.snap_to_anchor()
|
||||
self.subtitle_page.sync_from_config()
|
||||
self._schedule_save()
|
||||
|
||||
def _on_overlay_moved(self, *_) -> None:
|
||||
"""드래그로 옮기면 '자유 배치'가 되므로 설정 화면 표시를 맞춘다."""
|
||||
self.subtitle_page.sync_from_config()
|
||||
self._schedule_save()
|
||||
|
||||
def _on_glossary_changed(self) -> None:
|
||||
self.engine.reload_glossary()
|
||||
self._schedule_save()
|
||||
|
||||
def _goto_page(self, index: int) -> None:
|
||||
self.stack.setCurrentIndex(index)
|
||||
|
||||
def _schedule_save(self) -> None:
|
||||
self._save_timer.start()
|
||||
|
||||
# --- 기록 -----------------------------------------------------------
|
||||
def _write_transcript(self, line: TranslationLine) -> None:
|
||||
if self._transcript_file is None:
|
||||
folder = user_data_dir() / "transcripts"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
name = datetime.now().strftime("%Y%m%d-%H%M%S.txt")
|
||||
# 세션 내내 열어두는 핸들이다. _close_transcript() 에서 닫는다.
|
||||
self._transcript_file = open(folder / name, "a", encoding="utf-8") # noqa: SIM115
|
||||
stamp = datetime.now().strftime("%H:%M:%S")
|
||||
self._transcript_file.write(
|
||||
f"[{stamp}] {line.source_text}\n {line.translated_text}\n"
|
||||
)
|
||||
self._transcript_file.flush()
|
||||
|
||||
def _close_transcript(self) -> None:
|
||||
if self._transcript_file is not None:
|
||||
self._transcript_file.close()
|
||||
self._transcript_file = None
|
||||
|
||||
# --- 종료 -----------------------------------------------------------
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
|
||||
self.config.save()
|
||||
self.hotkeys.unregister_all() # OS에 잡아둔 키를 반드시 풀어준다
|
||||
self.engine.shutdown()
|
||||
self._close_transcript()
|
||||
self.overlay.close()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
def _scrollable(widget: QWidget) -> QScrollArea:
|
||||
area = QScrollArea()
|
||||
area.setWidgetResizable(True)
|
||||
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
holder = QWidget()
|
||||
layout = QVBoxLayout(holder)
|
||||
layout.setContentsMargins(26, 24, 26, 24)
|
||||
layout.addWidget(widget)
|
||||
area.setWidget(holder)
|
||||
return area
|
||||
Reference in New Issue
Block a user