scrub optimization
This commit is contained in:
@@ -24,12 +24,14 @@ class FramesPanel(Gtk.Box):
|
||||
"selection-changed": (GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||
"capture-requested": (GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||
"threshold-changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)),
|
||||
"seek-requested": (GObject.SignalFlags.RUN_FIRST, None, (float,)),
|
||||
}
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0, **kwargs)
|
||||
|
||||
self._widgets: dict[str, Gtk.Box] = {}
|
||||
self._timestamps: dict[str, float] = {}
|
||||
self._order: list[str] = []
|
||||
self._selected: str | None = None
|
||||
|
||||
@@ -105,10 +107,11 @@ class FramesPanel(Gtk.Box):
|
||||
box.append(label)
|
||||
|
||||
gesture = Gtk.GestureClick()
|
||||
gesture.connect("released", lambda g, n, x, y, fid=frame_id: self.select(fid))
|
||||
gesture.connect("released", self._on_frame_click, frame_id)
|
||||
box.add_controller(gesture)
|
||||
|
||||
self._widgets[frame_id] = box
|
||||
self._timestamps[frame_id] = timestamp
|
||||
self._order.append(frame_id)
|
||||
self._strip.append(box)
|
||||
|
||||
@@ -117,6 +120,12 @@ class FramesPanel(Gtk.Box):
|
||||
|
||||
log.info("Thumbnail: %s at %.1fs", frame_id, timestamp)
|
||||
|
||||
def _on_frame_click(self, gesture, n_press, x, y, frame_id):
|
||||
self.select(frame_id)
|
||||
if n_press == 2:
|
||||
ts = self._timestamps.get(frame_id, 0)
|
||||
self.emit("seek-requested", ts)
|
||||
|
||||
def load_items(self, items: list[dict]):
|
||||
"""Bulk load. Each dict has 'id', 'pixbuf', 'timestamp'."""
|
||||
self.clear()
|
||||
@@ -171,6 +180,7 @@ class FramesPanel(Gtk.Box):
|
||||
"""Remove all items and reset state."""
|
||||
self._selected = None
|
||||
self._widgets.clear()
|
||||
self._timestamps.clear()
|
||||
self._order.clear()
|
||||
while child := self._strip.get_first_child():
|
||||
self._strip.remove(child)
|
||||
|
||||
@@ -51,6 +51,8 @@ class MonitorWidget(Gtk.Box):
|
||||
self._live_loaded = False
|
||||
|
||||
self._review_player = None
|
||||
self._scrub_offset = 0.0 # global offset of the loaded scrub source
|
||||
self._scrub_active = False # True when scrub source is loaded
|
||||
|
||||
self._stack = Gtk.Stack()
|
||||
self._stack.set_hexpand(True)
|
||||
@@ -87,6 +89,21 @@ class MonitorWidget(Gtk.Box):
|
||||
self._recording_path = path
|
||||
log.info("Recording path: %s", path)
|
||||
|
||||
def set_scrub_source(self, proxy_path, global_offset=0.0):
|
||||
"""Load a proxy file for frame-accurate scrubbing."""
|
||||
self._recording_path = proxy_path
|
||||
self._scrub_offset = global_offset
|
||||
self._scrub_active = True
|
||||
if self._review_player:
|
||||
self._review_player.load_at(proxy_path, 0, pause=True, hr_seek=True)
|
||||
self._stack.set_visible_child_name("review")
|
||||
log.info("Scrub source: %s (offset %.1fs)", proxy_path, global_offset)
|
||||
|
||||
def scrub_to(self, seconds):
|
||||
"""Seek the review player to an exact frame (for scrub bar dragging)."""
|
||||
if self._review_player:
|
||||
self._review_player.show_frame_at(seconds)
|
||||
|
||||
def get_live_position(self):
|
||||
"""Return the live player's current time_pos, or None."""
|
||||
if self._live_player:
|
||||
@@ -105,6 +122,8 @@ class MonitorWidget(Gtk.Box):
|
||||
self._live_source_url = None
|
||||
self._recording_path = None
|
||||
self._live_loaded = False
|
||||
self._scrub_active = False
|
||||
self._scrub_offset = 0.0
|
||||
if self._live_player:
|
||||
self._live_player.command("stop")
|
||||
if self._review_player:
|
||||
@@ -180,6 +199,7 @@ class MonitorWidget(Gtk.Box):
|
||||
current = self._stack.get_visible_child_name()
|
||||
|
||||
if s.live:
|
||||
self._scrub_active = False
|
||||
# Ensure live player is loaded and playing
|
||||
if self._live_player and not self._live_loaded and self._live_source_url:
|
||||
self._live_player.load_live(self._live_source_url)
|
||||
@@ -190,17 +210,22 @@ class MonitorWidget(Gtk.Box):
|
||||
if current != "live":
|
||||
self._stack.set_visible_child_name("live")
|
||||
else:
|
||||
# Scrub mode
|
||||
if current == "live":
|
||||
# Scrub / review mode
|
||||
if self._scrub_active:
|
||||
# Scrub mode: driven directly by scrub_to(), not by timeline
|
||||
if current != "review":
|
||||
self._stack.set_visible_child_name("review")
|
||||
return
|
||||
elif current == "live":
|
||||
# Transitioning from live: load MKV at cursor position atomically
|
||||
pos = s.cursor # already set by toggle_live()
|
||||
pos = s.cursor
|
||||
if self._review_player and self._recording_path:
|
||||
self._review_player.load_at(self._recording_path, pos, pause=s.paused)
|
||||
if not s.paused:
|
||||
self._review_player.play()
|
||||
self._stack.set_visible_child_name("review")
|
||||
else:
|
||||
# Already in review: seek if cursor moved, then apply pause/play
|
||||
# Already in review (non-scrub): seek if cursor moved
|
||||
if self._review_player:
|
||||
player_pos = self._review_player.time_pos or 0
|
||||
if abs(s.cursor - player_pos) > 1.0:
|
||||
@@ -212,9 +237,12 @@ class MonitorWidget(Gtk.Box):
|
||||
|
||||
def _sync_cursor_from_player(self):
|
||||
s = self._timeline.state
|
||||
if self._scrub_active:
|
||||
# Scrub mode: don't sync cursor from player — scrub bar drives cursor
|
||||
return True
|
||||
if not s.live and not s.paused and self._review_player:
|
||||
pos = self._review_player.time_pos
|
||||
if pos is not None and pos > 0:
|
||||
self._timeline.set_cursor(pos)
|
||||
# Live mode: cursor driven by tick_live() in window.py
|
||||
# Live mode: cursor driven by tick_live()
|
||||
return True
|
||||
|
||||
@@ -92,9 +92,11 @@ class Player:
|
||||
log.info("mpv load: %s", path)
|
||||
self._player.loadfile(str(path), mode="replace")
|
||||
|
||||
def load_at(self, path, seconds, pause=True):
|
||||
def load_at(self, path, seconds, pause=True, hr_seek=False):
|
||||
"""Load a file and seek to position atomically. Avoids async seek race."""
|
||||
log.info("mpv load_at: %s at %.1fs pause=%s", path, seconds, pause)
|
||||
log.info("mpv load_at: %s at %.1fs pause=%s hr_seek=%s", path, seconds, pause, hr_seek)
|
||||
if hr_seek:
|
||||
self._player["hr-seek"] = "yes"
|
||||
self._player["pause"] = pause
|
||||
self._player.loadfile(str(path), mode="replace", start=str(seconds))
|
||||
|
||||
|
||||
245
cht/ui/scrub_bar.py
Normal file
245
cht/ui/scrub_bar.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""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
|
||||
@@ -144,54 +144,54 @@ class Timeline(GObject.Object):
|
||||
|
||||
|
||||
class TimelineControls(Gtk.Box):
|
||||
"""Slider + LIVE toggle. Scrub mode is always paused (seek-only, like a video editor).
|
||||
"""Scrub bar + time labels + LIVE toggle.
|
||||
|
||||
LIVE button is a toggle — active style when live=True.
|
||||
Slider is insensitive in live mode.
|
||||
The scrub bar shows segment blocks; labels and LIVE button sit below.
|
||||
"""
|
||||
|
||||
def __init__(self, timeline, **kwargs):
|
||||
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=4, **kwargs)
|
||||
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=2, **kwargs)
|
||||
self._timeline = timeline
|
||||
self._dragging = False
|
||||
|
||||
self.set_margin_start(4)
|
||||
self.set_margin_end(4)
|
||||
self.set_margin_top(2)
|
||||
self.set_margin_bottom(4)
|
||||
|
||||
# Current time label
|
||||
# Scrub bar (segment blocks)
|
||||
from cht.ui.scrub_bar import ScrubBar
|
||||
self._scrub_bar = ScrubBar()
|
||||
self.append(self._scrub_bar)
|
||||
|
||||
# Bottom row: time label + duration + LIVE button
|
||||
bottom = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
||||
|
||||
self._time_label = Gtk.Label(label="00:00")
|
||||
self._time_label.set_width_chars(6)
|
||||
self.append(self._time_label)
|
||||
bottom.append(self._time_label)
|
||||
|
||||
# Slider — disabled in live mode, scrub-seeks on release
|
||||
self._slider = Gtk.Scale(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
self._slider.set_hexpand(True)
|
||||
self._slider.set_range(0, 1)
|
||||
self._slider.set_draw_value(False)
|
||||
self._slider.connect("value-changed", self._on_slider_value_changed)
|
||||
spacer = Gtk.Box()
|
||||
spacer.set_hexpand(True)
|
||||
bottom.append(spacer)
|
||||
|
||||
press_ctrl = Gtk.GestureClick()
|
||||
press_ctrl.connect("pressed", self._on_slider_pressed)
|
||||
press_ctrl.connect("released", self._on_slider_released)
|
||||
press_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE)
|
||||
self._slider.add_controller(press_ctrl)
|
||||
self.append(self._slider)
|
||||
|
||||
# Duration label
|
||||
self._duration_label = Gtk.Label(label="00:00 / 00:00")
|
||||
self._duration_label.set_width_chars(14)
|
||||
self.append(self._duration_label)
|
||||
bottom.append(self._duration_label)
|
||||
|
||||
# LIVE toggle button
|
||||
self._live_btn = Gtk.Button(label="LIVE")
|
||||
self._live_btn.connect("clicked", self._on_live_clicked)
|
||||
self.append(self._live_btn)
|
||||
bottom.append(self._live_btn)
|
||||
|
||||
self.append(bottom)
|
||||
|
||||
timeline.connect("changed", self._on_changed)
|
||||
GLib.timeout_add(1000, self._tick_total)
|
||||
|
||||
@property
|
||||
def scrub_bar(self):
|
||||
"""Access the ScrubBar widget for signal connections."""
|
||||
return self._scrub_bar
|
||||
|
||||
def set_live_toggle_callback(self, cb):
|
||||
"""Override the LIVE button handler."""
|
||||
self._live_toggle_cb = cb
|
||||
@@ -202,32 +202,16 @@ class TimelineControls(Gtk.Box):
|
||||
else:
|
||||
self._timeline.toggle_live()
|
||||
|
||||
def _on_slider_value_changed(self, slider):
|
||||
if self._dragging:
|
||||
self._time_label.set_text(self._fmt_time(slider.get_value()))
|
||||
|
||||
def _on_slider_pressed(self, gesture, n_press, x, y):
|
||||
self._dragging = True
|
||||
|
||||
def _on_slider_released(self, gesture, n_press, x, y):
|
||||
if self._dragging:
|
||||
self._dragging = False
|
||||
self._timeline.seek(self._slider.get_value())
|
||||
|
||||
def _on_changed(self, timeline):
|
||||
s = timeline.state
|
||||
|
||||
self._slider.set_sensitive(not s.live)
|
||||
|
||||
if s.live:
|
||||
self._live_btn.add_css_class("suggested-action")
|
||||
else:
|
||||
self._live_btn.remove_css_class("suggested-action")
|
||||
|
||||
if not self._dragging:
|
||||
self._slider.set_range(0, max(s.duration, 0.1))
|
||||
self._slider.set_value(s.cursor)
|
||||
|
||||
self._scrub_bar.set_cursor(s.cursor)
|
||||
self._scrub_bar.set_scene_markers(s.scene_markers)
|
||||
self._time_label.set_text(self._fmt_time(s.cursor))
|
||||
self._update_duration_label()
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ class TranscriptPanel(Gtk.Box):
|
||||
"selection-changed": (GObject.SignalFlags.RUN_FIRST, None, ()),
|
||||
"min-chunk-changed": (GObject.SignalFlags.RUN_FIRST, None, (float,)),
|
||||
"lines-per-group-changed": (GObject.SignalFlags.RUN_FIRST, None, (int,)),
|
||||
"seek-requested": (GObject.SignalFlags.RUN_FIRST, None, (float,)),
|
||||
}
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
@@ -30,6 +31,7 @@ class TranscriptPanel(Gtk.Box):
|
||||
|
||||
self._rows: dict[str, Gtk.ListBoxRow] = {}
|
||||
self._texts: dict[str, str] = {}
|
||||
self._timestamps: dict[str, float] = {}
|
||||
self._order: list[str] = []
|
||||
self._selected: list[str] = []
|
||||
|
||||
@@ -176,6 +178,7 @@ class TranscriptPanel(Gtk.Box):
|
||||
self._selected.clear()
|
||||
self._rows.clear()
|
||||
self._texts.clear()
|
||||
self._timestamps.clear()
|
||||
self._order.clear()
|
||||
while child := self._list.get_first_child():
|
||||
self._list.remove(child)
|
||||
@@ -186,6 +189,10 @@ class TranscriptPanel(Gtk.Box):
|
||||
seg_id = getattr(row, "_seg_id", None)
|
||||
if seg_id:
|
||||
self.select(seg_id)
|
||||
# Double-activation (Enter key on focused row) → seek
|
||||
ts = self._timestamps.get(seg_id)
|
||||
if ts is not None:
|
||||
self.emit("seek-requested", ts)
|
||||
|
||||
def _make_row(self, seg):
|
||||
m1, s1 = divmod(int(seg.start), 60)
|
||||
@@ -208,6 +215,7 @@ class TranscriptPanel(Gtk.Box):
|
||||
self._list.append(row)
|
||||
self._rows[seg.id] = row
|
||||
self._texts[seg.id] = seg.text
|
||||
self._timestamps[seg.id] = seg.start
|
||||
self._order.append(seg.id)
|
||||
|
||||
def _clear_visual(self):
|
||||
|
||||
Reference in New Issue
Block a user