Files
ThePendulum acd63f570d Polish transport spacing, drop shortcut hints, add Detect Next Clap button
- PlayerPanel now leaves an 8px bottom margin so the transport row
  isn't flush against the tab's edge.
- Frame-step buttons read '⏮ Frame' / 'Frame ⏭' instead of
  '⏮ Frame (,)' / 'Frame ⏭ (.)' -- the shortcuts still work, just not
  spelled out on the buttons.
- Detect Next Clap is now a button in the Sync markers panel next to
  Detect All Claps (still also in the Tools menu), kept in sync with
  its menu action's enabled state the same way Detect All Claps was.
2026-07-26 04:19:55 +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, 8)
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()