Give each tab its own full timeline and transport (PlayerPanel refactor)

Extracts a new PlayerPanel widget (video + its own seek slider/marker
strip + its own centered frame-step/play/mute transport row) into
player_panel.py. Both Source and Preview tabs now use one, so
switching tabs switches a complete, independent monitor -- its own
scrubber, its own play state, its own frame-stepping -- not just the
video frame with a single shared play button underneath.

main.py's global keyboard shortcuts (,/./space/left/right) now
dispatch to whichever panel is active via a small _active_panel()
helper, so they follow the same 'whichever monitor you're looking at'
rule as the on-screen controls.

MarkerStrip (clap detection) and the Mark Audio/Video/offset/export
row stay outside the tabs, at the MainWindow level -- marking and
exporting are inherently about the source file, not the preview clip,
so only the timeline+transport moved, not the whole marking workflow.

Updated every existing smoke test for the new win.source_panel.* /
win.preview_panel.* structure (previously flat win.player,
win.seek_slider, etc.), and added a new check to
smoke_test_preview.py confirming the Preview tab's slider is a
genuinely separate, functional widget -- dragging it seeks the preview
player without moving the source player, which is the actual thing
this change was for.

Verified the full suite on the virtual Xvfb display, plus a live
click-through (mark both events, build preview, confirm the Preview
tab shows its own populated timeline/transport, not the source's).
This commit is contained in:
ThePendulum
2026-07-26 03:58:23 +02:00
parent 0a8f9f45bd
commit 8c34a0f0c2
10 changed files with 309 additions and 258 deletions
+40 -193
View File
@@ -8,38 +8,14 @@ from PyQt6.QtCore import Qt, QTimer, QThread, pyqtSignal
from PyQt6.QtGui import QShortcut, QKeySequence, QAction
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QPushButton, QLabel, QSlider, QCheckBox, QDoubleSpinBox, QFileDialog,
QPushButton, QLabel, QDoubleSpinBox, QFileDialog,
QMessageBox, QPlainTextEdit, QGroupBox, QTabWidget,
)
from player_widget import PlayerWidget
from player_panel import PlayerPanel, format_timestamp
from sync_logic import compute_offset, SyncOffset
from ffmpeg_export import export_synced, FfmpegExportError, ExportResult
from clap_detection import detect_claps, ClapDetectionUnavailable, ClapEvent
from marker_strip import MarkerStrip
def format_timestamp(seconds: float) -> str:
m, s = divmod(max(seconds, 0.0), 60)
return f"{int(m):02d}:{s:06.3f}"
class ClickableSlider(QSlider):
"""QSlider normally only jumps to the clicked position if you click
exactly on the handle; clicking elsewhere in the groove pages by a
fixed step. Override so any click along the track jumps straight
there, then let the base class take over so dragging still works
(the handle is now under the cursor, so its own drag tracking picks
up seamlessly).
"""
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton and self.maximum() > self.minimum():
ratio = min(max(event.position().x() / max(self.width(), 1), 0.0), 1.0)
value = round(self.minimum() + ratio * (self.maximum() - self.minimum()))
self.setValue(value)
self.sliderMoved.emit(value)
super().mousePressEvent(event)
class ExportWorker(QThread):
@@ -100,22 +76,16 @@ class MainWindow(QMainWindow):
self._export_worker: Optional[ExportWorker] = None
self._detect_worker: Optional[DetectWorker] = None
self._preview_worker: Optional[ExportWorker] = None
self._preview_temp_path: Optional[str] = None # currently loaded in preview_player
self._preview_temp_path: Optional[str] = None # currently loaded in preview_panel
self._pending_preview_path: Optional[str] = None # being built by _preview_worker
self._slider_being_dragged = False
self.player = PlayerWidget()
self.player.setMinimumHeight(400)
self.player.fileDropped.connect(self._load)
self.player.rightClicked.connect(self._toggle_play)
# Two independent monitors (Kdenlive-style project/clip monitor
# tabs): each is a full player with its own timeline and transport,
# not just a shared video frame with one set of controls.
self.source_panel = PlayerPanel(show_marker_strip=True)
self.source_panel.fileDropped.connect(self._load)
# Independent second mpv instance for the Preview tab (Kdenlive-style
# project/clip monitor tabs) -- kept alive for the whole session
# rather than created per-preview, so switching tabs back and forth
# doesn't tear down and rebuild an mpv instance each time.
self.preview_player = PlayerWidget()
self.preview_player.setMinimumHeight(320)
self.preview_player.rightClicked.connect(self._toggle_preview_play)
self.preview_panel = PlayerPanel(show_marker_strip=False)
self._build_ui()
self._build_menu()
@@ -128,80 +98,18 @@ class MainWindow(QMainWindow):
# -- UI construction ---------------------------------------------------
def _active_panel(self) -> PlayerPanel:
return self.source_panel if self.video_tabs.currentIndex() == 0 else self.preview_panel
def _build_ui(self):
central = QWidget()
layout = QVBoxLayout(central)
self.video_tabs = QTabWidget()
self.video_tabs.addTab(self.player, "Source")
preview_tab = QWidget()
preview_tab_layout = QVBoxLayout(preview_tab)
preview_tab_layout.setContentsMargins(0, 0, 0, 0)
preview_tab_layout.addWidget(self.preview_player, stretch=1)
preview_controls = QHBoxLayout()
self.preview_play_button = QPushButton("Play")
self.preview_play_button.clicked.connect(self._toggle_preview_play)
preview_controls.addWidget(self.preview_play_button)
self.preview_position_label = QLabel("00:00.000")
preview_controls.addWidget(self.preview_position_label)
preview_controls.addStretch(1)
preview_tab_layout.addLayout(preview_controls)
self.video_tabs.addTab(preview_tab, "Preview")
self.video_tabs.addTab(self.source_panel, "Source")
self.video_tabs.addTab(self.preview_panel, "Preview")
layout.addWidget(self.video_tabs, stretch=1)
# Timeline directly under the video (LosslessCut-style), transport
# controls below that.
seek_row = QHBoxLayout()
self.position_label = QLabel("00:00.000")
seek_row.addWidget(self.position_label)
slider_stack = QVBoxLayout()
slider_stack.setSpacing(0)
self.marker_strip = MarkerStrip()
self.marker_strip.markerClicked.connect(self._on_marker_clicked)
slider_stack.addWidget(self.marker_strip)
self.seek_slider = ClickableSlider(Qt.Orientation.Horizontal)
self.seek_slider.setRange(0, 0)
self.seek_slider.sliderPressed.connect(lambda: setattr(self, "_slider_being_dragged", True))
self.seek_slider.sliderMoved.connect(self._on_slider_moved)
self.seek_slider.sliderReleased.connect(self._on_slider_released)
slider_stack.addWidget(self.seek_slider)
seek_row.addLayout(slider_stack, stretch=1)
self.duration_label = QLabel("00:00.000")
seek_row.addWidget(self.duration_label)
layout.addLayout(seek_row)
# Transport row: frame-step/play controls centered as a group, mute
# pinned to the right. Load File now lives in the File menu.
transport = QHBoxLayout()
transport.addStretch(1)
self.prev_frame_button = QPushButton("⏮ Frame (,)")
self.prev_frame_button.clicked.connect(lambda: self._step_frame(-1))
transport.addWidget(self.prev_frame_button)
self.play_button = QPushButton("Play")
self.play_button.clicked.connect(self._toggle_play)
transport.addWidget(self.play_button)
self.next_frame_button = QPushButton("Frame ⏭ (.)")
self.next_frame_button.clicked.connect(lambda: self._step_frame(1))
transport.addWidget(self.next_frame_button)
transport.addStretch(1)
self.mute_checkbox = QCheckBox("Mute audio")
self.mute_checkbox.toggled.connect(self._on_mute_toggled)
transport.addWidget(self.mute_checkbox)
layout.addLayout(transport)
# Marker row
marker_group = QGroupBox("Sync markers")
marker_layout = QHBoxLayout(marker_group)
@@ -288,13 +196,17 @@ class MainWindow(QMainWindow):
tools_menu.addAction(self.detect_next_action)
def _wire_shortcuts(self):
QShortcut(QKeySequence(","), self, activated=lambda: self._step_frame(-1))
QShortcut(QKeySequence("."), self, activated=lambda: self._step_frame(1))
QShortcut(QKeySequence(Qt.Key.Key_Space), self, activated=self._toggle_play)
# These act on whichever tab (Source/Preview) is currently active --
# each is a fully independent monitor with its own transport, so the
# keyboard shortcuts follow the same "whichever one you're looking
# at" rule as the on-screen buttons.
QShortcut(QKeySequence(","), self, activated=lambda: self._active_panel().step_frame(-1))
QShortcut(QKeySequence("."), self, activated=lambda: self._active_panel().step_frame(1))
QShortcut(QKeySequence(Qt.Key.Key_Space), self, activated=lambda: self._active_panel().toggle_play())
# QShortcut auto-repeats by default while the key is held, so this
# keeps skipping for as long as the arrow key is down.
QShortcut(QKeySequence(Qt.Key.Key_Left), self, activated=lambda: self._seek_relative(-5.0))
QShortcut(QKeySequence(Qt.Key.Key_Right), self, activated=lambda: self._seek_relative(5.0))
QShortcut(QKeySequence(Qt.Key.Key_Left), self, activated=lambda: self._active_panel().seek_relative(-5.0))
QShortcut(QKeySequence(Qt.Key.Key_Right), self, activated=lambda: self._active_panel().seek_relative(5.0))
# -- transport -----------------------------------------------------
@@ -304,15 +216,15 @@ class MainWindow(QMainWindow):
self._load(path)
def _load(self, path: str):
# player.load() blocks the GUI thread until mpv reports the file's
# duration, so show the status message and force one repaint before
# starting it -- otherwise Qt wouldn't paint the message until after
# the blocking call already returned.
# source_panel.load() blocks the GUI thread until mpv reports the
# file's duration, so show the status message and force one repaint
# before starting it -- otherwise Qt wouldn't paint the message
# until after the blocking call already returned.
self.statusBar().showMessage(f"Loading {Path(path).name}…")
QApplication.processEvents()
self.input_path = path
self.player.load(path)
self.source_panel.load(path)
self.video_mark = None
self.audio_mark = None
self.offset = None
@@ -324,13 +236,7 @@ class MainWindow(QMainWindow):
self.override_spinbox.setEnabled(False)
self.log_view.clear()
duration = self.player.mpv.duration or 0.0
self.seek_slider.setRange(0, int(duration * 1000))
self.duration_label.setText(format_timestamp(duration))
self.setWindowTitle(f"A/V Sync Marker — {Path(path).name}")
self.marker_strip.set_duration(duration)
self.marker_strip.clear()
self.statusBar().showMessage(f"Loaded {Path(path).name}", 4000)
self.detect_all_action.setEnabled(True)
self.detect_all_button.setEnabled(True)
@@ -342,73 +248,19 @@ class MainWindow(QMainWindow):
self._preview_temp_path = None
self.video_tabs.setCurrentIndex(0)
def _toggle_play(self):
if self.player.mpv.pause:
self.player.play()
self.play_button.setText("Pause")
else:
self.player.pause()
self.play_button.setText("Play")
def _step_frame(self, n: int):
self.player.pause()
self.play_button.setText("Play")
self.player.step_frame(n)
self._update_position_ui()
def _on_mute_toggled(self, checked: bool):
self.player.mpv.mute = checked
def _toggle_preview_play(self):
if self.preview_player.mpv.pause:
self.preview_player.play()
self.preview_play_button.setText("Pause")
else:
self.preview_player.pause()
self.preview_play_button.setText("Play")
def _seek_relative(self, delta_seconds: float):
# Unlike frame-stepping (which always pauses -- you're precisely
# inspecting a frame), a 5s skip is a coarse nudge while scrubbing
# around; seeking doesn't change mpv's pause state on its own, so
# just not touching play/pause here is enough to preserve whichever
# state the user was already in.
if self.input_path is None:
return
duration = self.player.mpv.duration or 0.0
target = min(max(self.player.get_position() + delta_seconds, 0.0), duration)
self.player.seek(target)
self._update_position_ui()
def _on_slider_moved(self, value: int):
self.player.seek(value / 1000.0)
self._update_position_ui()
def _on_slider_released(self):
self._slider_being_dragged = False
self._update_position_ui()
def _update_position_ui(self):
if self.input_path is not None:
pos = self.player.get_position()
self.position_label.setText(format_timestamp(pos))
if not self._slider_being_dragged:
self.seek_slider.blockSignals(True)
self.seek_slider.setValue(int(pos * 1000))
self.seek_slider.blockSignals(False)
if self._preview_temp_path is not None:
self.preview_position_label.setText(format_timestamp(self.preview_player.get_position()))
self.source_panel.update_position()
self.preview_panel.update_position()
# -- markers / offset -----------------------------------------------
def _mark_video(self):
self.video_mark = self.player.get_position()
self.video_mark = self.source_panel.player.get_position()
self.video_mark_label.setText(f"video: {format_timestamp(self.video_mark)}")
self._recompute_offset()
def _mark_audio(self):
self.audio_mark = self.player.get_position()
self.audio_mark = self.source_panel.player.get_position()
self.audio_mark_label.setText(f"audio: {format_timestamp(self.audio_mark)}")
self._recompute_offset()
@@ -466,15 +318,14 @@ class MainWindow(QMainWindow):
if result.stderr.strip():
self.log_view.appendPlainText("ffmpeg preview log:\n" + result.stderr)
# The preview player is long-lived (one mpv instance for the whole
# The preview panel is long-lived (one mpv instance for the whole
# session), so swap files instead of spinning up a new player each
# time: load the new clip first, then delete the old temp file --
# only safe to unlink it once mpv is no longer holding it open.
old_path = self._preview_temp_path
new_path = self._pending_preview_path
self.preview_player.load(new_path)
self.preview_player.play()
self.preview_play_button.setText("Pause")
self.preview_panel.load(new_path)
self.preview_panel.force_play()
self._preview_temp_path = new_path
self._pending_preview_path = None
self.video_tabs.setCurrentIndex(1)
@@ -493,15 +344,11 @@ class MainWindow(QMainWindow):
# -- clap detection -----------------------------------------------------
def _on_marker_clicked(self, time_sec: float):
self.player.seek(time_sec)
self._update_position_ui()
def _on_detect_all_clicked(self):
self._start_detection(start_offset=0.0, max_results=None, label="Scanning whole file for claps…")
def _on_detect_next_clicked(self):
cursor = self.player.get_position()
cursor = self.source_panel.player.get_position()
self._start_detection(start_offset=cursor, max_results=1, label=f"Scanning from {format_timestamp(cursor)} for next clap…")
def _start_detection(self, start_offset: float, max_results, label: str):
@@ -522,9 +369,9 @@ class MainWindow(QMainWindow):
self.detect_all_button.setEnabled(True)
self.detect_next_action.setEnabled(True)
existing = set(self.marker_strip.markers())
existing = set(self.source_panel.marker_strip.markers())
existing.update(e.time_sec for e in events)
self.marker_strip.set_markers(list(existing))
self.source_panel.marker_strip.set_markers(list(existing))
if not events:
self.statusBar().showMessage("No claps found.", 5000)
@@ -587,8 +434,8 @@ class MainWindow(QMainWindow):
# get_position() on an already-terminated core and crashes
# (mpv.ShutdownError, observed as a hard crash in testing).
self._position_timer.stop()
self.player.shutdown()
self.preview_player.shutdown()
self.source_panel.shutdown()
self.preview_panel.shutdown()
if self._preview_temp_path and os.path.exists(self._preview_temp_path):
os.unlink(self._preview_temp_path)
super().closeEvent(event)