--- /dev/null
+from dataclasses import dataclass
+import importlib.resources
+
+import obsws_python as obs
+import obsws_python.error as obs_error
+from textual.app import App
+from textual.containers import HorizontalGroup, VerticalGroup, Vertical, Horizontal, Container
+from textual.widgets import Footer, Header, Label, Placeholder
+
+from . import log, data
+from .widget import StreamControls, RecordControls, HorizontalLabels, VerticalLabels
+
+logger = log.getLogger(__name__)
+obs_req_client = obs.ReqClient()
+obs_evnt_client = obs.EventClient()
+
+@dataclass(frozen=True)
+class OBSStats:
+ record_output_active: bool
+ record_output_paused: bool
+ record_output_bytes: float
+ record_output_duration: float
+ stream_output_active: bool
+ stream_output_skipped_frames: float
+ stream_output_total_frames: float
+ stats_render_skipped_frames: float
+ stats_render_total_frames: float
+ stats_output_skipped_frames: float
+ stats_output_total_frames: float
+ cpu_usage_percent: float
+ memory_usage_mb: float
+ active_fps: float
+ available_disk_space: float
+
+ @property
+ def estimated_hours_remaining(self):
+ try:
+ output = (
+ (
+ self.available_disk_space*1024*1024) / \
+ (
+ self.record_output_bytes / \
+ self.record_output_duration
+ )
+ ) / 1000 / 60 / 60
+ except:
+ output = "N/A"
+
+ return output
+
+class OBSCtlApp(App):
+ logger.debug(importlib.resources.files(data))
+ CSS_PATH = importlib.resources.files(data).joinpath("layout.tcss")
+
+ def on_mount(self):
+ self.set_interval(1., self.process_obs_stats)
+
+
+ def compose(self):
+ yield Header()
+ yield Footer()
+ logger.debug("Composing OBSCtlApp")
+ yield Container(
+ Label("Controls", id="controls_label", classes="labels"),
+ StreamControls(id="stream_ctl", classes="controls"),
+ RecordControls(id="record_ctl", classes="controls"),
+ id="controls",
+ classes="blocks"
+ )
+ yield Container(
+ Label("Statistics", id="stats1_label", classes="labels"),
+ Horizontal(
+ VerticalLabels("CPU Usage", "-.-%", id="cpu_usage", classes="stats"),
+ VerticalLabels("Memory Usage", "-b", id="memory_usage", classes="stats"),
+ VerticalLabels("FPS", "--.--", id="fps", classes="stats")
+ ),
+ classes="blocks",
+ )
+ yield Container(
+ Label("More Statistics", id="stats2_label", classes="labels"),
+ HorizontalLabels("Disk full in (approx):", "--:--:--", id="disk_full", classes="stats"),
+ HorizontalLabels("Dropped frames (network):", "-/- (-.-%)", id="stream_dropped_frames", classes="stats"),
+ classes="blocks",
+ )
+ yield Container(
+ Label("Frames Missed", id="missed_label", classes="labels"),
+ Horizontal(
+ VerticalLabels("Rendering Lag", "-/- (-.-%)", id="rendering_lag", classes="stats"),
+ VerticalLabels("Encoding Lag", "-/- (-.-%)", id="encoding_lag", classes="stats"),
+ ),
+ classes="blocks"
+ )
+
+
+ def on_stream_controls_stopped(self, message):
+ logger.debug("Stream Stopped message rcv'd")
+ try:
+ obs_req_client.stop_stream()
+ except obs_error.OBSSDKRequestError as err:
+ if err.code == 501:
+ logger.debug("Stream already stopped.")
+ else:
+ raise
+
+
+ def on_stream_controls_started(self, message):
+ logger.debug("Stream Started message rcv'd")
+ try:
+ obs_req_client.start_stream()
+ except obs_error.OBSSDKRequestError as err:
+ if err.code == 500:
+ logger.debug("Stream already started.")
+ else:
+ raise
+
+
+ def on_record_controls_stopped(self, message):
+ logger.debug("Record Stopped message rcv'd")
+ try:
+ obs_req_client.stop_record()
+ except obs_error.OBSSDKRequestError as err:
+ if err.code == 501:
+ logger.debug("Record already stopped.")
+ else:
+ raise
+
+
+ def on_record_controls_started(self, message):
+ logger.debug("Record Started message rcv'd")
+ try:
+ obs_req_client.start_record()
+ except obs_error.OBSSDKRequestError as err:
+ if err.code == 500:
+ logger.debug("Record already started.")
+ else:
+ raise
+
+
+ def on_record_controls_paused(self, message):
+ logger.debug("Record Paused message rcv'd")
+ try:
+ obs_req_client.pause_record()
+ except obs_error.OBSSDKRequestError as err:
+ if err.code == 502:
+ logger.debug("Record already paused.")
+ else:
+ raise
+
+
+ def on_record_controls_unpaused(self, message):
+ logger.debug("Record Unpaused message rcv'd")
+ try:
+ obs_req_client.resume_record()
+ except obs_error.OBSSDKRequestError as err:
+ if err.code == 503:
+ logger.debug("Record already unpaused.")
+ else:
+ raise
+
+
+ @staticmethod
+ def get_obs_stats():
+ record_status = obs_req_client.get_record_status()
+ stream_status = obs_req_client.get_stream_status()
+ obs_stats = obs_req_client.get_stats()
+ # logger.debug(record_status)
+ # logger.debug(stream_status)
+ # logger.debug(obs_stats.attrs())
+ output = OBSStats(
+ record_output_active=record_status.output_active,
+ record_output_paused=record_status.output_paused,
+ record_output_bytes=record_status.output_bytes,
+ record_output_duration=record_status.output_duration,
+ stream_output_active=stream_status.output_active,
+ stream_output_skipped_frames=stream_status.output_skipped_frames,
+ stream_output_total_frames=stream_status.output_total_frames,
+ stats_render_skipped_frames=obs_stats.render_skipped_frames,
+ stats_render_total_frames=obs_stats.render_total_frames,
+ stats_output_skipped_frames=obs_stats.output_skipped_frames,
+ stats_output_total_frames=obs_stats.output_total_frames,
+ cpu_usage_percent=obs_stats.cpu_usage,
+ memory_usage_mb=obs_stats.memory_usage,
+ active_fps=obs_stats.active_fps,
+ available_disk_space=obs_stats.available_disk_space,
+ )
+
+ return output
+
+
+ def update_monitors(self, obs_stats: OBSStats):
+ stream_ctl: StreamControls = self.query_exactly_one("#stream_ctl")
+ record_ctl: RecordControls = self.query_exactly_one("#record_ctl")
+ cpu_usage: VerticalLabels = self.query_exactly_one("#cpu_usage")
+ memory_usage: VerticalLabels = self.query_exactly_one("#memory_usage")
+ active_fp: VerticalLabels = self.query_exactly_one("#fps")
+ disk_full: HorizontalLabels = self.query_exactly_one("#disk_full")
+ stream_dropped_frames: HorizontalLabels = self.query_exactly_one("#stream_dropped_frames")
+ rendering_lag: VerticalLabels = self.query_exactly_one("#rendering_lag")
+ encoding_lag: VerticalLabels = self.query_exactly_one("#encoding_lag")
+
+ stream_ctl.set_light(obs_stats.stream_output_active)
+ record_ctl.set_light(obs_stats.record_output_active)
+ record_ctl.set_pause_light(obs_stats.record_output_paused)
+ cpu_usage.text = f"{obs_stats.cpu_usage_percent:0.5}%"
+ memory_usage.text = f"{obs_stats.memory_usage_mb:0.5}MB"
+ active_fp.text = f"{obs_stats.active_fps}"
+ disk_full.text = f"{obs_stats.estimated_hours_remaining:0.5} hours"
+ stream_dropped_frames.text = f"{obs_stats.stream_output_skipped_frames}/{obs_stats.stream_output_total_frames}"
+ rendering_lag.text = f"{obs_stats.stats_render_skipped_frames}/{obs_stats.stats_render_total_frames}"
+ encoding_lag.text = f"{obs_stats.stats_output_skipped_frames}/{obs_stats.stats_output_total_frames}"
+
+
+ def process_obs_stats(self):
+ logger.debug("Processing OBS stats")
+ stats = self.get_obs_stats()
+ self.update_monitors(stats)
+
+
+class OBSLayoutTest(App):
+ CSS_PATH = importlib.resources.files(data).joinpath("layout.tcss")
+ def compose(self):
+ yield Header()
+ yield Footer()
+ logger.debug("Composing OBSCtlApp")
+ yield Vertical(
+ Placeholder("Controls Label", id="controls_label", classes="labels"),
+ Placeholder("SteamControls", id="stream_ctl", classes="controls"),
+ Placeholder("RecordControls", id="record_ctl", classes="controls"),
+ id="controls",
+ classes="block"
+ )
+ yield Vertical(
+ Placeholder("Statistics Label", id="stats1_label", classes="labels"),
+ Horizontal(
+ Placeholder("CPU Usage", id="cpu_usage", classes="stats"),
+ Placeholder("Memory Usage", id="memory_usage", classes="stats"),
+ Placeholder("FPS", id="fps", classes="stats"),
+ classes="block",
+ )
+ )
+ yield Vertical(
+ Placeholder("More Statistics Label", id="stats2_label", classes="labels"),
+ Placeholder("Disk full in (approx):", id="disk_full", classes="stats"),
+ Placeholder("/Dropped frames (network):", id="stream_dropped_frames", classes="stats"),
+ classes="blocks",
+ )
+ yield Vertical(
+ Placeholder("Frames Missed Label", id="missed_label", classes="labels"),
+ Horizontal(
+ Placeholder("Rendering Lag", id="rendering_lag", classes="stats"),
+ Placeholder("Encoding Lag", id="encoding_lag", classes="stats"),
+ classes="stats"
+ ),
+ classes="blocks"
+ )
+
+
+def main():
+ app = OBSCtlApp()
+ # app = OBSLayoutTest()
+ logger.debug("App entrypoint reached")
+ logger.debug(f"Request Client: {obs_req_client}")
+ logger.debug(f"Event Client: {obs_evnt_client}")
+ app.run()
\ No newline at end of file
--- /dev/null
+from textual.app import App, ComposeResult
+from textual.containers import Horizontal, Vertical, Grid, Container
+from textual.message import Message
+from textual.reactive import reactive
+from textual.widgets import Label, RadioButton, Button
+from textual.widget import Widget
+
+from .. import log
+
+logger = log.getLogger(__name__)
+
+
+class SRControls(Widget):
+ light_active = reactive(False)
+ class MsgBase(Message):
+ def __init__(self, srcontrols: "SRControls"):
+ logger.debug(f"{self} sent from {srcontrols}")
+ self.srcontrol = srcontrols
+ super().__init__()
+
+ def on_button_pressed(self, event: Button.Pressed):
+ # light: RadioButton = self.query_exactly_one("#light")
+ if event.button.id == "start":
+ logger.debug(f"Start pressed on {self}")
+ self.post_message(self.Started(self))
+ # light.value = True
+ elif event.button.id == "stop":
+ logger.debug(f"Stop pressed on {self}")
+ self.post_message(self.Stopped(self))
+ # light.value = False
+
+
+ def set_light(self, value: bool) -> None:
+ light: RadioButton = self.query_exactly_one("#light")
+ light.value = value
+
+
+class StreamControls(SRControls):
+ # DEFAULT_CSS = """
+ # StreamControls {
+ # layout: grid;
+ # grid-size:3 1;
+ # height:1fr;
+ # width:1fr;
+ # # grid-columns:2fr 1fr 1fr;
+ # # grid-rows:1fr;
+ # }
+ # """
+ class Started(SRControls.MsgBase): ...
+ class Stopped(SRControls.MsgBase): ...
+ def compose(self) -> ComposeResult:
+ logger.debug("Composing StreamControls")
+ yield RadioButton(
+ label="Streaming",
+ value=False,
+ button_first=False,
+ id="light",
+ compact=True,
+ disabled=True
+ )
+ yield Button(
+ label="Start",
+ id="start",
+ variant="success",
+ compact=True,
+ )
+ yield Button(
+ label="Stop",
+ id="stop",
+ variant="error",
+ compact=True,
+ )
+
+
+class RecordControls(SRControls):
+ # DEFAULT_CSS = """
+ # RecordControls {
+ # layout: grid;
+ # grid-size:3 2;
+ # height:1fr;
+ # width:1fr;
+ # grid-columns: 2fr 1fr 1fr;
+ # grid-rows: 1fr 1fr;
+ # }
+ # """
+
+ class Started(SRControls.MsgBase): ...
+ class Stopped(SRControls.MsgBase): ...
+ class Paused(SRControls.MsgBase): ...
+ class Unpaused(SRControls.MsgBase): ...
+
+
+ def compose(self):
+ logger.debug("Composing RecordControls")
+ with Horizontal():
+ yield RadioButton(
+ label="Recording",
+ value=False,
+ button_first=False,
+ compact=True,
+ disabled=True,
+ id="light",
+ )
+ yield Button(
+ "Start",
+ id="start",
+ variant="success",
+ compact = True,
+ )
+ yield Button(
+ "Stop",
+ id="stop",
+ variant="error",
+ compact=True,
+ )
+ with Container():
+ yield RadioButton(
+ label="Paused",
+ value=False,
+ button_first=False,
+ id="pause_light",
+ compact=True,
+ disabled=True
+ )
+ yield Button(
+ "Pause",
+ id="pause_record",
+ variant="error",
+ compact=True,
+ )
+ yield Button(
+ "Unpause",
+ id="unpause_record",
+ variant="success",
+ compact=True,
+ )
+
+
+ def on_button_pressed(self, event: Button.Pressed):
+ if event.button.id == "pause_record":
+ logger.debug(f"Pause pressed on {self}")
+ self.post_message(self.Paused(self))
+ elif event.button.id == "unpause_record":
+ logger.debug(f"Unpause pressed on {self}")
+ self.post_message(self.Unpaused(self))
+
+
+ def set_pause_light(self, value):
+ light: RadioButton = self.query_exactly_one("#pause_light")
+ light.value = value
+
+
+class HorizontalLabels(Widget):
+ # DEFAULT_CSS = """
+ # HorizontalLabels {
+ # layout: horizontal;
+ # height:1fr;
+ # width:1fr;
+ # }
+ # """
+ text = reactive("")
+ def __init__(self, label_one, label_two, *children, name = None, id = None, classes = None, disabled = False, markup = True):
+ self._label_one_param = label_one
+ self._label_two_param = label_two
+ super().__init__(*children, name=name, id=id, classes=classes, disabled=disabled, markup=markup)
+
+
+ def compose(self):
+ yield Label(self._label_one_param, id="constant")
+ yield Label(self._label_two_param, id="variable")
+
+
+ def watch_text(self, text: str):
+ logger.debug(f"Setting label {self} text to {text}")
+ label: Label = self.query_exactly_one("#variable")
+ label.update(text)
+
+class VerticalLabels(Widget):
+ # DEFAULT_CSS = """
+ # VerticalLabels {
+ # layout: vertical;
+ # height: 1fr;
+ # width:1fr;
+ # }
+ # """
+ text = reactive("")
+ def __init__(self, label_one, label_two, *children, name = None, id = None, classes = None, disabled = False, markup = True):
+ self._label_one_param = label_one
+ self._label_two_param = label_two
+ super().__init__(*children, name=name, id=id, classes=classes, disabled=disabled, markup=markup)
+
+ def compose(self):
+ yield Label(self._label_one_param, id="constant")
+ yield Label(self._label_two_param, id="variable")
+
+
+ def watch_text(self, text: str):
+ logger.debug(f"Setting label {self} text to {text}")
+ label: Label = self.query_exactly_one("#variable")
+ label.update(text)
\ No newline at end of file