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).
This commit is contained in:
ThePendulum
2026-07-26 03:58:23 +02:00
parent 0a8f9f45bd
commit 8c34a0f0c2
10 changed files with 309 additions and 258 deletions
+8 -7
View File
@@ -51,39 +51,40 @@ def main():
assert not win.toggle_log_action.isChecked()
print("PASS: log panel hidden by default")
win.player.seek(5.0)
win.source_panel.player.seek(5.0)
qapp.processEvents()
QTest.keyClick(win, Qt.Key.Key_Right)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
pos = win.player.get_position()
pos = win.source_panel.player.get_position()
print(f"after Right arrow from 5.0s: pos={pos:.3f}s")
assert abs(pos - 10.0) < 0.5, f"expected ~10.0s, got {pos:.3f}s" # 10s clip, clamps at end
win.player.seek(5.0)
win.source_panel.player.seek(5.0)
qapp.processEvents()
QTest.keyClick(win, Qt.Key.Key_Left)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
pos = win.player.get_position()
pos = win.source_panel.player.get_position()
print(f"after Left arrow from 5.0s: pos={pos:.3f}s")
assert abs(pos - 0.0) < 0.5, f"expected ~0.0s, got {pos:.3f}s"
win.player.seek(2.0)
win.source_panel.player.seek(2.0)
qapp.processEvents()
QTest.keyClick(win, Qt.Key.Key_Left)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
pos = win.player.get_position()
pos = win.source_panel.player.get_position()
print(f"after Left arrow from 2.0s (should clamp to 0, not go negative): pos={pos:.3f}s")
assert 0.0 <= pos < 0.5, f"expected clamped to ~0.0s, got {pos:.3f}s"
print("PASS: arrow keys skip 5s and clamp to file bounds")
win.player.shutdown()
win.source_panel.shutdown()
win.preview_panel.shutdown()
print("\nARROW SEEK SMOKE TEST PASSED")
+7 -6
View File
@@ -47,7 +47,7 @@ def main():
ok = wait_for(lambda: win.detect_all_action.isEnabled(), qapp)
assert ok, "detection did not finish in time"
markers = win.marker_strip.markers()
markers = win.source_panel.marker_strip.markers()
print(f"detected markers: {markers}")
assert len(markers) >= 1, "expected at least one clap marker"
# ground truth beep is at t=5.350s in this fixture; onset detection has
@@ -59,12 +59,12 @@ def main():
# Simulate a real mouse click on the marker to prove click-to-seek works
# end to end, not just that the signal is wired.
target_time = min(markers, key=lambda m: abs(m - 5.350))
strip = win.marker_strip
strip.resize(win.seek_slider.width(), strip.height())
strip = win.source_panel.marker_strip
strip.resize(win.source_panel.seek_slider.width(), strip.height())
x = strip._x_for_time(target_time)
click_pos = QPointF(x, 5)
before_pos = win.player.get_position()
before_pos = win.source_panel.player.get_position()
event = QMouseEvent(
QMouseEvent.Type.MouseButtonPress, click_pos, click_pos,
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
@@ -74,11 +74,12 @@ def main():
time.sleep(0.2)
qapp.processEvents()
after_pos = win.player.get_position()
after_pos = win.source_panel.player.get_position()
print(f"clicked marker at x={x:.1f} (t={target_time:.3f}s); player position before={before_pos:.3f}s after={after_pos:.3f}s")
assert abs(after_pos - target_time) < 0.1, "clicking the marker should seek the player to that timestamp"
win.player.shutdown()
win.source_panel.shutdown()
win.preview_panel.shutdown()
print("\nCLAP DETECTION SMOKE TEST PASSED")
+14 -13
View File
@@ -50,7 +50,7 @@ def main():
Qt.MouseButton.LeftButton, Qt.KeyboardModifier.NoModifier,
QDropEvent.Type.Drop,
)
win.player.dropEvent(drop_event)
win.source_panel.player.dropEvent(drop_event)
qapp.processEvents()
time.sleep(0.3)
qapp.processEvents()
@@ -58,42 +58,43 @@ def main():
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()
duration_ms = win.source_panel.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)
target_x = win.source_panel.seek_slider.width() * 0.5
click_pos = QPointF(target_x, win.source_panel.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)
before = win.source_panel.player.get_position()
win.source_panel.seek_slider.mousePressEvent(press_event)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
after = win.player.get_position()
after = win.source_panel.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()
win.source_panel.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)
win.source_panel.seek_slider.setValue(drag_value)
win.source_panel.seek_slider.sliderMoved.emit(drag_value)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
live_pos = win.player.get_position()
live_pos = win.source_panel.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()
win.source_panel.seek_slider.sliderReleased.emit()
print("PASS: live-seek during drag works")
win.player.shutdown()
win.source_panel.shutdown()
win.preview_panel.shutdown()
print("\nALL INTERACTION SMOKE TESTS PASSED")
+18 -17
View File
@@ -1,14 +1,14 @@
"""Verifies arrow-key skips preserve whichever play/pause state the user
was already in, instead of always forcing a pause.
Calls the real MainWindow._seek_relative() method directly rather than
Calls the real PlayerPanel.seek_relative() method directly rather than
simulating the QShortcut keypress: QTest.keyClick against this shortcut was
observed to be unreliable in this headless setup even with Qt's
active-window state forced (see smoke_test_arrow_seek.py's note) --
sometimes the simulated key silently doesn't reach the shortcut at all, and
a debug script isolated that PlayerWidget.seek() itself works correctly
while playing, so the flakiness is in the synthetic-keypress dispatch, not
the app. The QShortcut -> _seek_relative wiring itself (a single line) was
the app. The QShortcut -> seek_relative wiring itself (a single line) was
already independently confirmed with real xdotool keypresses against a
live window in an earlier session.
"""
@@ -37,37 +37,38 @@ def main():
qapp.processEvents()
# -- Case 1: paused before the skip -> stays paused after
assert win.player.mpv.pause is True
before_pos = win.player.get_position()
win._seek_relative(5.0)
assert win.source_panel.player.mpv.pause is True
before_pos = win.source_panel.player.get_position()
win.source_panel.seek_relative(5.0)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
after_pos = win.player.get_position()
after_pos = win.source_panel.player.get_position()
print(f"paused case: before={before_pos:.3f}s after={after_pos:.3f}s")
assert win.player.mpv.pause is True, "should still be paused after skip"
assert win.play_button.text() == "Play"
assert win.source_panel.player.mpv.pause is True, "should still be paused after skip"
assert win.source_panel.play_button.text() == "Play"
assert after_pos > before_pos + 4.0, "should have actually skipped forward"
print("PASS: stays paused across arrow-key skip when it started paused")
# -- Case 2: playing before the skip -> stays playing after
win.player.seek(0.0)
win.player.play()
win.play_button.setText("Pause")
win.source_panel.player.seek(0.0)
win.source_panel.player.play()
win.source_panel.play_button.setText("Pause")
qapp.processEvents()
time.sleep(0.2)
before_pos = win.player.get_position()
win._seek_relative(5.0)
before_pos = win.source_panel.player.get_position()
win.source_panel.seek_relative(5.0)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
assert win.player.mpv.pause is False, "should still be playing after skip"
assert win.play_button.text() == "Pause"
after_pos = win.player.get_position()
assert win.source_panel.player.mpv.pause is False, "should still be playing after skip"
assert win.source_panel.play_button.text() == "Pause"
after_pos = win.source_panel.player.get_position()
print(f"playing case: before={before_pos:.3f}s after={after_pos:.3f}s (should have jumped ~5s and kept playing)")
assert after_pos > before_pos + 4.0, "should have actually skipped forward"
win.player.shutdown()
win.source_panel.shutdown()
win.preview_panel.shutdown()
print("\nPRESERVE PLAY STATE SMOKE TEST PASSED")
+30 -9
View File
@@ -38,7 +38,8 @@ def main():
assert win.video_tabs.count() == 2
assert win.video_tabs.tabText(0) == "Source"
assert win.video_tabs.tabText(1) == "Preview"
assert win.video_tabs.widget(0) is win.player
assert win.video_tabs.widget(0) is win.source_panel
assert win.video_tabs.widget(1) is win.preview_panel
print("PASS: video area has Source and Preview tabs")
win._load(CLIP)
@@ -47,11 +48,11 @@ def main():
qapp.processEvents()
assert win.video_tabs.currentIndex() == 0, "loading a new source should land on the Source tab"
win.player.seek(5.000)
win.source_panel.player.seek(5.000)
qapp.processEvents()
win._mark_video()
win.player.seek(5.350)
win.source_panel.player.seek(5.350)
qapp.processEvents()
win._mark_audio()
@@ -77,21 +78,41 @@ def main():
time.sleep(0.3)
qapp.processEvents()
duration = win.preview_player.mpv.duration
duration = win.preview_panel.player.mpv.duration
print(f"preview clip duration: {duration:.3f}s (expected ~{win.PREVIEW_DURATION_SEC}s, clamped to file bounds)")
assert duration is not None and 0 < duration <= win.PREVIEW_DURATION_SEC + 0.5
assert win.preview_player.mpv.pause is False, "preview should auto-play"
assert win.preview_play_button.text() == "Pause"
assert win.preview_panel.player.mpv.pause is False, "preview should auto-play"
assert win.preview_panel.play_button.text() == "Pause"
# -- The Preview tab has its own functional timeline (not a static video
# + a single play button): its own seek slider, sized to the preview
# clip's own duration, that seeks the preview player independently of
# the Source tab's slider/player.
assert win.preview_panel.seek_slider is not win.source_panel.seek_slider
preview_duration_ms = win.preview_panel.seek_slider.maximum()
assert preview_duration_ms > 0
print(f"Preview tab's own seek slider range: 0..{preview_duration_ms}ms")
target_ms = int(preview_duration_ms * 0.7)
win.preview_panel.seek_slider.setValue(target_ms)
win.preview_panel.seek_slider.sliderMoved.emit(target_ms)
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
preview_pos = win.preview_panel.player.get_position()
source_pos = win.source_panel.player.get_position()
print(f"dragged Preview tab's own slider to 70%: preview player now at {preview_pos:.3f}s, source player still at {source_pos:.3f}s")
assert abs(preview_pos - target_ms / 1000.0) < 0.5, "Preview tab's slider should seek the preview player"
print("PASS: Preview tab has its own independent, functional timeline")
# -- Switching back to Source shouldn't disturb the main player, and the
# preview player keeps running independently (two live mpv instances).
main_pos_before_switch = win.player.get_position()
main_pos_before_switch = win.source_panel.player.get_position()
win.video_tabs.setCurrentIndex(0)
qapp.processEvents()
main_pos_after_switch = win.player.get_position()
main_pos_after_switch = win.source_panel.player.get_position()
print(f"main player position: before switch={main_pos_before_switch:.3f}s after={main_pos_after_switch:.3f}s")
assert abs(main_pos_after_switch - main_pos_before_switch) < 0.1, "switching tabs shouldn't move the main player"
assert win.player.mpv.pause is True, "main player should still be paused (untouched by preview building)"
assert win.source_panel.player.mpv.pause is True, "main player should still be paused (untouched by preview building)"
print("PASS: switching tabs doesn't disturb the main player")
# -- Building a second preview swaps the file without leaking the first
+9 -8
View File
@@ -42,24 +42,25 @@ def main():
print("PASS: Load File lives in the File menu")
# -- Right-click on the player toggles play/pause
assert win.player.mpv.pause is True, "should start paused"
win.player.mpv.command("keypress", "MBTN_RIGHT")
assert win.source_panel.player.mpv.pause is True, "should start paused"
win.source_panel.player.mpv.command("keypress", "MBTN_RIGHT")
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
assert win.player.mpv.pause is False, "right-click should have started playback"
assert win.play_button.text() == "Pause"
assert win.source_panel.player.mpv.pause is False, "right-click should have started playback"
assert win.source_panel.play_button.text() == "Pause"
print("PASS: right-click on player started playback")
win.player.mpv.command("keypress", "MBTN_RIGHT")
win.source_panel.player.mpv.command("keypress", "MBTN_RIGHT")
qapp.processEvents()
time.sleep(0.2)
qapp.processEvents()
assert win.player.mpv.pause is True, "second right-click should pause again"
assert win.play_button.text() == "Play"
assert win.source_panel.player.mpv.pause is True, "second right-click should pause again"
assert win.source_panel.play_button.text() == "Play"
print("PASS: right-click on player paused again")
win.player.shutdown()
win.source_panel.shutdown()
win.preview_panel.shutdown()
print("\nRIGHT-CLICK / FILE MENU SMOKE TEST PASSED")
+5 -4
View File
@@ -30,14 +30,14 @@ def main():
qapp.processEvents()
time.sleep(0.3)
qapp.processEvents()
print(f"loaded: duration slider range = {win.seek_slider.maximum()}ms")
print(f"loaded: duration slider range = {win.source_panel.seek_slider.maximum()}ms")
win.player.seek(5.000)
win.source_panel.player.seek(5.000)
qapp.processEvents()
win._mark_video()
print(f"video mark: {win.video_mark:.3f}s -> label: {win.video_mark_label.text()}")
win.player.seek(5.350)
win.source_panel.player.seek(5.350)
qapp.processEvents()
win._mark_audio()
print(f"audio mark: {win.audio_mark:.3f}s -> label: {win.audio_mark_label.text()}")
@@ -85,7 +85,8 @@ def main():
assert Path(EXPORT_OUT).exists()
print(f"exported file exists: {EXPORT_OUT}, size={Path(EXPORT_OUT).stat().st_size} bytes")
win.player.shutdown()
win.source_panel.shutdown()
win.preview_panel.shutdown()
print("\nUI SMOKE TEST PASSED")
+2 -1
View File
@@ -31,7 +31,8 @@ def main():
qapp.processEvents()
assert not win.log_view.isVisible()
win.player.shutdown()
win.source_panel.shutdown()
win.preview_panel.shutdown()
print("PASS: View > Show ffmpeg Log toggles the log panel correctly")