diff --git a/src/scripto/gui_qt/history_page.py b/src/scripto/gui_qt/history_page.py index a6ac7fb..b43a550 100644 --- a/src/scripto/gui_qt/history_page.py +++ b/src/scripto/gui_qt/history_page.py @@ -287,10 +287,16 @@ def _play(self) -> None: self.t("gui.player_missing_video"), ok=False ) return - srt = self.group.existing.get(self.lang or "", "") + # Current viewing language first — it becomes the primary subtitle. + ordered = sorted( + self.group.existing.items(), key=lambda kv: kv[0] != self.lang + ) + tracks = { + self.page.window_ref.lang_label(lang): path + for lang, path in ordered if path.endswith(".srt") + } PlayerDialog( - self, self.page.window_ref, self.group.source, - srt if srt.endswith(".srt") else None, + self, self.page.window_ref, self.group.source, tracks ).exec() # ------------------------------------------------------------------ # diff --git a/src/scripto/gui_qt/player.py b/src/scripto/gui_qt/player.py index d9983dd..66f2c0a 100644 --- a/src/scripto/gui_qt/player.py +++ b/src/scripto/gui_qt/player.py @@ -1,9 +1,17 @@ """Built-in media player: the source video with live subtitles from the SRT. -Subtitles are drawn by us (a label overlaid on the video), not by the media +Subtitles are drawn by us (labels overlaid on the video), not by the media backend: that keeps every produced language selectable and renders exactly what the .srt on disk says — including edits the user just made. Qt 6 ships the FFmpeg media backend, so mkv/mp4/m4a all play. + +Display rules: +- One subtitle track: shown as-is. Two: stacked, primary over secondary. + More than two: the selectors in the control bar pick which (defaults: + the language being viewed + none). +- Overlong cues are split at word boundaries into chunks of at most + ``MAX_CUE_CHARS`` characters, and the chunks share the cue's time span + evenly — long paragraphs page through instead of flooding the screen. """ from __future__ import annotations @@ -17,6 +25,7 @@ from PySide6.QtMultimediaWidgets import QVideoWidget from PySide6.QtWidgets import ( QApplication, + QComboBox, QDialog, QGridLayout, QHBoxLayout, @@ -35,6 +44,10 @@ Cue = tuple[int, int, str] # start_ms, end_ms, text +MAX_CUE_CHARS = 100 +SKIP_MS = 10_000 +RATES = (0.25, 0.5, 1.0, 1.5, 2.0, 2.5, 3.0) + def timestamp_ms(text: str) -> int: """`00:01:02,345` (or `.345`) → milliseconds; 0 when unparsable.""" @@ -45,11 +58,50 @@ def timestamp_ms(text: str) -> int: return ((hours * 60 + minutes) * 60 + seconds) * 1000 + ms -def build_cues(content: str) -> list[Cue]: +def split_text(text: str, max_chars: int = MAX_CUE_CHARS) -> list[str]: + """Chunks of at most ``max_chars``, never breaking a word. + + A single word longer than the limit (typical for CJK text, which has no + spaces) is hard-cut — any cut point is acceptable there. + """ + flat = " ".join(text.split()) + if len(flat) <= max_chars: + return [flat] if flat else [] + + chunks: list[str] = [] + current = "" + for word in flat.split(" "): + candidate = f"{current} {word}" if current else word + if len(candidate) <= max_chars: + current = candidate + continue + if current: + chunks.append(current) + current = word + while len(current) > max_chars: # oversized single word: hard cut + chunks.append(current[:max_chars]) + current = current[max_chars:] + if current: + chunks.append(current) + return chunks + + +def build_cues(content: str, max_chars: int = MAX_CUE_CHARS) -> list[Cue]: cues: list[Cue] = [] for block in parse_srt(content): start_raw, _, end_raw = block.timestamp.partition("-->") - cues.append((timestamp_ms(start_raw), timestamp_ms(end_raw), block.text)) + start = timestamp_ms(start_raw) + end = timestamp_ms(end_raw) + chunks = split_text(block.text, max_chars) + if not chunks: + continue + # Chunks share the cue's span evenly, so a long paragraph pages + # through at a steady rhythm instead of sitting there for 20s. + span = max(0, end - start) + for i, chunk in enumerate(chunks): + piece_start = start + span * i // len(chunks) + piece_end = start + span * (i + 1) // len(chunks) + cues.append((piece_start, piece_end, chunk)) cues.sort(key=lambda c: (c[0], c[1])) return cues @@ -74,25 +126,28 @@ def format_ms(ms: int) -> str: class PlayerDialog(QDialog): - def __init__(self, parent, window, video_path: str, srt_path: str | None): + def __init__(self, parent, window, video_path: str, + tracks: dict[str, str] | None = None): + """``tracks``: display label → .srt path, in preference order.""" super().__init__(parent) self.window_ref = window self.setWindowTitle(Path(video_path).name) screen = QApplication.primaryScreen().availableGeometry() self.resize( min(920, int(screen.width() * 0.8)), - min(600, int(screen.height() * 0.8)), + min(620, int(screen.height() * 0.8)), ) - self.cues: list[Cue] = [] - if srt_path: + self.tracks: dict[str, list[Cue]] = {} + for label, path in (tracks or {}).items(): try: - self.cues = build_cues( - Path(srt_path).read_text(encoding="utf-8", errors="replace") + self.tracks[label] = build_cues( + Path(path).read_text(encoding="utf-8", errors="replace") ) except Exception: - pass - self._current_text = "" + continue + self._active: list[list[Cue]] = [] + self._current: list[str] = ["", ""] self._dragging = False self.player = QMediaPlayer(self) @@ -105,14 +160,22 @@ def __init__(self, parent, window, video_path: str, srt_path: str | None): video.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) self.player.setVideoOutput(video) - self.subtitle = QLabel("") - self.subtitle.setWordWrap(True) - self.subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.subtitle.setStyleSheet( - "background: rgba(0, 0, 0, 168); color: white; font-size: 16px;" - "padding: 6px 14px; border-radius: 8px; margin-bottom: 26px;" + sub_style = ( + "background: rgba(0, 0, 0, 168); color: white; font-size: {}px;" + "padding: 4px 12px; border-radius: 7px;" ) - self.subtitle.hide() + self.sub_labels = [QLabel(""), QLabel("")] + for i, label in enumerate(self.sub_labels): + label.setWordWrap(True) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + label.setStyleSheet(sub_style.format(16 if i == 0 else 14)) + label.hide() + sub_stack = QWidget() + sub_box = QVBoxLayout(sub_stack) + sub_box.setContentsMargins(0, 0, 0, 22) + sub_box.setSpacing(4) + for label in self.sub_labels: + sub_box.addWidget(label, 0, Qt.AlignmentFlag.AlignHCenter) stage = QWidget() stage.setStyleSheet("background: black;") @@ -120,13 +183,19 @@ def __init__(self, parent, window, video_path: str, srt_path: str | None): grid.setContentsMargins(0, 0, 0, 0) grid.addWidget(video, 0, 0) grid.addWidget( - self.subtitle, 0, 0, + sub_stack, 0, 0, Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignHCenter, ) + # Controls: skip / play / skip · slider · time · rate · subtitles + self.back_btn = QPushButton("⏪ 10") + self.back_btn.clicked.connect(lambda: self._skip(-SKIP_MS)) self.play_btn = QPushButton("⏸") self.play_btn.setFixedWidth(44) self.play_btn.clicked.connect(self._toggle) + self.fwd_btn = QPushButton("10 ⏩") + self.fwd_btn.clicked.connect(lambda: self._skip(SKIP_MS)) + self.slider = QSlider(Qt.Orientation.Horizontal) self.slider.setRange(0, 0) self.slider.sliderPressed.connect(lambda: setattr(self, "_dragging", True)) @@ -134,12 +203,42 @@ def __init__(self, parent, window, video_path: str, srt_path: str | None): self.slider.sliderMoved.connect(self._preview_position) self.time_label = subtext("0:00 / 0:00") + self.rate_combo = QComboBox() + for rate in RATES: + self.rate_combo.addItem(f"{rate:g}x", rate) + self.rate_combo.setCurrentIndex(self.rate_combo.findData(1.0)) + self.rate_combo.currentIndexChanged.connect( + lambda i: self.player.setPlaybackRate(self.rate_combo.itemData(i)) + ) + + track_labels = list(self.tracks) + none_label = window.t("gui.sub_none") if window is not None else "—" + self.sub_combos = [QComboBox(), QComboBox()] + for slot, combo in enumerate(self.sub_combos): + combo.addItem(none_label, "") + for label in track_labels: + combo.addItem(label, label) + combo.currentIndexChanged.connect(self._sync_tracks) + # Defaults: first track on top; the second stacks below only when + # exactly two exist — with more, the user picks (你选). + if track_labels: + self.sub_combos[0].setCurrentIndex(1) + if len(track_labels) == 2: + self.sub_combos[1].setCurrentIndex(2) + for combo in self.sub_combos: + combo.setVisible(len(track_labels) >= 2) + controls = QHBoxLayout() controls.setContentsMargins(12, 8, 12, 10) - controls.setSpacing(10) + controls.setSpacing(8) + controls.addWidget(self.back_btn) controls.addWidget(self.play_btn) + controls.addWidget(self.fwd_btn) controls.addWidget(self.slider, 1) controls.addWidget(self.time_label) + controls.addWidget(self.rate_combo) + controls.addWidget(self.sub_combos[0]) + controls.addWidget(self.sub_combos[1]) root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) @@ -147,6 +246,7 @@ def __init__(self, parent, window, video_path: str, srt_path: str | None): root.addWidget(stage, 1) root.addLayout(controls) + self._sync_tracks() self.player.positionChanged.connect(self._on_position) self.player.durationChanged.connect(lambda d: self.slider.setRange(0, int(d))) self.player.playbackStateChanged.connect(self._on_state) @@ -155,12 +255,26 @@ def __init__(self, parent, window, video_path: str, srt_path: str | None): # ------------------------------------------------------------------ # + def _sync_tracks(self) -> None: + self._active = [ + self.tracks.get(combo.currentData() or "", []) + for combo in self.sub_combos + ] + self._current = ["", ""] + self._update_subtitles(int(self.player.position())) + def _toggle(self) -> None: if self.player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: self.player.pause() else: self.player.play() + def _skip(self, delta_ms: int) -> None: + target = int(self.player.position()) + delta_ms + target = max(0, min(target, int(self.player.duration()))) + self.player.setPosition(target) + self._update_subtitles(target) + def _on_state(self, state) -> None: playing = state == QMediaPlayer.PlaybackState.PlayingState self.play_btn.setText("⏸" if playing else "▶") @@ -170,27 +284,28 @@ def _seek_released(self) -> None: self.player.setPosition(self.slider.value()) def _preview_position(self, ms: int) -> None: - self._update_subtitle(ms) + self._update_subtitles(ms) self._update_time(ms) def _on_position(self, ms: int) -> None: if not self._dragging: self.slider.setValue(int(ms)) self._update_time(int(ms)) - self._update_subtitle(int(ms)) + self._update_subtitles(int(ms)) def _update_time(self, ms: int) -> None: self.time_label.setText( f"{format_ms(ms)} / {format_ms(int(self.player.duration()))}" ) - def _update_subtitle(self, ms: int) -> None: - text = cue_at(self.cues, ms) - if text == self._current_text: - return - self._current_text = text - self.subtitle.setText(text) - self.subtitle.setVisible(bool(text)) + def _update_subtitles(self, ms: int) -> None: + for i, cues in enumerate(self._active[:2]): + text = cue_at(cues, ms) if cues else "" + if text == self._current[i]: + continue + self._current[i] = text + self.sub_labels[i].setText(text) + self.sub_labels[i].setVisible(bool(text)) def closeEvent(self, event) -> None: # noqa: N802 self.player.stop() diff --git a/src/scripto/i18n/en.py b/src/scripto/i18n/en.py index 6dbda22..d4bdc8d 100644 --- a/src/scripto/i18n/en.py +++ b/src/scripto/i18n/en.py @@ -167,6 +167,7 @@ "gui.save": "Save", "gui.cancel": "Cancel", "gui.play": "Play", + "gui.sub_none": "No subtitle", "gui.player_missing_video": "The source video no longer exists on disk.", "gui.history_translate_to": "Translate to {lang}", "gui.history_translating": "Translating to {lang}…", diff --git a/src/scripto/i18n/zh.py b/src/scripto/i18n/zh.py index c459578..015bf07 100644 --- a/src/scripto/i18n/zh.py +++ b/src/scripto/i18n/zh.py @@ -167,6 +167,7 @@ "gui.save": "保存", "gui.cancel": "取消", "gui.play": "播放", + "gui.sub_none": "无字幕", "gui.player_missing_video": "源视频已不在磁盘上。", "gui.history_translate_to": "翻译成{lang}", "gui.history_translating": "正在翻译成{lang}…", diff --git a/tests/test_player.py b/tests/test_player.py index 69cf013..ea7c45a 100644 --- a/tests/test_player.py +++ b/tests/test_player.py @@ -39,3 +39,37 @@ def test_format_ms_hours_and_minutes(): assert format_ms(0) == "0:00" assert format_ms(65_000) == "1:05" assert format_ms(3_600_000) == "1:00:00" + + +def test_split_text_keeps_words_whole(): + from scripto.gui_qt.player import split_text + + text = "alpha beta gamma delta epsilon" + chunks = split_text(text, max_chars=12) + assert chunks == ["alpha beta", "gamma delta", "epsilon"] + assert all(len(c) <= 12 for c in chunks) + assert " ".join(chunks) == text # nothing lost, no word broken + + +def test_split_text_hard_cuts_spaceless_cjk(): + from scripto.gui_qt.player import split_text + + text = "这是一段没有空格的很长的中文字幕内容" + chunks = split_text(text, max_chars=8) + assert all(len(c) <= 8 for c in chunks) + assert "".join(chunks) == text + + +def test_long_cue_splits_time_evenly(): + from scripto.gui_qt.player import build_cues, cue_at + + long_text = " ".join(f"word{i:02d}" for i in range(30)) # 209 chars + srt = f"1\n00:00:10,000 --> 00:00:16,000\n{long_text}\n" + cues = build_cues(srt, max_chars=80) + assert len(cues) == 3 + starts = [c[0] for c in cues] + ends = [c[1] for c in cues] + assert starts == [10000, 12000, 14000] # 6s span shared evenly by 3 + assert ends == [12000, 14000, 16000] # contiguous, no gaps + assert cue_at(cues, 11000).startswith("word00") + assert cue_at(cues, 15999).endswith("word29")