feat: timeline audio-mode — hide clip span, draggable/resizable audio band

This commit is contained in:
2026-07-03 02:10:04 +02:00
parent 283bc65588
commit a993de615c
2 changed files with 137 additions and 19 deletions
+95 -19
View File
@@ -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