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).
177 lines
6.4 KiB
Python
177 lines
6.4 KiB
Python
from typing import Optional
|
|
|
|
from PyQt6.QtCore import Qt, pyqtSignal
|
|
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QCheckBox, QSlider
|
|
|
|
from player_widget import PlayerWidget
|
|
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 PlayerPanel(QWidget):
|
|
"""A self-contained video monitor: player + its own timeline + its own
|
|
transport row (Kdenlive-style project/clip monitor). Two of these are
|
|
used side by side as tabs (Source, Preview) in main.py, each with an
|
|
independent PlayerWidget/mpv instance and independent playback state --
|
|
switching tabs switches which monitor's timeline and transport you're
|
|
looking at, not just the video frame.
|
|
"""
|
|
|
|
fileDropped = pyqtSignal(str)
|
|
|
|
def __init__(self, show_marker_strip: bool = False, parent=None):
|
|
super().__init__(parent)
|
|
|
|
self.player = PlayerWidget()
|
|
self.player.setMinimumHeight(320)
|
|
self.player.fileDropped.connect(self.fileDropped)
|
|
self.player.rightClicked.connect(self.toggle_play)
|
|
|
|
self._loaded = False
|
|
self._slider_being_dragged = False
|
|
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|
layout.addWidget(self.player, stretch=1)
|
|
|
|
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: Optional[MarkerStrip] = None
|
|
if show_marker_strip:
|
|
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 = 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(lambda checked: setattr(self.player.mpv, "mute", checked))
|
|
transport.addWidget(self.mute_checkbox)
|
|
|
|
layout.addLayout(transport)
|
|
|
|
# -- playback -----------------------------------------------------
|
|
|
|
def load(self, path: str):
|
|
self.player.load(path)
|
|
self._loaded = True
|
|
duration = self.player.mpv.duration or 0.0
|
|
self.seek_slider.setRange(0, int(duration * 1000))
|
|
self.duration_label.setText(format_timestamp(duration))
|
|
if self.marker_strip is not None:
|
|
self.marker_strip.set_duration(duration)
|
|
self.marker_strip.clear()
|
|
self.play_button.setText("Play")
|
|
self.update_position()
|
|
|
|
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 force_play(self):
|
|
self.player.play()
|
|
self.play_button.setText("Pause")
|
|
|
|
def step_frame(self, n: int):
|
|
if not self._loaded:
|
|
return
|
|
self.player.pause()
|
|
self.play_button.setText("Play")
|
|
self.player.step_frame(n)
|
|
self.update_position()
|
|
|
|
def seek_relative(self, delta_seconds: float):
|
|
# Unlike frame-stepping (which always pauses -- you're precisely
|
|
# inspecting a frame), a coarse skip is meant to preserve whichever
|
|
# play/pause state the user was already in; seeking doesn't change
|
|
# mpv's pause state on its own, so not touching it here is enough.
|
|
if not self._loaded:
|
|
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()
|
|
|
|
def _on_slider_moved(self, value: int):
|
|
self.player.seek(value / 1000.0)
|
|
self.update_position()
|
|
|
|
def _on_slider_released(self):
|
|
self._slider_being_dragged = False
|
|
self.update_position()
|
|
|
|
def _on_marker_clicked(self, time_sec: float):
|
|
self.player.seek(time_sec)
|
|
self.update_position()
|
|
|
|
def update_position(self):
|
|
if not self._loaded:
|
|
return
|
|
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)
|
|
|
|
def shutdown(self):
|
|
self.player.shutdown()
|