working the player
This commit is contained in:
272
cht/window.py
272
cht/window.py
@@ -1,3 +1,5 @@
|
||||
"""Main application window — wires Timeline to all components."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
@@ -9,8 +11,10 @@ gi.require_version("GdkPixbuf", "2.0")
|
||||
from gi.repository import Gtk, Adw, GLib, Pango, GdkPixbuf
|
||||
|
||||
from cht.config import APP_NAME
|
||||
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__)
|
||||
|
||||
@@ -21,22 +25,25 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
self.set_title(APP_NAME)
|
||||
self.set_default_size(1400, 900)
|
||||
self._streaming = False
|
||||
self._stream_mgr = None
|
||||
self._tracker = None
|
||||
self._known_frames = set()
|
||||
|
||||
# Main horizontal paned: agent output (left) | right panels
|
||||
# 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)
|
||||
|
||||
# Left: Agent output panel
|
||||
self._agent_output = self._build_agent_output()
|
||||
self._main_paned.set_start_child(self._agent_output)
|
||||
self._main_paned.set_start_child(self._build_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
|
||||
# Header
|
||||
toolbar = Adw.ToolbarView()
|
||||
header = Adw.HeaderBar()
|
||||
header.set_title_widget(Gtk.Label(label=APP_NAME))
|
||||
@@ -48,13 +55,8 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
|
||||
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")
|
||||
|
||||
@@ -71,124 +73,134 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
self._connect_btn.add_css_class("destructive-action")
|
||||
self._streaming = True
|
||||
|
||||
# Create session
|
||||
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
|
||||
# Start ffmpeg recorder (listens for sender, relays to UDP)
|
||||
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)
|
||||
# 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)
|
||||
|
||||
# 3. ffmpeg scene detection runs periodically on the recording
|
||||
self._stream_mgr.start_scene_detector()
|
||||
log.info("Scene detector started")
|
||||
# Start tracking recording duration
|
||||
self._tracker = RecordingTracker(
|
||||
self._stream_mgr.recording_path,
|
||||
on_duration_update=self._on_duration_update,
|
||||
)
|
||||
self._tracker.start()
|
||||
|
||||
# 4. Poll for new frames and show thumbnails
|
||||
self._known_frames = set()
|
||||
# Go LIVE after a short delay — ffmpeg needs time to establish TCP
|
||||
# and begin writing both outputs. UDP relay starts immediately after.
|
||||
GLib.timeout_add(4000, self._go_live_once)
|
||||
|
||||
# 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(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
|
||||
# Tick the LIVE cursor every second
|
||||
GLib.timeout_add(1000, self._tick_live)
|
||||
|
||||
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)
|
||||
|
||||
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_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()
|
||||
log.info("Monitor stopped")
|
||||
if self._tracker:
|
||||
self._tracker.stop()
|
||||
self._tracker = None
|
||||
if self._stream_mgr:
|
||||
self._stream_mgr.stop_all()
|
||||
log.info("Stream manager stopped")
|
||||
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
|
||||
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()
|
||||
self._stop_stream()
|
||||
|
||||
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
|
||||
# -- 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._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_panel("Waveform", height=250, width=200)
|
||||
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_panel(self, title, height=200, width=-1):
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
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:
|
||||
@@ -196,7 +208,6 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
area.set_vexpand(False)
|
||||
area.set_hexpand(True)
|
||||
box.append(area)
|
||||
|
||||
frame = Gtk.Frame()
|
||||
frame.set_child(box)
|
||||
return frame
|
||||
@@ -209,18 +220,18 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
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)
|
||||
# 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(180) # 144px thumb + label + padding
|
||||
|
||||
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)
|
||||
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)
|
||||
@@ -251,6 +262,32 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
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)
|
||||
@@ -261,45 +298,29 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
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)
|
||||
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", self._on_send_clicked)
|
||||
send_btn.connect("clicked", lambda b: self._send_message())
|
||||
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")
|
||||
buf.insert(buf.get_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")
|
||||
# -- Frame thumbnails --
|
||||
|
||||
def _poll_frames(self):
|
||||
"""Check for new extracted frames and add thumbnails."""
|
||||
if not self._stream_mgr:
|
||||
return False
|
||||
|
||||
@@ -308,8 +329,7 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
return True
|
||||
|
||||
try:
|
||||
with open(index_path) as f:
|
||||
index = json.load(f)
|
||||
index = json.loads(index_path.read_text())
|
||||
except (json.JSONDecodeError, IOError):
|
||||
return True
|
||||
|
||||
@@ -322,31 +342,39 @@ class ChtWindow(Adw.ApplicationWindow):
|
||||
continue
|
||||
|
||||
self._known_frames.add(fid)
|
||||
timestamp = entry.get("timestamp", 0)
|
||||
try:
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(
|
||||
str(fpath), 160, 90, True
|
||||
)
|
||||
self._add_frame_thumbnail(fid, pixbuf, entry.get("timestamp"))
|
||||
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("Failed to load thumbnail for %s: %s", fid, e)
|
||||
log.warning("Thumbnail load failed for %s: %s", fid, e)
|
||||
|
||||
return True # keep polling
|
||||
return True
|
||||
|
||||
def _add_frame_thumbnail(self, frame_id, pixbuf, timestamp=None):
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
|
||||
def _add_frame_thumbnail(self, frame_id, pixbuf, timestamp):
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||
box.set_size_request(256, -1)
|
||||
|
||||
img = Gtk.Image.new_from_pixbuf(pixbuf)
|
||||
img.set_size_request(256, 144)
|
||||
img.set_vexpand(False)
|
||||
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)
|
||||
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)
|
||||
|
||||
self._frames_flow.append(box)
|
||||
log.info("Added thumbnail: %s", frame_id)
|
||||
# Click to seek into scrub mode
|
||||
gesture = Gtk.GestureClick()
|
||||
gesture.connect("released", lambda g, n, x, y: self._timeline.seek(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)
|
||||
|
||||
Reference in New Issue
Block a user