diff --git a/cht/app.py b/cht/app.py index 1e0592c..7e9067e 100644 --- a/cht/app.py +++ b/cht/app.py @@ -42,8 +42,8 @@ class ChtApp(Adw.Application): if not win: css = Gtk.CssProvider() css.load_from_string( - ".frame-selected { border: 3px solid @accent_color; border-radius: 6px; }\n" - "row.frame-selected { background: alpha(@accent_color, 0.25); border: none; border-radius: 0; }" + ".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; }" ) Gtk.StyleContext.add_provider_for_display( Gdk.Display.get_default(), diff --git a/cht/ui/frames_panel.py b/cht/ui/frames_panel.py new file mode 100644 index 0000000..1d407f2 --- /dev/null +++ b/cht/ui/frames_panel.py @@ -0,0 +1,197 @@ +""" +FramesPanel: horizontal thumbnail strip with single-selection. + +Owns its own widget tree, selection state, scroll, and row management. +Window reads `selected` property and calls methods — no internal state leaks. +""" + +import logging + +import gi +gi.require_version("Gtk", "4.0") +gi.require_version("GdkPixbuf", "2.0") +from gi.repository import Gtk, Gdk, GLib, Pango, GdkPixbuf, GObject + +from cht.config import SCENE_THRESHOLD + +log = logging.getLogger(__name__) + + +class FramesPanel(Gtk.Box): + """Horizontal thumbnail strip with single-selection.""" + + __gsignals__ = { + "selection-changed": (GObject.SignalFlags.RUN_FIRST, None, ()), + "capture-requested": (GObject.SignalFlags.RUN_FIRST, None, ()), + "threshold-changed": (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._order: list[str] = [] + self._selected: str | None = None + + # Header + header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + header.set_margin_top(4) + header.set_margin_bottom(4) + header.set_margin_start(8) + header.set_margin_end(8) + + self._scene_label = Gtk.Label(label=f"Frames (scene: {SCENE_THRESHOLD:.2f})") + self._scene_label.add_css_class("heading") + header.append(self._scene_label) + + scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0.01, 0.50, 0.01) + scale.set_value(SCENE_THRESHOLD) + scale.set_hexpand(True) + scale.set_draw_value(False) + scale.connect("value-changed", self._on_threshold_changed) + header.append(scale) + + capture_btn = Gtk.Button(label="Capture") + capture_btn.add_css_class("flat") + capture_btn.connect("clicked", lambda b: self.emit("capture-requested")) + header.append(capture_btn) + + self.append(header) + + # Scroll strip + self._scroll = Gtk.ScrolledWindow() + self._scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER) + self._scroll.set_min_content_height(168) + self._scroll.set_size_request(-1, 168) + + self._strip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + self._strip.set_margin_start(4) + self._strip.set_margin_end(4) + self._strip.set_margin_top(4) + self._strip.set_margin_bottom(4) + self._scroll.set_child(self._strip) + self.append(self._scroll) + + # -- Properties -- + + @property + def selected(self) -> str | None: + return self._selected + + @property + def order(self) -> list[str]: + return list(self._order) + + # -- Public API -- + + def add_item(self, frame_id: str, pixbuf, timestamp: float, auto_select: bool = True): + """Add a single thumbnail. Optionally auto-select it.""" + if frame_id in self._widgets: + return + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + box.set_size_request(256, -1) + + texture = Gdk.Texture.new_for_pixbuf(pixbuf) + pic = Gtk.Picture.new_for_paintable(texture) + pic.set_content_fit(Gtk.ContentFit.CONTAIN) + pic.set_size_request(256, 144) + pic.set_vexpand(False) + box.append(pic) + + m, s = divmod(int(timestamp), 60) + label = Gtk.Label(label=f"{frame_id} [{m:02d}:{s:02d}]") + label.add_css_class("caption") + label.set_ellipsize(Pango.EllipsizeMode.END) + box.append(label) + + gesture = Gtk.GestureClick() + gesture.connect("released", lambda g, n, x, y, fid=frame_id: self.select(fid)) + box.add_controller(gesture) + + self._widgets[frame_id] = box + self._order.append(frame_id) + self._strip.append(box) + + if auto_select: + self.select(frame_id) + + log.info("Thumbnail: %s at %.1fs", frame_id, timestamp) + + def load_items(self, items: list[dict]): + """Bulk load. Each dict has 'id', 'pixbuf', 'timestamp'.""" + self.clear() + for item in items: + self.add_item(item["id"], item["pixbuf"], item["timestamp"], auto_select=False) + if self._order: + self.select(self._order[-1]) + + def select(self, frame_id: str): + """Select or deselect (toggle) a frame.""" + # Deselect previous + if self._selected and self._selected in self._widgets: + self._widgets[self._selected].remove_css_class("frame-selected") + + if self._selected == frame_id: + self._selected = None + self.emit("selection-changed") + return + + self._selected = frame_id + if frame_id in self._widgets: + self._widgets[frame_id].add_css_class("frame-selected") + GLib.timeout_add(50, self._scroll_to, self._widgets[frame_id]) + self.emit("selection-changed") + + def select_adjacent(self, delta: int): + """Move selection by delta. Stops at edges.""" + if not self._order: + return + if self._selected is None: + idx = 0 if delta > 0 else len(self._order) - 1 + else: + try: + cur = self._order.index(self._selected) + except ValueError: + cur = 0 + idx = cur + delta + if idx < 0 or idx >= len(self._order): + return + self.select(self._order[idx]) + + def clear_selection(self): + """Deselect without removing items.""" + if self._selected is None: + return + if self._selected in self._widgets: + self._widgets[self._selected].remove_css_class("frame-selected") + self._selected = None + self.emit("selection-changed") + + def clear(self): + """Remove all items and reset state.""" + self._selected = None + self._widgets.clear() + self._order.clear() + while child := self._strip.get_first_child(): + self._strip.remove(child) + + # -- Internal -- + + def _scroll_to(self, widget): + adj = self._scroll.get_hadjustment() + alloc = widget.get_allocation() + x, w = alloc.x, alloc.width + if w <= 0: + return False + page = adj.get_page_size() + val = adj.get_value() + if x < val: + adj.set_value(x) + elif x + w > val + page: + adj.set_value(x + w - page) + return False + + def _on_threshold_changed(self, scale): + val = scale.get_value() + self._scene_label.set_label(f"Frames (scene: {val:.2f})") + self.emit("threshold-changed", val) diff --git a/cht/ui/keyboard.py b/cht/ui/keyboard.py new file mode 100644 index 0000000..e368683 --- /dev/null +++ b/cht/ui/keyboard.py @@ -0,0 +1,92 @@ +""" +KeyboardManager: centralized keyboard shortcut handling. + +Captures all key events at the window level before any child widget. +Routes to registered handlers based on keyval. +""" + +import logging +from typing import Callable + +import gi +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk, Gdk + +log = logging.getLogger(__name__) + +SHIFT = Gdk.ModifierType.SHIFT_MASK +CTRL = Gdk.ModifierType.CONTROL_MASK + +KEY_LEFT = Gdk.KEY_Left +KEY_RIGHT = Gdk.KEY_Right +KEY_UP = Gdk.KEY_Up +KEY_DOWN = Gdk.KEY_Down +KEY_RETURN = Gdk.KEY_Return +KEY_KP_ENTER = Gdk.KEY_KP_Enter +KEY_ESCAPE = Gdk.KEY_Escape +KEY_DELETE = Gdk.KEY_Delete + + +class KeyboardManager: + """Captures key events at window level before child widgets. + + Usage: + kb = KeyboardManager() + kb.bind(KEY_LEFT, on_left) + kb.bind(KEY_UP, on_up) + kb.set_passthrough(lambda: isinstance(window.get_focus(), Gtk.Entry)) + kb.attach(window) + """ + + def __init__(self): + self._bindings: dict[int, Callable] = {} + self._passthrough: Callable[[], bool] | None = None + self._window = None + + def bind(self, keyval: int, handler: Callable): + """Register a handler for a key. Handler receives shift=bool.""" + self._bindings[keyval] = handler + + def set_passthrough(self, check: Callable[[], bool]): + """When check() returns True, keys pass through to focused widget.""" + self._passthrough = check + + def attach(self, window): + """Attach to a GTK4 window.""" + self._window = window + + # EventControllerKey on capture phase + key_ctrl = Gtk.EventControllerKey() + key_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) + key_ctrl.connect("key-pressed", self._on_key_pressed) + window.add_controller(key_ctrl) + + # Reclaim focus from non-interactive widgets on click + click_ctrl = Gtk.GestureClick() + click_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) + click_ctrl.connect("released", self._on_click) + window.add_controller(click_ctrl) + + def _on_click(self, gesture, n_press, x, y): + """After any click, if focus landed on a non-text widget, clear it.""" + if not self._window: + return + focus = self._window.get_focus() + if focus and not isinstance(focus, (Gtk.Entry, Gtk.TextView)): + self._window.set_focus(None) + + def _on_key_pressed(self, controller, keyval, keycode, state): + if self._passthrough and self._passthrough(): + return False + + handler = self._bindings.get(keyval) + if handler is None: + return False + + shift = bool(state & SHIFT) + try: + result = handler(shift=shift) + return result if result is not None else True + except TypeError: + result = handler() + return result if result is not None else True diff --git a/cht/ui/transcript_panel.py b/cht/ui/transcript_panel.py new file mode 100644 index 0000000..7ca3cf6 --- /dev/null +++ b/cht/ui/transcript_panel.py @@ -0,0 +1,201 @@ +""" +TranscriptPanel: vertical transcript list with single/multi-selection. + +Uses Gtk.ListBox. New rows are always added; auto-scroll is suppressed +while a selection is active. +""" + +import logging + +import gi +gi.require_version("Gtk", "4.0") +from gi.repository import Gtk, GLib, GObject + +log = logging.getLogger(__name__) + + +class TranscriptPanel(Gtk.Box): + """Vertical transcript list with single/multi-selection.""" + + __gsignals__ = { + "selection-changed": (GObject.SignalFlags.RUN_FIRST, None, ()), + } + + def __init__(self, **kwargs): + super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=0, **kwargs) + + self._rows: dict[str, Gtk.ListBoxRow] = {} + self._texts: dict[str, str] = {} + self._order: list[str] = [] + self._selected: list[str] = [] + + label = Gtk.Label(label="Transcript") + label.add_css_class("heading") + label.set_margin_top(4) + label.set_margin_bottom(4) + self.append(label) + + self._list = Gtk.ListBox() + self._list.set_selection_mode(Gtk.SelectionMode.NONE) + self._list.connect("row-activated", self._on_row_activated) + + self._scroll = Gtk.ScrolledWindow() + self._scroll.set_vexpand(True) + self._scroll.set_min_content_height(150) + self._scroll.set_child(self._list) + self.append(self._scroll) + + # -- Properties -- + + @property + def selected(self) -> list[str]: + return list(self._selected) + + @property + def selected_texts(self) -> list[str]: + return [self._texts[sid] for sid in self._selected if sid in self._texts] + + @property + def order(self) -> list[str]: + return list(self._order) + + @property + def has_selection(self) -> bool: + return len(self._selected) > 0 + + # -- Public API -- + + def add_items(self, segments): + """Append new segments. Always adds; only auto-scrolls if no selection.""" + for seg in segments: + if seg.id in self._rows: + continue + self._make_row(seg) + + if not self._selected: + adj = self._scroll.get_vadjustment() + GLib.idle_add(lambda: adj.set_value(adj.get_upper()) or False) + + def select(self, seg_id: str, extend: bool = False): + """Select a segment. extend=True adds to selection.""" + if not extend: + self._clear_visual() + self._selected.clear() + + if seg_id in self._selected: + if not extend: + self.emit("selection-changed") + return + self._selected.remove(seg_id) + if seg_id in self._rows: + self._rows[seg_id].remove_css_class("frame-selected") + self.emit("selection-changed") + return + + self._selected.append(seg_id) + if seg_id in self._rows: + self._rows[seg_id].add_css_class("frame-selected") + GLib.timeout_add(50, self._scroll_to_row, self._rows[seg_id]) + self.emit("selection-changed") + + def select_adjacent(self, delta: int, extend: bool = False): + """Move selection by delta. Shift-extend grows/shrinks range.""" + if not self._order: + return + if not self._selected: + idx = 0 if delta > 0 else len(self._order) - 1 + self.select(self._order[idx], extend=False) + return + + if extend and len(self._selected) > 1: + last = self._selected[-1] + prev = self._selected[-2] + try: + last_idx = self._order.index(last) + prev_idx = self._order.index(prev) + except ValueError: + last_idx = prev_idx = 0 + if (delta < 0 and last_idx > prev_idx) or (delta > 0 and last_idx < prev_idx): + self._selected.remove(last) + if last in self._rows: + self._rows[last].remove_css_class("frame-selected") + if self._selected and self._selected[-1] in self._rows: + GLib.timeout_add(50, self._scroll_to_row, self._rows[self._selected[-1]]) + self.emit("selection-changed") + return + + last = self._selected[-1] + try: + cur = self._order.index(last) + except ValueError: + cur = 0 + idx = cur + delta + if idx < 0 or idx >= len(self._order): + return + self.select(self._order[idx], extend=extend) + + def clear_selection(self): + """Deselect all, then flush any pending items.""" + if not self._selected: + return + self._clear_visual() + self._selected.clear() + self.emit("selection-changed") + + def clear(self): + """Remove all items and reset state.""" + self._selected.clear() + self._rows.clear() + self._texts.clear() + self._order.clear() + while child := self._list.get_first_child(): + self._list.remove(child) + + # -- Internal -- + + def _on_row_activated(self, listbox, row): + seg_id = getattr(row, "_seg_id", None) + if seg_id: + self.select(seg_id) + + def _make_row(self, seg): + m1, s1 = divmod(int(seg.start), 60) + m2, s2 = divmod(int(seg.end), 60) + text = f"{seg.id} [{m1:02d}:{s1:02d}-{m2:02d}:{s2:02d}] {seg.text}" + + row_label = Gtk.Label(label=text) + row_label.set_xalign(0) + row_label.set_wrap(True) + row_label.set_margin_start(8) + row_label.set_margin_end(8) + row_label.set_margin_top(2) + row_label.set_margin_bottom(2) + + row = Gtk.ListBoxRow() + row.set_child(row_label) + row.set_activatable(True) + row._seg_id = seg.id + + self._list.append(row) + self._rows[seg.id] = row + self._texts[seg.id] = seg.text + self._order.append(seg.id) + + def _clear_visual(self): + for sid in self._selected: + if sid in self._rows: + self._rows[sid].remove_css_class("frame-selected") + + def _scroll_to_row(self, row): + adj = self._scroll.get_vadjustment() + alloc = row.get_allocation() + y, h = alloc.y, alloc.height + if h <= 0: + return False + page = adj.get_page_size() + val = adj.get_value() + if y < val: + adj.set_value(y) + elif y + h > val + page: + adj.set_value(y + h - page) + return False diff --git a/cht/window.py b/cht/window.py index 5180d92..6b5e447 100644 --- a/cht/window.py +++ b/cht/window.py @@ -12,13 +12,16 @@ from gi.repository import Gtk, Gdk, Adw, GLib, Pango, GdkPixbuf from threading import Thread -from cht.config import APP_NAME, SCENE_THRESHOLD +from cht.config import APP_NAME, SCENE_THRESHOLD, TRANSCRIBE_MIN_CHUNK_S from cht.ui.timeline import Timeline, TimelineControls from cht.ui.monitor import MonitorWidget from cht.ui.waveform import WaveformWidget +from cht.ui.frames_panel import FramesPanel +from cht.ui.transcript_panel import TranscriptPanel +from cht.ui.keyboard import KeyboardManager, KEY_LEFT, KEY_RIGHT, KEY_UP, KEY_DOWN, KEY_RETURN, KEY_KP_ENTER, KEY_ESCAPE, KEY_DELETE from cht.audio.waveform import WaveformEngine from cht.transcriber.engine import TranscriberEngine, LANGUAGES -from cht.stream.manager import StreamManager, list_sessions +from cht.stream.manager import StreamManager, list_sessions, delete_sessions from cht.stream.tracker import RecordingTracker from cht.agent.runner import AgentRunner, ACTIONS, check_claude_cli @@ -35,19 +38,18 @@ class ChtWindow(Adw.ApplicationWindow): self._stream_mgr = None self._tracker = None self._known_frames = set() - self._selected_frame = None # currently selected frame ID - self._frame_widgets = {} # frame_id → outer Box widget - self._frame_order = [] # ordered list of frame IDs - self._transcript_order = [] # ordered list of transcript segment IDs - self._transcript_rows = {} # segment_id → ListBoxRow - self._transcript_texts = {} # segment_id → text (clean, no timestamps) - self._selected_transcripts = [] # ordered list of selected transcript IDs - # Timeline is the central state machine + # Core components self._timeline = Timeline() self._agent = AgentRunner() self._waveform_engine = WaveformEngine() self._transcriber = TranscriberEngine() + self._pending_transcript_audio = [] + self._pending_transcript_duration = 0.0 + + # Panels (own their selection state) + self._frames_panel = FramesPanel() + self._transcript_panel = TranscriptPanel() # Main layout self._main_paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL) @@ -80,27 +82,50 @@ class ChtWindow(Adw.ApplicationWindow): self.connect("close-request", self._on_close) - # Global keyboard shortcuts for frame navigation (capture phase - # so we intercept before GTK activates focused buttons) - key_ctrl = Gtk.EventControllerKey() - key_ctrl.set_propagation_phase(Gtk.PropagationPhase.CAPTURE) - key_ctrl.connect("key-pressed", self._on_key_pressed) - self.add_controller(key_ctrl) + # Keyboard shortcuts + self._setup_keyboard() + + # Wire panel signals + self._frames_panel.connect("capture-requested", lambda p: self._on_capture_clicked()) + self._frames_panel.connect("threshold-changed", lambda p, v: self._on_scene_threshold(v)) + # Cross-panel exclusion: selecting frame clears transcript and vice versa + self._frames_panel.connect("selection-changed", self._on_frame_selection_changed) + self._transcript_panel.connect("selection-changed", self._on_transcript_selection_changed) log.info("Window initialized") - GLib.idle_add(self._check_agent_auth) + # -- Cross-panel selection exclusion -- + + def _on_frame_selection_changed(self, panel): + if panel.selected is not None: + self._transcript_panel.clear_selection() + + def _on_transcript_selection_changed(self, panel): + if panel.has_selection: + self._frames_panel.clear_selection() + + # -- Connect / Disconnect -- + def _on_connect_clicked(self, button): if self._streaming: self._stop_stream(reload_session=True) else: - # If a session is loaded, continue it; otherwise start fresh session_id = self._stream_mgr.session_id if self._stream_mgr else None if self._stream_mgr: - self._stop_stream() # clean teardown first + self._stop_stream() self._start_stream(session_id=session_id) + def _on_capture_clicked(self): + if self._stream_mgr: + self._stream_mgr.capture_now(on_new_frames=self._on_new_scene_frames) + + def _on_scene_threshold(self, val): + if self._stream_mgr: + self._stream_mgr.scene_threshold = val + + # -- Session loading -- + def _on_load_session_clicked(self, button): sessions = list_sessions() if not sessions: @@ -113,6 +138,14 @@ class ChtWindow(Adw.ApplicationWindow): toolbar = Adw.ToolbarView() header = Adw.HeaderBar() + + select_all_btn = Gtk.CheckButton(label="All") + header.pack_start(select_all_btn) + + delete_btn = Gtk.Button(label="Delete") + delete_btn.add_css_class("destructive-action") + header.pack_end(delete_btn) + toolbar.add_top_bar(header) scroll = Gtk.ScrolledWindow() @@ -121,6 +154,8 @@ class ChtWindow(Adw.ApplicationWindow): listbox.set_selection_mode(Gtk.SelectionMode.NONE) listbox.add_css_class("boxed-list") + checks: list[tuple[str, Gtk.CheckButton]] = [] + for sid, sdir in sessions: idx = sdir / "frames" / "index.json" nframes = 0 @@ -130,16 +165,40 @@ class ChtWindow(Adw.ApplicationWindow): pass nrec = len(list((sdir / "stream").glob("recording_*.mp4"))) + check = Gtk.CheckButton() + checks.append((sid, check)) + row = Adw.ActionRow() row.set_title(sid) row.set_subtitle(f"{nframes} frames, {nrec} segments") row.set_activatable(True) + row.add_prefix(check) + def _on_row_activated(r, s=sid, d=dialog): d.close() self._load_session(s) row.connect("activated", _on_row_activated) listbox.append(row) + def _on_select_all(btn): + active = btn.get_active() + for _, cb in checks: + cb.set_active(active) + select_all_btn.connect("toggled", _on_select_all) + + def _on_delete(btn): + to_delete = [sid for sid, cb in checks if cb.get_active()] + if not to_delete: + return + current = self._stream_mgr.session_id if self._stream_mgr else None + if current in to_delete: + to_delete.remove(current) + if to_delete: + delete_sessions(to_delete) + dialog.close() + self._on_load_session_clicked(None) + delete_btn.connect("clicked", _on_delete) + scroll.set_child(listbox) toolbar.set_content(scroll) dialog.set_content(toolbar) @@ -147,7 +206,6 @@ class ChtWindow(Adw.ApplicationWindow): def _load_session(self, session_id): """Load an existing session for review (no streaming).""" - # Stop any active stream or previous loaded session if self._streaming or self._stream_mgr: self._stop_stream() @@ -160,15 +218,13 @@ class ChtWindow(Adw.ApplicationWindow): self.set_title(f"{APP_NAME} — {session_id}") self._append_agent_output(f"Loaded session: {session_id}\n") - # Load recording into review player if segments exist segments = self._stream_mgr.recording_segments if segments: self._monitor.set_recording(segments[0]) - # Probe total duration and set timeline duration = self._stream_mgr.total_duration() if duration > 0: self._timeline.set_duration(duration) - self._timeline.seek(0) # enter scrub mode at start + self._timeline.seek(0) self._append_agent_output( f" Recording: {len(segments)} segment(s), " f"{int(duration)}s duration\n" @@ -176,19 +232,10 @@ class ChtWindow(Adw.ApplicationWindow): else: self._append_agent_output(" No recordings found (frames only).\n") - # Load existing frames into the strip self._load_existing_frames() + self._load_existing_transcript() - # Load existing transcript - transcript_index = self._stream_mgr.transcript_dir / "index.json" - if transcript_index.exists(): - self._transcriber.load_index(transcript_index) - segs = self._transcriber.all_segments() - if segs: - self._append_transcript_segments(segs) - self._append_agent_output(f" Loaded {len(segs)} transcript segments.\n") - - # Compute waveform from existing recordings (background thread) + # Waveform from recording (background) if segments: from cht.stream import ffmpeg as ff @@ -207,9 +254,10 @@ class ChtWindow(Adw.ApplicationWindow): Thread(target=_compute_waveform, daemon=True, name="waveform_load").start() - # Set up agent auth/model if not already done self._populate_model_dropdown() + # -- Streaming -- + def _start_stream(self, session_id=None): log.info("Starting stream...") self._connect_btn.set_label("Disconnect") @@ -218,125 +266,115 @@ class ChtWindow(Adw.ApplicationWindow): self._streaming = True self._gone_live = False - # Continue existing session or create new one self._stream_mgr = StreamManager(session_id=session_id) self._stream_mgr.setup_dirs() - - # Start ffmpeg recorder (listens for sender, relays to UDP) self._stream_mgr.start_recorder() - # Tell monitor where the recording will be and what URL to stream live from self._monitor.set_recording(self._stream_mgr.recording_path) self._monitor.set_live_source(self._stream_mgr.relay_url) - # Start tracking recording duration (across segments) self._tracker = RecordingTracker( get_segments=lambda: self._stream_mgr.recording_segments if self._stream_mgr else [], on_duration_update=self._on_duration_update, ) self._tracker.start() - # Start scene detection self._stream_mgr.start_scene_detector(on_new_frames=self._on_new_scene_frames) - - # Start audio extraction (waveform + transcription) self._stream_mgr.start_audio_extractor(on_new_audio=self._on_new_audio) - # Start polling for frame thumbnails GLib.timeout_add(1000, self._poll_frames) - - # Tick the LIVE cursor every second GLib.timeout_add(1000, self._tick_live) - - # Watchdog: restart recorder on crash/disconnect GLib.timeout_add(2000, self._check_recorder) - # If resuming a session, reload existing frames/transcript/waveform + # Reload existing data if resuming if session_id: self._load_existing_frames() - transcript_index = self._stream_mgr.transcript_dir / "index.json" - if transcript_index.exists(): - self._transcriber.load_index(transcript_index) - segs = self._transcriber.all_segments() - if segs: - self._append_transcript_segments(segs) + self._load_existing_transcript() self.set_title(f"{APP_NAME} — {self._stream_mgr.session_id}") log.info("Waiting for sender...") def _go_live_once(self): - """Called once after startup delay — go LIVE.""" if self._stream_mgr: log.info("Going LIVE (startup delay elapsed)") self._timeline.go_live() - return False # one-shot + return False def _tick_live(self): - """Tick cursor in LIVE mode so timer advances smoothly.""" if not self._streaming: return False self._timeline.tick_live() return True def _on_duration_update(self, duration): - """Called from RecordingTracker thread.""" GLib.idle_add(self._timeline.set_duration, duration) if not self._gone_live: self._gone_live = True GLib.idle_add(self._go_live_once) - # Capture initial frame — scene detector only fires on changes if self._stream_mgr: self._stream_mgr.capture_now(on_new_frames=self._on_new_scene_frames) def _on_new_scene_frames(self, frames): - """Called from scene detector thread when new frames are found.""" for f in frames: GLib.idle_add(self._timeline.add_scene_marker, f["timestamp"]) def _on_new_audio(self, wav_path, start_time, duration): - """Called from audio extractor thread with new WAV chunk.""" if not self._stream_mgr: return - # Compute waveform peaks (fast, ~1ms) self._waveform_engine.append_chunk(wav_path, start_time) peaks = self._waveform_engine.peaks bucket_dur = self._waveform_engine.bucket_duration GLib.idle_add(self._waveform_widget.set_peaks, peaks.copy(), bucket_dur) - # Transcribe in separate thread (GPU-bound, ~1-2s per chunk) - mgr = self._stream_mgr # capture ref before thread starts + self._pending_transcript_audio.append((wav_path, start_time, duration)) + self._pending_transcript_duration += duration + if self._pending_transcript_duration < TRANSCRIBE_MIN_CHUNK_S: + return + + first_start = self._pending_transcript_audio[0][1] + total_dur = self._pending_transcript_duration + self._pending_transcript_audio.clear() + self._pending_transcript_duration = 0.0 + + mgr = self._stream_mgr + chunk_wav = mgr.audio_dir / f"transcript_{int(first_start):06d}.wav" def _transcribe(): - new_segs = self._transcriber.transcribe_chunk(wav_path, time_offset=start_time) - if mgr: - self._transcriber.save_index(mgr.transcript_dir / "index.json") + from cht.stream import ffmpeg as ff + try: + ff.extract_audio_chunk( + mgr.recording_path, chunk_wav, + start_time=first_start, duration=total_dur, + ) + except Exception as e: + log.error("Transcript audio extraction failed: %s", e) + return + if not chunk_wav.exists(): + return + new_segs = self._transcriber.transcribe_chunk(chunk_wav, time_offset=first_start) + self._transcriber.save_index(mgr.transcript_dir / "index.json") if new_segs: - GLib.idle_add(self._append_transcript_segments, new_segs) + GLib.idle_add(self._transcript_panel.add_items, new_segs) Thread(target=_transcribe, daemon=True, name="transcriber").start() def _check_recorder(self): - """Watchdog: restart recorder if it died (sender disconnect, etc).""" if not self._streaming or not self._stream_mgr: - return False # stop polling + return False if not self._stream_mgr.recorder_alive(): log.warning("Recorder died — restarting into new segment") self._stream_mgr.restart_recorder() - # Re-point monitor to new recording segment self._monitor.set_recording(self._stream_mgr.recording_path) - return True # keep polling + return True def _on_live_toggle(self): - """LIVE button handler — passes the live player's current position.""" pos = self._monitor.get_live_position() self._timeline.toggle_live(live_player_pos=pos) def _stop_stream(self, reload_session=False): log.info("Stopping stream...") - # Remember session for reload last_session_id = self._stream_mgr.session_id if self._stream_mgr and not self._stream_mgr.readonly else None - # Stop background threads first (sets stop flags, kills procs) if self._tracker: self._tracker.stop() self._tracker = None @@ -344,27 +382,19 @@ class ChtWindow(Adw.ApplicationWindow): if not self._stream_mgr.readonly: self._stream_mgr.stop_all() self._stream_mgr = None - # Then clean up UI + self._timeline.reset() self._monitor.reset() self._waveform_engine.reset() self._waveform_widget.set_peaks(None, 0.05) self._transcriber.reset() self._agent.clear_history() - self._transcript_order.clear() - self._transcript_rows.clear() - self._transcript_texts.clear() - self._selected_transcripts.clear() - while child := self._transcript_list.get_first_child(): - self._transcript_list.remove(child) + self._pending_transcript_audio.clear() + self._pending_transcript_duration = 0.0 self._known_frames = set() - self._selected_frame = None - self._frame_widgets = {} - self._frame_order = [] - # Clear frame strip - while child := self._frames_strip.get_first_child(): - self._frames_strip.remove(child) + self._frames_panel.clear() + self._transcript_panel.clear() self._connect_btn.set_label("Connect") self._connect_btn.remove_css_class("destructive-action") @@ -372,7 +402,6 @@ class ChtWindow(Adw.ApplicationWindow): self._streaming = False self.set_title(APP_NAME) - # Reload last session in review mode if reload_session and last_session_id: GLib.idle_add(self._load_session, last_session_id) @@ -383,15 +412,14 @@ class ChtWindow(Adw.ApplicationWindow): """Full cleanup for app exit — safe to call multiple times.""" if self._stream_mgr or self._streaming: self._stop_stream() - # Terminate mpv players and GL contexts (only on app exit) self._monitor.stop() - # -- Right panels -- + # -- Layout -- def _build_right_panels(self): right_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - # Top row: player + waveform placeholder + # Video + waveform top_paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL) top_paned.set_shrink_start_child(False) top_paned.set_shrink_end_child(False) @@ -409,18 +437,20 @@ class ChtWindow(Adw.ApplicationWindow): top_paned.set_position(650) right_box.append(top_paned) - # Shared timeline slider (spans under player + waveform) + # Timeline slider self._timeline_controls = TimelineControls(self._timeline) self._timeline_controls.set_live_toggle_callback(self._on_live_toggle) right_box.append(self._timeline_controls) - # Frames extracted - self._frames_panel = self._build_frames_panel() - right_box.append(self._frames_panel) + # Frames + frames_frame = Gtk.Frame() + frames_frame.set_child(self._frames_panel) + right_box.append(frames_frame) # Transcript - self._transcript_panel = self._build_transcript_panel() - right_box.append(self._transcript_panel) + transcript_frame = Gtk.Frame() + transcript_frame.set_child(self._transcript_panel) + right_box.append(transcript_frame) # Agent input self._agent_input = self._build_agent_input() @@ -428,100 +458,6 @@ class ChtWindow(Adw.ApplicationWindow): return right_box - def _build_placeholder(self, title, height=200, width=-1): - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - label = Gtk.Label(label=title) - label.add_css_class("heading") - label.set_margin_top(4) - label.set_margin_bottom(4) - box.append(label) - area = Gtk.DrawingArea() - area.set_content_height(height) - if width > 0: - area.set_content_width(width) - area.set_vexpand(False) - area.set_hexpand(True) - box.append(area) - frame = Gtk.Frame() - frame.set_child(box) - return frame - - def _on_capture_clicked(self, button): - if self._stream_mgr: - self._stream_mgr.capture_now(on_new_frames=self._on_new_scene_frames) - - def _on_scene_threshold_changed(self, scale): - val = scale.get_value() - self._scene_label.set_label(f"Frames (scene: {val:.2f})") - if self._stream_mgr: - self._stream_mgr.scene_threshold = val - - def _build_frames_panel(self): - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) - - header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) - header.set_margin_top(4) - header.set_margin_bottom(4) - header.set_margin_start(8) - header.set_margin_end(8) - - self._scene_label = Gtk.Label(label=f"Frames (scene: {SCENE_THRESHOLD:.2f})") - self._scene_label.add_css_class("heading") - header.append(self._scene_label) - - scale = Gtk.Scale.new_with_range(Gtk.Orientation.HORIZONTAL, 0.01, 0.50, 0.01) - scale.set_value(SCENE_THRESHOLD) - scale.set_hexpand(True) - scale.set_draw_value(False) - scale.connect("value-changed", self._on_scene_threshold_changed) - header.append(scale) - - capture_btn = Gtk.Button(label="Capture") - capture_btn.add_css_class("flat") - capture_btn.connect("clicked", self._on_capture_clicked) - header.append(capture_btn) - - box.append(header) - - # Horizontal scrolling strip — storyboard style - self._frames_scroll = Gtk.ScrolledWindow() - self._frames_scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.NEVER) - self._frames_scroll.set_min_content_height(168) - self._frames_scroll.set_size_request(-1, 168) # 144px thumb + label + padding - - self._frames_strip = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) - self._frames_strip.set_margin_start(4) - self._frames_strip.set_margin_end(4) - self._frames_strip.set_margin_top(4) - self._frames_strip.set_margin_bottom(4) - self._frames_scroll.set_child(self._frames_strip) - box.append(self._frames_scroll) - - frame = Gtk.Frame() - frame.set_child(box) - return frame - - def _build_transcript_panel(self): - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) - label = Gtk.Label(label="Transcript") - label.add_css_class("heading") - label.set_margin_top(4) - label.set_margin_bottom(4) - box.append(label) - - self._transcript_list = Gtk.ListBox() - self._transcript_list.set_selection_mode(Gtk.SelectionMode.NONE) - - self._transcript_scroll = Gtk.ScrolledWindow() - self._transcript_scroll.set_vexpand(True) - self._transcript_scroll.set_min_content_height(150) - self._transcript_scroll.set_child(self._transcript_list) - box.append(self._transcript_scroll) - - frame = Gtk.Frame() - frame.set_child(box) - return frame - # -- Agent panels -- def _build_agent_output(self): @@ -569,7 +505,6 @@ class ChtWindow(Adw.ApplicationWindow): outer.set_margin_top(4) outer.set_margin_bottom(4) - # Quick action buttons + model selector actions_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) for label, verb in ACTIONS.items(): btn = Gtk.Button(label=label) @@ -577,7 +512,6 @@ class ChtWindow(Adw.ApplicationWindow): btn.connect("clicked", lambda b, v=verb: self._send_action(v)) actions_box.append(btn) - # Model dropdown (right-aligned) spacer = Gtk.Box() spacer.set_hexpand(True) actions_box.append(spacer) @@ -608,11 +542,10 @@ class ChtWindow(Adw.ApplicationWindow): outer.append(actions_box) - # Text entry + send input_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) self._input_entry = Gtk.Entry() self._input_entry.set_hexpand(True) - self._input_entry.set_placeholder_text("Message agent... (use @F0001 to reference a frame)") + self._input_entry.set_placeholder_text("Message agent... (@F1-3 frames, @T1-5 transcript)") self._input_entry.connect("activate", lambda e: self._send_message()) input_row.append(self._input_entry) @@ -626,181 +559,39 @@ class ChtWindow(Adw.ApplicationWindow): frame.set_child(outer) return frame - def _scroll_to_frame(self, widget): - """Scroll the frames strip so that widget is visible.""" - adj = self._frames_scroll.get_hadjustment() - alloc = widget.get_allocation() - x = alloc.x - w = alloc.width - if w <= 0: - return False # not allocated yet - page = adj.get_page_size() - val = adj.get_value() - if x < val: - adj.set_value(x) - elif x + w > val + page: - adj.set_value(x + w - page) - return False + # -- Keyboard -- - def _clear_frame_selection(self): - if self._selected_frame and self._selected_frame in self._frame_widgets: - self._frame_widgets[self._selected_frame].remove_css_class("frame-selected") - self._selected_frame = None + def _setup_keyboard(self): + kb = KeyboardManager() + kb.set_passthrough(lambda: self.get_focus() is self._input_entry) + kb.bind(KEY_LEFT, lambda **_: self._frames_panel.select_adjacent(-1)) + kb.bind(KEY_RIGHT, lambda **_: self._frames_panel.select_adjacent(1)) + kb.bind(KEY_UP, lambda shift=False, **_: self._transcript_panel.select_adjacent(-1, extend=shift)) + kb.bind(KEY_DOWN, lambda shift=False, **_: self._transcript_panel.select_adjacent(1, extend=shift)) + kb.bind(KEY_RETURN, lambda **_: self._send_message(self._build_selection_message("answer")) if self._build_selection_message("answer") else None) + kb.bind(KEY_KP_ENTER, lambda **_: self._send_message(self._build_selection_message("answer")) if self._build_selection_message("answer") else None) + kb.bind(KEY_ESCAPE, lambda **_: (self._frames_panel.clear_selection(), self._transcript_panel.clear_selection())) + kb.bind(KEY_DELETE, lambda **_: self._on_clear_agent_output(None)) + kb.attach(self) - def _clear_transcript_selection(self): - for old_id in self._selected_transcripts: - if old_id in self._transcript_rows: - self._transcript_rows[old_id].remove_css_class("frame-selected") - self._selected_transcripts.clear() + # -- Agent actions -- def _build_selection_message(self, verb: str) -> str | None: - """Build a message from verb + selected frame ref + transcript texts.""" parts = [verb] - if self._selected_frame: - parts.append(f"@{self._selected_frame}") - if self._selected_transcripts: - texts = [self._transcript_texts[tid] - for tid in self._selected_transcripts - if tid in self._transcript_texts] - if texts: - parts.append(" ".join(texts)) + if self._frames_panel.selected: + parts.append(f"@{self._frames_panel.selected}") + texts = self._transcript_panel.selected_texts + if texts: + parts.append(" ".join(texts)) return " ".join(parts) if len(parts) > 1 else None def _send_action(self, verb: str): - """Send a predefined action with selected frame/transcript.""" msg = self._build_selection_message(verb) if not msg: self._append_agent_output("Select a frame or transcript first.\n") return self._send_message(msg) - def _select_frame(self, frame_id: str): - """Select a frame thumbnail (or deselect if already selected).""" - self._clear_transcript_selection() - # Deselect previous - if self._selected_frame and self._selected_frame in self._frame_widgets: - self._frame_widgets[self._selected_frame].remove_css_class("frame-selected") - - if self._selected_frame == frame_id: - self._selected_frame = None - return - - self._selected_frame = frame_id - if frame_id in self._frame_widgets: - widget = self._frame_widgets[frame_id] - widget.add_css_class("frame-selected") - # Scroll after layout settles (idle may fire before allocation) - GLib.timeout_add(50, self._scroll_to_frame, widget) - - def _select_transcript(self, seg_id, extend=False): - """Select a transcript segment. If extend=True, add to selection.""" - self._clear_frame_selection() - if not extend: - # Clear previous selection - for old_id in self._selected_transcripts: - if old_id in self._transcript_rows: - self._transcript_rows[old_id].remove_css_class("frame-selected") - self._selected_transcripts.clear() - - if seg_id in self._selected_transcripts: - # Deselect if clicking same one (only in non-extend mode) - if not extend: - return - self._selected_transcripts.remove(seg_id) - if seg_id in self._transcript_rows: - self._transcript_rows[seg_id].remove_css_class("frame-selected") - return - - self._selected_transcripts.append(seg_id) - if seg_id in self._transcript_rows: - row = self._transcript_rows[seg_id] - row.add_css_class("frame-selected") - # Scroll row into view - GLib.timeout_add(50, self._scroll_transcript_to_row, row) - - def _scroll_transcript_to_row(self, row): - adj = self._transcript_scroll.get_vadjustment() - alloc = row.get_allocation() - y = alloc.y - h = alloc.height - if h <= 0: - return False - page = adj.get_page_size() - val = adj.get_value() - if y < val: - adj.set_value(y) - elif y + h > val + page: - adj.set_value(y + h - page) - return False - - def _select_adjacent_transcript(self, delta, extend=False): - """Select next/prev transcript segment. Shift extends selection.""" - if not self._transcript_order: - return - if not self._selected_transcripts: - idx = 0 if delta > 0 else len(self._transcript_order) - 1 - else: - last = self._selected_transcripts[-1] - try: - cur = self._transcript_order.index(last) - except ValueError: - cur = 0 - idx = cur + delta - if idx < 0 or idx >= len(self._transcript_order): - return - self._select_transcript(self._transcript_order[idx], extend=extend) - - def _on_key_pressed(self, controller, keyval, keycode, state): - """Keyboard shortcuts: Left/Right=frames, Up/Down=transcript, Enter=answer.""" - # Don't intercept when text entry is focused - focus = self.get_focus() - if isinstance(focus, (Gtk.Entry, Gtk.TextView)): - return False - - shift = bool(state & Gdk.ModifierType.SHIFT_MASK) - - if keyval == Gdk.KEY_Left: - self._clear_transcript_selection() - self._select_adjacent_frame(-1) - return True - elif keyval == Gdk.KEY_Right: - self._clear_transcript_selection() - self._select_adjacent_frame(1) - return True - elif keyval == Gdk.KEY_Up: - self._clear_frame_selection() - self._select_adjacent_transcript(-1, extend=shift) - return True - elif keyval == Gdk.KEY_Down: - self._clear_frame_selection() - self._select_adjacent_transcript(1, extend=shift) - return True - elif keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): - msg = self._build_selection_message("answer") - if msg: - self._send_message(msg) - return True - elif keyval == Gdk.KEY_Delete: - self._on_clear_agent_output(None) - return True - return False - - def _select_adjacent_frame(self, delta): - """Select the next (+1) or previous (-1) frame. Stops at ends.""" - if not self._frame_order: - return - if self._selected_frame is None: - idx = 0 if delta > 0 else len(self._frame_order) - 1 - else: - try: - cur = self._frame_order.index(self._selected_frame) - except ValueError: - cur = 0 - idx = cur + delta - if idx < 0 or idx >= len(self._frame_order): - return # at the edge, do nothing - self._select_frame(self._frame_order[idx]) - def _send_message(self, text: str | None = None): if text is None: text = self._input_entry.get_text().strip() @@ -825,15 +616,13 @@ class ChtWindow(Adw.ApplicationWindow): self._response_accum = [] def _replace_thinking(self, chunk: str): - """Replace the '…' placeholder with the first real chunk.""" buf = self._agent_output_view.get_buffer() if not self._thinking_replaced: self._thinking_replaced = True end = buf.get_end_iter() start = end.copy() - start.backward_chars(2) # remove '…\n' + start.backward_chars(2) buf.delete(start, end) - # Mark where the response starts for later MD formatting self._response_start_mark = buf.create_mark( None, buf.get_end_iter(), left_gravity=True ) @@ -844,7 +633,6 @@ class ChtWindow(Adw.ApplicationWindow): if err: self._append_agent_output(f"[Error: {err}]\n") return - # Re-render accumulated response with markdown formatting if self._response_start_mark and self._response_accum: buf = self._agent_output_view.get_buffer() start = buf.get_iter_at_mark(self._response_start_mark) @@ -854,12 +642,14 @@ class ChtWindow(Adw.ApplicationWindow): buf.delete_mark(self._response_start_mark) self._append_agent_output("\n") + # -- Markdown rendering -- + def _setup_md_tags(self, buf): buf.create_tag("h1", weight=700, scale=1.4) buf.create_tag("h2", weight=700, scale=1.2) buf.create_tag("h3", weight=700, scale=1.05) buf.create_tag("bold", weight=700) - buf.create_tag("italic", style=2) # Pango.Style.ITALIC = 2 + buf.create_tag("italic", style=2) buf.create_tag("code", family="monospace", background="#2a2a2a", foreground="#e8e8e8") buf.create_tag("codeblock", family="monospace", background="#1e1e1e", foreground="#e8e8e8", left_margin=16, right_margin=16, @@ -867,14 +657,11 @@ class ChtWindow(Adw.ApplicationWindow): buf.create_tag("bullet", left_margin=16) def _render_md(self, buf, insert_iter, text: str): - """Insert markdown-formatted text into buf at insert_iter using text tags.""" import re lines = text.split("\n") i = 0 while i < len(lines): line = lines[i] - - # Fenced code block if line.startswith("```"): lang = line[3:].strip() block_lines = [] @@ -890,8 +677,6 @@ class ChtWindow(Adw.ApplicationWindow): buf.insert(insert_iter, "\n") i += 1 continue - - # Headers header_match = re.match(r"^(#{1,3})\s+(.*)", line) if header_match: level = len(header_match.group(1)) @@ -902,16 +687,12 @@ class ChtWindow(Adw.ApplicationWindow): buf.apply_tag_by_name(tag, buf.get_iter_at_mark(mark), insert_iter) i += 1 continue - - # Bullet points bullet_match = re.match(r"^[\-\*]\s+(.*)", line) if bullet_match: self._insert_inline(buf, insert_iter, "• " + bullet_match.group(1), "bullet") buf.insert(insert_iter, "\n") i += 1 continue - - # Inline formatting pass on normal lines self._insert_inline_line(buf, insert_iter, line) buf.insert(insert_iter, "\n") i += 1 @@ -923,14 +704,12 @@ class ChtWindow(Adw.ApplicationWindow): buf.apply_tag_by_name(outer_tag, buf.get_iter_at_mark(mark), it) def _insert_inline_line(self, buf, it, text: str): - """Insert text with inline bold/italic/code formatting.""" import re pattern = re.compile(r"(\*\*(.+?)\*\*|__(.+?)__|" r"\*(.+?)\*|_(.+?)_|" r"`([^`]+?)`)") pos = 0 for m in pattern.finditer(text): - # Plain text before match if m.start() > pos: buf.insert(it, text[pos:m.start()]) full = m.group(0) @@ -954,14 +733,12 @@ class ChtWindow(Adw.ApplicationWindow): buf.insert(it, text[pos:]) def _insert_highlighted_code(self, buf, it, code: str, lang: str): - """Insert syntax-highlighted code using Pygments token tags.""" try: from pygments.lexers import get_lexer_by_name, guess_lexer from pygments.styles import get_style_by_name except ImportError: buf.insert(it, code) return - try: lexer = get_lexer_by_name(lang, stripall=False) if lang else guess_lexer(code) except Exception: @@ -970,13 +747,11 @@ class ChtWindow(Adw.ApplicationWindow): except Exception: buf.insert(it, code) return - try: style = get_style_by_name("monokai") except Exception: buf.insert(it, code) return - tag_table = buf.get_tag_table() for ttype, value in lexer.get_tokens(code): tag_name = f"pyg_{ttype}" @@ -995,6 +770,8 @@ class ChtWindow(Adw.ApplicationWindow): buf.apply_tag(tag, buf.get_iter_at_mark(mark), it) buf.delete_mark(mark) + # -- Settings callbacks -- + def _on_clear_agent_output(self, _button): self._agent_output_view.get_buffer().set_text("") @@ -1019,7 +796,6 @@ class ChtWindow(Adw.ApplicationWindow): return string_list = Gtk.StringList.new(models) self._model_dropdown.set_model(string_list) - # Select current model current = self._agent.model for i, m in enumerate(models): if m == current: @@ -1041,101 +817,60 @@ class ChtWindow(Adw.ApplicationWindow): def _append_agent_output(self, text: str): buf = self._agent_output_view.get_buffer() buf.insert(buf.get_end_iter(), text) - # Auto-scroll to bottom self._agent_output_view.scroll_to_iter(buf.get_end_iter(), 0, False, 0, 0) - def _append_transcript_segments(self, segments): - """Append transcription segments to the transcript ListBox.""" - for seg in segments: - m1, s1 = divmod(int(seg.start), 60) - m2, s2 = divmod(int(seg.end), 60) - text = f"{seg.id} [{m1:02d}:{s1:02d}-{m2:02d}:{s2:02d}] {seg.text}" - - row_label = Gtk.Label(label=text) - row_label.set_xalign(0) - row_label.set_wrap(True) - row_label.set_margin_start(8) - row_label.set_margin_end(8) - row_label.set_margin_top(2) - row_label.set_margin_bottom(2) - - row = Gtk.ListBoxRow() - row.set_child(row_label) - - gesture = Gtk.GestureClick() - gesture.connect("released", lambda g, n, x, y, sid=seg.id: self._select_transcript(sid)) - row.add_controller(gesture) - - self._transcript_list.append(row) - self._transcript_rows[seg.id] = row - self._transcript_texts[seg.id] = seg.text - self._transcript_order.append(seg.id) - - # Auto-scroll to bottom - adj = self._transcript_scroll.get_vadjustment() - GLib.idle_add(lambda: adj.set_value(adj.get_upper()) or False) - - # -- Frame thumbnails -- + # -- Data loading -- def _load_existing_frames(self): - """Load all frames from an existing session's index into the strip.""" if not self._stream_mgr: return index_path = self._stream_mgr.frames_dir / "index.json" if not index_path.exists(): self._append_agent_output(" No frames found.\n") return - try: index = json.loads(index_path.read_text()) except (json.JSONDecodeError, IOError): return - - # Clear existing strip - while child := self._frames_strip.get_first_child(): - self._frames_strip.remove(child) - self._known_frames.clear() - self._frame_widgets.clear() - self._frame_order.clear() - self._selected_frame = None - - loaded = 0 + items = [] for entry in index: - fid = entry["id"] fpath = Path(entry["path"]) - # Resolve path: try absolute first, then relative to frames_dir if not fpath.exists(): fpath = self._stream_mgr.frames_dir / fpath.name if not fpath.exists(): continue - self._known_frames.add(fid) - timestamp = entry.get("timestamp", 0) try: pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(fpath), 256, 144, True) - self._add_frame_thumbnail(fid, pixbuf, timestamp, auto_select=False) - loaded += 1 + items.append({"id": entry["id"], "pixbuf": pixbuf, "timestamp": entry.get("timestamp", 0)}) except Exception as e: - log.warning("Thumbnail load failed for %s: %s", fid, e) + log.warning("Thumbnail load failed for %s: %s", entry["id"], e) + if items: + self._frames_panel.load_items(items) + self._known_frames = {item["id"] for item in items} + self._append_agent_output(f" Loaded {len(items)} frame thumbnails.\n") - # Select the last frame after bulk load - if self._frame_order: - self._select_frame(self._frame_order[-1]) - - self._append_agent_output(f" Loaded {loaded} frame thumbnails.\n") + def _load_existing_transcript(self): + if not self._stream_mgr: + return + transcript_index = self._stream_mgr.transcript_dir / "index.json" + if not transcript_index.exists(): + return + self._transcriber.load_index(transcript_index) + segs = self._transcriber.all_segments() + if segs: + self._transcript_panel.add_items(segs) + self._append_agent_output(f" Loaded {len(segs)} transcript segments.\n") def _poll_frames(self): if not self._stream_mgr: return False - index_path = self._stream_mgr.frames_dir / "index.json" if not index_path.exists(): return True - try: index = json.loads(index_path.read_text()) except (json.JSONDecodeError, IOError): return True - for entry in index: fid = entry["id"] if fid in self._known_frames: @@ -1143,43 +878,12 @@ class ChtWindow(Adw.ApplicationWindow): fpath = Path(entry["path"]) if not fpath.exists(): continue - self._known_frames.add(fid) timestamp = entry.get("timestamp", 0) try: pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(str(fpath), 256, 144, True) - self._add_frame_thumbnail(fid, pixbuf, timestamp) + auto = not self._transcript_panel.has_selection + self._frames_panel.add_item(fid, pixbuf, timestamp, auto_select=auto) except Exception as e: log.warning("Thumbnail load failed for %s: %s", fid, e) - return True - - def _add_frame_thumbnail(self, frame_id, pixbuf, timestamp, auto_select=True): - box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - box.set_size_request(256, -1) - - texture = Gdk.Texture.new_for_pixbuf(pixbuf) - pic = Gtk.Picture.new_for_paintable(texture) - pic.set_content_fit(Gtk.ContentFit.CONTAIN) - pic.set_size_request(256, 144) - pic.set_vexpand(False) - box.append(pic) - - m, s = divmod(int(timestamp), 60) - label = Gtk.Label(label=f"{frame_id} [{m:02d}:{s:02d}]") - label.add_css_class("caption") - label.set_ellipsize(Pango.EllipsizeMode.END) - box.append(label) - - gesture = Gtk.GestureClick() - gesture.connect("released", lambda g, n, x, y, fid=frame_id: self._select_frame(fid)) - box.add_controller(gesture) - - self._frame_widgets[frame_id] = box - self._frame_order.append(frame_id) - self._frames_strip.append(box) - - if auto_select: - self._select_frame(frame_id) - - log.info("Thumbnail: %s at %.1fs", frame_id, timestamp)