feat: timeline audio-mode — hide clip span, draggable/resizable audio band
This commit is contained in:
@@ -1871,11 +1871,14 @@ class TimelineWidget(QWidget):
|
||||
autoclip_requested = pyqtSignal() # mouse back/side button
|
||||
# (index, new_start, new_end, old_start, old_end)
|
||||
scan_region_resized = pyqtSignal(int, float, float, float, float)
|
||||
audio_region_changed = pyqtSignal(float, float) # audio band dragged/resized
|
||||
|
||||
_SCROLLBAR_H = 8 # pixels reserved for the overview scrollbar
|
||||
_RULER_H = 22 # pixels reserved for the time ruler
|
||||
_HANDLE_H = 8 # height of the playhead triangle
|
||||
_EDGE_PX = 3 # pixel tolerance for edge hit detection
|
||||
_AUDIO_EDGE_PX = 6 # pixel tolerance for audio-band edge grab
|
||||
_AUDIO_MIN_W = 0.05 # seconds — min audio-band width
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -1904,6 +1907,11 @@ class TimelineWidget(QWidget):
|
||||
# Manual "Extract audio area" band (start, end) — drawn as a distinct
|
||||
# teal dashed region so it reads apart from the blue clip selection.
|
||||
self._audio_region: tuple[float, float] | None = None
|
||||
# Audio mode: hide the clip span and make the teal band draggable.
|
||||
self._audio_mode = False
|
||||
self._audio_drag: str | None = None # None | "left" | "right" | "move"
|
||||
self._audio_drag_anchor = 0.0
|
||||
self._audio_drag_orig: tuple[float, float] | None = None
|
||||
|
||||
# View window for zoom/pan. When _view_span <= 0 the full duration is shown.
|
||||
self._view_start: float = 0.0
|
||||
@@ -2077,6 +2085,51 @@ class TimelineWidget(QWidget):
|
||||
self._audio_region = None
|
||||
self.update()
|
||||
|
||||
def set_audio_mode(self, on: bool) -> None:
|
||||
self._audio_mode = bool(on)
|
||||
self.update()
|
||||
|
||||
def _audio_begin_drag_at_x(self, x: float) -> None:
|
||||
if not self._audio_mode or self._audio_region is None:
|
||||
return
|
||||
a0, a1 = self._audio_region
|
||||
ax0 = self._time_to_x(a0); ax1 = self._time_to_x(a1)
|
||||
if abs(x - ax0) <= self._AUDIO_EDGE_PX:
|
||||
self._audio_drag = "left"
|
||||
elif abs(x - ax1) <= self._AUDIO_EDGE_PX:
|
||||
self._audio_drag = "right"
|
||||
elif ax0 < x < ax1:
|
||||
self._audio_drag = "move"
|
||||
self._audio_drag_anchor = self._pos_to_time(int(x)) - a0
|
||||
else:
|
||||
self._audio_drag = None
|
||||
if self._audio_drag is not None:
|
||||
self._audio_drag_orig = self._audio_region
|
||||
|
||||
def _audio_drag_to_x(self, x: float) -> None:
|
||||
if self._audio_drag is None or self._audio_region is None:
|
||||
return
|
||||
t = max(0.0, min(self._pos_to_time(int(x)), self._duration))
|
||||
a0, a1 = self._audio_region
|
||||
if self._audio_drag == "left":
|
||||
a0 = min(t, a1 - self._AUDIO_MIN_W)
|
||||
elif self._audio_drag == "right":
|
||||
a1 = max(t, a0 + self._AUDIO_MIN_W)
|
||||
else: # move
|
||||
width = a1 - a0
|
||||
a0 = max(0.0, min(t - self._audio_drag_anchor, self._duration - width))
|
||||
a1 = a0 + width
|
||||
self._audio_region = (a0, a1)
|
||||
self.update()
|
||||
|
||||
def _audio_end_drag(self) -> None:
|
||||
if self._audio_drag is None:
|
||||
return
|
||||
self._audio_drag = None
|
||||
if (self._audio_region is not None
|
||||
and self._audio_region != self._audio_drag_orig):
|
||||
self.audio_region_changed.emit(self._audio_region[0], self._audio_region[1])
|
||||
|
||||
def set_play_position(self, t: float | None) -> None:
|
||||
# In lock mode, ignore mpv position updates while the user is dragging
|
||||
# — the async seek hasn't caught up yet, so mpv reports stale values.
|
||||
@@ -2286,13 +2339,14 @@ class TimelineWidget(QWidget):
|
||||
|
||||
# ── selection region (full clip span) ─────────────────────────
|
||||
x_start = int(self._time_to_x(self._cursor))
|
||||
if not self._scan_mode:
|
||||
if not self._scan_mode and not self._audio_mode:
|
||||
x_end = int(self._time_to_x(min(self._cursor + self._clip_span, self._duration)))
|
||||
sel_w = max(x_end - x_start, 1)
|
||||
p.fillRect(x_start, rh, sel_w, th, QColor(60, 130, 220, 90))
|
||||
|
||||
# ── playback progress fill ────────────────────────────────────
|
||||
if not self._scan_mode and self._play_pos is not None and self._play_pos > self._cursor:
|
||||
if (not self._scan_mode and not self._audio_mode
|
||||
and self._play_pos is not None and self._play_pos > self._cursor):
|
||||
prog_end = min(self._play_pos, self._cursor + self._clip_span, self._duration)
|
||||
x_prog = int(self._time_to_x(prog_end))
|
||||
prog_w = max(x_prog - x_start, 0)
|
||||
@@ -2300,7 +2354,7 @@ class TimelineWidget(QWidget):
|
||||
p.fillRect(x_start, rh, prog_w, th, QColor(100, 200, 255, 60))
|
||||
|
||||
# left/right edges of selection
|
||||
if not self._scan_mode:
|
||||
if not self._scan_mode and not self._audio_mode:
|
||||
p.setPen(QPen(QColor(60, 130, 220, 180), 1))
|
||||
p.drawLine(x_start, rh, x_start, h)
|
||||
p.drawLine(x_end, rh, x_end, h)
|
||||
@@ -2314,8 +2368,14 @@ class TimelineWidget(QWidget):
|
||||
aw = max(ax2 - ax1, 1)
|
||||
p.fillRect(ax1, rh, aw, th, QColor(0, 200, 180, 45))
|
||||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||||
p.setPen(QPen(QColor(0, 220, 190), 1, Qt.PenStyle.DashLine))
|
||||
p.drawRect(ax1, rh + 1, aw, th - 2)
|
||||
if self._audio_mode:
|
||||
# Solid bright edges so the band reads as draggable/resizable.
|
||||
p.setPen(QPen(QColor(0, 220, 190), 2))
|
||||
p.drawLine(ax1, rh, ax1, h)
|
||||
p.drawLine(ax2, rh, ax2, h)
|
||||
else:
|
||||
p.setPen(QPen(QColor(0, 220, 190), 1, Qt.PenStyle.DashLine))
|
||||
p.drawRect(ax1, rh + 1, aw, th - 2)
|
||||
|
||||
# ── ghost of the previous cursor position (undo-by-eye) ──────────
|
||||
if (not self._scan_mode and self._ghost_cursor is not None
|
||||
@@ -2363,13 +2423,14 @@ class TimelineWidget(QWidget):
|
||||
mx2 = int(self._time_to_x(min(t + span, self._duration)))
|
||||
if mx2 > mx1 and mx2 > 0 and mx1 < w:
|
||||
p.fillRect(mx1, rh, mx2 - mx1, th, self._c_span)
|
||||
p.setPen(self._pen_span_tick)
|
||||
ct = t + self._spread
|
||||
while ct < t + span - 0.1:
|
||||
cx = int(self._time_to_x(ct))
|
||||
if mx1 < cx < mx2:
|
||||
p.drawLine(cx, rh, cx, rh + th)
|
||||
ct += self._spread
|
||||
if not self._audio_mode:
|
||||
p.setPen(self._pen_span_tick)
|
||||
ct = t + self._spread
|
||||
while ct < t + span - 0.1:
|
||||
cx = int(self._time_to_x(ct))
|
||||
if mx1 < cx < mx2:
|
||||
p.drawLine(cx, rh, cx, rh + th)
|
||||
ct += self._spread
|
||||
|
||||
# ── export markers ────────────────────────────────────────────
|
||||
p.setFont(self._marker_font)
|
||||
@@ -2404,13 +2465,14 @@ class TimelineWidget(QWidget):
|
||||
mx2 = int(self._time_to_x(min(t + span, self._duration)))
|
||||
if mx2 > mx:
|
||||
p.fillRect(mx, rh, mx2 - mx, th, dim)
|
||||
p.setPen(tickpen)
|
||||
ct = t + self._spread
|
||||
while ct < t + span - 0.1:
|
||||
cx = int(self._time_to_x(ct))
|
||||
if mx < cx < mx2:
|
||||
p.drawLine(cx, rh, cx, rh + th)
|
||||
ct += self._spread
|
||||
if not self._audio_mode:
|
||||
p.setPen(tickpen)
|
||||
ct = t + self._spread
|
||||
while ct < t + span - 0.1:
|
||||
cx = int(self._time_to_x(ct))
|
||||
if mx < cx < mx2:
|
||||
p.drawLine(cx, rh, cx, rh + th)
|
||||
ct += self._spread
|
||||
p.setPen(pen)
|
||||
p.drawLine(mx, rh, mx, h)
|
||||
p.fillRect(mx, rh + 2, 14, 12, color)
|
||||
@@ -2508,6 +2570,12 @@ class TimelineWidget(QWidget):
|
||||
self._sb_drag_offset = thumb_w / 2
|
||||
self.update()
|
||||
return
|
||||
# Audio mode: dragging/resizing the teal band takes priority over seeking.
|
||||
if (self._audio_mode and self._audio_region is not None
|
||||
and event.button() == Qt.MouseButton.LeftButton):
|
||||
self._audio_begin_drag_at_x(x)
|
||||
if self._audio_drag is not None:
|
||||
return
|
||||
# Middle button: drag pans (when zoomed); a click bumps the clip count.
|
||||
if event.button() == Qt.MouseButton.MiddleButton:
|
||||
self._mid_press_x = x
|
||||
@@ -2561,6 +2629,11 @@ class TimelineWidget(QWidget):
|
||||
x = event.position().x()
|
||||
w = self.width()
|
||||
|
||||
# Active audio-band drag (audio mode) — takes priority over seeking.
|
||||
if self._audio_drag is not None and event.buttons():
|
||||
self._audio_drag_to_x(x)
|
||||
return
|
||||
|
||||
# Active scrollbar drag
|
||||
if self._sb_drag and event.buttons() & Qt.MouseButton.LeftButton:
|
||||
new_x = x - self._sb_drag_offset
|
||||
@@ -2629,6 +2702,9 @@ class TimelineWidget(QWidget):
|
||||
self.cursor_changed.emit(self._cursor)
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
if self._audio_drag is not None:
|
||||
self._audio_end_drag()
|
||||
return
|
||||
if self._sb_drag and event.button() == Qt.MouseButton.LeftButton:
|
||||
self._sb_drag = False
|
||||
return
|
||||
|
||||
@@ -274,6 +274,48 @@ def test_audio_region_tracks_cursor_and_length(win):
|
||||
assert win._timeline._audio_region is None
|
||||
|
||||
|
||||
def test_timeline_audio_mode_flag(win):
|
||||
tl = win._timeline
|
||||
tl.set_audio_mode(True)
|
||||
assert tl._audio_mode is True
|
||||
tl.set_audio_mode(False)
|
||||
assert tl._audio_mode is False
|
||||
|
||||
|
||||
def test_timeline_audio_band_drag_move(win):
|
||||
tl = win._timeline
|
||||
tl._duration = 20.0
|
||||
tl._view_start = 0.0; tl._view_span = 20.0 # full view
|
||||
tl.resize(400, tl.height() or 80)
|
||||
tl.set_audio_mode(True)
|
||||
tl.set_audio_region(4.0, 8.0) # [4,8]
|
||||
got = []
|
||||
tl.audio_region_changed.connect(lambda s, e: got.append((s, e)))
|
||||
# grab the middle of the band and drag it +2s
|
||||
mid_t = 6.0
|
||||
tl._audio_begin_drag_at_x(tl._time_to_x(mid_t))
|
||||
tl._audio_drag_to_x(tl._time_to_x(mid_t + 2.0))
|
||||
tl._audio_end_drag()
|
||||
assert got, "audio_region_changed should fire on release"
|
||||
s, e = tl._audio_region
|
||||
assert abs((e - s) - 4.0) < 0.1 and s > 4.0 # same width, moved right
|
||||
tl.set_audio_mode(False)
|
||||
|
||||
|
||||
def test_timeline_audio_band_resize_right(win):
|
||||
tl = win._timeline
|
||||
tl._duration = 20.0; tl._view_start = 0.0; tl._view_span = 20.0
|
||||
tl.resize(400, tl.height() or 80)
|
||||
tl.set_audio_mode(True)
|
||||
tl.set_audio_region(4.0, 8.0)
|
||||
tl._audio_begin_drag_at_x(tl._time_to_x(8.0)) # grab right edge
|
||||
tl._audio_drag_to_x(tl._time_to_x(10.0)) # drag to 10
|
||||
tl._audio_end_drag()
|
||||
s, e = tl._audio_region
|
||||
assert abs(s - 4.0) < 0.1 and abs(e - 10.0) < 0.2
|
||||
tl.set_audio_mode(False)
|
||||
|
||||
|
||||
def test_audio_deck_tab_exists(win):
|
||||
# The old "Scan" deck tab is now "Audio".
|
||||
assert hasattr(win, "_tab_audio")
|
||||
|
||||
Reference in New Issue
Block a user