UI/UX polish: Tools menu, clickable timeline, live drag-seek, drag-and-drop load

- Clap detection moved from inline buttons to a Tools menu, plain labels
  (no more '(whole file)' / '(from cursor)' parentheticals) -- status
  messages moved to the window's status bar since the dedicated label
  widget went away with the button row.
- ClickableSlider subclass: clicking anywhere along the seek track jumps
  straight there instead of QSlider's default page-by-a-step behavior;
  reposition-then-delegate-to-super keeps native drag behavior intact.
- Dragging now seeks live via sliderMoved instead of waiting for
  sliderReleased.
- PlayerWidget accepts drag-and-drop of a local file to load it.

Verified with real Qt events (QMouseEvent clicks, a QDropEvent, a real
mouse click via xdotool opening the Tools menu on an actual running
window) rather than just calling the underlying methods directly.
This commit is contained in:
ThePendulum
2026-07-26 01:55:32 +02:00
parent f7c15e924c
commit 5a6c4e6e84
4 changed files with 166 additions and 40 deletions
+50 -35
View File
@@ -22,6 +22,24 @@ def format_timestamp(seconds: float) -> str:
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):
finished_ok = pyqtSignal(object) # ExportResult
finished_err = pyqtSignal(str, str) # message, stderr
@@ -78,6 +96,7 @@ class MainWindow(QMainWindow):
self.player = PlayerWidget()
self.player.setMinimumHeight(400)
self.player.fileDropped.connect(self._load)
self._build_ui()
self._build_menu()
@@ -130,9 +149,10 @@ class MainWindow(QMainWindow):
self.marker_strip.markerClicked.connect(self._on_marker_clicked)
slider_stack.addWidget(self.marker_strip)
self.seek_slider = QSlider(Qt.Orientation.Horizontal)
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)
@@ -141,25 +161,6 @@ class MainWindow(QMainWindow):
seek_row.addWidget(self.duration_label)
layout.addLayout(seek_row)
# Clap/onset auto-detect row
detect_group = QGroupBox("Clap detection")
detect_layout = QHBoxLayout(detect_group)
self.detect_all_button = QPushButton("Detect All Claps (whole file)")
self.detect_all_button.setEnabled(False)
self.detect_all_button.clicked.connect(self._on_detect_all_clicked)
detect_layout.addWidget(self.detect_all_button)
self.detect_next_button = QPushButton("Detect Next Clap (from cursor)")
self.detect_next_button.setEnabled(False)
self.detect_next_button.clicked.connect(self._on_detect_next_clicked)
detect_layout.addWidget(self.detect_next_button)
self.detect_status_label = QLabel("")
detect_layout.addWidget(self.detect_status_label, stretch=1)
layout.addWidget(detect_group)
# Marker row
marker_group = QGroupBox("Sync markers")
marker_layout = QHBoxLayout(marker_group)
@@ -214,6 +215,18 @@ class MainWindow(QMainWindow):
self.toggle_log_action.toggled.connect(self.log_view.setVisible)
view_menu.addAction(self.toggle_log_action)
tools_menu = self.menuBar().addMenu("&Tools")
self.detect_all_action = QAction("Detect All Claps", self)
self.detect_all_action.setEnabled(False)
self.detect_all_action.triggered.connect(self._on_detect_all_clicked)
tools_menu.addAction(self.detect_all_action)
self.detect_next_action = QAction("Detect Next Clap", self)
self.detect_next_action.setEnabled(False)
self.detect_next_action.triggered.connect(self._on_detect_next_clicked)
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))
@@ -246,9 +259,9 @@ class MainWindow(QMainWindow):
self.marker_strip.set_duration(duration)
self.marker_strip.clear()
self.detect_status_label.setText("")
self.detect_all_button.setEnabled(True)
self.detect_next_button.setEnabled(True)
self.statusBar().clearMessage()
self.detect_all_action.setEnabled(True)
self.detect_next_action.setEnabled(True)
def _toggle_play(self):
if self.player.mpv.pause:
@@ -267,9 +280,11 @@ class MainWindow(QMainWindow):
def _on_mute_toggled(self, checked: bool):
self.player.mpv.mute = checked
def _on_slider_moved(self, value: int):
self.player.seek(value / 1000.0)
self._update_position_ui()
def _on_slider_released(self):
target_sec = self.seek_slider.value() / 1000.0
self.player.seek(target_sec)
self._slider_being_dragged = False
self._update_position_ui()
@@ -320,9 +335,9 @@ class MainWindow(QMainWindow):
def _start_detection(self, start_offset: float, max_results, label: str):
if self.input_path is None:
return
self.detect_all_button.setEnabled(False)
self.detect_next_button.setEnabled(False)
self.detect_status_label.setText(label)
self.detect_all_action.setEnabled(False)
self.detect_next_action.setEnabled(False)
self.statusBar().showMessage(label)
self._detect_worker = DetectWorker(self.input_path, start_offset=start_offset, max_results=max_results)
self._detect_worker.finished_ok.connect(self._on_detect_done)
@@ -330,26 +345,26 @@ class MainWindow(QMainWindow):
self._detect_worker.start()
def _on_detect_done(self, events: list):
self.detect_all_button.setEnabled(True)
self.detect_next_button.setEnabled(True)
self.detect_all_action.setEnabled(True)
self.detect_next_action.setEnabled(True)
existing = set(self.marker_strip.markers())
existing.update(e.time_sec for e in events)
self.marker_strip.set_markers(list(existing))
if not events:
self.detect_status_label.setText("No claps found.")
self.statusBar().showMessage("No claps found.", 5000)
self.log_view.appendPlainText("Clap detection: no onsets found.")
return
self.detect_status_label.setText(f"Found {len(events)} clap(s) — click a marker above the timeline to jump to it.")
self.statusBar().showMessage(f"Found {len(events)} clap(s) — click a marker above the timeline to jump to it.", 8000)
for e in events:
self.log_view.appendPlainText(f"Clap detected at {format_timestamp(e.time_sec)} (strength {e.strength:.2f})")
def _on_detect_failed(self, message: str):
self.detect_all_button.setEnabled(True)
self.detect_next_button.setEnabled(True)
self.detect_status_label.setText("Detection failed (see log).")
self.detect_all_action.setEnabled(True)
self.detect_next_action.setEnabled(True)
self.statusBar().showMessage("Detection failed (see log).", 8000)
self.log_view.appendPlainText(f"Clap detection FAILED: {message}")
# -- export -----------------------------------------------------