""" MonitorWidget: embeds ffplay into the GTK4 window for live stream monitoring. On X11, uses the --wid flag of mpv for native embedding, or reparents ffplay's window via xdotool. GTK4 dropped GtkSocket so we use X11-level tricks. """ import logging import subprocess import signal from threading import Timer import gi gi.require_version("Gtk", "4.0") from gi.repository import Gtk, GLib, Gdk log = logging.getLogger(__name__) class MonitorWidget(Gtk.Box): """Widget that embeds an ffplay/mpv window for live stream monitoring.""" def __init__(self, **kwargs): super().__init__(orientation=Gtk.Orientation.VERTICAL, **kwargs) self._proc = None self._xid = None self._drawing_area = Gtk.DrawingArea() self._drawing_area.set_hexpand(True) self._drawing_area.set_vexpand(True) self._drawing_area.set_content_height(250) self.append(self._drawing_area) log.info("MonitorWidget initialized") def start_ffplay(self, input_path): log.info("Starting ffplay with input: %s", input_path) cmd = [ "ffplay", "-fflags", "nobuffer", "-flags", "low_delay", "-framedrop", "-noborder", "-i", str(input_path), ] log.info("ffplay cmd: %s", " ".join(cmd)) self._proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) log.info("ffplay started: pid=%s", self._proc.pid) self._start_stderr_log("ffplay") Timer(1.0, self._try_reparent).start() def start_mpv(self, input_path): log.info("Starting mpv with input: %s", input_path) surface = self._drawing_area.get_native().get_surface() xid = None if hasattr(surface, "get_xid"): xid = surface.get_xid() log.info("Got X11 window ID: %s", xid) else: log.warning("No get_xid on surface (type=%s), mpv will open separate window", type(surface).__name__) cmd = [ "mpv", "--no-terminal", "--no-osc", "--no-input-default-bindings", "--profile=low-latency", "--demuxer-lavf-o=fflags=+nobuffer", ] if xid: cmd.append(f"--wid={xid}") cmd.append(str(input_path)) log.info("mpv cmd: %s", " ".join(cmd)) self._proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) log.info("mpv started: pid=%s", self._proc.pid) self._start_stderr_log("mpv") def _start_stderr_log(self, name): """Read stderr in background thread and log it.""" import threading def _read(): try: for line in self._proc.stderr: text = line.decode("utf-8", errors="replace").rstrip() if text: log.info("[%s:stderr] %s", name, text) except Exception as e: log.warning("[%s:stderr] read error: %s", name, e) retcode = self._proc.poll() if self._proc else None log.info("[%s] process exited: code=%s", name, retcode) t = threading.Thread(target=_read, daemon=True, name=f"{name}_stderr") t.start() def _try_reparent(self): if self._proc is None or self._proc.poll() is not None: log.warning("ffplay not running, cannot reparent") return log.info("Attempting to reparent ffplay window (pid=%s)", self._proc.pid) try: result = subprocess.run( ["xdotool", "search", "--pid", str(self._proc.pid)], capture_output=True, text=True, timeout=5, ) windows = result.stdout.strip().split("\n") log.info("xdotool found windows: %s", windows) if windows and windows[0]: ffplay_wid = windows[0] surface = self._drawing_area.get_native().get_surface() if hasattr(surface, "get_xid"): parent_xid = surface.get_xid() log.info("Reparenting ffplay %s into %s", ffplay_wid, parent_xid) subprocess.run( ["xdotool", "windowreparent", ffplay_wid, str(parent_xid)], timeout=5, ) subprocess.run( ["xdotool", "windowsize", ffplay_wid, "100%", "100%"], timeout=5, ) log.info("Reparenting done") else: log.warning("No get_xid on surface, cannot reparent") else: log.warning("No windows found for ffplay pid=%s", self._proc.pid) except FileNotFoundError: log.error("xdotool not found, cannot reparent ffplay window") except subprocess.TimeoutExpired: log.error("xdotool timed out") def stop(self): if self._proc and self._proc.poll() is None: log.info("Stopping monitor process pid=%s", self._proc.pid) self._proc.send_signal(signal.SIGINT) try: self._proc.wait(timeout=3) log.info("Monitor process stopped gracefully") except subprocess.TimeoutExpired: self._proc.kill() log.warning("Monitor process killed (did not stop in 3s)") self._proc = None else: log.info("Monitor process already stopped or never started")