diff --git a/cht/app.py b/cht/app.py index 7e9067e..cee2e43 100644 --- a/cht/app.py +++ b/cht/app.py @@ -43,7 +43,9 @@ class ChtApp(Adw.Application): css = Gtk.CssProvider() css.load_from_string( ".frame-selected { outline: 3px solid @accent_color; outline-offset: -3px; border-radius: 6px; }\n" - "row.frame-selected, row.frame-selected:hover { background: alpha(@accent_color, 0.25); outline: none; border-radius: 0; }" + "row.frame-selected, row.frame-selected:hover { background: alpha(@accent_color, 0.25); outline: none; border-radius: 0; }\n" + ".frame-highlight { outline: 2px solid @warning_color; outline-offset: -2px; border-radius: 4px; opacity: 0.9; }\n" + "row.frame-highlight { background: alpha(@warning_color, 0.15); }" ) Gtk.StyleContext.add_provider_for_display( Gdk.Display.get_default(), diff --git a/cht/stream/lifecycle.py b/cht/stream/lifecycle.py index a8439db..c035d35 100644 --- a/cht/stream/lifecycle.py +++ b/cht/stream/lifecycle.py @@ -23,7 +23,8 @@ class StreamLifecycle: def __init__(self, *, timeline, waveform_engine, transcriber, on_new_frames, on_waveform_update, on_transcript_ready, - on_scene_marker, on_recorder_restarted): + on_scene_marker, on_recorder_restarted, + on_manifest_updated=None): self._timeline = timeline self._waveform_engine = waveform_engine self._transcriber = transcriber @@ -34,6 +35,7 @@ class StreamLifecycle: self._on_transcript_ready = on_transcript_ready self._on_scene_marker = on_scene_marker self._on_recorder_restarted = on_recorder_restarted + self._on_manifest_updated = on_manifest_updated # State self._streaming = False @@ -196,4 +198,11 @@ class StreamLifecycle: log.warning("Recorder died — restarting into new segment") self._stream_mgr.restart_recorder() self._on_recorder_restarted(self._stream_mgr.recording_path) + # Rebuild manifest with the newly completed segment + try: + rebuild_manifest(self._stream_mgr.session_dir) + except Exception as e: + log.error("Manifest rebuild failed: %s", e) + if self._on_manifest_updated: + GLib.idle_add(self._on_manifest_updated) return True diff --git a/cht/ui/frames_panel.py b/cht/ui/frames_panel.py index bbe607c..5deb55c 100644 --- a/cht/ui/frames_panel.py +++ b/cht/ui/frames_panel.py @@ -176,6 +176,24 @@ class FramesPanel(Gtk.Box): self._selected = None self.emit("selection-changed") + def highlight_nearest(self, timestamp: float) -> None: + """Scroll to and briefly highlight the frame closest to *timestamp*.""" + if not self._order: + return + best_id = None + best_dist = float("inf") + for fid, ts in self._timestamps.items(): + d = abs(ts - timestamp) + if d < best_dist: + best_dist = d + best_id = fid + if not best_id or best_id not in self._widgets: + return + widget = self._widgets[best_id] + widget.add_css_class("frame-highlight") + self._scroll_to(widget) + GLib.timeout_add(400, lambda: widget.remove_css_class("frame-highlight") or False) + def clear(self): """Remove all items and reset state.""" self._selected = None diff --git a/cht/ui/monitor.py b/cht/ui/monitor.py index 7159dec..00fe821 100644 --- a/cht/ui/monitor.py +++ b/cht/ui/monitor.py @@ -217,12 +217,8 @@ class MonitorWidget(Gtk.Box): self._stack.set_visible_child_name("review") return elif current == "live": - # Transitioning from live: load MKV at cursor position atomically - 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() + # Transitioning from live to scrub: just switch stack. + # Don't auto-load the growing MKV — user picks a segment via scrub bar. self._stack.set_visible_child_name("review") else: # Already in review (non-scrub): seek if cursor moved diff --git a/cht/ui/scrub_bar.py b/cht/ui/scrub_bar.py index 78e9e99..f64b21f 100644 --- a/cht/ui/scrub_bar.py +++ b/cht/ui/scrub_bar.py @@ -9,9 +9,12 @@ Drag within a block to scrub frame-by-frame at mouse speed. import logging +from pathlib import Path + import gi gi.require_version("Gtk", "4.0") -from gi.repository import Gtk, Gdk, GLib, GObject, Pango +gi.require_version("GdkPixbuf", "2.0") +from gi.repository import Gtk, Gdk, GLib, GObject, GdkPixbuf import cairo @@ -49,6 +52,7 @@ class ScrubBar(Gtk.DrawingArea): self._proxy_states = {} # segment_index → "generating" | "ready" self._scene_markers = [] # global timestamps self._scrubbing = False + self._frame_thumbs = [] # list of {timestamp, surface} — cairo surfaces self.set_draw_func(self._draw) @@ -77,6 +81,12 @@ class ScrubBar(Gtk.DrawingArea): self._total_duration = sum(s["duration"] for s in manifest) self.queue_draw() + def set_duration(self, duration: float) -> None: + """Update total duration (from Timeline, overrides manifest sum if larger).""" + if duration > self._total_duration: + self._total_duration = duration + self.queue_draw() + def set_cursor(self, global_time: float) -> None: """Update the cursor position (from Timeline).""" self._cursor = global_time @@ -97,6 +107,66 @@ class ScrubBar(Gtk.DrawingArea): self._proxy_states[segment_index] = state self.queue_draw() + def set_frames(self, frames: list[dict]) -> None: + """Set frame thumbnails. Each dict: {timestamp, path}. + + Loads thumbnails scaled to fit the bar height and caches as cairo surfaces. + """ + self._frame_thumbs = [] + thumb_h = BAR_HEIGHT - 4 # 2px margin top/bottom + thumb_w = int(thumb_h * 16 / 9) # assume 16:9 aspect + for f in frames: + path = f.get("path") + ts = f.get("timestamp", 0) + if not path or not Path(path).exists(): + continue + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( + str(path), thumb_w, thumb_h, True + ) + surface = self._pixbuf_to_surface(pixbuf) + self._frame_thumbs.append({ + "timestamp": ts, + "surface": surface, + "width": pixbuf.get_width(), + "height": pixbuf.get_height(), + }) + except Exception as e: + log.debug("Thumb load failed for %s: %s", path, e) + self.queue_draw() + + def add_frame(self, timestamp: float, path: str) -> None: + """Add a single frame thumbnail incrementally.""" + if not Path(path).exists(): + return + thumb_h = BAR_HEIGHT - 4 + thumb_w = int(thumb_h * 16 / 9) + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, thumb_w, thumb_h, True) + surface = self._pixbuf_to_surface(pixbuf) + self._frame_thumbs.append({ + "timestamp": timestamp, + "surface": surface, + "width": pixbuf.get_width(), + "height": pixbuf.get_height(), + }) + self.queue_draw() + except Exception as e: + log.debug("Thumb load failed for %s: %s", path, e) + + @staticmethod + def _pixbuf_to_surface(pixbuf): + """Convert a GdkPixbuf to a cairo ImageSurface.""" + w, h = pixbuf.get_width(), pixbuf.get_height() + has_alpha = pixbuf.get_has_alpha() + fmt = cairo.FORMAT_ARGB32 if has_alpha else cairo.FORMAT_RGB24 + surface = cairo.ImageSurface(fmt, w, h) + cr = cairo.Context(surface) + Gdk.cairo_set_source_pixbuf(cr, pixbuf, 0, 0) + cr.paint() + surface.flush() + return surface + # -- Drawing -- def _draw(self, area, cr, width, height): @@ -144,7 +214,31 @@ class ScrubBar(Gtk.DrawingArea): cr.arc(x + w - 8, 8, 3, 0, 6.28) cr.fill() - # Scene markers + # Frame thumbnails at their timestamp positions + for thumb in self._frame_thumbs: + tx = self._global_to_x(thumb["timestamp"], width) + tw, th = thumb["width"], thumb["height"] + # Center thumbnail on its timestamp, vertically centered in bar + x0 = tx - tw / 2 + y0 = (height - th) / 2 + cr.save() + # Dark outline for separation against any background + cr.set_source_rgba(0, 0, 0, 0.7) + cr.set_line_width(2) + cr.rectangle(x0 - 1, y0 - 1, tw + 2, th + 2) + cr.stroke() + # Thumbnail + cr.set_source_surface(thumb["surface"], x0, y0) + cr.rectangle(x0, y0, tw, th) + cr.fill() + # Bright inner border + cr.set_source_rgba(1, 1, 1, 0.5) + cr.set_line_width(1) + cr.rectangle(x0, y0, tw, th) + cr.stroke() + cr.restore() + + # Scene markers (on top of thumbs) cr.set_source_rgb(*MARKER_COLOR) for ts in self._scene_markers: mx = self._global_to_x(ts, width) diff --git a/cht/ui/timeline.py b/cht/ui/timeline.py index 25bf4eb..74cafb2 100644 --- a/cht/ui/timeline.py +++ b/cht/ui/timeline.py @@ -210,6 +210,7 @@ class TimelineControls(Gtk.Box): else: self._live_btn.remove_css_class("suggested-action") + self._scrub_bar.set_duration(s.duration) 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)) diff --git a/cht/ui/transcript_panel.py b/cht/ui/transcript_panel.py index 81fef34..5f22ae9 100644 --- a/cht/ui/transcript_panel.py +++ b/cht/ui/transcript_panel.py @@ -173,6 +173,24 @@ class TranscriptPanel(Gtk.Box): self._selected.clear() self.emit("selection-changed") + def highlight_nearest(self, timestamp: float) -> None: + """Scroll to and briefly highlight the transcript segment closest to *timestamp*.""" + if not self._order: + return + best_id = None + best_dist = float("inf") + for sid, ts in self._timestamps.items(): + d = abs(ts - timestamp) + if d < best_dist: + best_dist = d + best_id = sid + if not best_id or best_id not in self._rows: + return + row = self._rows[best_id] + row.add_css_class("frame-highlight") + self._scroll_to_row(row) + GLib.timeout_add(400, lambda: row.remove_css_class("frame-highlight") or False) + def clear(self): """Remove all items and reset state.""" self._selected.clear() diff --git a/cht/window.py b/cht/window.py index ca2db81..67d0af7 100644 --- a/cht/window.py +++ b/cht/window.py @@ -60,6 +60,7 @@ class ChtWindow(Adw.ApplicationWindow): on_transcript_ready=lambda segs: self._transcript_panel.add_items(segs), on_scene_marker=lambda ts: self._timeline.add_scene_marker(ts), on_recorder_restarted=lambda path: self._monitor.set_recording(path), + on_manifest_updated=lambda: self._update_scrub_bar_manifest(), ) # Panels (own their selection state) @@ -252,20 +253,33 @@ class ChtWindow(Adw.ApplicationWindow): log.info("Waiting for sender...") def _on_live_toggle(self): - pos = self._monitor.get_live_position() - self._timeline.toggle_live(live_player_pos=pos) + if self._timeline.state.live: + # Live → Scrub: don't load growing MKV, let user pick a segment + self._timeline.toggle_live(live_player_pos=self._monitor.get_live_position()) + # Refresh manifest so scrub bar shows completed segments + self._update_scrub_bar_manifest() + else: + # Scrub → Live: restore recording path and resume + mgr = self._lifecycle.stream_mgr + if mgr: + self._monitor.set_recording(mgr.recording_path) + self._timeline.toggle_live() # -- Scrub -- def _update_scrub_bar_manifest(self): - """Refresh the scrub bar with the current session's segment manifest.""" + """Refresh the scrub bar with the current session's segment manifest and frames.""" mgr = self._lifecycle.stream_mgr if not mgr: return self._manifest = load_segment_manifest(mgr.session_dir) if not self._manifest: self._manifest = rebuild_manifest(mgr.session_dir) - self._timeline_controls.scrub_bar.set_manifest(self._manifest) + scrub_bar = self._timeline_controls.scrub_bar + scrub_bar.set_manifest(self._manifest) + # Feed frame thumbnails to the scrub bar + frames = load_frame_index(mgr.frames_dir) + scrub_bar.set_frames([{"timestamp": f["timestamp"], "path": str(f["path"])} for f in frames]) def _on_segment_activated(self, scrub_bar, segment_index): """User clicked a segment block — request its proxy.""" @@ -327,12 +341,16 @@ class ChtWindow(Adw.ApplicationWindow): def _scrub_tick(self): """Release throttle so next scrub motion can update monitor.""" self._scrub_pending = False + cursor = self._timeline.state.cursor # Apply latest cursor position to monitor - seg, local_time = global_time_to_segment( - self._manifest, self._timeline.state.cursor - ) + seg, local_time = global_time_to_segment(self._manifest, cursor) if seg: self._monitor.scrub_to(local_time) + # Sync waveform, time labels, etc. at throttled rate + self._timeline.emit("changed") + # Highlight nearest frame/transcript + self._frames_panel.highlight_nearest(cursor) + self._transcript_panel.highlight_nearest(cursor) return False def _capture_at_scrub_position(self): @@ -583,6 +601,10 @@ class ChtWindow(Adw.ApplicationWindow): self._frames_panel.load_items(items) self._known_frames = {item["id"] for item in items} self._agent_output.append(f" Loaded {len(items)} frame thumbnails.\n") + # Update scrub bar thumbnails + self._timeline_controls.scrub_bar.set_frames( + [{"timestamp": e["timestamp"], "path": str(e["path"])} for e in entries] + ) def _load_existing_transcript(self): if not self._lifecycle.stream_mgr: @@ -608,6 +630,7 @@ class ChtWindow(Adw.ApplicationWindow): pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(entry["path"]), 256, 144, True) auto = not self._transcript_panel.has_selection self._frames_panel.add_item(fid, pixbuf, entry["timestamp"], auto_select=auto) + self._timeline_controls.scrub_bar.add_frame(entry["timestamp"], str(entry["path"])) except Exception as e: log.warning("Thumbnail load failed for %s: %s", fid, e) return True