"""Main application window — wires Timeline to all components.""" 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, Gdk, Adw, GLib, Pango, GdkPixbuf from cht.config import APP_NAME, SCENE_THRESHOLD from cht.ui.timeline import Timeline, TimelineControls from cht.ui.monitor import MonitorWidget from cht.stream.manager import StreamManager from cht.stream.tracker import RecordingTracker 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 self._gone_live = False self._stream_mgr = None self._tracker = None self._known_frames = set() # Timeline is the central state machine self._timeline = Timeline() # Main layout 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) self._main_paned.set_start_child(self._build_agent_output()) right_box = self._build_right_panels() self._main_paned.set_end_child(right_box) # 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) self.connect("close-request", self._on_close) log.info("Window initialized") # Auto-connect on startup GLib.idle_add(self._start_stream) 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._gone_live = False # Create session self._stream_mgr = StreamManager() 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 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) 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 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 _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 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 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): log.info("Stopping stream...") self._timeline.reset() self._monitor.stop() if self._tracker: self._tracker.stop() self._tracker = None if self._stream_mgr: self._stream_mgr.stop_all() self._stream_mgr = None self._known_frames = set() 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 def _on_close(self, *args): self._stop_stream() # -- Right panels -- def _build_right_panels(self): right_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) # Top row: player + waveform placeholder 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._timeline) 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_placeholder("Waveform", height=250, width=200) top_paned.set_end_child(self._waveform_area) top_paned.set_position(650) right_box.append(top_paned) # Shared timeline slider (spans under player + waveform) 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) # Transcript self._transcript_panel = self._build_transcript_panel() right_box.append(self._transcript_panel) # Agent input self._agent_input = self._build_agent_input() right_box.append(self._agent_input) 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_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 # -- Agent panels -- def _build_agent_output(self): 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) 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_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", lambda e: self._send_message()) box.append(self._input_entry) send_btn = Gtk.Button(label="Send") send_btn.add_css_class("suggested-action") send_btn.connect("clicked", lambda b: self._send_message()) box.append(send_btn) frame = Gtk.Frame() frame.set_child(box) return frame def _send_message(self): text = self._input_entry.get_text().strip() if not text: return buf = self._agent_output_view.get_buffer() buf.insert(buf.get_end_iter(), f"\n> {text}\n") self._input_entry.set_text("") # -- Frame thumbnails -- 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: continue 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) 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): 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) # Click to highlight — does NOT switch mode or seek # (future: jump to timestamp in scrub bar without leaving live) gesture = Gtk.GestureClick() gesture.connect("released", lambda g, n, x, y: log.debug("Frame clicked: %s at %.1fs", frame_id, timestamp)) box.add_controller(gesture) self._frames_strip.append(box) # Auto-scroll to show the latest frame adj = self._frames_scroll.get_hadjustment() GLib.idle_add(lambda: adj.set_value(adj.get_upper()) or False) log.info("Thumbnail: %s at %.1fs", frame_id, timestamp)