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