"""ScrubBar: tall segmented block bar for frame-accurate scrubbing. Replaces the thin timeline slider with a horizontal row of blocks, one per recording segment, proportional in width to duration. Click a block to activate it (trigger proxy generation). Drag within a block to scrub frame-by-frame at mouse speed. """ import logging import gi gi.require_version("Gtk", "4.0") from gi.repository import Gtk, Gdk, GLib, GObject, Pango import cairo log = logging.getLogger(__name__) BAR_HEIGHT = 50 BLOCK_GAP = 2 BLOCK_COLOR = (0.25, 0.25, 0.30) BLOCK_ACTIVE_COLOR = (0.35, 0.45, 0.60) BLOCK_HOVER_COLOR = (0.30, 0.35, 0.40) BLOCK_GENERATING_COLOR = (0.30, 0.40, 0.35) CURSOR_COLOR = (0.9, 0.2, 0.2) MARKER_COLOR = (0.9, 0.8, 0.2) TEXT_COLOR = (0.8, 0.8, 0.8) class ScrubBar(Gtk.DrawingArea): """Segmented block bar for scrubbing through recording segments.""" __gsignals__ = { "segment-activated": (GObject.SignalFlags.RUN_FIRST, None, (int,)), "scrub-position": (GObject.SignalFlags.RUN_FIRST, None, (float,)), } def __init__(self, **kwargs): super().__init__(**kwargs) self.set_content_height(BAR_HEIGHT) self.set_hexpand(True) self._manifest = [] # list of {path, index, duration, global_offset} self._total_duration = 0.0 self._cursor = 0.0 # global cursor position self._active_index = -1 # currently active segment index self._hover_index = -1 # segment under mouse self._proxy_states = {} # segment_index → "generating" | "ready" self._scene_markers = [] # global timestamps self._scrubbing = False self.set_draw_func(self._draw) # Mouse events click = Gtk.GestureClick() click.connect("pressed", self._on_pressed) click.connect("released", self._on_released) self.add_controller(click) motion = Gtk.EventControllerMotion() motion.connect("motion", self._on_motion) motion.connect("leave", self._on_leave) self.add_controller(motion) drag = Gtk.GestureDrag() drag.connect("drag-begin", self._on_drag_begin) drag.connect("drag-update", self._on_drag_update) drag.connect("drag-end", self._on_drag_end) self.add_controller(drag) # -- Public API -- def set_manifest(self, manifest: list[dict]) -> None: """Update the segment manifest. Triggers redraw.""" self._manifest = manifest self._total_duration = sum(s["duration"] for s in manifest) self.queue_draw() def set_cursor(self, global_time: float) -> None: """Update the cursor position (from Timeline).""" self._cursor = global_time self.queue_draw() def set_scene_markers(self, markers: list[float]) -> None: """Set scene change marker positions.""" self._scene_markers = markers self.queue_draw() def set_active_segment(self, index: int) -> None: """Set which segment is active (loaded for scrubbing).""" self._active_index = index self.queue_draw() def set_proxy_state(self, segment_index: int, state: str) -> None: """Update proxy state for a segment ('generating', 'ready').""" self._proxy_states[segment_index] = state self.queue_draw() # -- Drawing -- def _draw(self, area, cr, width, height): if not self._manifest or self._total_duration <= 0: cr.set_source_rgb(0.15, 0.15, 0.15) cr.rectangle(0, 0, width, height) cr.fill() return # Draw segment blocks for seg in self._manifest: x, w = self._segment_rect(seg, width) idx = seg["index"] # Block color based on state if idx == self._active_index: cr.set_source_rgb(*BLOCK_ACTIVE_COLOR) elif idx == self._hover_index: cr.set_source_rgb(*BLOCK_HOVER_COLOR) elif self._proxy_states.get(idx) == "generating": cr.set_source_rgb(*BLOCK_GENERATING_COLOR) else: cr.set_source_rgb(*BLOCK_COLOR) cr.rectangle(x, 0, w, height) cr.fill() # Segment label dur = seg["duration"] m, s = divmod(int(dur), 60) label = f"S{idx}" if w < 40 else f"S{idx} {m}:{s:02d}" cr.set_source_rgb(*TEXT_COLOR) cr.set_font_size(11) cr.move_to(x + 4, height - 6) cr.show_text(label) # Proxy state indicator state = self._proxy_states.get(idx) if state == "ready": cr.set_source_rgb(0.3, 0.7, 0.3) cr.arc(x + w - 8, 8, 3, 0, 6.28) cr.fill() elif state == "generating": cr.set_source_rgb(0.7, 0.7, 0.3) cr.arc(x + w - 8, 8, 3, 0, 6.28) cr.fill() # Scene markers cr.set_source_rgb(*MARKER_COLOR) for ts in self._scene_markers: mx = self._global_to_x(ts, width) if 0 <= mx <= width: cr.rectangle(mx, 0, 1, 6) cr.fill() # Cursor cx = self._global_to_x(self._cursor, width) if 0 <= cx <= width: cr.set_source_rgb(*CURSOR_COLOR) cr.rectangle(cx - 1, 0, 2, height) cr.fill() # -- Geometry helpers -- def _segment_rect(self, seg, total_width): """Return (x, width) for a segment block.""" if self._total_duration <= 0: return 0, 0 x = (seg["global_offset"] / self._total_duration) * total_width + BLOCK_GAP / 2 w = (seg["duration"] / self._total_duration) * total_width - BLOCK_GAP return max(0, x), max(1, w) def _global_to_x(self, global_time, total_width): """Map global time to pixel x position.""" if self._total_duration <= 0: return 0 return (global_time / self._total_duration) * total_width def _x_to_global(self, x, total_width): """Map pixel x to global time.""" if total_width <= 0 or self._total_duration <= 0: return 0.0 return (x / total_width) * self._total_duration def _segment_at_x(self, x, total_width): """Return the segment index at pixel x, or -1.""" for seg in self._manifest: sx, sw = self._segment_rect(seg, total_width) if sx <= x <= sx + sw: return seg["index"] return -1 # -- Mouse handlers -- def _on_pressed(self, gesture, n_press, x, y): width = self.get_width() idx = self._segment_at_x(x, width) if idx >= 0: if idx != self._active_index: # New segment — activate it (proxy will be requested) self._active_index = idx self.emit("segment-activated", idx) else: # Already active — seek to click position gt = self._x_to_global(x, width) self.emit("scrub-position", gt) self.queue_draw() def _on_released(self, gesture, n_press, x, y): self._scrubbing = False def _on_motion(self, controller, x, y): width = self.get_width() old_hover = self._hover_index self._hover_index = self._segment_at_x(x, width) if self._hover_index != old_hover: self.queue_draw() # If scrubbing (dragging within active block), emit position if self._scrubbing and self._active_index >= 0: gt = self._x_to_global(x, width) self.emit("scrub-position", gt) def _on_leave(self, controller): if self._hover_index != -1: self._hover_index = -1 self.queue_draw() def _on_drag_begin(self, gesture, start_x, start_y): width = self.get_width() idx = self._segment_at_x(start_x, width) if idx >= 0 and idx == self._active_index: self._scrubbing = True def _on_drag_update(self, gesture, offset_x, offset_y): if self._scrubbing: ok, start_x, start_y = gesture.get_start_point() if ok: x = start_x + offset_x width = self.get_width() gt = self._x_to_global(x, width) gt = max(0, min(gt, self._total_duration)) self.emit("scrub-position", gt) def _on_drag_end(self, gesture, offset_x, offset_y): self._scrubbing = False