Files
WeAreSyncing/player_panel.py
T
ThePendulum 8c34a0f0c2 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).
2026-07-26 03:58:23 +02:00

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()