"""홈 — 대상 선택, 언어, 시작/정지, 실시간 로그.""" from __future__ import annotations from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QComboBox, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QVBoxLayout, QWidget, ) from ...audio import capture_capabilities, list_sources from ...config import AppConfig from ...constants import LANGUAGES from ...core.events import EngineState, EngineStatus, TranslationLine from ..theme import SPACING, palette from ..widgets import Card, LevelMeter, PageHeader, StatusPill MAX_LOG_ROWS = 300 class HomePage(QWidget): start_requested = Signal() stop_requested = Signal() config_changed = Signal() def __init__(self, config: AppConfig, parent: QWidget | None = None): super().__init__(parent) self.config = config self._sources: list = [] root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(SPACING + 4) root.addWidget( PageHeader("실시간 번역", "소리를 받아올 프로그램과 번역할 언어를 고르세요.") ) root.addWidget(self._build_source_card()) root.addWidget(self._build_control_card()) root.addWidget(self._build_log_card(), 1) self.refresh_sources() # --- 구성 ----------------------------------------------------------- def _build_source_card(self) -> Card: card = Card("소리 받아올 곳", "") self.capability_label = QLabel() self.capability_label.setObjectName("Hint") self.capability_label.setWordWrap(True) card.add(self.capability_label) row = QWidget() layout = QHBoxLayout(row) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(10) self.source_combo = QComboBox() self.source_combo.setMinimumWidth(360) self.source_combo.currentIndexChanged.connect(self._on_source_changed) layout.addWidget(self.source_combo, 1) refresh = QPushButton("새로고침") refresh.clicked.connect(self.refresh_sources) layout.addWidget(refresh) card.add(row) return card def _build_control_card(self) -> Card: card = Card() row = QWidget() layout = QHBoxLayout(row) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(12) self.source_lang = QComboBox() self.source_lang.addItem("자동 감지", "auto") for code, meta in LANGUAGES.items(): self.source_lang.addItem(meta["label"], code) self.source_lang.setCurrentIndex( max(0, self.source_lang.findData(self.config.models.source_lang)) ) self.source_lang.currentIndexChanged.connect(self._on_lang_changed) self.target_lang = QComboBox() for code, meta in LANGUAGES.items(): self.target_lang.addItem(meta["label"], code) self.target_lang.setCurrentIndex( max(0, self.target_lang.findData(self.config.models.target_lang)) ) self.target_lang.currentIndexChanged.connect(self._on_lang_changed) layout.addWidget(QLabel("원본")) layout.addWidget(self.source_lang) arrow = QLabel("→") arrow.setStyleSheet(f"color: {palette('dark').accent}; font-size: 18px;") layout.addWidget(arrow) layout.addWidget(QLabel("번역")) layout.addWidget(self.target_lang) layout.addStretch(1) self.status_pill = StatusPill() layout.addWidget(self.status_pill) self.toggle_button = QPushButton("번역 시작") self.toggle_button.setObjectName("Primary") self.toggle_button.setMinimumWidth(140) self.toggle_button.clicked.connect(self._on_toggle) layout.addWidget(self.toggle_button) card.add(row) self.level_meter = LevelMeter() card.add(self.level_meter) return card def _build_log_card(self) -> Card: card = Card("최근 자막", "확정된 문장이 위로 쌓입니다. 지연 시간도 함께 보여줍니다.") self.log_list = QListWidget() self.log_list.setWordWrap(True) self.log_list.setAlternatingRowColors(False) card.add(self.log_list) return card # --- 동작 ----------------------------------------------------------- def refresh_sources(self) -> None: caps = capture_capabilities() self._sources = list_sources() self.source_combo.blockSignals(True) self.source_combo.clear() for src in self._sources: prefix = "🎮" if src.kind == "process" else "🔊" self.source_combo.addItem(f"{prefix} {src.label} — {src.detail}", src) if not self._sources: self.source_combo.addItem("사용 가능한 오디오 대상이 없습니다", None) self._select_saved_source() self.source_combo.blockSignals(False) self._on_source_changed() if caps["process"]: msg = "프로그램별 캡처 사용 가능 — 선택한 프로그램의 소리만 정확히 받아옵니다." elif caps["device"]: msg = ( "프로그램별 캡처 보조 프로그램(livesub_capture.exe)이 없어 " "출력 장치 전체 소리를 받습니다. native/process_loopback/build.ps1 로 빌드하면 " "프로그램 단위로 분리됩니다." ) else: msg = "이 환경에서는 오디오 캡처를 할 수 없습니다. Windows에서 실행하세요." self.capability_label.setText(msg) def _select_saved_source(self) -> None: cfg = self.config.audio for i, src in enumerate(self._sources): if cfg.backend == "process" and src.kind == "process" and src.pid == cfg.target_pid: self.source_combo.setCurrentIndex(i) return if cfg.backend == "device" and src.kind == "device" and src.device_index == cfg.device_index: self.source_combo.setCurrentIndex(i) return def _on_source_changed(self, *_) -> None: src = self.source_combo.currentData() cfg = self.config.audio if src is None: return if src.kind == "process": cfg.backend = "process" cfg.target_pid = src.pid cfg.target_process_name = src.label else: cfg.backend = "device" cfg.device_index = src.device_index cfg.target_pid = 0 self.config_changed.emit() def _on_lang_changed(self, *_) -> None: self.config.models.source_lang = self.source_lang.currentData() self.config.models.target_lang = self.target_lang.currentData() self.config_changed.emit() def _on_toggle(self) -> None: if self.toggle_button.property("running"): self.stop_requested.emit() else: self.start_requested.emit() # --- 엔진 연동 ------------------------------------------------------ def on_status(self, status: EngineStatus) -> None: running = status.state in (EngineState.RUNNING, EngineState.LOADING) self.toggle_button.setProperty("running", running) self.toggle_button.setText("번역 정지" if running else "번역 시작") self.toggle_button.setObjectName("Danger" if running else "Primary") self.toggle_button.style().polish(self.toggle_button) kind = { EngineState.RUNNING: "ok", EngineState.LOADING: "busy", EngineState.STOPPING: "busy", EngineState.ERROR: "error", }.get(status.state, "idle") self.status_pill.set_status(status.message or status.state.value, kind) self.level_meter.set_level(status.level) for widget in (self.source_combo, self.source_lang, self.target_lang): widget.setEnabled(not running) def on_line(self, line: TranslationLine) -> None: if not line.is_final: return item = QListWidgetItem( f"{line.translated_text}\n" f" {line.source_text} · {line.latency_s:.1f}초 " f"(인식 {line.asr_ms}ms · 번역 {line.mt_ms}ms)" ) item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) item.setToolTip(line.source_text) self.log_list.insertItem(0, item) while self.log_list.count() > MAX_LOG_ROWS: self.log_list.takeItem(self.log_list.count() - 1) self.log_list.scrollToTop()