shortcuts
This commit is contained in:
@@ -69,17 +69,30 @@ def _parse_mentions(message: str, frames: list[FrameRef]) -> list[FrameRef]:
|
|||||||
return mentioned
|
return mentioned
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_frame_path(frames_dir: Path, raw_path: str) -> Path | None:
|
||||||
|
"""Resolve a frame path from index.json, handling mounted/remote sessions."""
|
||||||
|
p = Path(raw_path)
|
||||||
|
if p.exists():
|
||||||
|
return p
|
||||||
|
# Try relative to frames_dir (handles path prefix mismatch from remote)
|
||||||
|
local = frames_dir / p.name
|
||||||
|
if local.exists():
|
||||||
|
return local
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _load_frames(frames_dir: Path) -> list[FrameRef]:
|
def _load_frames(frames_dir: Path) -> list[FrameRef]:
|
||||||
index_path = frames_dir / "index.json"
|
index_path = frames_dir / "index.json"
|
||||||
if not index_path.exists():
|
if not index_path.exists():
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
entries = json.loads(index_path.read_text())
|
entries = json.loads(index_path.read_text())
|
||||||
return [
|
frames = []
|
||||||
FrameRef(id=e["id"], path=Path(e["path"]), timestamp=e["timestamp"])
|
for e in entries:
|
||||||
for e in entries
|
resolved = _resolve_frame_path(frames_dir, e["path"])
|
||||||
if Path(e["path"]).exists()
|
if resolved:
|
||||||
]
|
frames.append(FrameRef(id=e["id"], path=resolved, timestamp=e["timestamp"]))
|
||||||
|
return frames
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning("Could not load frames index: %s", e)
|
log.warning("Could not load frames index: %s", e)
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
APP_ID = "com.cht.StreamAgent"
|
APP_ID = "com.cht.StreamAgent"
|
||||||
@@ -6,7 +7,7 @@ APP_NAME = "CHT"
|
|||||||
# Default session data location — in project dir for easy clearing
|
# Default session data location — in project dir for easy clearing
|
||||||
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
PROJECT_DIR = Path(__file__).resolve().parent.parent
|
||||||
DATA_DIR = PROJECT_DIR / "data"
|
DATA_DIR = PROJECT_DIR / "data"
|
||||||
SESSIONS_DIR = DATA_DIR / "sessions"
|
SESSIONS_DIR = Path(os.environ.get("CHT_SESSIONS_DIR", DATA_DIR / "sessions"))
|
||||||
|
|
||||||
# Stream defaults
|
# Stream defaults
|
||||||
STREAM_HOST = "0.0.0.0"
|
STREAM_HOST = "0.0.0.0"
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ from cht.stream import ffmpeg as ff
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def list_sessions():
|
||||||
|
"""Return list of (session_id, session_dir) sorted newest first."""
|
||||||
|
if not SESSIONS_DIR.exists():
|
||||||
|
return []
|
||||||
|
sessions = []
|
||||||
|
for d in sorted(SESSIONS_DIR.iterdir(), reverse=True):
|
||||||
|
if d.is_dir() and (d / "frames").exists():
|
||||||
|
sessions.append((d.name, d))
|
||||||
|
return sessions
|
||||||
|
|
||||||
|
|
||||||
class StreamManager:
|
class StreamManager:
|
||||||
def __init__(self, session_id=None):
|
def __init__(self, session_id=None):
|
||||||
if session_id is None:
|
if session_id is None:
|
||||||
@@ -42,8 +53,55 @@ class StreamManager:
|
|||||||
self._stop_flags = set()
|
self._stop_flags = set()
|
||||||
self._segment = 0
|
self._segment = 0
|
||||||
self.scene_threshold = SCENE_THRESHOLD
|
self.scene_threshold = SCENE_THRESHOLD
|
||||||
|
self.readonly = False # True when loaded from existing session
|
||||||
log.info("Session: %s", session_id)
|
log.info("Session: %s", session_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_existing(cls, session_id):
|
||||||
|
"""Load an existing session without starting any ffmpeg processes."""
|
||||||
|
mgr = cls(session_id=session_id)
|
||||||
|
if not mgr.session_dir.exists():
|
||||||
|
raise FileNotFoundError(f"Session not found: {session_id}")
|
||||||
|
mgr.readonly = True
|
||||||
|
# Point _segment to last recording segment
|
||||||
|
segments = mgr.recording_segments
|
||||||
|
if segments:
|
||||||
|
mgr._segment = len(segments) - 1
|
||||||
|
log.info("Loaded existing session: %s (%d segments, %d frames)",
|
||||||
|
session_id, len(segments), mgr.frame_count)
|
||||||
|
return mgr
|
||||||
|
|
||||||
|
@property
|
||||||
|
def frame_count(self):
|
||||||
|
index_path = self.frames_dir / "index.json"
|
||||||
|
if index_path.exists():
|
||||||
|
try:
|
||||||
|
return len(json.loads(index_path.read_text()))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def total_duration(self):
|
||||||
|
"""Probe total duration across all segments (for completed sessions)."""
|
||||||
|
total = 0.0
|
||||||
|
for seg in self.recording_segments:
|
||||||
|
try:
|
||||||
|
import ffmpeg as ffmpeg_lib
|
||||||
|
info = ffmpeg_lib.probe(str(seg))
|
||||||
|
dur = float(info.get("format", {}).get("duration", 0))
|
||||||
|
if dur <= 0:
|
||||||
|
for s in info.get("streams", []):
|
||||||
|
sdur = float(s.get("duration", 0))
|
||||||
|
if sdur > 0:
|
||||||
|
dur = sdur
|
||||||
|
break
|
||||||
|
if dur <= 0:
|
||||||
|
dur = seg.stat().st_size / 65_000
|
||||||
|
total += dur
|
||||||
|
except Exception:
|
||||||
|
total += seg.stat().st_size / 65_000
|
||||||
|
return total
|
||||||
|
|
||||||
def setup_dirs(self):
|
def setup_dirs(self):
|
||||||
for d in (self.stream_dir, self.frames_dir, self.transcript_dir, self.agent_dir):
|
for d in (self.stream_dir, self.frames_dir, self.transcript_dir, self.agent_dir):
|
||||||
d.mkdir(parents=True, exist_ok=True)
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
239
cht/window.py
239
cht/window.py
@@ -13,7 +13,7 @@ from gi.repository import Gtk, Gdk, Adw, GLib, Pango, GdkPixbuf
|
|||||||
from cht.config import APP_NAME, SCENE_THRESHOLD
|
from cht.config import APP_NAME, SCENE_THRESHOLD
|
||||||
from cht.ui.timeline import Timeline, TimelineControls
|
from cht.ui.timeline import Timeline, TimelineControls
|
||||||
from cht.ui.monitor import MonitorWidget
|
from cht.ui.monitor import MonitorWidget
|
||||||
from cht.stream.manager import StreamManager
|
from cht.stream.manager import StreamManager, list_sessions
|
||||||
from cht.stream.tracker import RecordingTracker
|
from cht.stream.tracker import RecordingTracker
|
||||||
from cht.agent.runner import AgentRunner, ACTIONS, check_claude_cli
|
from cht.agent.runner import AgentRunner, ACTIONS, check_claude_cli
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._known_frames = set()
|
self._known_frames = set()
|
||||||
self._selected_frame = None # currently selected frame ID
|
self._selected_frame = None # currently selected frame ID
|
||||||
self._frame_widgets = {} # frame_id → outer Box widget
|
self._frame_widgets = {} # frame_id → outer Box widget
|
||||||
|
self._frame_order = [] # ordered list of frame IDs
|
||||||
|
|
||||||
# Timeline is the central state machine
|
# Timeline is the central state machine
|
||||||
self._timeline = Timeline()
|
self._timeline = Timeline()
|
||||||
@@ -58,11 +59,23 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._connect_btn.connect("clicked", self._on_connect_clicked)
|
self._connect_btn.connect("clicked", self._on_connect_clicked)
|
||||||
header.pack_start(self._connect_btn)
|
header.pack_start(self._connect_btn)
|
||||||
|
|
||||||
|
self._load_btn = Gtk.Button(label="Load Session")
|
||||||
|
self._load_btn.connect("clicked", self._on_load_session_clicked)
|
||||||
|
header.pack_start(self._load_btn)
|
||||||
|
|
||||||
toolbar.add_top_bar(header)
|
toolbar.add_top_bar(header)
|
||||||
toolbar.set_content(self._main_paned)
|
toolbar.set_content(self._main_paned)
|
||||||
self.set_content(toolbar)
|
self.set_content(toolbar)
|
||||||
|
|
||||||
self.connect("close-request", self._on_close)
|
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)
|
||||||
|
|
||||||
log.info("Window initialized")
|
log.info("Window initialized")
|
||||||
|
|
||||||
GLib.idle_add(self._start_stream)
|
GLib.idle_add(self._start_stream)
|
||||||
@@ -74,6 +87,87 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
else:
|
else:
|
||||||
self._start_stream()
|
self._start_stream()
|
||||||
|
|
||||||
|
def _on_load_session_clicked(self, button):
|
||||||
|
sessions = list_sessions()
|
||||||
|
if not sessions:
|
||||||
|
self._append_agent_output("No previous sessions found.\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
dialog = Adw.Window(transient_for=self, modal=True)
|
||||||
|
dialog.set_title("Load Session")
|
||||||
|
dialog.set_default_size(500, 400)
|
||||||
|
|
||||||
|
toolbar = Adw.ToolbarView()
|
||||||
|
header = Adw.HeaderBar()
|
||||||
|
toolbar.add_top_bar(header)
|
||||||
|
|
||||||
|
scroll = Gtk.ScrolledWindow()
|
||||||
|
scroll.set_vexpand(True)
|
||||||
|
listbox = Gtk.ListBox()
|
||||||
|
listbox.set_selection_mode(Gtk.SelectionMode.NONE)
|
||||||
|
listbox.add_css_class("boxed-list")
|
||||||
|
|
||||||
|
for sid, sdir in sessions:
|
||||||
|
idx = sdir / "frames" / "index.json"
|
||||||
|
nframes = 0
|
||||||
|
try:
|
||||||
|
nframes = len(json.loads(idx.read_text()))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
nrec = len(list((sdir / "stream").glob("recording_*.mp4")))
|
||||||
|
|
||||||
|
row = Adw.ActionRow()
|
||||||
|
row.set_title(sid)
|
||||||
|
row.set_subtitle(f"{nframes} frames, {nrec} segments")
|
||||||
|
row.set_activatable(True)
|
||||||
|
def _on_row_activated(r, s=sid, d=dialog):
|
||||||
|
d.close()
|
||||||
|
self._load_session(s)
|
||||||
|
row.connect("activated", _on_row_activated)
|
||||||
|
listbox.append(row)
|
||||||
|
|
||||||
|
scroll.set_child(listbox)
|
||||||
|
toolbar.set_content(scroll)
|
||||||
|
dialog.set_content(toolbar)
|
||||||
|
dialog.present()
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._stream_mgr = StreamManager.from_existing(session_id)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
self._append_agent_output(f"Error: {e}\n")
|
||||||
|
return
|
||||||
|
|
||||||
|
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._append_agent_output(
|
||||||
|
f" Recording: {len(segments)} segment(s), "
|
||||||
|
f"{int(duration)}s duration\n"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._append_agent_output(" No recordings found (frames only).\n")
|
||||||
|
|
||||||
|
# Load existing frames into the strip
|
||||||
|
self._load_existing_frames()
|
||||||
|
|
||||||
|
# Set up agent auth/model if not already done
|
||||||
|
self._populate_model_dropdown()
|
||||||
|
|
||||||
def _start_stream(self):
|
def _start_stream(self):
|
||||||
log.info("Starting stream...")
|
log.info("Starting stream...")
|
||||||
self._connect_btn.set_label("Disconnect")
|
self._connect_btn.set_label("Disconnect")
|
||||||
@@ -167,16 +261,23 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._tracker.stop()
|
self._tracker.stop()
|
||||||
self._tracker = None
|
self._tracker = None
|
||||||
if self._stream_mgr:
|
if self._stream_mgr:
|
||||||
self._stream_mgr.stop_all()
|
if not self._stream_mgr.readonly:
|
||||||
|
self._stream_mgr.stop_all()
|
||||||
self._stream_mgr = None
|
self._stream_mgr = None
|
||||||
self._known_frames = set()
|
self._known_frames = set()
|
||||||
self._selected_frame = None
|
self._selected_frame = None
|
||||||
self._frame_widgets = {}
|
self._frame_widgets = {}
|
||||||
|
self._frame_order = []
|
||||||
|
|
||||||
|
# Clear frame strip
|
||||||
|
while child := self._frames_strip.get_first_child():
|
||||||
|
self._frames_strip.remove(child)
|
||||||
|
|
||||||
self._connect_btn.set_label("Connect")
|
self._connect_btn.set_label("Connect")
|
||||||
self._connect_btn.remove_css_class("destructive-action")
|
self._connect_btn.remove_css_class("destructive-action")
|
||||||
self._connect_btn.add_css_class("suggested-action")
|
self._connect_btn.add_css_class("suggested-action")
|
||||||
self._streaming = False
|
self._streaming = False
|
||||||
|
self.set_title(APP_NAME)
|
||||||
|
|
||||||
def _on_close(self, *args):
|
def _on_close(self, *args):
|
||||||
self._stop_stream()
|
self._stop_stream()
|
||||||
@@ -323,11 +424,24 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
def _build_agent_output(self):
|
def _build_agent_output(self):
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||||
|
|
||||||
|
header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
|
||||||
|
header.set_margin_start(8)
|
||||||
|
header.set_margin_end(8)
|
||||||
|
header.set_margin_top(8)
|
||||||
|
header.set_margin_bottom(8)
|
||||||
label = Gtk.Label(label="Agent Output")
|
label = Gtk.Label(label="Agent Output")
|
||||||
label.add_css_class("heading")
|
label.add_css_class("heading")
|
||||||
label.set_margin_top(8)
|
label.set_hexpand(True)
|
||||||
label.set_margin_bottom(8)
|
label.set_halign(Gtk.Align.START)
|
||||||
box.append(label)
|
header.append(label)
|
||||||
|
|
||||||
|
clear_btn = Gtk.Button(label="Clear")
|
||||||
|
clear_btn.add_css_class("flat")
|
||||||
|
clear_btn.connect("clicked", lambda b: self._agent_output_view.get_buffer().set_text(""))
|
||||||
|
header.append(clear_btn)
|
||||||
|
|
||||||
|
box.append(header)
|
||||||
|
|
||||||
self._agent_output_view = Gtk.TextView()
|
self._agent_output_view = Gtk.TextView()
|
||||||
self._agent_output_view.set_editable(False)
|
self._agent_output_view.set_editable(False)
|
||||||
@@ -395,6 +509,22 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
frame.set_child(outer)
|
frame.set_child(outer)
|
||||||
return frame
|
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
|
||||||
|
|
||||||
def _send_action(self, verb: str):
|
def _send_action(self, verb: str):
|
||||||
"""Send a predefined action with the selected frame."""
|
"""Send a predefined action with the selected frame."""
|
||||||
if not self._selected_frame:
|
if not self._selected_frame:
|
||||||
@@ -403,7 +533,7 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
self._send_message(f"{verb} @{self._selected_frame}")
|
self._send_message(f"{verb} @{self._selected_frame}")
|
||||||
|
|
||||||
def _select_frame(self, frame_id: str):
|
def _select_frame(self, frame_id: str):
|
||||||
"""Toggle selection of a frame thumbnail."""
|
"""Select a frame thumbnail (or deselect if already selected)."""
|
||||||
# Deselect previous
|
# Deselect previous
|
||||||
if self._selected_frame and self._selected_frame in self._frame_widgets:
|
if self._selected_frame and self._selected_frame in self._frame_widgets:
|
||||||
self._frame_widgets[self._selected_frame].remove_css_class("frame-selected")
|
self._frame_widgets[self._selected_frame].remove_css_class("frame-selected")
|
||||||
@@ -414,7 +544,48 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
self._selected_frame = frame_id
|
self._selected_frame = frame_id
|
||||||
if frame_id in self._frame_widgets:
|
if frame_id in self._frame_widgets:
|
||||||
self._frame_widgets[frame_id].add_css_class("frame-selected")
|
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 _on_key_pressed(self, controller, keyval, keycode, state):
|
||||||
|
"""Handle Left/Right arrow for frame selection, Enter for answer."""
|
||||||
|
# Don't intercept when text entry is focused
|
||||||
|
focus = self.get_focus()
|
||||||
|
if isinstance(focus, (Gtk.Entry, Gtk.TextView)):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if keyval == Gdk.KEY_Left:
|
||||||
|
self._select_adjacent_frame(-1)
|
||||||
|
return True
|
||||||
|
elif keyval == Gdk.KEY_Right:
|
||||||
|
self._select_adjacent_frame(1)
|
||||||
|
return True
|
||||||
|
elif keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
|
||||||
|
if self._selected_frame:
|
||||||
|
self._send_action("answer")
|
||||||
|
return True
|
||||||
|
elif keyval == Gdk.KEY_Delete:
|
||||||
|
self._agent_output_view.get_buffer().set_text("")
|
||||||
|
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):
|
def _send_message(self, text: str | None = None):
|
||||||
if text is None:
|
if text is None:
|
||||||
@@ -650,6 +821,52 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
# -- Frame thumbnails --
|
# -- Frame thumbnails --
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Thumbnail load failed for %s: %s", fid, e)
|
||||||
|
|
||||||
|
# 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 _poll_frames(self):
|
def _poll_frames(self):
|
||||||
if not self._stream_mgr:
|
if not self._stream_mgr:
|
||||||
return False
|
return False
|
||||||
@@ -681,7 +898,7 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _add_frame_thumbnail(self, frame_id, pixbuf, timestamp):
|
def _add_frame_thumbnail(self, frame_id, pixbuf, timestamp, auto_select=True):
|
||||||
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||||
box.set_size_request(256, -1)
|
box.set_size_request(256, -1)
|
||||||
|
|
||||||
@@ -703,10 +920,10 @@ class ChtWindow(Adw.ApplicationWindow):
|
|||||||
box.add_controller(gesture)
|
box.add_controller(gesture)
|
||||||
|
|
||||||
self._frame_widgets[frame_id] = box
|
self._frame_widgets[frame_id] = box
|
||||||
|
self._frame_order.append(frame_id)
|
||||||
self._frames_strip.append(box)
|
self._frames_strip.append(box)
|
||||||
|
|
||||||
# Auto-scroll to show the latest frame
|
if auto_select:
|
||||||
adj = self._frames_scroll.get_hadjustment()
|
self._select_frame(frame_id)
|
||||||
GLib.idle_add(lambda: adj.set_value(adj.get_upper()) or False)
|
|
||||||
|
|
||||||
log.info("Thumbnail: %s at %.1fs", frame_id, timestamp)
|
log.info("Thumbnail: %s at %.1fs", frame_id, timestamp)
|
||||||
|
|||||||
Reference in New Issue
Block a user