Some checks failed
Windows / verify (push) Has been cancelled
요청 네 가지를 넣는다. 1) 자막 배경 여백(padding) 배경 상자를 창 전체가 아니라 **글자를 감싸는 크기**로 그리도록 바꿨다. 창 전체를 칠하던 동안에는 여백을 키워도 글자만 안으로 밀릴 뿐 배경 크기는 그대로여서, 설정 항목을 만들어도 의미가 없었다. 2) 평소 잠금 + '자막 위치 조정' locked 기본값을 True 로 바꾼다. 게임 중에 자막을 실수로 끌고 다니는 사고를 막는 쪽이 기본이어야 한다. 자막 화면의 버튼으로 조정 모드를 열면 그때만 이동·네 모서리 크기 조절·휠이 열린다. 조정 중에는 클릭 통과를 강제로 끈다 — 켜둔 채로는 마우스가 창을 지나가 잡을 수 없다. 크기를 바꾸면 글자 크기가 높이 비율을 그대로 따라간다. 상자만 커지고 글자가 그대로면 여백만 늘어나 '자막을 키우는' 조작이 되지 않는다. 조정 모드는 저장하지 않는다. 다음 실행은 다시 잠긴 상태로 시작한다. 3) GPU 가 없을 때 CPU 추천 티어 표의 recommended 는 GPU 가 넉넉할 때를 전제한 값이라, 그대로 쓰면 GPU 없는 사람에게 10GB 짜리를 권하게 된다. 환경을 보고 고르는 recommended_tier() 를 만들고 배지도 그 결과로 붙인다. GPU 가 없으면 CTranslate2 계열만 후보에 두고 가장 가벼운 쪽을 권한다 — LLM 계열은 CPU 에서 한 문장에 수십 초가 걸려 실시간 자막이 되지 않는다. '추천대로 맞추기' 버튼과, 실제로는 CPU 로 도는데 화면은 GPU 라고 표시하던 것도 같이 고친다. nvidia-smi 실패 문구가 명령줄을 통째로 토해내던 것도 사람이 읽을 문장으로 바꿨다. 4) 마이크 등 입력 장치 선택 InputDeviceCapture 를 추가하고 홈 화면에 목록 전환 버튼을 둔다. 두 목록을 한 번에 섞지 않는다 — 다 합치면 정작 찾으려던 게임이 라인입력들 사이에 파묻힌다. 버튼 글자는 '지금 상태'가 아니라 '누르면 가는 곳'을 적는다. 고른 쪽은 설정에 기억한다.
520 lines
20 KiB
Python
520 lines
20 KiB
Python
"""자막 오버레이 창.
|
|
|
|
게임/영상 위에 얹는 무테두리 반투명 창. 기본 동작은 유튜브 자막과 같은
|
|
'외곽선 있는 큰 흰 글씨'로, 어떤 배경 위에서도 읽히는 것을 최우선으로 한다.
|
|
|
|
평소에는 **잠겨 있다.** 게임 중에 자막을 실수로 끌고 다니는 일이 없어야 하기
|
|
때문이다. 자막 화면의 '자막 위치 조정'을 켜야 아래 조작이 열린다.
|
|
|
|
위치 조정 모드에서:
|
|
* 드래그 — 위치 이동
|
|
* 네 모서리 — 크기 조절 (글자 크기도 같은 비율로 따라온다)
|
|
* 휠 — 글자 크기 조절
|
|
* 우클릭 — 잠금 / 클릭 통과 / 숨기기 메뉴
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from PySide6.QtCore import QPoint, QRect, Qt, QTimer, Signal
|
|
from PySide6.QtGui import (
|
|
QAction,
|
|
QColor,
|
|
QFont,
|
|
QFontMetrics,
|
|
QPainter,
|
|
QPainterPath,
|
|
QPen,
|
|
)
|
|
from PySide6.QtWidgets import QMenu, QWidget
|
|
|
|
from ..config import OverlayConfig, SubtitleStyle
|
|
from .placement import ANCHOR_CUSTOM, list_screens, pick_screen, resolve_rect, screen_for_point
|
|
|
|
GRIP = 18 # 우하단 리사이즈 핸들 크기
|
|
|
|
|
|
def paint_subtitle(
|
|
painter: QPainter,
|
|
rect: QRect,
|
|
st: SubtitleStyle,
|
|
translated: str,
|
|
source: str = "",
|
|
partial: str = "",
|
|
) -> None:
|
|
"""자막 한 화면을 그린다. 오버레이와 설정 미리보기가 이 함수를 공유한다.
|
|
|
|
배경 상자는 창 전체가 아니라 **글자를 감싸는 크기**로 그린다. 창 전체를
|
|
칠하면 자막이 짧아도 커다란 띠가 남고, 그 상태에서는 여백을 키워도 글자만
|
|
안쪽으로 밀릴 뿐 '배경 크기'는 그대로다. 감싸도록 그려야 여백이 곧 배경
|
|
크기가 된다.
|
|
"""
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
|
|
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
|
|
|
|
rows: list[tuple[str, QColor, int]] = []
|
|
if st.show_source and source and translated:
|
|
rows.append((source, QColor(st.source_text_color), int(st.font_size * 0.62)))
|
|
main = translated or partial
|
|
if main:
|
|
color = QColor(st.text_color)
|
|
if not translated: # 아직 확정 전 — 흐리게
|
|
color.setAlpha(170)
|
|
rows.append((main, color, st.font_size))
|
|
if not rows:
|
|
return
|
|
|
|
pad = max(0, st.padding)
|
|
available = rect.width() - pad * 2
|
|
blocks: list[tuple[list[str], QColor, QFont]] = []
|
|
total_h = 0
|
|
text_w = 0
|
|
for text, color, size in rows:
|
|
font = QFont(st.font_family, size)
|
|
font.setBold(st.bold)
|
|
fm = QFontMetrics(font)
|
|
lines = _wrap(text, fm, available, st.max_lines)
|
|
blocks.append((lines, color, font))
|
|
total_h += len(lines) * (fm.height() + st.line_spacing)
|
|
for line in lines:
|
|
text_w = max(text_w, fm.horizontalAdvance(line))
|
|
|
|
box = _content_box(rect, text_w, total_h, pad, st.align)
|
|
|
|
if st.background_opacity > 0:
|
|
bg = QColor(st.background_color)
|
|
bg.setAlpha(int(255 * st.background_opacity / 100))
|
|
painter.setBrush(bg)
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.drawRoundedRect(box, 12, 12)
|
|
|
|
y = box.y() + pad
|
|
for lines, color, font in blocks:
|
|
painter.setFont(font)
|
|
fm = QFontMetrics(font)
|
|
for line in lines:
|
|
width = fm.horizontalAdvance(line)
|
|
if st.align == "left":
|
|
x = box.x() + pad
|
|
elif st.align == "right":
|
|
x = box.right() - pad - width
|
|
else:
|
|
x = box.x() + (box.width() - width) // 2
|
|
_draw_outlined_text(painter, x, y + fm.ascent(), line, color, font, st)
|
|
y += fm.height() + st.line_spacing
|
|
|
|
|
|
def _content_box(rect: QRect, text_w: int, text_h: int, pad: int, align: str) -> QRect:
|
|
"""글자를 감싸는 배경 상자. 창 밖으로는 넘지 않는다."""
|
|
width = min(rect.width(), text_w + pad * 2)
|
|
height = min(rect.height(), text_h + pad * 2)
|
|
if align == "left":
|
|
x = rect.x()
|
|
elif align == "right":
|
|
x = rect.right() - width + 1
|
|
else:
|
|
x = rect.x() + (rect.width() - width) // 2
|
|
y = rect.y() + (rect.height() - height) // 2
|
|
return QRect(x, y, width, height)
|
|
|
|
|
|
def _draw_outlined_text(painter, x, y, text, color, font, st: SubtitleStyle) -> None:
|
|
"""외곽선 있는 글자.
|
|
|
|
스트로크는 글자 윤곽선 '가운데'에 그려지므로 절반이 글자 안쪽을 파먹는다.
|
|
한글처럼 획이 얇은 글꼴은 그대로 두면 글자가 통째로 외곽선 색이 된다.
|
|
그래서 스트로크를 먼저 깔고 그 위에 글자를 채우는 2패스로 그린다.
|
|
"""
|
|
if st.outline_width <= 0:
|
|
painter.setPen(color)
|
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
painter.drawText(x, y, text)
|
|
return
|
|
|
|
path = QPainterPath()
|
|
path.addText(x, y, font, text)
|
|
|
|
pen = QPen(QColor(st.outline_color))
|
|
pen.setWidth(st.outline_width * 2) # 절반이 안쪽으로 먹히므로 2배
|
|
pen.setJoinStyle(Qt.PenJoinStyle.RoundJoin)
|
|
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
|
painter.setPen(pen)
|
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
painter.drawPath(path)
|
|
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.setBrush(color)
|
|
painter.drawPath(path)
|
|
|
|
|
|
class SubtitlePreview(QWidget):
|
|
"""설정 화면에서 쓰는 미리보기. 실제 자막과 같은 코드로 그린다."""
|
|
|
|
def __init__(self, style: SubtitleStyle, parent: QWidget | None = None):
|
|
super().__init__(parent)
|
|
self.style_cfg = style
|
|
self.translated = ""
|
|
self.source = ""
|
|
self.setMinimumHeight(140)
|
|
|
|
def set_sample(self, translated: str, source: str) -> None:
|
|
self.translated = translated
|
|
self.source = source
|
|
self.update()
|
|
|
|
def paintEvent(self, event) -> None: # noqa: N802
|
|
painter = QPainter(self)
|
|
paint_subtitle(
|
|
painter, self.rect(), self.style_cfg, self.translated, self.source
|
|
)
|
|
|
|
|
|
class SubtitleOverlay(QWidget):
|
|
"""항상 위에 떠 있는 자막 창."""
|
|
|
|
geometry_changed = Signal(int, int, int, int)
|
|
#: 자막 on/off 가 바뀔 때 (단축키로도 바뀌므로 UI가 따라와야 한다)
|
|
visibility_changed = Signal(bool)
|
|
#: 크기 조정으로 글자 크기가 같이 바뀌었을 때 (설정 화면이 따라와야 한다)
|
|
font_size_changed = Signal(int)
|
|
#: 위치 조정 모드가 켜지고 꺼질 때
|
|
edit_mode_changed = Signal(bool)
|
|
closed = Signal()
|
|
|
|
def __init__(self, style: SubtitleStyle, overlay: OverlayConfig) -> None:
|
|
super().__init__(None)
|
|
self.style_cfg = style
|
|
self.overlay_cfg = overlay
|
|
|
|
self._final_text = ""
|
|
self._partial_text = ""
|
|
self._source_text = ""
|
|
self._drag_from: QPoint | None = None
|
|
#: 위치 조정 모드. 설정에 저장하지 않는다 — 다음 실행 때는 다시 잠긴
|
|
#: 상태로 시작해야 게임 중에 자막을 실수로 끌고 다니지 않는다.
|
|
self._edit_mode = False
|
|
self._resize_corner = ""
|
|
self._resize_geo = QRect()
|
|
self._resize_mouse = QPoint()
|
|
self._resize_font = 0
|
|
|
|
self._fade = QTimer(self)
|
|
self._fade.setSingleShot(True)
|
|
self._fade.timeout.connect(self._clear_text)
|
|
|
|
self.setWindowFlags(
|
|
Qt.WindowType.FramelessWindowHint
|
|
| Qt.WindowType.Tool
|
|
| Qt.WindowType.WindowStaysOnTopHint
|
|
)
|
|
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
|
|
self.setMinimumSize(320, 90)
|
|
self.apply_config()
|
|
|
|
# --- 외부 API -------------------------------------------------------
|
|
def apply_config(self) -> None:
|
|
cfg = self.overlay_cfg
|
|
flags = self.windowFlags()
|
|
flags = (
|
|
flags | Qt.WindowType.WindowStaysOnTopHint
|
|
if cfg.always_on_top
|
|
else flags & ~Qt.WindowType.WindowStaysOnTopHint
|
|
)
|
|
self.setWindowFlags(flags)
|
|
# 위치 조정 중에는 클릭 통과를 반드시 꺼야 한다. 켜둔 채로는 마우스가
|
|
# 창을 그냥 지나가서 잡을 수가 없다.
|
|
self.setAttribute(
|
|
Qt.WidgetAttribute.WA_TransparentForMouseEvents,
|
|
cfg.click_through and not self._edit_mode,
|
|
)
|
|
|
|
# 앵커가 지정돼 있거나 아직 배치된 적이 없으면 다시 계산한다.
|
|
if cfg.anchor != ANCHOR_CUSTOM or cfg.x < 0 or cfg.y < 0:
|
|
self.snap_to_anchor(save_only=True)
|
|
self.setGeometry(cfg.x, cfg.y, cfg.width, cfg.height)
|
|
self.setVisible(cfg.visible)
|
|
self.update()
|
|
|
|
# --- 위치 조정 모드 --------------------------------------------------
|
|
@property
|
|
def edit_mode(self) -> bool:
|
|
return self._edit_mode
|
|
|
|
def set_edit_mode(self, on: bool) -> None:
|
|
"""자막을 옮기고 크기를 바꿀 수 있는 상태로 전환한다.
|
|
|
|
평소에는 잠겨 있다. 게임 중에 자막을 잘못 건드려 끌고 다니는 일이
|
|
없어야 하기 때문이다. 이 모드에서만 드래그·모서리 크기 조절·휠이
|
|
동작하고, 켜는 동안에는 자막이 안 보여도 잡을 수 있게 창을 띄운다.
|
|
"""
|
|
if on == self._edit_mode:
|
|
return
|
|
self._edit_mode = on
|
|
if on and not self.isVisible():
|
|
self.setVisible(True)
|
|
self.setAttribute(
|
|
Qt.WidgetAttribute.WA_TransparentForMouseEvents,
|
|
self.overlay_cfg.click_through and not on,
|
|
)
|
|
self.setCursor(
|
|
Qt.CursorShape.SizeAllCursor if on else Qt.CursorShape.ArrowCursor
|
|
)
|
|
if not on:
|
|
self._drag_from = None
|
|
self._resize_corner = ""
|
|
self.update()
|
|
self.edit_mode_changed.emit(on)
|
|
|
|
@property
|
|
def _interactive(self) -> bool:
|
|
return self._edit_mode or not self.overlay_cfg.locked
|
|
|
|
def _corner_at(self, pos: QPoint) -> str:
|
|
"""모서리 손잡이를 집었는지. 집었으면 어느 모서리인지."""
|
|
left = pos.x() <= GRIP
|
|
right = pos.x() >= self.width() - GRIP
|
|
top = pos.y() <= GRIP
|
|
bottom = pos.y() >= self.height() - GRIP
|
|
if (left or right) and (top or bottom):
|
|
return ("top" if top else "bottom") + ("left" if left else "right")
|
|
return ""
|
|
|
|
def snap_to_anchor(self, anchor: str | None = None, save_only: bool = False) -> None:
|
|
"""선택한 모니터의 9분할 위치로 자막 창을 붙인다 (디스코드 오버레이 방식)."""
|
|
cfg = self.overlay_cfg
|
|
if anchor is not None:
|
|
cfg.anchor = anchor
|
|
if cfg.anchor == ANCHOR_CUSTOM:
|
|
return
|
|
|
|
screens = list_screens()
|
|
screen = pick_screen(screens, cfg.screen_index)
|
|
if screen is None:
|
|
return
|
|
cfg.screen_index = screen.index
|
|
cfg.x, cfg.y, cfg.width, cfg.height = resolve_rect(
|
|
screen, cfg.anchor, cfg.width, cfg.height, cfg.margin
|
|
)
|
|
if not save_only:
|
|
self.setGeometry(cfg.x, cfg.y, cfg.width, cfg.height)
|
|
self.geometry_changed.emit(cfg.x, cfg.y, cfg.width, cfg.height)
|
|
|
|
def set_subtitle_visible(self, visible: bool) -> None:
|
|
"""자막 on/off. 단축키와 체크박스가 모두 여기로 들어온다."""
|
|
self.overlay_cfg.visible = visible
|
|
self.setVisible(visible)
|
|
if not visible:
|
|
self._clear_text()
|
|
self.visibility_changed.emit(visible)
|
|
|
|
def toggle_subtitle(self) -> bool:
|
|
self.set_subtitle_visible(not self.overlay_cfg.visible)
|
|
return self.overlay_cfg.visible
|
|
|
|
def set_line(self, translated: str, source: str = "", is_final: bool = True) -> None:
|
|
if is_final:
|
|
self._final_text = translated or source
|
|
self._partial_text = ""
|
|
self._source_text = source
|
|
else:
|
|
self._partial_text = source
|
|
self._restart_fade()
|
|
self.update()
|
|
|
|
def clear(self) -> None:
|
|
self._clear_text()
|
|
|
|
# --- 그리기 ---------------------------------------------------------
|
|
def paintEvent(self, event) -> None: # noqa: N802 - Qt 규약
|
|
painter = QPainter(self)
|
|
paint_subtitle(
|
|
painter,
|
|
self.rect(),
|
|
self.style_cfg,
|
|
self._final_text,
|
|
self._source_text,
|
|
self._partial_text,
|
|
)
|
|
if self._edit_mode:
|
|
self._draw_edit_chrome(painter)
|
|
|
|
def _draw_edit_chrome(self, painter) -> None:
|
|
"""위치 조정 중이라는 것과 어디를 잡으면 되는지 보여준다."""
|
|
painter.setBrush(Qt.BrushStyle.NoBrush)
|
|
painter.setPen(QPen(QColor(90, 170, 255, 220), 2, Qt.PenStyle.DashLine))
|
|
painter.drawRect(self.rect().adjusted(1, 1, -2, -2))
|
|
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.setBrush(QColor(90, 170, 255, 235))
|
|
for rect in self._corner_handles():
|
|
painter.drawRect(rect)
|
|
|
|
if not (self._final_text or self._partial_text):
|
|
painter.setPen(QColor(255, 255, 255, 170))
|
|
font = QFont(self.style_cfg.font_family, 13)
|
|
painter.setFont(font)
|
|
painter.drawText(
|
|
self.rect(),
|
|
Qt.AlignmentFlag.AlignCenter,
|
|
"끌어서 이동 · 모서리를 잡아 크기 조절 (글자 크기도 같이 바뀝니다)",
|
|
)
|
|
|
|
def _corner_handles(self) -> list[QRect]:
|
|
w, h = self.width(), self.height()
|
|
return [
|
|
QRect(0, 0, GRIP, GRIP),
|
|
QRect(w - GRIP, 0, GRIP, GRIP),
|
|
QRect(0, h - GRIP, GRIP, GRIP),
|
|
QRect(w - GRIP, h - GRIP, GRIP, GRIP),
|
|
]
|
|
|
|
# --- 마우스 ---------------------------------------------------------
|
|
def mousePressEvent(self, event) -> None: # noqa: N802
|
|
if not self._interactive or event.button() != Qt.MouseButton.LeftButton:
|
|
return
|
|
corner = self._corner_at(event.position().toPoint())
|
|
if corner:
|
|
self._resize_corner = corner
|
|
self._resize_geo = QRect(self.geometry())
|
|
self._resize_mouse = event.globalPosition().toPoint()
|
|
self._resize_font = self.style_cfg.font_size
|
|
else:
|
|
self._drag_from = event.globalPosition().toPoint() - self.pos()
|
|
|
|
def mouseMoveEvent(self, event) -> None: # noqa: N802
|
|
if self._resize_corner:
|
|
self._resize_to(event.globalPosition().toPoint())
|
|
elif self._drag_from is not None:
|
|
self.move(event.globalPosition().toPoint() - self._drag_from)
|
|
|
|
def _resize_to(self, mouse: QPoint) -> None:
|
|
"""잡은 모서리만 움직이고 반대쪽 모서리는 제자리에 둔다.
|
|
|
|
글자 크기는 높이 비율을 그대로 따라간다. 상자만 커지고 글자는 그대로면
|
|
여백만 늘어나므로, 크기 조절이 '자막을 키우는' 조작이 되지 않는다.
|
|
"""
|
|
start = self._resize_geo
|
|
dx = mouse.x() - self._resize_mouse.x()
|
|
dy = mouse.y() - self._resize_mouse.y()
|
|
left, top = start.left(), start.top()
|
|
right, bottom = start.right(), start.bottom()
|
|
|
|
if "left" in self._resize_corner:
|
|
left = min(left + dx, right - self.minimumWidth())
|
|
else:
|
|
right = max(right + dx, left + self.minimumWidth())
|
|
if "top" in self._resize_corner:
|
|
top = min(top + dy, bottom - self.minimumHeight())
|
|
else:
|
|
bottom = max(bottom + dy, top + self.minimumHeight())
|
|
|
|
rect = QRect(QPoint(left, top), QPoint(right, bottom))
|
|
self.setGeometry(rect)
|
|
|
|
scaled = round(self._resize_font * rect.height() / max(1, start.height()))
|
|
size = max(12, min(120, scaled))
|
|
if size != self.style_cfg.font_size:
|
|
self.style_cfg.font_size = size
|
|
self.font_size_changed.emit(size)
|
|
self.update()
|
|
|
|
def mouseReleaseEvent(self, event) -> None: # noqa: N802
|
|
if self._drag_from is None and not self._resize_corner:
|
|
return
|
|
self._drag_from = None
|
|
self._resize_corner = ""
|
|
|
|
cfg = self.overlay_cfg
|
|
g = self.geometry()
|
|
cfg.x, cfg.y = g.x(), g.y()
|
|
cfg.width, cfg.height = g.width(), g.height()
|
|
# 손으로 옮긴 순간부터는 앵커가 아니라 그 자리를 그대로 지킨다.
|
|
cfg.anchor = ANCHOR_CUSTOM
|
|
# 다른 모니터로 끌고 갔을 수 있으므로 소속을 다시 찾는다.
|
|
owner = screen_for_point(list_screens(), g.center().x(), g.center().y())
|
|
if owner is not None:
|
|
cfg.screen_index = owner.index
|
|
self.geometry_changed.emit(g.x(), g.y(), g.width(), g.height())
|
|
|
|
def wheelEvent(self, event) -> None: # noqa: N802
|
|
if not self._interactive:
|
|
return
|
|
step = 2 if event.angleDelta().y() > 0 else -2
|
|
size = max(12, min(120, self.style_cfg.font_size + step))
|
|
if size != self.style_cfg.font_size:
|
|
self.style_cfg.font_size = size
|
|
self.font_size_changed.emit(size)
|
|
self.update()
|
|
|
|
def contextMenuEvent(self, event) -> None: # noqa: N802
|
|
menu = QMenu(self)
|
|
cfg = self.overlay_cfg
|
|
|
|
lock = QAction("위치 잠금", self, checkable=True, checked=cfg.locked)
|
|
lock.toggled.connect(self._set_locked)
|
|
menu.addAction(lock)
|
|
|
|
through = QAction("클릭 통과", self, checkable=True, checked=cfg.click_through)
|
|
through.toggled.connect(self._set_click_through)
|
|
menu.addAction(through)
|
|
|
|
top = QAction("항상 위에", self, checkable=True, checked=cfg.always_on_top)
|
|
top.toggled.connect(self._set_always_on_top)
|
|
menu.addAction(top)
|
|
|
|
menu.addSeparator()
|
|
menu.addAction("자막 지우기", self.clear)
|
|
menu.addAction("자막 창 숨기기", self.hide_overlay)
|
|
menu.exec(event.globalPos())
|
|
|
|
# --- 메뉴 동작 ------------------------------------------------------
|
|
def _set_locked(self, value: bool) -> None:
|
|
self.overlay_cfg.locked = value
|
|
self.update()
|
|
|
|
def _set_click_through(self, value: bool) -> None:
|
|
self.overlay_cfg.click_through = value
|
|
# 위치 조정 중이면 통과를 켜지 않는다. 켜는 순간 창을 못 잡게 된다.
|
|
self.setAttribute(
|
|
Qt.WidgetAttribute.WA_TransparentForMouseEvents,
|
|
value and not self._edit_mode,
|
|
)
|
|
self.update()
|
|
|
|
def _set_always_on_top(self, value: bool) -> None:
|
|
self.overlay_cfg.always_on_top = value
|
|
self.apply_config()
|
|
|
|
def hide_overlay(self) -> None:
|
|
self.set_subtitle_visible(False)
|
|
self.closed.emit()
|
|
|
|
def _restart_fade(self) -> None:
|
|
self._fade.stop()
|
|
if self.style_cfg.fade_out_ms > 0:
|
|
self._fade.start(self.style_cfg.fade_out_ms)
|
|
|
|
def _clear_text(self) -> None:
|
|
self._final_text = ""
|
|
self._partial_text = ""
|
|
self._source_text = ""
|
|
self.update()
|
|
|
|
|
|
def _wrap(text: str, fm: QFontMetrics, width: int, max_lines: int) -> list[str]:
|
|
"""단어 단위 줄바꿈. 마지막 줄이 넘치면 앞부분을 버리고 최신 내용을 남긴다."""
|
|
if width <= 0 or not text:
|
|
return [text] if text else []
|
|
lines: list[str] = []
|
|
current = ""
|
|
for word in text.split():
|
|
candidate = f"{current} {word}".strip()
|
|
if fm.horizontalAdvance(candidate) <= width or not current:
|
|
current = candidate
|
|
else:
|
|
lines.append(current)
|
|
current = word
|
|
if current:
|
|
lines.append(current)
|
|
# 실시간 자막에서는 앞이 아니라 뒤(최신)를 보여주는 게 맞다.
|
|
return lines[-max_lines:] if max_lines > 0 else lines
|