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
|
autoclip_requested = pyqtSignal() # mouse back/side button
|
||||||
# (index, new_start, new_end, old_start, old_end)
|
# (index, new_start, new_end, old_start, old_end)
|
||||||
scan_region_resized = pyqtSignal(int, float, float, float, float)
|
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
|
_SCROLLBAR_H = 8 # pixels reserved for the overview scrollbar
|
||||||
_RULER_H = 22 # pixels reserved for the time ruler
|
_RULER_H = 22 # pixels reserved for the time ruler
|
||||||
_HANDLE_H = 8 # height of the playhead triangle
|
_HANDLE_H = 8 # height of the playhead triangle
|
||||||
_EDGE_PX = 3 # pixel tolerance for edge hit detection
|
_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):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -1904,6 +1907,11 @@ class TimelineWidget(QWidget):
|
|||||||
# Manual "Extract audio area" band (start, end) — drawn as a distinct
|
# Manual "Extract audio area" band (start, end) — drawn as a distinct
|
||||||
# teal dashed region so it reads apart from the blue clip selection.
|
# teal dashed region so it reads apart from the blue clip selection.
|
||||||
self._audio_region: tuple[float, float] | None = None
|
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.
|
# View window for zoom/pan. When _view_span <= 0 the full duration is shown.
|
||||||
self._view_start: float = 0.0
|
self._view_start: float = 0.0
|
||||||
@@ -2077,6 +2085,51 @@ class TimelineWidget(QWidget):
|
|||||||
self._audio_region = None
|
self._audio_region = None
|
||||||
self.update()
|
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:
|
def set_play_position(self, t: float | None) -> None:
|
||||||
# In lock mode, ignore mpv position updates while the user is dragging
|
# 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.
|
# — 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) ─────────────────────────
|
# ── selection region (full clip span) ─────────────────────────
|
||||||
x_start = int(self._time_to_x(self._cursor))
|
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)))
|
x_end = int(self._time_to_x(min(self._cursor + self._clip_span, self._duration)))
|
||||||
sel_w = max(x_end - x_start, 1)
|
sel_w = max(x_end - x_start, 1)
|
||||||
p.fillRect(x_start, rh, sel_w, th, QColor(60, 130, 220, 90))
|
p.fillRect(x_start, rh, sel_w, th, QColor(60, 130, 220, 90))
|
||||||
|
|
||||||
# ── playback progress fill ────────────────────────────────────
|
# ── 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)
|
prog_end = min(self._play_pos, self._cursor + self._clip_span, self._duration)
|
||||||
x_prog = int(self._time_to_x(prog_end))
|
x_prog = int(self._time_to_x(prog_end))
|
||||||
prog_w = max(x_prog - x_start, 0)
|
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))
|
p.fillRect(x_start, rh, prog_w, th, QColor(100, 200, 255, 60))
|
||||||
|
|
||||||
# left/right edges of selection
|
# 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.setPen(QPen(QColor(60, 130, 220, 180), 1))
|
||||||
p.drawLine(x_start, rh, x_start, h)
|
p.drawLine(x_start, rh, x_start, h)
|
||||||
p.drawLine(x_end, rh, x_end, h)
|
p.drawLine(x_end, rh, x_end, h)
|
||||||
@@ -2314,6 +2368,12 @@ class TimelineWidget(QWidget):
|
|||||||
aw = max(ax2 - ax1, 1)
|
aw = max(ax2 - ax1, 1)
|
||||||
p.fillRect(ax1, rh, aw, th, QColor(0, 200, 180, 45))
|
p.fillRect(ax1, rh, aw, th, QColor(0, 200, 180, 45))
|
||||||
p.setBrush(Qt.BrushStyle.NoBrush)
|
p.setBrush(Qt.BrushStyle.NoBrush)
|
||||||
|
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.setPen(QPen(QColor(0, 220, 190), 1, Qt.PenStyle.DashLine))
|
||||||
p.drawRect(ax1, rh + 1, aw, th - 2)
|
p.drawRect(ax1, rh + 1, aw, th - 2)
|
||||||
|
|
||||||
@@ -2363,6 +2423,7 @@ class TimelineWidget(QWidget):
|
|||||||
mx2 = int(self._time_to_x(min(t + span, self._duration)))
|
mx2 = int(self._time_to_x(min(t + span, self._duration)))
|
||||||
if mx2 > mx1 and mx2 > 0 and mx1 < w:
|
if mx2 > mx1 and mx2 > 0 and mx1 < w:
|
||||||
p.fillRect(mx1, rh, mx2 - mx1, th, self._c_span)
|
p.fillRect(mx1, rh, mx2 - mx1, th, self._c_span)
|
||||||
|
if not self._audio_mode:
|
||||||
p.setPen(self._pen_span_tick)
|
p.setPen(self._pen_span_tick)
|
||||||
ct = t + self._spread
|
ct = t + self._spread
|
||||||
while ct < t + span - 0.1:
|
while ct < t + span - 0.1:
|
||||||
@@ -2404,6 +2465,7 @@ class TimelineWidget(QWidget):
|
|||||||
mx2 = int(self._time_to_x(min(t + span, self._duration)))
|
mx2 = int(self._time_to_x(min(t + span, self._duration)))
|
||||||
if mx2 > mx:
|
if mx2 > mx:
|
||||||
p.fillRect(mx, rh, mx2 - mx, th, dim)
|
p.fillRect(mx, rh, mx2 - mx, th, dim)
|
||||||
|
if not self._audio_mode:
|
||||||
p.setPen(tickpen)
|
p.setPen(tickpen)
|
||||||
ct = t + self._spread
|
ct = t + self._spread
|
||||||
while ct < t + span - 0.1:
|
while ct < t + span - 0.1:
|
||||||
@@ -2508,6 +2570,12 @@ class TimelineWidget(QWidget):
|
|||||||
self._sb_drag_offset = thumb_w / 2
|
self._sb_drag_offset = thumb_w / 2
|
||||||
self.update()
|
self.update()
|
||||||
return
|
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.
|
# Middle button: drag pans (when zoomed); a click bumps the clip count.
|
||||||
if event.button() == Qt.MouseButton.MiddleButton:
|
if event.button() == Qt.MouseButton.MiddleButton:
|
||||||
self._mid_press_x = x
|
self._mid_press_x = x
|
||||||
@@ -2561,6 +2629,11 @@ class TimelineWidget(QWidget):
|
|||||||
x = event.position().x()
|
x = event.position().x()
|
||||||
w = self.width()
|
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
|
# Active scrollbar drag
|
||||||
if self._sb_drag and event.buttons() & Qt.MouseButton.LeftButton:
|
if self._sb_drag and event.buttons() & Qt.MouseButton.LeftButton:
|
||||||
new_x = x - self._sb_drag_offset
|
new_x = x - self._sb_drag_offset
|
||||||
@@ -2629,6 +2702,9 @@ class TimelineWidget(QWidget):
|
|||||||
self.cursor_changed.emit(self._cursor)
|
self.cursor_changed.emit(self._cursor)
|
||||||
|
|
||||||
def mouseReleaseEvent(self, event):
|
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:
|
if self._sb_drag and event.button() == Qt.MouseButton.LeftButton:
|
||||||
self._sb_drag = False
|
self._sb_drag = False
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -274,6 +274,48 @@ def test_audio_region_tracks_cursor_and_length(win):
|
|||||||
assert win._timeline._audio_region is None
|
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):
|
def test_audio_deck_tab_exists(win):
|
||||||
# The old "Scan" deck tab is now "Audio".
|
# The old "Scan" deck tab is now "Audio".
|
||||||
assert hasattr(win, "_tab_audio")
|
assert hasattr(win, "_tab_audio")
|
||||||
|
|||||||
Reference in New Issue
Block a user