import json import logging from pathlib import Path import gi gi.require_version("Gtk", "4.0") gi.require_version("Adw", "1") gi.require_version("GdkPixbuf", "2.0") from gi.repository import Gtk, Adw, GLib, Pango, GdkPixbuf from cht.config import APP_NAME from cht.ui.monitor import MonitorWidget from cht.stream.manager import StreamManager log = logging.getLogger(__name__) class ChtWindow(Adw.ApplicationWindow): def __init__(self, **kwargs): super().__init__(**kwargs) self.set_title(APP_NAME) self.set_default_size(1400, 900) self._streaming = False # Main horizontal paned: agent output (left) | right panels self._main_paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL) self._main_paned.set_shrink_start_child(False) self._main_paned.set_shrink_end_child(False) self._main_paned.set_position(450) # Left: Agent output panel self._agent_output = self._build_agent_output() self._main_paned.set_start_child(self._agent_output) # Right: vertical stack of panels right_box = self._build_right_panels() self._main_paned.set_end_child(right_box) # Wrap in toolbar view with header toolbar = Adw.ToolbarView() header = Adw.HeaderBar() header.set_title_widget(Gtk.Label(label=APP_NAME)) self._connect_btn = Gtk.Button(label="Connect") self._connect_btn.add_css_class("suggested-action") self._connect_btn.connect("clicked", self._on_connect_clicked) header.pack_start(self._connect_btn) toolbar.add_top_bar(header) toolbar.set_content(self._main_paned) self.set_content(toolbar) # Stream manager self._stream_mgr = None # Connect window close to cleanup self.connect("close-request", self._on_close) log.info("Window initialized") def _on_connect_clicked(self, button): if self._streaming: self._stop_stream() else: self._start_stream() def _start_stream(self): log.info("Starting stream...") self._connect_btn.set_label("Disconnect") self._connect_btn.remove_css_class("suggested-action") self._connect_btn.add_css_class("destructive-action") self._streaming = True self._stream_mgr = StreamManager() log.info("Session: %s", self._stream_mgr.session_id) self._stream_mgr.setup_dirs() # 1. ffmpeg receives TCP and writes growing recording.ts self._stream_mgr.start_recorder() log.info("Recorder started, waiting for sender...") # 2. mpv plays the recording file (DVR: live edge + scrub) # Small delay to let ffmpeg create the file GLib.timeout_add(2000, self._start_playback) # 3. ffmpeg scene detection runs periodically on the recording self._stream_mgr.start_scene_detector() log.info("Scene detector started") # 4. Poll for new frames and show thumbnails self._known_frames = set() GLib.timeout_add(3000, self._poll_frames) def _start_playback(self): """Start mpv playback once recording file exists.""" if self._stream_mgr and self._stream_mgr.recording_path.exists(): size = self._stream_mgr.recording_path.stat().st_size if size > 10_000: self._monitor.start_recording(self._stream_mgr.recording_path) log.info("Playback started") return False # stop timer log.info("Waiting for recording data...") return True # retry def _stop_stream(self): log.info("Stopping stream...") self._monitor.stop() log.info("Monitor stopped") if self._stream_mgr: self._stream_mgr.stop_all() log.info("Stream manager stopped") self._stream_mgr = None self._connect_btn.set_label("Connect") self._connect_btn.remove_css_class("destructive-action") self._connect_btn.add_css_class("suggested-action") self._streaming = False log.info("Stream stopped, ready to reconnect") def _on_close(self, *args): log.info("Window closing, cleaning up...") self._monitor.stop() if self._stream_mgr: self._stream_mgr.stop_all() def _build_agent_output(self): """Left panel: agent output log.""" box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) label = Gtk.Label(label="Agent Output") label.add_css_class("heading") label.set_margin_top(8) label.set_margin_bottom(8) box.append(label) self._agent_output_view = Gtk.TextView() self._agent_output_view.set_editable(False) self._agent_output_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) self._agent_output_view.set_cursor_visible(False) self._agent_output_view.set_left_margin(8) self._agent_output_view.set_right_margin(8) self._agent_output_view.set_top_margin(4) self._agent_output_view.set_bottom_margin(4) scroll = Gtk.ScrolledWindow() scroll.set_vexpand(True) scroll.set_child(self._agent_output_view) box.append(scroll) frame = Gtk.Frame() frame.set_child(box) return frame def _build_right_panels(self): right_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) top_paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL) top_paned.set_shrink_start_child(False) top_paned.set_shrink_end_child(False) self._monitor = MonitorWidget() self._monitor.set_hexpand(True) stream_frame = Gtk.Frame() stream_frame.set_child(self._monitor) top_paned.set_start_child(stream_frame) self._waveform_area = self._build_panel("Waveform", height=250, width=200) top_paned.set_end_child(self._waveform_area) top_paned.set_position(650) right_box.append(top_paned) self._frames_panel = self._build_frames_panel() right_box.append(self._frames_panel) self._transcript_panel = self._build_transcript_panel() right_box.append(self._transcript_panel) self._agent_input = self._build_agent_input() right_box.append(self._agent_input) return right_box def _build_panel(self, title, height=200, width=-1): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) 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 _build_frames_panel(self): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) label = Gtk.Label(label="Frames Extracted") label.add_css_class("heading") label.set_margin_top(4) label.set_margin_bottom(4) box.append(label) self._frames_flow = Gtk.FlowBox() self._frames_flow.set_orientation(Gtk.Orientation.HORIZONTAL) self._frames_flow.set_max_children_per_line(20) self._frames_flow.set_min_children_per_line(1) self._frames_flow.set_selection_mode(Gtk.SelectionMode.MULTIPLE) self._frames_flow.set_homogeneous(True) scroll = Gtk.ScrolledWindow() scroll.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scroll.set_min_content_height(120) scroll.set_child(self._frames_flow) box.append(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_view = Gtk.TextView() self._transcript_view.set_editable(False) self._transcript_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) self._transcript_view.set_cursor_visible(False) self._transcript_view.set_left_margin(8) self._transcript_view.set_right_margin(8) scroll = Gtk.ScrolledWindow() scroll.set_vexpand(True) scroll.set_min_content_height(150) scroll.set_child(self._transcript_view) box.append(scroll) frame = Gtk.Frame() frame.set_child(box) return frame def _build_agent_input(self): box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) box.set_margin_start(4) box.set_margin_end(4) box.set_margin_top(4) box.set_margin_bottom(4) self._input_entry = Gtk.Entry() self._input_entry.set_hexpand(True) self._input_entry.set_placeholder_text("Message agent... (use @ to reference frames/transcripts)") self._input_entry.connect("activate", self._on_input_activate) box.append(self._input_entry) send_btn = Gtk.Button(label="Send") send_btn.add_css_class("suggested-action") send_btn.connect("clicked", self._on_send_clicked) box.append(send_btn) frame = Gtk.Frame() frame.set_child(box) return frame def _on_input_activate(self, entry): self._send_message() def _on_send_clicked(self, button): self._send_message() def _send_message(self): text = self._input_entry.get_text().strip() if not text: return buf = self._agent_output_view.get_buffer() end_iter = buf.get_end_iter() buf.insert(end_iter, f"\n> {text}\n") self._input_entry.set_text("") def append_agent_output(self, text): buf = self._agent_output_view.get_buffer() end_iter = buf.get_end_iter() buf.insert(end_iter, text + "\n") def append_transcript(self, entry_id, text): buf = self._transcript_view.get_buffer() end_iter = buf.get_end_iter() buf.insert(end_iter, f"[{entry_id}] {text}\n") def _poll_frames(self): """Check for new extracted frames and add thumbnails.""" if not self._stream_mgr: return False index_path = self._stream_mgr.frames_dir / "index.json" if not index_path.exists(): return True try: with open(index_path) as f: index = json.load(f) except (json.JSONDecodeError, IOError): return True for entry in index: fid = entry["id"] if fid in self._known_frames: continue fpath = Path(entry["path"]) if not fpath.exists(): continue self._known_frames.add(fid) try: pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( str(fpath), 160, 90, True ) self._add_frame_thumbnail(fid, pixbuf, entry.get("timestamp")) except Exception as e: log.warning("Failed to load thumbnail for %s: %s", fid, e) return True # keep polling def _add_frame_thumbnail(self, frame_id, pixbuf, timestamp=None): box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1) img = Gtk.Image.new_from_pixbuf(pixbuf) box.append(img) label_text = frame_id if timestamp is not None: m, s = divmod(int(timestamp), 60) label_text = f"{frame_id} [{m:02d}:{s:02d}]" label = Gtk.Label(label=label_text) label.add_css_class("caption") label.set_ellipsize(Pango.EllipsizeMode.END) box.append(label) self._frames_flow.append(box) log.info("Added thumbnail: %s", frame_id)