Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions src/scripto/gui_qt/history_page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

# ------------------------------------------------------------------ #
Expand Down
173 changes: 144 additions & 29 deletions src/scripto/gui_qt/player.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,6 +25,7 @@
from PySide6.QtMultimediaWidgets import QVideoWidget
from PySide6.QtWidgets import (
QApplication,
QComboBox,
QDialog,
QGridLayout,
QHBoxLayout,
Expand All @@ -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."""
Expand All @@ -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

Expand All @@ -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)
Expand All @@ -105,48 +160,93 @@ 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;")
grid = QGridLayout(stage)
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))
self.slider.sliderReleased.connect(self._seek_released)
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)
root.setSpacing(0)
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)
Expand All @@ -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 "▶")
Expand All @@ -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()
Expand Down
1 change: 1 addition & 0 deletions src/scripto/i18n/en.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}…",
Expand Down
1 change: 1 addition & 0 deletions src/scripto/i18n/zh.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}…",
Expand Down
34 changes: 34 additions & 0 deletions tests/test_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading