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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user