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:
@@ -22,6 +22,24 @@ def format_timestamp(seconds: float) -> str:
|
|||||||
return f"{int(m):02d}:{s:06.3f}"
|
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):
|
class ExportWorker(QThread):
|
||||||
finished_ok = pyqtSignal(object) # ExportResult
|
finished_ok = pyqtSignal(object) # ExportResult
|
||||||
finished_err = pyqtSignal(str, str) # message, stderr
|
finished_err = pyqtSignal(str, str) # message, stderr
|
||||||
@@ -78,6 +96,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self.player = PlayerWidget()
|
self.player = PlayerWidget()
|
||||||
self.player.setMinimumHeight(400)
|
self.player.setMinimumHeight(400)
|
||||||
|
self.player.fileDropped.connect(self._load)
|
||||||
|
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._build_menu()
|
self._build_menu()
|
||||||
@@ -130,9 +149,10 @@ class MainWindow(QMainWindow):
|
|||||||
self.marker_strip.markerClicked.connect(self._on_marker_clicked)
|
self.marker_strip.markerClicked.connect(self._on_marker_clicked)
|
||||||
slider_stack.addWidget(self.marker_strip)
|
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.setRange(0, 0)
|
||||||
self.seek_slider.sliderPressed.connect(lambda: setattr(self, "_slider_being_dragged", True))
|
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)
|
self.seek_slider.sliderReleased.connect(self._on_slider_released)
|
||||||
slider_stack.addWidget(self.seek_slider)
|
slider_stack.addWidget(self.seek_slider)
|
||||||
seek_row.addLayout(slider_stack, stretch=1)
|
seek_row.addLayout(slider_stack, stretch=1)
|
||||||
@@ -141,25 +161,6 @@ class MainWindow(QMainWindow):
|
|||||||
seek_row.addWidget(self.duration_label)
|
seek_row.addWidget(self.duration_label)
|
||||||
layout.addLayout(seek_row)
|
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 row
|
||||||
marker_group = QGroupBox("Sync markers")
|
marker_group = QGroupBox("Sync markers")
|
||||||
marker_layout = QHBoxLayout(marker_group)
|
marker_layout = QHBoxLayout(marker_group)
|
||||||
@@ -214,6 +215,18 @@ class MainWindow(QMainWindow):
|
|||||||
self.toggle_log_action.toggled.connect(self.log_view.setVisible)
|
self.toggle_log_action.toggled.connect(self.log_view.setVisible)
|
||||||
view_menu.addAction(self.toggle_log_action)
|
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):
|
def _wire_shortcuts(self):
|
||||||
QShortcut(QKeySequence(","), self, activated=lambda: self._step_frame(-1))
|
QShortcut(QKeySequence(","), self, activated=lambda: self._step_frame(-1))
|
||||||
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.set_duration(duration)
|
||||||
self.marker_strip.clear()
|
self.marker_strip.clear()
|
||||||
self.detect_status_label.setText("")
|
self.statusBar().clearMessage()
|
||||||
self.detect_all_button.setEnabled(True)
|
self.detect_all_action.setEnabled(True)
|
||||||
self.detect_next_button.setEnabled(True)
|
self.detect_next_action.setEnabled(True)
|
||||||
|
|
||||||
def _toggle_play(self):
|
def _toggle_play(self):
|
||||||
if self.player.mpv.pause:
|
if self.player.mpv.pause:
|
||||||
@@ -267,9 +280,11 @@ class MainWindow(QMainWindow):
|
|||||||
def _on_mute_toggled(self, checked: bool):
|
def _on_mute_toggled(self, checked: bool):
|
||||||
self.player.mpv.mute = checked
|
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):
|
def _on_slider_released(self):
|
||||||
target_sec = self.seek_slider.value() / 1000.0
|
|
||||||
self.player.seek(target_sec)
|
|
||||||
self._slider_being_dragged = False
|
self._slider_being_dragged = False
|
||||||
self._update_position_ui()
|
self._update_position_ui()
|
||||||
|
|
||||||
@@ -320,9 +335,9 @@ class MainWindow(QMainWindow):
|
|||||||
def _start_detection(self, start_offset: float, max_results, label: str):
|
def _start_detection(self, start_offset: float, max_results, label: str):
|
||||||
if self.input_path is None:
|
if self.input_path is None:
|
||||||
return
|
return
|
||||||
self.detect_all_button.setEnabled(False)
|
self.detect_all_action.setEnabled(False)
|
||||||
self.detect_next_button.setEnabled(False)
|
self.detect_next_action.setEnabled(False)
|
||||||
self.detect_status_label.setText(label)
|
self.statusBar().showMessage(label)
|
||||||
|
|
||||||
self._detect_worker = DetectWorker(self.input_path, start_offset=start_offset, max_results=max_results)
|
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)
|
self._detect_worker.finished_ok.connect(self._on_detect_done)
|
||||||
@@ -330,26 +345,26 @@ class MainWindow(QMainWindow):
|
|||||||
self._detect_worker.start()
|
self._detect_worker.start()
|
||||||
|
|
||||||
def _on_detect_done(self, events: list):
|
def _on_detect_done(self, events: list):
|
||||||
self.detect_all_button.setEnabled(True)
|
self.detect_all_action.setEnabled(True)
|
||||||
self.detect_next_button.setEnabled(True)
|
self.detect_next_action.setEnabled(True)
|
||||||
|
|
||||||
existing = set(self.marker_strip.markers())
|
existing = set(self.marker_strip.markers())
|
||||||
existing.update(e.time_sec for e in events)
|
existing.update(e.time_sec for e in events)
|
||||||
self.marker_strip.set_markers(list(existing))
|
self.marker_strip.set_markers(list(existing))
|
||||||
|
|
||||||
if not events:
|
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.")
|
self.log_view.appendPlainText("Clap detection: no onsets found.")
|
||||||
return
|
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:
|
for e in events:
|
||||||
self.log_view.appendPlainText(f"Clap detected at {format_timestamp(e.time_sec)} (strength {e.strength:.2f})")
|
self.log_view.appendPlainText(f"Clap detected at {format_timestamp(e.time_sec)} (strength {e.strength:.2f})")
|
||||||
|
|
||||||
def _on_detect_failed(self, message: str):
|
def _on_detect_failed(self, message: str):
|
||||||
self.detect_all_button.setEnabled(True)
|
self.detect_all_action.setEnabled(True)
|
||||||
self.detect_next_button.setEnabled(True)
|
self.detect_next_action.setEnabled(True)
|
||||||
self.detect_status_label.setText("Detection failed (see log).")
|
self.statusBar().showMessage("Detection failed (see log).", 8000)
|
||||||
self.log_view.appendPlainText(f"Clap detection FAILED: {message}")
|
self.log_view.appendPlainText(f"Clap detection FAILED: {message}")
|
||||||
|
|
||||||
# -- export -----------------------------------------------------
|
# -- export -----------------------------------------------------
|
||||||
|
|||||||
+15
-1
@@ -9,7 +9,7 @@ import time
|
|||||||
|
|
||||||
import mpv
|
import mpv
|
||||||
from PyQt6.QtWidgets import QWidget
|
from PyQt6.QtWidgets import QWidget
|
||||||
from PyQt6.QtCore import Qt
|
from PyQt6.QtCore import Qt, pyqtSignal
|
||||||
|
|
||||||
|
|
||||||
class PlayerWidget(QWidget):
|
class PlayerWidget(QWidget):
|
||||||
@@ -20,10 +20,13 @@ class PlayerWidget(QWidget):
|
|||||||
this exists instead of a QMediaPlayer/HTML5-video-style backend.
|
this exists instead of a QMediaPlayer/HTML5-video-style backend.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
fileDropped = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_DontCreateNativeAncestors)
|
self.setAttribute(Qt.WidgetAttribute.WA_DontCreateNativeAncestors)
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow)
|
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow)
|
||||||
|
self.setAcceptDrops(True)
|
||||||
|
|
||||||
# QApplication resets LC_NUMERIC to the system locale on construction,
|
# QApplication resets LC_NUMERIC to the system locale on construction,
|
||||||
# which undoes python-mpv's own import-time fix and makes libmpv abort
|
# which undoes python-mpv's own import-time fix and makes libmpv abort
|
||||||
@@ -108,3 +111,14 @@ class PlayerWidget(QWidget):
|
|||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
self.mpv.terminate()
|
self.mpv.terminate()
|
||||||
|
|
||||||
|
def dragEnterEvent(self, event):
|
||||||
|
if event.mimeData().hasUrls() and any(u.isLocalFile() for u in event.mimeData().urls()):
|
||||||
|
event.acceptProposedAction()
|
||||||
|
|
||||||
|
def dropEvent(self, event):
|
||||||
|
for url in event.mimeData().urls():
|
||||||
|
if url.isLocalFile():
|
||||||
|
self.fileDropped.emit(url.toLocalFile())
|
||||||
|
event.acceptProposedAction()
|
||||||
|
return
|
||||||
|
|||||||
@@ -39,12 +39,12 @@ def main():
|
|||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
|
|
||||||
assert win.detect_all_button.isEnabled()
|
assert win.detect_all_action.isEnabled()
|
||||||
win.detect_all_button.click()
|
win.detect_all_action.trigger()
|
||||||
qapp.processEvents()
|
qapp.processEvents()
|
||||||
assert not win.detect_all_button.isEnabled(), "button should disable while detection runs"
|
assert not win.detect_all_action.isEnabled(), "action should disable while detection runs"
|
||||||
|
|
||||||
ok = wait_for(lambda: win.detect_all_button.isEnabled(), qapp)
|
ok = wait_for(lambda: win.detect_all_action.isEnabled(), qapp)
|
||||||
assert ok, "detection did not finish in time"
|
assert ok, "detection did not finish in time"
|
||||||
|
|
||||||
markers = win.marker_strip.markers()
|
markers = win.marker_strip.markers()
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Verifies the four interaction changes requested after the first review:
|
||||||
|
- clap detection moved to a Tools menu (no more buttons in the layout)
|
||||||
|
- clicking anywhere on the slider track jumps straight there
|
||||||
|
- dragging the slider seeks live (via sliderMoved), not just on release
|
||||||
|
- dropping a file onto the player widget loads it
|
||||||
|
|
||||||
|
Drives real Qt events (QMouseEvent, QDropEvent) against the actual
|
||||||
|
MainWindow/PlayerWidget classes rather than calling internal methods
|
||||||
|
directly, so this proves the actual event wiring, not just the logic
|
||||||
|
behind it.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PyQt6.QtCore import Qt, QPointF, QUrl, QMimeData
|
||||||
|
from PyQt6.QtGui import QMouseEvent, QDropEvent
|
||||||
|
from PyQt6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
import main as app_main
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
CLIP = str(ROOT / "test_media" / "sync_test_INSYNC.mp4")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
qapp = QApplication(sys.argv)
|
||||||
|
win = app_main.MainWindow()
|
||||||
|
win.show()
|
||||||
|
qapp.processEvents()
|
||||||
|
|
||||||
|
# -- Tools menu has the clap detection actions, no buttons in the layout
|
||||||
|
menu_titles = [a.text() for a in win.menuBar().actions()]
|
||||||
|
assert "&Tools" in menu_titles, f"expected a Tools menu, got {menu_titles}"
|
||||||
|
assert not hasattr(win, "detect_all_button"), "old button attribute should be gone"
|
||||||
|
assert win.detect_all_action.text() == "Detect All Claps"
|
||||||
|
assert win.detect_next_action.text() == "Detect Next Clap"
|
||||||
|
print("PASS: Tools menu has plain-labeled clap detection actions")
|
||||||
|
|
||||||
|
# -- Load via drag-and-drop simulation on the player widget
|
||||||
|
mime = QMimeData()
|
||||||
|
mime.setUrls([QUrl.fromLocalFile(CLIP)])
|
||||||
|
drop_event = QDropEvent(
|
||||||
|
QPointF(10, 10), Qt.DropAction.CopyAction, mime,
|
||||||
|
Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
|
||||||
|
QDropEvent.Type.Drop,
|
||||||
|
)
|
||||||
|
win.player.dropEvent(drop_event)
|
||||||
|
qapp.processEvents()
|
||||||
|
time.sleep(0.3)
|
||||||
|
qapp.processEvents()
|
||||||
|
assert win.input_path == CLIP, f"drag-and-drop should have loaded the file, got {win.input_path}"
|
||||||
|
print(f"PASS: drag-and-drop loaded {Path(win.input_path).name}")
|
||||||
|
|
||||||
|
# -- Clicking directly on the slider track (not the handle) jumps there
|
||||||
|
duration_ms = win.seek_slider.maximum()
|
||||||
|
assert duration_ms > 0
|
||||||
|
target_x = win.seek_slider.width() * 0.5
|
||||||
|
click_pos = QPointF(target_x, win.seek_slider.height() / 2)
|
||||||
|
press_event = QMouseEvent(
|
||||||
|
QMouseEvent.Type.MouseButtonPress, click_pos, click_pos,
|
||||||
|
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
|
||||||
|
)
|
||||||
|
before = win.player.get_position()
|
||||||
|
win.seek_slider.mousePressEvent(press_event)
|
||||||
|
qapp.processEvents()
|
||||||
|
time.sleep(0.2)
|
||||||
|
qapp.processEvents()
|
||||||
|
after = win.player.get_position()
|
||||||
|
expected = 0.5 * (duration_ms / 1000.0)
|
||||||
|
print(f"clicked slider track at 50%: before={before:.3f}s after={after:.3f}s expected~{expected:.3f}s")
|
||||||
|
assert abs(after - expected) < 0.5, "clicking the track should jump the player near that position"
|
||||||
|
print("PASS: click-anywhere-on-track seeking works")
|
||||||
|
|
||||||
|
# -- Dragging (sliderMoved) seeks live, without waiting for release
|
||||||
|
win.seek_slider.sliderPressed.emit()
|
||||||
|
qapp.processEvents()
|
||||||
|
drag_value = int(0.2 * duration_ms)
|
||||||
|
win.seek_slider.setValue(drag_value)
|
||||||
|
win.seek_slider.sliderMoved.emit(drag_value)
|
||||||
|
qapp.processEvents()
|
||||||
|
time.sleep(0.2)
|
||||||
|
qapp.processEvents()
|
||||||
|
live_pos = win.player.get_position()
|
||||||
|
expected_drag = drag_value / 1000.0
|
||||||
|
print(f"dragged to 20% (still 'pressed', not released): live player position={live_pos:.3f}s expected~{expected_drag:.3f}s")
|
||||||
|
assert abs(live_pos - expected_drag) < 0.5, "dragging should seek immediately, before release"
|
||||||
|
win.seek_slider.sliderReleased.emit()
|
||||||
|
print("PASS: live-seek during drag works")
|
||||||
|
|
||||||
|
win.player.shutdown()
|
||||||
|
print("\nALL INTERACTION SMOKE TESTS PASSED")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user