diff --git a/actions/InputDial/InputDial.py b/actions/InputDial/InputDial.py new file mode 100644 index 0000000..82ae407 --- /dev/null +++ b/actions/InputDial/InputDial.py @@ -0,0 +1,221 @@ +from plugins.com_core447_OBSPlugin.OBSActionBase import OBSActionBase +from src.backend.DeckManagement.DeckController import DeckController +from src.backend.DeckManagement.InputIdentifier import Input, InputEvent +from src.backend.PageManagement.Page import Page +from src.backend.PluginManager.PluginBase import PluginBase +from GtkHelper.GtkHelper import ComboRow + +import os +import threading +import math + +# Import gtk modules +import gi +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") +from gi.repository import Gtk, Adw + +class InputDial(OBSActionBase): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.muted = None + self.volume = None + self.last_muted = None + self.last_volume = None + + def on_ready(self): + # Connect ot obs if not connected + if self.plugin_base.backend is not None: + if not self.plugin_base.get_connected(): + self.reconnect_obs() + + # Show current input volume + self.muted = None + self.volume = None + threading.Thread(target=self.show_current_input_volume, daemon=True, name="show_current_input_volume").start() + + def show_current_input_volume(self): + if self.plugin_base.backend is None: + self.current_state = None + self.show_error() + self.set_media(media_path=os.path.join(self.plugin_base.PATH, "assets", "error.png")) + return + if not self.plugin_base.backend.get_connected(): + self.current_state = None + self.show_error() + self.set_media(media_path=os.path.join(self.plugin_base.PATH, "assets", "error.png")) + return + if not self.get_settings().get("input"): + self.current_state = None + self.show_error() + self.set_media(media_path=os.path.join(self.plugin_base.PATH, "assets", "error.png")) + return + + # update muted + status = self.plugin_base.backend.get_input_muted(self.get_settings().get("input")) + if status is None: + self.current_state = -1 + self.show_error() + self.set_media(media_path=os.path.join(self.plugin_base.PATH, "assets", "error.png")) + return + self.muted = status["muted"] + + # update volume + status = self.plugin_base.backend.get_input_volume(self.get_settings().get("input")) + if status is None: + self.current_state = -1 + self.show_error() + self.set_media(media_path=os.path.join(self.plugin_base.PATH, "assets", "error.png")) + return + self.volume = self.db_to_volume(status["volume"]) + + # Now render the button + image = "input_muted.png" if self.muted else "input_unmuted.png" + label = f"{self.volume}%" + + if self.last_muted != self.muted: + self.last_muted = self.muted + self.set_media(media_path=os.path.join(self.plugin_base.PATH, "assets", image), size=0.9) + if self.last_volume != self.volume: + self.last_volume = self.volume + self.set_label(label) + + def get_config_rows(self) -> list: + super_rows = super().get_config_rows() + + self.input_model = Gtk.StringList() + self.input_row = Adw.ComboRow(model=self.input_model, title=self.plugin_base.lm.get("actions.input-dial-row.label")) + + self.connect_signals() + + self.load_input_model() + self.load_configs() + + super_rows.append(self.input_row) + return super_rows + + def connect_signals(self): + self.input_row.connect("notify::selected", self.on_input_change) + + def disconnect_signals(self): + try: + self.input_row.disconnect_by_func(self.on_input_change) + except TypeError as e: + pass + + def load_input_model(self): + self.disconnect_signals() + # Clear model + while self.input_model.get_n_items() > 0: + self.input_model.remove(0) + + # Load model + if self.plugin_base.backend.get_connected(): + inputs = self.plugin_base.backend.get_inputs() + if inputs is None: + self.set_media(media_path=os.path.join(self.plugin_base.PATH, "assets", "error.png")) + return + for input in inputs: + self.input_model.append(input) + + self.connect_signals() + + def load_configs(self): + self.load_selected_device() + + def load_selected_device(self): + self.disconnect_signals() + settings = self.get_settings() + for i, input_name in enumerate(self.input_model): + if input_name.get_string() == settings.get("input"): + self.input_row.set_selected(i) + self.connect_signals() + return + + self.input_row.set_selected(Gtk.INVALID_LIST_POSITION) + self.connect_signals() + + def on_input_change(self, *args): + settings = self.get_settings() + selected_index = self.input_row.get_selected() + settings["input"] = self.input_model[selected_index].get_string() + self.set_settings(settings) + + def event_callback(self, event: InputEvent, data: dict = None): + if event == Input.Key.Events.DOWN or event == Input.Dial.Events.DOWN: + self.mute_toggle() + if str(event) == str(Input.Dial.Events.TURN_CW): + self.volume_change(+5) + if str(event) == str(Input.Dial.Events.TURN_CCW): + self.volume_change(-5) + + def mute_toggle(self): + if self.plugin_base.backend is None: + self.current_state = None + self.show_error() + self.set_media(media_path=os.path.join(self.plugin_base.PATH, "assets", "error.png")) + return + if not self.plugin_base.backend.get_connected(): + self.current_state = None + self.show_error() + self.set_media(media_path=os.path.join(self.plugin_base.PATH, "assets", "error.png")) + return + + input_name = self.get_settings().get("input") + if input_name in [None, ""]: + self.set_media(media_path=os.path.join(self.plugin_base.PATH, "assets", "error.png")) + return + + self.muted = not self.muted + self.plugin_base.backend.set_input_muted(input_name, self.muted) + self.on_tick() + + def volume_change(self, diff): + if self.plugin_base.backend is None: + self.current_state = None + self.show_error() + self.set_media(media_path=os.path.join(self.plugin_base.PATH, "assets", "error.png")) + return + if not self.plugin_base.backend.get_connected(): + self.current_state = None + self.show_error() + self.set_media(media_path=os.path.join(self.plugin_base.PATH, "assets", "error.png")) + return + + input_name = self.get_settings().get("input") + if input_name in [None, ""]: + self.set_media(media_path=os.path.join(self.plugin_base.PATH, "assets", "error.png")) + return + + self.volume += diff + if self.volume < 0: + self.volume = 0 + if self.volume > 100: + self.volume = 100 + self.plugin_base.backend.set_input_volume(input_name, self.volume_to_db(self.volume)) + self.on_tick() + + def on_tick(self): + self.show_current_input_volume() + + def reconnect_obs(self): + super().reconnect_obs() + if hasattr(self, "input_model"): + self.load_input_model() + self.load_configs() + self.muted = None + self.volume = None + + def volume_to_db(self, vol): + if vol == 0: + return -100 + if vol > 100: + return 0 + return math.log(vol/100)*10/math.log(1.5) + + def db_to_volume(self, db): + if db < -100: + return 0 + if db > 0: + return 100 + return math.floor(1.5**(db/10) * 100) \ No newline at end of file diff --git a/backend/OBSController.py b/backend/OBSController.py index 278a64b..6a39ab4 100644 --- a/backend/OBSController.py +++ b/backend/OBSController.py @@ -230,7 +230,7 @@ class OBSController(obsws): log.error(e) - ## Input Muting + ## Input mixer def get_inputs(self) -> list: try: inputs = self.call(requests.GetInputList()).getInputs() @@ -259,6 +259,24 @@ class OBSController(obsws): except (obswebsocket.exceptions.MessageTimeout, websocket._exceptions.WebSocketConnectionClosedException, KeyError) as e: log.error(e) + def get_input_volume(self, input: str): + try: + request = self.call(requests.GetInputVolume(inputName=input)) + + if not request.datain: + log.warning("Cannot find the input!") + return + else: + return request + except (obswebsocket.exceptions.MessageTimeout, websocket._exceptions.WebSocketConnectionClosedException, KeyError) as e: + log.error(e) + + def set_input_volume(self, input: str, volume: int) -> None: + try: + self.call(requests.SetInputVolume(inputName=input, inputVolumeDb=volume)) + except (obswebsocket.exceptions.MessageTimeout, websocket._exceptions.WebSocketConnectionClosedException, KeyError) as e: + log.error(e) + ## Scene Items def get_scene_items(self, sceneName: str) -> list: diff --git a/backend/backend.py b/backend/backend.py index 06d61ab..9da031f 100644 --- a/backend/backend.py +++ b/backend/backend.py @@ -121,7 +121,7 @@ class Backend(BackendBase): def trigger_transition(self): self.OBSController.trigger_transition() - # Input Muting + # Input Mixing def get_inputs(self) -> list[str]: return self.OBSController.get_inputs() @@ -136,6 +136,17 @@ class Backend(BackendBase): def set_input_muted(self, input: str, muted: bool): self.OBSController.set_input_muted(input, muted) + def get_input_volume(self, input: str): + status = self.OBSController.get_input_volume(input) + if status is None: + return + return { + "volume": status.datain["inputVolumeDb"] + } + + def set_input_volume(self, input: str, volume: int): + self.OBSController.set_input_volume(input, volume) + # Scenes def get_scene_names(self) -> list[str]: return self.OBSController.get_scenes() diff --git a/locales/en_US.json b/locales/en_US.json index 61598b2..c5e164d 100644 --- a/locales/en_US.json +++ b/locales/en_US.json @@ -16,6 +16,8 @@ "actions.toggle-input-mute-row.label": "Input:", "actions.toggle-input-mute.name": "Toggle Input Mute", + "actions.input-dial-row.label": "Input:", + "actions.input-dial.name": "Input Dial", "actions.switch.scene-row.label": "Scene:", "actions.switch-scene.name": "Switch Scene", diff --git a/main.py b/main.py index 86d015a..27d5f9d 100644 --- a/main.py +++ b/main.py @@ -35,6 +35,7 @@ from actions.ToggleStudioMode.ToggleStudioMode import ToggleStudioMode from actions.TriggerTransition.TriggerTransition import TriggerTransition from actions.ToggleInputMute.ToggleInputMute import ToggleInputMute +from actions.InputDial.InputDial import InputDial from actions.SwitchScene.SwitchScene import SwitchScene from actions.ToggleSceneItemEnabled.ToggleSceneItemEnabled import ToggleSceneItemEnabled @@ -169,7 +170,7 @@ class OBS(PluginBase): ) self.add_action_holder(trigger_transition_action_holder) - # Input Muting + # Input mixing toggle_input_mute_action_holder = ActionHolder( plugin_base=self, action_base=ToggleInputMute, @@ -183,6 +184,19 @@ class OBS(PluginBase): ) self.add_action_holder(toggle_input_mute_action_holder) + input_dial_holder = ActionHolder( + plugin_base=self, + action_base=InputDial, + action_id_suffix="InputDial", + action_name=self.lm.get("actions.input-dial.name"), + action_support={ + Input.Key: ActionInputSupport.UNTESTED, + Input.Dial: ActionInputSupport.SUPPORTED, + Input.Touchscreen: ActionInputSupport.UNSUPPORTED, + } + ) + self.add_action_holder(input_dial_holder) + # Scenes switch_scene_action_holder = ActionHolder( plugin_base=self,