From a3d24cbe5b9254d437c5d502f9e91503f8e79c73 Mon Sep 17 00:00:00 2001 From: Nikos Chatzimpontozis Date: Wed, 31 Dec 2025 16:03:09 +0100 Subject: [PATCH 1/2] feat(actions): add per-user volume control via dial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add UserVolume action that allows adjusting individual user volumes while in a Discord voice channel using a Stream Deck+ dial. Features: - Dial rotation adjusts selected user's volume (±5% per tick, 0-200%) - Dial press cycles through users in the voice channel - Display shows channel name, username, and volume percentage - Real-time updates when users join/leave the channel Also fixes callback cleanup bug in main.py: DiscordCore.cleanup_callbacks() was calling remove_callback() but main.py defined clear_callbacks(). Renamed to remove_callback() for consistency with add_callback(). --- actions/UserVolume.py | 335 +++++++++++++++++++++++++++++++++++++ backend.py | 98 ++++++++++- discordrpc/asyncdiscord.py | 19 +++ main.py | 16 +- 4 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 actions/UserVolume.py diff --git a/actions/UserVolume.py b/actions/UserVolume.py new file mode 100644 index 0000000..c0ac506 --- /dev/null +++ b/actions/UserVolume.py @@ -0,0 +1,335 @@ +from loguru import logger as log + +from .DiscordCore import DiscordCore +from src.backend.PluginManager.EventAssigner import EventAssigner +from src.backend.PluginManager.InputBases import Input + +from ..discordrpc.commands import ( + VOICE_STATE_CREATE, + VOICE_STATE_DELETE, + VOICE_STATE_UPDATE, + VOICE_CHANNEL_SELECT, + GET_CHANNEL, +) + + +class UserVolume(DiscordCore): + """Action for controlling per-user volume via dial. + + Dial behavior: + - Rotate: Adjust volume of selected user (+/- 5% per tick) + - Press: Cycle to next user in voice channel + + Display: + - Top label: Current voice channel name (or "Not in voice") + - Center label: Username/nick + - Bottom label: Volume percentage + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.has_configuration = False + + # Current state + self._users: list = [] # List of user dicts [{id, username, nick, volume, muted}, ...] + self._current_user_index: int = 0 + self._current_channel_id: str = None + self._current_channel_name: str = "" + self._in_voice_channel: bool = False + + # Volume adjustment step (percentage points per dial tick) + self.VOLUME_STEP = 5 + + def on_ready(self): + super().on_ready() + + # Subscribe to voice channel changes (doesn't need channel_id) + self.register_backend_callback(VOICE_CHANNEL_SELECT, self._on_voice_channel_select) + + # Subscribe to GET_CHANNEL responses + self.register_backend_callback(GET_CHANNEL, self._on_get_channel) + + # Initialize display + self._update_display() + + # Request current voice channel state (in case we're already in a channel) + self.backend.request_current_voice_channel() + + def create_event_assigners(self): + # Dial rotation: adjust volume + self.event_manager.add_event_assigner( + EventAssigner( + id="volume-up", + ui_label="volume-up", + default_event=Input.Dial.Events.TURN_CW, + callback=self._on_volume_up, + ) + ) + self.event_manager.add_event_assigner( + EventAssigner( + id="volume-down", + ui_label="volume-down", + default_event=Input.Dial.Events.TURN_CCW, + callback=self._on_volume_down, + ) + ) + + # Dial press: cycle user + self.event_manager.add_event_assigner( + EventAssigner( + id="cycle-user", + ui_label="cycle-user", + default_event=Input.Dial.Events.DOWN, + callback=self._on_cycle_user, + ) + ) + + # Also support key press for cycling (for key-based assignment) + self.event_manager.add_event_assigner( + EventAssigner( + id="cycle-user-key", + ui_label="cycle-user-key", + default_event=Input.Key.Events.DOWN, + callback=self._on_cycle_user, + ) + ) + + # === Event Handlers === + + def _on_volume_up(self, _): + """Increase current user's volume.""" + self._adjust_volume(self.VOLUME_STEP) + + def _on_volume_down(self, _): + """Decrease current user's volume.""" + self._adjust_volume(-self.VOLUME_STEP) + + def _on_cycle_user(self, _): + """Cycle to next user in voice channel.""" + if not self._users: + return + self._current_user_index = (self._current_user_index + 1) % len(self._users) + self._update_display() + + def _adjust_volume(self, delta: int): + """Adjust current user's volume by delta.""" + if not self._users or self._current_user_index >= len(self._users): + return + + user = self._users[self._current_user_index] + current_volume = user.get("volume", 100) + new_volume = max(0, min(200, current_volume + delta)) + + try: + if self.backend.set_user_volume(user["id"], new_volume): + user["volume"] = new_volume + self._update_display() + except Exception as ex: + log.error(f"Failed to set user volume: {ex}") + self.show_error(3) + + # === Discord Event Callbacks === + + def _on_voice_channel_select(self, data: dict): + """Handle user joining/leaving voice channel.""" + try: + if data is None or data.get("channel_id") is None: + # Left voice channel - unsubscribe from previous channel + if self._current_channel_id: + self.backend.unsubscribe_voice_states(self._current_channel_id) + self._in_voice_channel = False + self._current_channel_id = None + self._current_channel_name = "" + self._users.clear() + self._current_user_index = 0 + self.backend.clear_voice_channel_users() + else: + # Joined voice channel + new_channel_id = data.get("channel_id") + + # If switching channels, unsubscribe from old channel first + if self._current_channel_id and self._current_channel_id != new_channel_id: + self.backend.unsubscribe_voice_states(self._current_channel_id) + self._users.clear() + self._current_user_index = 0 + + self._in_voice_channel = True + self._current_channel_id = new_channel_id + self._current_channel_name = data.get("name", "Voice") + + # Register frontend callbacks for voice state events + self.plugin_base.add_callback(VOICE_STATE_CREATE, self._on_voice_state_create) + self.plugin_base.add_callback(VOICE_STATE_DELETE, self._on_voice_state_delete) + self.plugin_base.add_callback(VOICE_STATE_UPDATE, self._on_voice_state_update) + + # Subscribe to voice state events via backend (with channel_id) + self.backend.subscribe_voice_states(self._current_channel_id) + + # Fetch initial user list + self.backend.get_channel(self._current_channel_id) + + self._update_display() + except Exception as ex: + log.error(f"UserVolume[{id(self)}]: Error in _on_voice_channel_select: {ex}") + + def _on_get_channel(self, data: dict): + """Handle GET_CHANNEL response with initial user list.""" + if not data: + return + + # Check if this is for our current channel + channel_id = data.get("id") + if channel_id != self._current_channel_id: + return + + # Update channel name if available + if data.get("name"): + self._current_channel_name = data.get("name") + + # Process voice_states array + voice_states = data.get("voice_states", []) + current_user_id = self.backend.current_user_id + + for vs in voice_states: + user_data = vs.get("user", {}) + user_id = user_data.get("id") + + if not user_id: + continue + + # Filter out self + if user_id == current_user_id: + continue + + user_info = { + "id": user_id, + "username": user_data.get("username", "Unknown"), + "nick": vs.get("nick"), + "volume": vs.get("volume", 100), + "muted": vs.get("mute", False), + } + + # Add if not already present (idempotent) + if not any(u["id"] == user_id for u in self._users): + self._users.append(user_info) + + # Update backend cache + self.backend.update_voice_channel_user( + user_id, + user_info["username"], + user_info["nick"], + user_info["volume"], + user_info["muted"] + ) + + self._update_display() + + def _on_voice_state_create(self, data: dict): + """Handle user joining voice channel.""" + if not data: + return + + user_data = data.get("user", {}) + user_id = user_data.get("id") + if not user_id: + return + + # Filter out self + if user_id == self.backend.current_user_id: + return + + user_info = { + "id": user_id, + "username": user_data.get("username", "Unknown"), + "nick": data.get("nick"), + "volume": data.get("volume", 100), + "muted": data.get("mute", False), + } + + # Add to local list (avoid duplicates) + if not any(u["id"] == user_id for u in self._users): + self._users.append(user_info) + + # Update backend cache + self.backend.update_voice_channel_user( + user_id, + user_info["username"], + user_info["nick"], + user_info["volume"], + user_info["muted"] + ) + + self._update_display() + + def _on_voice_state_delete(self, data: dict): + """Handle user leaving voice channel.""" + if not data: + return + + user_data = data.get("user", {}) + user_id = user_data.get("id") + if not user_id: + return + + # Remove from local list + self._users = [u for u in self._users if u["id"] != user_id] + + # Adjust current index if needed + if self._current_user_index >= len(self._users): + self._current_user_index = max(0, len(self._users) - 1) + + # Update backend cache + self.backend.remove_voice_channel_user(user_id) + + self._update_display() + + def _on_voice_state_update(self, data: dict): + """Handle user voice state change (volume, mute, etc).""" + if not data: + return + + user_data = data.get("user", {}) + user_id = user_data.get("id") + if not user_id: + return + + # Find and update user + for user in self._users: + if user["id"] == user_id: + if "volume" in data: + user["volume"] = data.get("volume") + if "mute" in data: + user["muted"] = data.get("mute") + if "nick" in data: + user["nick"] = data.get("nick") + break + + self._update_display() + + # === Display === + + def _update_display(self): + """Update the dial display with current user info.""" + if not self._in_voice_channel or not self._users: + self.set_top_label("Not in voice" if not self._in_voice_channel else self._current_channel_name[:12]) + self.set_center_label("") + self.set_bottom_label("No users" if self._in_voice_channel else "") + return + + # Truncate channel name for space + channel_display = self._current_channel_name[:12] if len(self._current_channel_name) > 12 else self._current_channel_name + self.set_top_label(channel_display) + + if self._current_user_index < len(self._users): + user = self._users[self._current_user_index] + display_name = user.get("nick") or user.get("username", "Unknown") + volume = user.get("volume", 100) + + # Truncate name for display + display_name = display_name[:10] if len(display_name) > 10 else display_name + + self.set_center_label(display_name) + self.set_bottom_label(f"{volume}%") + else: + self.set_center_label("") + self.set_bottom_label("No selection") diff --git a/backend.py b/backend.py index 1dea3c0..357f73c 100644 --- a/backend.py +++ b/backend.py @@ -19,6 +19,8 @@ def __init__(self): self._is_authed: bool = False self._current_voice_channel: str = None self._is_reconnecting: bool = False + self._voice_channel_users: dict = {} # {user_id: {username, nick, volume, muted}} + self._current_user_id: str = None # Current user's ID (for filtering) def discord_callback(self, code, event): if code == 0: @@ -59,6 +61,10 @@ def discord_callback(self, code, event): case commands.AUTHENTICATE: self.frontend.on_auth_callback(True) self._is_authed = True + # Capture current user ID for filtering in UserVolume + data = event.get("data", {}) + user = data.get("user", {}) + self._current_user_id = user.get("id") for k in self.callbacks: self.discord_client.subscribe(k) self._get_current_voice_channel() @@ -72,6 +78,11 @@ def discord_callback(self, code, event): self.frontend.handle_callback( commands.VOICE_CHANNEL_SELECT, event.get("data") ) + case commands.GET_CHANNEL: + # Dispatch channel info (including voice_states) to frontend + self.frontend.handle_callback( + commands.GET_CHANNEL, event.get("data") + ) def _update_tokens(self, access_token: str = "", refresh_token: str = ""): self.access_token = access_token @@ -125,14 +136,14 @@ def update_client_credentials( def is_authed(self) -> bool: return self._is_authed - def register_callback(self, key: str, callback: callable): + def register_callback(self, key: str, callback: callable, args: dict = None): callbacks = self.callbacks.get(key, []) # Deduplicate callbacks to prevent multiple executions if callback not in callbacks: callbacks.append(callback) self.callbacks[key] = callbacks if self._is_authed: - self.discord_client.subscribe(key) + self.discord_client.subscribe(key, args) def unregister_callback(self, key: str, callback: callable): """Remove a callback from the callback list.""" @@ -190,6 +201,10 @@ def set_push_to_talk(self, ptt: str) -> bool: def current_voice_channel(self): return self._current_voice_channel + @property + def current_user_id(self): + return self._current_user_id + def _get_current_voice_channel(self): if not self._ensure_connected(): log.warning( @@ -198,6 +213,85 @@ def _get_current_voice_channel(self): return self.discord_client.get_selected_voice_channel() + def request_current_voice_channel(self): + """Public method to request current voice channel state (dispatches to callbacks).""" + self._get_current_voice_channel() + + # User volume control methods + + def set_user_volume(self, user_id: str, volume: int) -> bool: + """Set volume for a specific user (0-200, 100 = normal).""" + if not self._ensure_connected(): + log.warning("Discord client not connected, cannot set user volume") + return False + self.discord_client.set_user_voice_settings(user_id, volume=volume) + if user_id in self._voice_channel_users: + self._voice_channel_users[user_id]["volume"] = volume + return True + + def set_user_mute(self, user_id: str, muted: bool) -> bool: + """Mute/unmute a specific user locally.""" + if not self._ensure_connected(): + log.warning("Discord client not connected, cannot set user mute") + return False + self.discord_client.set_user_voice_settings(user_id, mute=muted) + if user_id in self._voice_channel_users: + self._voice_channel_users[user_id]["muted"] = muted + return True + + def update_voice_channel_user(self, user_id: str, username: str, nick: str = None, + volume: int = 100, muted: bool = False): + """Track a user in the current voice channel.""" + self._voice_channel_users[user_id] = { + "username": username, + "nick": nick, + "volume": volume, + "muted": muted + } + + def remove_voice_channel_user(self, user_id: str): + """Remove a user from tracking when they leave.""" + self._voice_channel_users.pop(user_id, None) + + def clear_voice_channel_users(self): + """Clear all tracked users (when leaving voice channel).""" + self._voice_channel_users.clear() + + def get_voice_channel_users(self) -> dict: + """Get a copy of the current voice channel users.""" + return self._voice_channel_users.copy() + + def get_channel(self, channel_id: str) -> bool: + """Fetch channel information including voice states.""" + if not self._ensure_connected(): + log.warning("Discord client not connected, cannot get channel") + return False + self.discord_client.get_channel(channel_id) + return True + + def subscribe_voice_states(self, channel_id: str) -> bool: + """Subscribe to voice state events for a specific channel.""" + if not self._ensure_connected(): + log.warning("Discord client not connected, cannot subscribe to voice states") + return False + log.debug(f"Subscribing to voice state events for channel {channel_id}") + args = {"channel_id": channel_id} + self.discord_client.subscribe(commands.VOICE_STATE_CREATE, args) + self.discord_client.subscribe(commands.VOICE_STATE_DELETE, args) + self.discord_client.subscribe(commands.VOICE_STATE_UPDATE, args) + return True + + def unsubscribe_voice_states(self, channel_id: str) -> bool: + """Unsubscribe from voice state events for a specific channel.""" + if not self._ensure_connected(): + return False + log.debug(f"Unsubscribing from voice state events for channel {channel_id}") + args = {"channel_id": channel_id} + self.discord_client.unsubscribe(commands.VOICE_STATE_CREATE, args) + self.discord_client.unsubscribe(commands.VOICE_STATE_DELETE, args) + self.discord_client.unsubscribe(commands.VOICE_STATE_UPDATE, args) + return True + def close(self): if self.discord_client: try: diff --git a/discordrpc/asyncdiscord.py b/discordrpc/asyncdiscord.py index e21a9a7..242a299 100644 --- a/discordrpc/asyncdiscord.py +++ b/discordrpc/asyncdiscord.py @@ -171,3 +171,22 @@ def select_text_channel(self, channel_id: str): def get_selected_voice_channel(self) -> str: self._send_rpc_command(GET_SELECTED_VOICE_CHANNEL) + + def set_user_voice_settings(self, user_id: str, volume: int = None, mute: bool = None): + """Set voice settings for a specific user in the current voice channel. + + Args: + user_id: The user's Discord ID (string) + volume: Volume level 0-200 (100 = normal, 200 = 200%) + mute: Whether to locally mute the user + """ + args = {"user_id": user_id} + if volume is not None: + args["volume"] = max(0, min(200, volume)) + if mute is not None: + args["mute"] = mute + self._send_rpc_command(SET_USER_VOICE_SETTINGS, args) + + def get_channel(self, channel_id: str): + """Get channel information including voice states for voice channels.""" + self._send_rpc_command(GET_CHANNEL, {"channel_id": channel_id}) diff --git a/main.py b/main.py index 719cc3b..ca5aeb2 100644 --- a/main.py +++ b/main.py @@ -19,6 +19,7 @@ from .actions.ChangeVoiceChannel import ChangeVoiceChannel from .actions.ChangeTextChannel import ChangeTextChannel from .actions.TogglePTT import TogglePTT +from .actions.UserVolume import UserVolume class PluginTemplate(PluginBase): @@ -146,6 +147,19 @@ def _register_actions(self): ) self.add_action_holder(toggle_ptt) + user_volume = ActionHolder( + plugin_base=self, + action_base=UserVolume, + action_id="com_imdevinc_StreamControllerDiscordPlugin::UserVolume", + action_name="User Volume", + action_support={ + Input.Key: ActionInputSupport.SUPPORTED, + Input.Dial: ActionInputSupport.SUPPORTED, + Input.Touchscreen: ActionInputSupport.UNTESTED, + }, + ) + self.add_action_holder(user_volume) + def setup_backend(self): if self.backend and self.backend.is_authed(): return @@ -191,7 +205,7 @@ def on_auth_callback(self, success: bool, message: str = None): def get_settings_area(self): return self._settings_manager.get_settings_area() - def clear_callbacks(self, key: str, callback: callable): + def remove_callback(self, key: str, callback: callable): callbacks = self.callbacks.get(key, []) if callback in callbacks: callbacks.remove(callback) From f6f34af5942f57d5c655f1be5a9857a839b2e55c Mon Sep 17 00:00:00 2001 From: Nikos Chatzimpontozis Date: Thu, 1 Jan 2026 13:56:51 +0100 Subject: [PATCH 2/2] - Remove unused `args` parameter from `register_callback()` in backend.py - Bump version from 1.9.2 to 1.10.0 in manifest.json - Revert callback rename (remove_callback -> clear_callbacks) to be handled in separate bug-fix PR --- backend.py | 4 ++-- main.py | 2 +- manifest.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend.py b/backend.py index 357f73c..ff7a4da 100644 --- a/backend.py +++ b/backend.py @@ -136,14 +136,14 @@ def update_client_credentials( def is_authed(self) -> bool: return self._is_authed - def register_callback(self, key: str, callback: callable, args: dict = None): + def register_callback(self, key: str, callback: callable): callbacks = self.callbacks.get(key, []) # Deduplicate callbacks to prevent multiple executions if callback not in callbacks: callbacks.append(callback) self.callbacks[key] = callbacks if self._is_authed: - self.discord_client.subscribe(key, args) + self.discord_client.subscribe(key) def unregister_callback(self, key: str, callback: callable): """Remove a callback from the callback list.""" diff --git a/main.py b/main.py index ca5aeb2..1448142 100644 --- a/main.py +++ b/main.py @@ -205,7 +205,7 @@ def on_auth_callback(self, success: bool, message: str = None): def get_settings_area(self): return self._settings_manager.get_settings_area() - def remove_callback(self, key: str, callback: callable): + def clear_callbacks(self, key: str, callback: callable): callbacks = self.callbacks.get(key, []) if callback in callbacks: callbacks.remove(callback) diff --git a/manifest.json b/manifest.json index d5bafa7..db6981c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,5 +1,5 @@ { - "version": "1.9.2", + "version": "1.10.0", "thumbnail": "store/thumbnail.png", "id": "com_imdevinc_StreamControllerDiscordPlugin", "name": "Discord - Debug",