From 84b746af71d73d5978fbd42a553986e853bfbb50 Mon Sep 17 00:00:00 2001 From: Grant Abell Date: Thu, 2 Apr 2026 12:12:00 -0400 Subject: [PATCH 1/8] initial commit --- actions/ChangeVoiceChannel.py | 427 ++++++++++++++++++++++++++++++++-- backend.py | 29 +++ discordrpc/asyncdiscord.py | 4 + main.py | 32 ++- 4 files changed, 476 insertions(+), 16 deletions(-) diff --git a/actions/ChangeVoiceChannel.py b/actions/ChangeVoiceChannel.py index 74024cf..25a5f01 100644 --- a/actions/ChangeVoiceChannel.py +++ b/actions/ChangeVoiceChannel.py @@ -1,6 +1,10 @@ +import io +import math from enum import StrEnum +import requests from loguru import logger as log +from PIL import Image, ImageDraw from .DiscordCore import DiscordCore from src.backend.PluginManager.EventAssigner import EventAssigner @@ -8,7 +12,24 @@ from GtkHelper.GenerativeUI.EntryRow import EntryRow -from ..discordrpc.commands import VOICE_CHANNEL_SELECT +from ..discordrpc.commands import ( + VOICE_CHANNEL_SELECT, + GET_CHANNEL, + GET_GUILD, + VOICE_STATE_CREATE, + VOICE_STATE_DELETE, + SPEAKING_START, + SPEAKING_STOP, +) + +from GtkHelper.GenerativeUI.ComboRow import ComboRow + +# Button canvas size (Stream Deck key render size) +_BUTTON_SIZE = 72 + +# Speaking indicator ring colour (Discord green) +_SPEAKING_COLOR = (88, 201, 96, 255) +_RING_WIDTH = 3 class Icons(StrEnum): @@ -16,6 +37,62 @@ class Icons(StrEnum): VOICE_CHANNEL_INACTIVE = "voice-inactive" +def _make_circle_avatar(img: Image.Image, size: int) -> Image.Image: + """Resize *img* to *size*×*size* and clip it to a circle.""" + img = img.convert("RGBA").resize((size, size), Image.LANCZOS) + mask = Image.new("L", (size, size), 0) + ImageDraw.Draw(mask).ellipse((0, 0, size - 1, size - 1), fill=255) + result = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + result.paste(img, mask=mask) + return result + + +def _draw_speaking_ring(img: Image.Image, size: int) -> Image.Image: + """Draw a green ring around an avatar image.""" + overlay = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + half = _RING_WIDTH // 2 + draw.ellipse( + (half, half, size - 1 - half, size - 1 - half), + outline=_SPEAKING_COLOR, + width=_RING_WIDTH, + ) + result = img.copy() + result.paste(overlay, mask=overlay) + return result + + +def _compose_avatars(avatars: list[tuple[Image.Image, bool]]) -> Image.Image: + """Compose up to 4 avatar images (with optional speaking ring) onto a button canvas. + + *avatars* is a list of ``(image, is_speaking)`` tuples. + """ + canvas = Image.new("RGBA", (_BUTTON_SIZE, _BUTTON_SIZE), (0, 0, 0, 255)) + n = min(len(avatars), 4) + if n == 0: + return canvas + + # Determine grid: 1→full, 2→side-by-side, 3-4→2×2 + if n == 1: + size = _BUTTON_SIZE + positions = [(0, 0)] + elif n == 2: + size = _BUTTON_SIZE // 2 + positions = [(0, size // 2), (size, size // 2)] # centred vertically + else: + size = _BUTTON_SIZE // 2 + positions = [(0, 0), (size, 0), (0, size), (size, size)] + + for i, (img, speaking) in enumerate(avatars[:n]): + avatar = _make_circle_avatar(img, size) + if speaking: + avatar = _draw_speaking_ring(avatar, size) + x, y = positions[i] + canvas.paste(avatar, (x, y), avatar) + + return canvas + + class ChangeVoiceChannel(DiscordCore): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -25,27 +102,327 @@ def __init__(self, *args, **kwargs): self.current_icon = self.get_icon(Icons.VOICE_CHANNEL_INACTIVE) self.icon_name = Icons.VOICE_CHANNEL_INACTIVE + # Guild info (for fallback display when not in channel) + self._guild_id: str = None + self._guild_name: str = None + self._guild_icon_image: Image.Image = None + self._guild_channel_id: str = None + + # Voice channel / avatar state + self._connected_channel_id: str = None # channel we're currently in + self._users: dict = {} # user_id → {username, avatar_hash, avatar_img} + self._speaking: set = set() # user_ids currently speaking + def on_ready(self): super().on_ready() self.plugin_base.connect_to_event( - event_id=f"{self.plugin_base.get_plugin_id()}::{VOICE_CHANNEL_SELECT}", - callback=self._update_display, - ) + event_id=f"{self.plugin_base.get_plugin_id()}::{VOICE_CHANNEL_SELECT}", + callback=self._on_voice_channel_select, + ) + self.plugin_base.connect_to_event( + event_id=f"{self.plugin_base.get_plugin_id()}::{GET_CHANNEL}", + callback=self._on_get_channel, + ) + self.plugin_base.connect_to_event( + event_id=f"{self.plugin_base.get_plugin_id()}::{GET_GUILD}", + callback=self._on_get_guild, + ) + self.plugin_base.connect_to_event( + event_id=f"{self.plugin_base.get_plugin_id()}::{SPEAKING_START}", + callback=self._on_speaking_start, + ) + self.plugin_base.connect_to_event( + event_id=f"{self.plugin_base.get_plugin_id()}::{SPEAKING_STOP}", + callback=self._on_speaking_stop, + ) + self.plugin_base.connect_to_event( + event_id=f"{self.plugin_base.get_plugin_id()}::{VOICE_STATE_CREATE}", + callback=self._on_voice_state_create, + ) + self.plugin_base.connect_to_event( + event_id=f"{self.plugin_base.get_plugin_id()}::{VOICE_STATE_DELETE}", + callback=self._on_voice_state_delete, + ) + # Eagerly fetch guild info for already-configured channel + self._request_guild_info() - def _update_display(self, *args, **kwargs): + # ------------------------------------------------------------------ + # Voice channel select + # ------------------------------------------------------------------ + + def _on_voice_channel_select(self, *args, **kwargs): if not self.backend: self.show_error() return self.hide_error() - value = args[1] - self._current_channel = value.get("channel_id", None) if value else None - self.icon_name = ( - Icons.VOICE_CHANNEL_INACTIVE - if self._current_channel is None - else Icons.VOICE_CHANNEL_ACTIVE + data = args[1] if len(args) > 1 else None + new_channel = data.get("channel_id", None) if data else None + + # Leaving or switching – unsubscribe old channel + if self._connected_channel_id and self._connected_channel_id != new_channel: + try: + self.backend.unsubscribe_voice_states(self._connected_channel_id) + self.backend.unsubscribe_speaking(self._connected_channel_id) + except Exception as ex: + log.error(f"Failed to unsubscribe from channel: {ex}") + self._users.clear() + self._speaking.clear() + + configured = self._channel_row.get_value() + + if new_channel is None: + # Disconnected + self._connected_channel_id = None + self._current_channel = "" + self.icon_name = Icons.VOICE_CHANNEL_INACTIVE + self.current_icon = self.get_icon(self.icon_name) + self._render_button() + elif new_channel == configured: + # Connected to our configured channel + self._connected_channel_id = new_channel + self._current_channel = new_channel + + # Subscribe to speaking + voice state changes + try: + self.backend.subscribe_voice_states(new_channel) + self.backend.subscribe_speaking(new_channel) + # Fetch full user list + self.backend.get_channel(new_channel) + except Exception as ex: + log.error(f"Failed to subscribe to channel events: {ex}") + else: + # Connected, but to a different channel than configured + self._connected_channel_id = new_channel + self._current_channel = new_channel + self.icon_name = Icons.VOICE_CHANNEL_ACTIVE + self.current_icon = self.get_icon(self.icon_name) + # Still request guild info for the configured channel button display + self._request_guild_info() + self._render_button() + + # ------------------------------------------------------------------ + # Voice state events (join/leave) + # ------------------------------------------------------------------ + + def _on_voice_state_create(self, *args, **kwargs): + data = args[1] if len(args) > 1 else None + if not data: + return + user_data = data.get("user", {}) + user_id = user_data.get("id") + if not user_id or user_id == self.backend.current_user_id: + return + if user_id not in self._users: + self._users[user_id] = { + "username": user_data.get("username", ""), + "avatar_hash": user_data.get("avatar"), + "avatar_img": None, + } + self.plugin_base._thread_pool.submit(self._fetch_avatar, user_id) + + def _on_voice_state_delete(self, *args, **kwargs): + data = args[1] if len(args) > 1 else None + if not data: + return + user_data = data.get("user", {}) + user_id = user_data.get("id") + if not user_id: + return + self._users.pop(user_id, None) + self._speaking.discard(user_id) + self._render_button() + + # ------------------------------------------------------------------ + # Speaking events + # ------------------------------------------------------------------ + + def _on_speaking_start(self, *args, **kwargs): + data = args[1] if len(args) > 1 else None + if not data: + return + user_id = str(data.get("user_id", "")) + if not user_id: + return + self._speaking.add(user_id) + self._render_button() + + def _on_speaking_stop(self, *args, **kwargs): + data = args[1] if len(args) > 1 else None + if not data: + return + user_id = str(data.get("user_id", "")) + if not user_id: + return + self._speaking.discard(user_id) + self._render_button() + + # ------------------------------------------------------------------ + # Channel / guild info (for fallback display) + # ------------------------------------------------------------------ + + def _request_guild_info(self): + if not self.backend: + return + channel = self._channel_row.get_value() + if not channel or channel == self._guild_channel_id: + return + try: + self.backend.get_channel(channel) + except Exception as ex: + log.error(f"Failed to request channel info: {ex}") + + def _on_get_channel(self, *args, **kwargs): + data = args[1] if len(args) > 1 else None + if not data: + return + channel_id = data.get("id") + configured_channel = self._channel_row.get_value() + + # ---------- populate users when joining our configured channel ---------- + if channel_id == self._connected_channel_id == configured_channel: + current_user_id = self.backend.current_user_id + for vs in data.get("voice_states", []): + user_data = vs.get("user", {}) + uid = user_data.get("id") + if not uid or uid == current_user_id: + continue + if uid not in self._users: + self._users[uid] = { + "username": user_data.get("username", ""), + "avatar_hash": user_data.get("avatar"), + "avatar_img": None, + } + self.plugin_base._thread_pool.submit(self._fetch_avatar, uid) + # Fall through — also fetch guild info if not yet cached + + # ---------- guild icon lookup for the configured button ---------- + if channel_id != configured_channel: + return + if self._guild_channel_id == channel_id: + return # Already have (or are fetching) guild info for this channel + guild_id = data.get("guild_id") + if not guild_id: + self._guild_id = None + self._guild_channel_id = channel_id + self._guild_icon_image = None + self._guild_name = data.get("name", "") + self._render_button() + return + self._guild_id = guild_id + self._guild_channel_id = channel_id + try: + self.backend.get_guild(guild_id) + except Exception as ex: + log.error(f"Failed to request guild info: {ex}") + + def _on_get_guild(self, *args, **kwargs): + data = args[1] if len(args) > 1 else None + if not data or data.get("id") != self._guild_id: + return + self._guild_name = data.get("name", "") + icon_url = data.get("icon_url") + if icon_url: + self.plugin_base._thread_pool.submit(self._fetch_guild_icon, icon_url) + else: + self._guild_icon_image = None + self._render_button() + + def _fetch_guild_icon(self, icon_url: str): + try: + resp = requests.get(icon_url, timeout=10) + resp.raise_for_status() + self._guild_icon_image = Image.open(io.BytesIO(resp.content)).convert("RGBA") + except Exception as ex: + log.error(f"Failed to fetch guild icon: {ex}") + self._guild_icon_image = None + self._render_button() + + # ------------------------------------------------------------------ + # Avatar fetching + # ------------------------------------------------------------------ + + def _fetch_avatar(self, user_id: str): + user = self._users.get(user_id) + if not user: + return + avatar_hash = user.get("avatar_hash") + if avatar_hash: + url = f"https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png?size=64" + else: + # Default Discord avatar (based on discriminator bucket) + url = "https://cdn.discordapp.com/embed/avatars/0.png" + try: + resp = requests.get(url, timeout=10) + resp.raise_for_status() + user["avatar_img"] = Image.open(io.BytesIO(resp.content)).convert("RGBA") + except Exception as ex: + log.error(f"Failed to fetch avatar for {user_id}: {ex}") + self._render_button() + + # ------------------------------------------------------------------ + # Rendering + # ------------------------------------------------------------------ + + def display_icon(self): + """Override: keep our composed image if connected, else default.""" + in_channel = self._connected_channel_id == self._channel_row.get_value() and self._connected_channel_id is not None + if in_channel: + self._render_button() + elif self._guild_icon_image is not None: + self.set_media(image=self._guild_icon_image) + else: + super().display_icon() + + def _render_button(self): + configured = self._channel_row.get_value() + in_our_channel = ( + self._connected_channel_id is not None + and self._connected_channel_id == configured ) - self.current_icon = self.get_icon(self.icon_name) - self.display_icon() + + if in_our_channel: + # Compose avatar grid + avatars = [ + (u["avatar_img"], uid in self._speaking) + for uid, u in list(self._users.items()) + if u.get("avatar_img") is not None + ] + if avatars: + composed = _compose_avatars(avatars) + self.set_media(image=composed) + else: + # In channel but avatars still loading – show active voice icon + self.current_icon = self.get_icon(Icons.VOICE_CHANNEL_ACTIVE) + super().display_icon() + self.set_top_label("") + self.set_center_label("") + self.set_bottom_label("") + elif self._guild_icon_image is not None: + self.set_media(image=self._guild_icon_image) + self.set_top_label("") + self.set_center_label("") + self.set_bottom_label("") + else: + # Show default voice icon + server name label at user-chosen position + self.current_icon = self.get_icon(Icons.VOICE_CHANNEL_INACTIVE) + super().display_icon() + label_text = self._guild_name or "" + position = self._label_position_row.get_value() or "bottom" + for pos in ("top", "center", "bottom"): + if pos == position and label_text: + self.set_label( + label_text, + position=pos, + font_size=8, + outline_width=2, + outline_color=[0, 0, 0, 255], + ) + else: + self.set_label("", position=pos) + + # ------------------------------------------------------------------ + # Config UI + # ------------------------------------------------------------------ def create_generative_ui(self): self._channel_row = EntryRow( @@ -55,10 +432,29 @@ def create_generative_ui(self): title="change-channel-voice", auto_add=False, complex_var_name=True, + on_change=self._on_channel_id_changed, + ) + self._label_position_row = ComboRow( + action_core=self, + var_name="change_voice_channel.label_position", + default_value="bottom", + items=["top", "center", "bottom"], + title="Server name label position", + auto_add=False, + complex_var_name=True, ) + def _on_channel_id_changed(self, widget, new_value, old_value): + """Invalidate guild cache and re-fetch when the channel ID is changed.""" + self._guild_channel_id = None + self._guild_icon_image = None + self._guild_name = None + self._guild_id = None + self._render_button() + self._request_guild_info() + def get_config_rows(self): - return [self._channel_row._widget] + return [self._channel_row._widget, self._label_position_row._widget] def create_event_assigners(self): self.event_manager.add_event_assigner( @@ -71,7 +467,7 @@ def create_event_assigners(self): ) def _on_change_channel(self, _): - if self._current_channel is not None: + if self._connected_channel_id is not None: try: self.backend.change_voice_channel(None) except Exception as ex: @@ -84,3 +480,4 @@ def _on_change_channel(self, _): except Exception as ex: log.error(ex) self.show_error(3) + diff --git a/backend.py b/backend.py index 5bf6241..8fbcbbe 100644 --- a/backend.py +++ b/backend.py @@ -76,6 +76,8 @@ def discord_callback(self, code, event): self.frontend.trigger_event(commands.VOICE_CHANNEL_SELECT, event.get("data")) case commands.GET_CHANNEL: self.frontend.trigger_event(commands.GET_CHANNEL, event.get("data")) + case commands.GET_GUILD: + self.frontend.trigger_event(commands.GET_GUILD, event.get("data")) def _update_tokens(self, access_token: str = "", refresh_token: str = ""): self.access_token = access_token @@ -243,6 +245,14 @@ def get_channel(self, channel_id: str) -> bool: self.discord_client.get_channel(channel_id) return True + def get_guild(self, guild_id: str) -> bool: + """Fetch guild information including name and icon URL.""" + if not self._ensure_connected(): + log.warning("Discord client not connected, cannot get guild") + return False + self.discord_client.get_guild(guild_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(): @@ -264,6 +274,25 @@ def unsubscribe_voice_states(self, channel_id: str) -> bool: self.discord_client.unsubscribe(commands.VOICE_STATE_UPDATE, args) return True + def subscribe_speaking(self, channel_id: str) -> bool: + """Subscribe to speaking start/stop events for a specific channel.""" + if not self._ensure_connected(): + log.warning("Discord client not connected, cannot subscribe to speaking events") + return False + args = {"channel_id": channel_id} + self.discord_client.subscribe(commands.SPEAKING_START, args) + self.discord_client.subscribe(commands.SPEAKING_STOP, args) + return True + + def unsubscribe_speaking(self, channel_id: str) -> bool: + """Unsubscribe from speaking events for a specific channel.""" + if not self._ensure_connected(): + return False + args = {"channel_id": channel_id} + self.discord_client.unsubscribe(commands.SPEAKING_START, args) + self.discord_client.unsubscribe(commands.SPEAKING_STOP, args) + return True + def close(self): if self.discord_client: try: diff --git a/discordrpc/asyncdiscord.py b/discordrpc/asyncdiscord.py index 242a299..8b49113 100644 --- a/discordrpc/asyncdiscord.py +++ b/discordrpc/asyncdiscord.py @@ -190,3 +190,7 @@ def set_user_voice_settings(self, user_id: str, volume: int = None, mute: bool = 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}) + + def get_guild(self, guild_id: str): + """Get guild information including name and icon URL.""" + self._send_rpc_command(GET_GUILD, {"guild_id": guild_id}) diff --git a/main.py b/main.py index 7c3d5aa..0d0fc41 100644 --- a/main.py +++ b/main.py @@ -23,7 +23,7 @@ from .actions.UserVolume import UserVolume # Import event IDs -from .discordrpc.commands import VOICE_CHANNEL_SELECT, VOICE_SETTINGS_UPDATE, GET_CHANNEL +from .discordrpc.commands import VOICE_CHANNEL_SELECT, VOICE_SETTINGS_UPDATE, GET_CHANNEL, GET_GUILD, SPEAKING_START, SPEAKING_STOP, VOICE_STATE_CREATE, VOICE_STATE_DELETE class PluginTemplate(PluginBase): @@ -91,10 +91,40 @@ def _create_event_holders(self): event_id_suffix=GET_CHANNEL, ) + get_guild = EventHolder( + plugin_base=self, + event_id_suffix=GET_GUILD, + ) + + speaking_start = EventHolder( + plugin_base=self, + event_id_suffix=SPEAKING_START, + ) + + speaking_stop = EventHolder( + plugin_base=self, + event_id_suffix=SPEAKING_STOP, + ) + + voice_state_create = EventHolder( + plugin_base=self, + event_id_suffix=VOICE_STATE_CREATE, + ) + + voice_state_delete = EventHolder( + plugin_base=self, + event_id_suffix=VOICE_STATE_DELETE, + ) + self.add_event_holders([ voice_channel_select, voice_settings_update, get_channel, + get_guild, + speaking_start, + speaking_stop, + voice_state_create, + voice_state_delete, ]) From 0c372bdcdcb89717b737e8cbe8a5bf717e9d4c68 Mon Sep 17 00:00:00 2001 From: Grant Abell Date: Thu, 2 Apr 2026 15:20:39 -0400 Subject: [PATCH 2/8] feat(voice): live voice channel display with avatars, speaking rings, and user count - Show Discord server thumbnail (guild icon) on voice channel buttons; fall back to server name label when no icon is available. Label position (top / center / bottom) is user-configurable. - When connected to the configured channel, render a composited grid of up to 4 circular user avatars. A green speaking ring animates around each avatar in real time as those users speak (SPEAKING_START/STOP events). - 'Show my own avatar' toggle: include or exclude your own avatar from the grid while still counting yourself toward the user total. - Observer mode (not joined): display a user-count badge in a configurable corner (top-left / top-right / bottom-left / bottom-right) over the guild icon or fallback voice-channel icon so you can see channel occupancy at a glance without being connected. - Live updates: VOICE_STATE_CREATE/DELETE events trigger a GET_CHANNEL re-fetch rather than directly mutating per-button state, preventing cross-button counter contamination when multiple ChangeVoiceChannel buttons are on the same deck. - Re-subscribe to voice state events after leaving a channel, since Discord silently drops those subscriptions on leave. - Guild info fetch is decoupled from voice-state subscription so the server thumbnail populates immediately on launch even if the subscription is not yet active. --- actions/ChangeVoiceChannel.py | 430 ++++++++++++++++++++++------------ 1 file changed, 285 insertions(+), 145 deletions(-) diff --git a/actions/ChangeVoiceChannel.py b/actions/ChangeVoiceChannel.py index 25a5f01..d2898d6 100644 --- a/actions/ChangeVoiceChannel.py +++ b/actions/ChangeVoiceChannel.py @@ -23,6 +23,7 @@ ) from GtkHelper.GenerativeUI.ComboRow import ComboRow +from GtkHelper.GenerativeUI.SwitchRow import SwitchRow # Button canvas size (Stream Deck key render size) _BUTTON_SIZE = 72 @@ -31,6 +32,53 @@ _SPEAKING_COLOR = (88, 201, 96, 255) _RING_WIDTH = 3 +# User-count badge colours / margin +_BADGE_BG = (32, 34, 37, 230) +_BADGE_FG = (255, 255, 255, 255) +_BADGE_MARGIN = 4 + +try: + from PIL import ImageFont as _ImageFont + _badge_font = _ImageFont.load_default(size=10) +except Exception: + from PIL import ImageFont as _ImageFont + _badge_font = _ImageFont.load_default() + + +def _draw_counter_badge(base: Image.Image, count: int, corner: str = "bottom-right") -> Image.Image: + """Draw a user-count badge in the specified corner of *base*. + + *corner* is one of: "top-left", "top-right", "bottom-left", "bottom-right". + """ + img = base.convert("RGBA").resize((_BUTTON_SIZE, _BUTTON_SIZE), Image.LANCZOS) + overlay = Image.new("RGBA", img.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + text = str(count) + bbox = draw.textbbox((0, 0), text, font=_badge_font) + tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] + pad_x, pad_y = 5, 3 + bw = max(tw + pad_x * 2, th + pad_y * 2) + bh = th + pad_y * 2 + right = corner.endswith("right") + bottom = corner.startswith("bottom") + if right: + x2 = _BUTTON_SIZE - _BADGE_MARGIN + x1 = x2 - bw + else: + x1 = _BADGE_MARGIN + x2 = x1 + bw + if bottom: + y2 = _BUTTON_SIZE - _BADGE_MARGIN + y1 = y2 - bh + else: + y1 = _BADGE_MARGIN + y2 = y1 + bh + draw.rounded_rectangle((x1, y1, x2, y2), radius=bh // 2, fill=_BADGE_BG) + cx, cy = (x1 + x2) // 2, (y1 + y2) // 2 + draw.text((cx, cy), text, fill=_BADGE_FG, font=_badge_font, anchor="mm") + img.alpha_composite(overlay) + return img + class Icons(StrEnum): VOICE_CHANNEL_ACTIVE = "voice-active" @@ -110,8 +158,10 @@ def __init__(self, *args, **kwargs): # Voice channel / avatar state self._connected_channel_id: str = None # channel we're currently in + self._watching_channel_id: str = None # channel we're subscribed to (for voice states) self._users: dict = {} # user_id → {username, avatar_hash, avatar_img} self._speaking: set = set() # user_ids currently speaking + self._fetching_avatars: set = set() # user_ids with in-flight avatar fetches def on_ready(self): super().on_ready() @@ -143,8 +193,51 @@ def on_ready(self): event_id=f"{self.plugin_base.get_plugin_id()}::{VOICE_STATE_DELETE}", callback=self._on_voice_state_delete, ) - # Eagerly fetch guild info for already-configured channel - self._request_guild_info() + # Subscribe to the configured channel and fetch initial state + self._start_watching_configured_channel() + + # ------------------------------------------------------------------ + # Persistent channel subscription + # ------------------------------------------------------------------ + + def _start_watching_configured_channel(self): + """Subscribe to voice state events and fetch fresh data for the configured channel. + Guild-info fetch and voice-state subscription are handled independently. + """ + if not self.backend: + return + channel = self._channel_row.get_value() + if not channel: + return + + # --- Guild info (thumbnail / server name) --- + # Always attempt this regardless of subscription state. + if self._guild_channel_id != channel: + try: + self.backend.get_channel(channel) + except Exception as ex: + log.error(f"Failed to request channel info for guild lookup: {ex}") + + # --- Voice state subscription (live user count / avatars) --- + if channel == self._watching_channel_id: + return # Already subscribed + # Unsubscribe from previous channel + if self._watching_channel_id: + try: + self.backend.unsubscribe_voice_states(self._watching_channel_id) + except Exception as ex: + log.error(f"Failed to unsubscribe from previous channel: {ex}") + self._users.clear() + self._speaking.clear() + self._fetching_avatars.clear() + try: + subscribed = self.backend.subscribe_voice_states(channel) + if subscribed: + self._watching_channel_id = channel + # Fetch initial user list now that subscription is active + self.backend.get_channel(channel) + except Exception as ex: + log.error(f"Failed to subscribe to voice states: {ex}") # ------------------------------------------------------------------ # Voice channel select @@ -155,82 +248,70 @@ def _on_voice_channel_select(self, *args, **kwargs): self.show_error() return self.hide_error() + # Retry watching the configured channel here — this is the first event + # fired after Discord authenticates, so it covers the case where on_ready + # was called before the backend was connected. + self._start_watching_configured_channel() data = args[1] if len(args) > 1 else None new_channel = data.get("channel_id", None) if data else None + configured = self._channel_row.get_value() - # Leaving or switching – unsubscribe old channel - if self._connected_channel_id and self._connected_channel_id != new_channel: + # If we were in the configured channel and are now leaving it, remove self + if self._connected_channel_id == configured and new_channel != configured: + current_user_id = self.backend.current_user_id + if current_user_id: + self._users.pop(current_user_id, None) + self._speaking.discard(current_user_id) try: - self.backend.unsubscribe_voice_states(self._connected_channel_id) - self.backend.unsubscribe_speaking(self._connected_channel_id) + self.backend.unsubscribe_speaking(configured) except Exception as ex: - log.error(f"Failed to unsubscribe from channel: {ex}") - self._users.clear() - self._speaking.clear() + log.error(f"Failed to unsubscribe speaking: {ex}") + # Discord silently drops voice-state subscriptions when the local user + # leaves the channel. Clear _watching_channel_id so the call to + # _start_watching_configured_channel below forces a fresh re-subscribe. + self._watching_channel_id = None - configured = self._channel_row.get_value() - - if new_channel is None: - # Disconnected - self._connected_channel_id = None - self._current_channel = "" - self.icon_name = Icons.VOICE_CHANNEL_INACTIVE - self.current_icon = self.get_icon(self.icon_name) - self._render_button() - elif new_channel == configured: - # Connected to our configured channel - self._connected_channel_id = new_channel - self._current_channel = new_channel + self._connected_channel_id = new_channel - # Subscribe to speaking + voice state changes + if new_channel == configured and new_channel is not None: + # Joined our configured channel — subscribe to speaking and re-sync user list + # (voice states already subscribed via _start_watching_configured_channel) try: - self.backend.subscribe_voice_states(new_channel) self.backend.subscribe_speaking(new_channel) - # Fetch full user list self.backend.get_channel(new_channel) except Exception as ex: - log.error(f"Failed to subscribe to channel events: {ex}") + log.error(f"Failed to subscribe after joining channel: {ex}") + self._render_button() else: - # Connected, but to a different channel than configured - self._connected_channel_id = new_channel - self._current_channel = new_channel - self.icon_name = Icons.VOICE_CHANNEL_ACTIVE - self.current_icon = self.get_icon(self.icon_name) - # Still request guild info for the configured channel button display - self._request_guild_info() - self._render_button() + # Re-subscribe to voice states for the configured channel (Discord dropped + # the subscription when we left). This also fetches a fresh GET_CHANNEL. + self._start_watching_configured_channel() + self._render_button() # Immediate render while waiting for GET_CHANNEL reply # ------------------------------------------------------------------ - # Voice state events (join/leave) + # Voice state events (join/leave) — used only as refresh triggers # ------------------------------------------------------------------ + # Discord's VOICE_STATE_CREATE/DELETE data contains no channel_id, so + # we cannot determine which channel the event belongs to directly. + # Instead we use the event as a signal to re-fetch GET_CHANNEL for the + # channel THIS button is watching. _on_get_channel then reconciles the + # user list from the authoritative voice_states array. def _on_voice_state_create(self, *args, **kwargs): - data = args[1] if len(args) > 1 else None - if not data: - return - user_data = data.get("user", {}) - user_id = user_data.get("id") - if not user_id or user_id == self.backend.current_user_id: + if not self._watching_channel_id: return - if user_id not in self._users: - self._users[user_id] = { - "username": user_data.get("username", ""), - "avatar_hash": user_data.get("avatar"), - "avatar_img": None, - } - self.plugin_base._thread_pool.submit(self._fetch_avatar, user_id) + try: + self.backend.get_channel(self._watching_channel_id) + except Exception as ex: + log.error(f"Failed to refresh channel on voice state create: {ex}") def _on_voice_state_delete(self, *args, **kwargs): - data = args[1] if len(args) > 1 else None - if not data: - return - user_data = data.get("user", {}) - user_id = user_data.get("id") - if not user_id: + if not self._watching_channel_id: return - self._users.pop(user_id, None) - self._speaking.discard(user_id) - self._render_button() + try: + self.backend.get_channel(self._watching_channel_id) + except Exception as ex: + log.error(f"Failed to refresh channel on voice state delete: {ex}") # ------------------------------------------------------------------ # Speaking events @@ -257,63 +338,65 @@ def _on_speaking_stop(self, *args, **kwargs): self._render_button() # ------------------------------------------------------------------ - # Channel / guild info (for fallback display) + # Channel / guild info # ------------------------------------------------------------------ - def _request_guild_info(self): - if not self.backend: - return - channel = self._channel_row.get_value() - if not channel or channel == self._guild_channel_id: - return - try: - self.backend.get_channel(channel) - except Exception as ex: - log.error(f"Failed to request channel info: {ex}") - def _on_get_channel(self, *args, **kwargs): data = args[1] if len(args) > 1 else None if not data: return channel_id = data.get("id") configured_channel = self._channel_row.get_value() - - # ---------- populate users when joining our configured channel ---------- - if channel_id == self._connected_channel_id == configured_channel: - current_user_id = self.backend.current_user_id - for vs in data.get("voice_states", []): - user_data = vs.get("user", {}) - uid = user_data.get("id") - if not uid or uid == current_user_id: - continue - if uid not in self._users: - self._users[uid] = { - "username": user_data.get("username", ""), - "avatar_hash": user_data.get("avatar"), - "avatar_img": None, - } - self.plugin_base._thread_pool.submit(self._fetch_avatar, uid) - # Fall through — also fetch guild info if not yet cached - - # ---------- guild icon lookup for the configured button ---------- if channel_id != configured_channel: return - if self._guild_channel_id == channel_id: - return # Already have (or are fetching) guild info for this channel - guild_id = data.get("guild_id") - if not guild_id: - self._guild_id = None - self._guild_channel_id = channel_id - self._guild_icon_image = None - self._guild_name = data.get("name", "") - self._render_button() - return - self._guild_id = guild_id - self._guild_channel_id = channel_id - try: - self.backend.get_guild(guild_id) - except Exception as ex: - log.error(f"Failed to request guild info: {ex}") + + connected = self._connected_channel_id == configured_channel + current_user_id = self.backend.current_user_id + show_self = self._show_self_row.get_value() + + # Reconcile user list against the authoritative voice_states snapshot. + # Self is excluded only in observer mode (not in the channel); show_self + # controls avatar *display* only and is handled in _render_button. + new_user_ids = set() + for vs in data.get("voice_states", []): + user_data = vs.get("user", {}) + uid = user_data.get("id") + if not uid: + continue + if uid == current_user_id and not connected: + continue + new_user_ids.add(uid) + if uid not in self._users: + self._users[uid] = { + "username": user_data.get("username", ""), + "avatar_hash": user_data.get("avatar"), + "avatar_img": None, + } + if connected: + self._submit_avatar_fetch(uid) + # Remove users who left + for uid in list(self._users): + if uid not in new_user_ids: + self._users.pop(uid) + self._speaking.discard(uid) + + # Guild info lookup (only if not yet cached for this channel) + if self._guild_channel_id != channel_id: + guild_id = data.get("guild_id") + if not guild_id: + self._guild_id = None + self._guild_channel_id = channel_id + self._guild_icon_image = None + self._guild_name = data.get("name", "") + else: + self._guild_id = guild_id + self._guild_channel_id = channel_id + try: + self.backend.get_guild(guild_id) + except Exception as ex: + log.error(f"Failed to request guild info: {ex}") + + self._render_button() def _on_get_guild(self, *args, **kwargs): data = args[1] if len(args) > 1 else None @@ -341,6 +424,16 @@ def _fetch_guild_icon(self, icon_url: str): # Avatar fetching # ------------------------------------------------------------------ + def _submit_avatar_fetch(self, user_id: str): + """Submit an avatar fetch task if not already cached or in-progress.""" + user = self._users.get(user_id) + if not user or user.get("avatar_img") is not None: + return + if user_id in self._fetching_avatars: + return + self._fetching_avatars.add(user_id) + self.plugin_base._thread_pool.submit(self._fetch_avatar, user_id) + def _fetch_avatar(self, user_id: str): user = self._users.get(user_id) if not user: @@ -357,6 +450,7 @@ def _fetch_avatar(self, user_id: str): user["avatar_img"] = Image.open(io.BytesIO(resp.content)).convert("RGBA") except Exception as ex: log.error(f"Failed to fetch avatar for {user_id}: {ex}") + self._fetching_avatars.discard(user_id) self._render_button() # ------------------------------------------------------------------ @@ -364,61 +458,80 @@ def _fetch_avatar(self, user_id: str): # ------------------------------------------------------------------ def display_icon(self): - """Override: keep our composed image if connected, else default.""" - in_channel = self._connected_channel_id == self._channel_row.get_value() and self._connected_channel_id is not None - if in_channel: - self._render_button() - elif self._guild_icon_image is not None: - self.set_media(image=self._guild_icon_image) - else: - super().display_icon() + self._render_button() def _render_button(self): configured = self._channel_row.get_value() - in_our_channel = ( + connected = ( self._connected_channel_id is not None and self._connected_channel_id == configured ) - if in_our_channel: - # Compose avatar grid + self.set_top_label("") + self.set_center_label("") + self.set_bottom_label("") + + if connected: + # Trigger fetches for any users who joined before avatars were loaded + for uid in list(self._users): + self._submit_avatar_fetch(uid) + show_self = self._show_self_row.get_value() + current_user_id = self.backend.current_user_id if self.backend else None avatars = [ (u["avatar_img"], uid in self._speaking) for uid, u in list(self._users.items()) if u.get("avatar_img") is not None + and (show_self or uid != current_user_id) ] if avatars: - composed = _compose_avatars(avatars) - self.set_media(image=composed) + self.set_media(image=_compose_avatars(avatars)) + elif self._users: + # Users present but no visible avatars (e.g. show_self=False and alone, + # or avatars still loading) — show count badge so channel feels occupied. + count = len(self._users) + corner = self._badge_corner_row.get_value() or "bottom-right" + if self._guild_icon_image is not None: + self.set_media(image=_draw_counter_badge(self._guild_icon_image, count, corner)) + else: + self.current_icon = self.get_icon(Icons.VOICE_CHANNEL_ACTIVE) + icon_asset = self.current_icon + _, base = icon_asset.get_values() if icon_asset else (None, None) + if base is not None: + self.set_media(image=_draw_counter_badge(base, count, corner)) + else: + super().display_icon() else: - # In channel but avatars still loading – show active voice icon self.current_icon = self.get_icon(Icons.VOICE_CHANNEL_ACTIVE) super().display_icon() - self.set_top_label("") - self.set_center_label("") - self.set_bottom_label("") - elif self._guild_icon_image is not None: - self.set_media(image=self._guild_icon_image) - self.set_top_label("") - self.set_center_label("") - self.set_bottom_label("") else: - # Show default voice icon + server name label at user-chosen position - self.current_icon = self.get_icon(Icons.VOICE_CHANNEL_INACTIVE) - super().display_icon() - label_text = self._guild_name or "" - position = self._label_position_row.get_value() or "bottom" - for pos in ("top", "center", "bottom"): - if pos == position and label_text: - self.set_label( - label_text, - position=pos, - font_size=8, - outline_width=2, - outline_color=[0, 0, 0, 255], - ) - else: - self.set_label("", position=pos) + # Observer mode: guild/voice icon with a user-count badge when occupied + count = len(self._users) + if self._guild_icon_image is not None: + base = self._guild_icon_image + else: + self.current_icon = self.get_icon(Icons.VOICE_CHANNEL_INACTIVE) + icon_asset = self.current_icon + _, base = icon_asset.get_values() if icon_asset else (None, None) + + if base is not None and count > 0: + corner = self._badge_corner_row.get_value() or "bottom-right" + self.set_media(image=_draw_counter_badge(base, count, corner)) + elif self._guild_icon_image is not None: + self.set_media(image=self._guild_icon_image) + else: + # Empty channel, no guild icon — voice icon + optional name label + super().display_icon() + label_text = self._guild_name or "" + position = self._label_position_row.get_value() or "bottom" + for pos in ("top", "center", "bottom"): + if pos == position and label_text: + self.set_label( + label_text, position=pos, + font_size=8, outline_width=2, + outline_color=[0, 0, 0, 255], + ) + else: + self.set_label("", position=pos) # ------------------------------------------------------------------ # Config UI @@ -443,18 +556,45 @@ def create_generative_ui(self): auto_add=False, complex_var_name=True, ) + self._show_self_row = SwitchRow( + action_core=self, + var_name="change_voice_channel.show_self", + default_value=True, + title="Show my own avatar", + subtitle="Include yourself in the user grid when connected", + auto_add=False, + complex_var_name=True, + ) + self._badge_corner_row = ComboRow( + action_core=self, + var_name="change_voice_channel.badge_corner", + default_value="bottom-right", + items=["top-left", "top-right", "bottom-left", "bottom-right"], + title="User count badge corner", + auto_add=False, + complex_var_name=True, + ) def _on_channel_id_changed(self, widget, new_value, old_value): - """Invalidate guild cache and re-fetch when the channel ID is changed.""" + """Invalidate all cached state and re-subscribe when the channel ID is changed.""" self._guild_channel_id = None self._guild_icon_image = None self._guild_name = None self._guild_id = None + self._watching_channel_id = None # Force _start_watching to re-subscribe + self._users.clear() + self._speaking.clear() + self._fetching_avatars.clear() self._render_button() - self._request_guild_info() + self._start_watching_configured_channel() def get_config_rows(self): - return [self._channel_row._widget, self._label_position_row._widget] + return [ + self._channel_row._widget, + self._label_position_row._widget, + self._show_self_row._widget, + self._badge_corner_row._widget, + ] def create_event_assigners(self): self.event_manager.add_event_assigner( From bbfa97d7e835a92f5d7df5327ce0217cf0d367be Mon Sep 17 00:00:00 2001 From: Grant Abell Date: Thu, 2 Apr 2026 16:16:24 -0400 Subject: [PATCH 3/8] refactor(voice): address PR review feedback - Move HTTP requests out of the action and into backend.py. New fetch_avatar() and fetch_guild_icon() methods on Backend perform the requests.get calls and return raw bytes; the action's existing thread-pool tasks call these methods and decode the bytes into PIL Images locally. This keeps blocking I/O out of the action layer without adding new events or infrastructure. - Scope label clearing to the configured position only. _render_button() previously blanked all three label slots on every render, which would erase any labels the user placed in the other positions. Now only the slot managed by this action (the configured label_position) is cleared. --- actions/ChangeVoiceChannel.py | 76 ++++++++++++----------------------- backend.py | 31 ++++++++++++++ main.py | 7 +++- 3 files changed, 63 insertions(+), 51 deletions(-) diff --git a/actions/ChangeVoiceChannel.py b/actions/ChangeVoiceChannel.py index d2898d6..d4bf560 100644 --- a/actions/ChangeVoiceChannel.py +++ b/actions/ChangeVoiceChannel.py @@ -2,7 +2,6 @@ import math from enum import StrEnum -import requests from loguru import logger as log from PIL import Image, ImageDraw @@ -196,9 +195,6 @@ def on_ready(self): # Subscribe to the configured channel and fetch initial state self._start_watching_configured_channel() - # ------------------------------------------------------------------ - # Persistent channel subscription - # ------------------------------------------------------------------ def _start_watching_configured_channel(self): """Subscribe to voice state events and fetch fresh data for the configured channel. @@ -239,10 +235,6 @@ def _start_watching_configured_channel(self): except Exception as ex: log.error(f"Failed to subscribe to voice states: {ex}") - # ------------------------------------------------------------------ - # Voice channel select - # ------------------------------------------------------------------ - def _on_voice_channel_select(self, *args, **kwargs): if not self.backend: self.show_error() @@ -288,9 +280,7 @@ def _on_voice_channel_select(self, *args, **kwargs): self._start_watching_configured_channel() self._render_button() # Immediate render while waiting for GET_CHANNEL reply - # ------------------------------------------------------------------ # Voice state events (join/leave) — used only as refresh triggers - # ------------------------------------------------------------------ # Discord's VOICE_STATE_CREATE/DELETE data contains no channel_id, so # we cannot determine which channel the event belongs to directly. # Instead we use the event as a signal to re-fetch GET_CHANNEL for the @@ -313,10 +303,7 @@ def _on_voice_state_delete(self, *args, **kwargs): except Exception as ex: log.error(f"Failed to refresh channel on voice state delete: {ex}") - # ------------------------------------------------------------------ # Speaking events - # ------------------------------------------------------------------ - def _on_speaking_start(self, *args, **kwargs): data = args[1] if len(args) > 1 else None if not data: @@ -337,10 +324,7 @@ def _on_speaking_stop(self, *args, **kwargs): self._speaking.discard(user_id) self._render_button() - # ------------------------------------------------------------------ # Channel / guild info - # ------------------------------------------------------------------ - def _on_get_channel(self, *args, **kwargs): data = args[1] if len(args) > 1 else None if not data: @@ -411,19 +395,22 @@ def _on_get_guild(self, *args, **kwargs): self._render_button() def _fetch_guild_icon(self, icon_url: str): + image_bytes = None try: - resp = requests.get(icon_url, timeout=10) - resp.raise_for_status() - self._guild_icon_image = Image.open(io.BytesIO(resp.content)).convert("RGBA") + image_bytes = self.backend.fetch_guild_icon(icon_url) except Exception as ex: log.error(f"Failed to fetch guild icon: {ex}") + if image_bytes: + try: + self._guild_icon_image = Image.open(io.BytesIO(image_bytes)).convert("RGBA") + except Exception as ex: + log.error(f"Failed to decode guild icon: {ex}") + self._guild_icon_image = None + else: self._guild_icon_image = None self._render_button() - # ------------------------------------------------------------------ # Avatar fetching - # ------------------------------------------------------------------ - def _submit_avatar_fetch(self, user_id: str): """Submit an avatar fetch task if not already cached or in-progress.""" user = self._users.get(user_id) @@ -438,25 +425,20 @@ def _fetch_avatar(self, user_id: str): user = self._users.get(user_id) if not user: return - avatar_hash = user.get("avatar_hash") - if avatar_hash: - url = f"https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png?size=64" - else: - # Default Discord avatar (based on discriminator bucket) - url = "https://cdn.discordapp.com/embed/avatars/0.png" + image_bytes = None try: - resp = requests.get(url, timeout=10) - resp.raise_for_status() - user["avatar_img"] = Image.open(io.BytesIO(resp.content)).convert("RGBA") + image_bytes = self.backend.fetch_avatar(user_id, user.get("avatar_hash")) except Exception as ex: log.error(f"Failed to fetch avatar for {user_id}: {ex}") + if image_bytes: + try: + user["avatar_img"] = Image.open(io.BytesIO(image_bytes)).convert("RGBA") + except Exception as ex: + log.error(f"Failed to decode avatar for {user_id}: {ex}") self._fetching_avatars.discard(user_id) self._render_button() - # ------------------------------------------------------------------ # Rendering - # ------------------------------------------------------------------ - def display_icon(self): self._render_button() @@ -467,9 +449,10 @@ def _render_button(self): and self._connected_channel_id == configured ) - self.set_top_label("") - self.set_center_label("") - self.set_bottom_label("") + # Only clear the label position this button manages — leave other positions + # untouched so users' own labels in those spots are not erased. + position = self._label_position_row.get_value() or "bottom" + self.set_label("", position=position) if connected: # Trigger fetches for any users who joined before avatars were loaded @@ -522,20 +505,13 @@ def _render_button(self): # Empty channel, no guild icon — voice icon + optional name label super().display_icon() label_text = self._guild_name or "" - position = self._label_position_row.get_value() or "bottom" - for pos in ("top", "center", "bottom"): - if pos == position and label_text: - self.set_label( - label_text, position=pos, - font_size=8, outline_width=2, - outline_color=[0, 0, 0, 255], - ) - else: - self.set_label("", position=pos) + if label_text: + self.set_label( + label_text, position=position, + font_size=8, outline_width=2, + outline_color=[0, 0, 0, 255], + ) - # ------------------------------------------------------------------ - # Config UI - # ------------------------------------------------------------------ def create_generative_ui(self): self._channel_row = EntryRow( diff --git a/backend.py b/backend.py index 8fbcbbe..ec9b997 100644 --- a/backend.py +++ b/backend.py @@ -1,5 +1,7 @@ +import io import json +import requests from streamcontroller_plugin_tools import BackendBase from loguru import logger as log @@ -293,6 +295,35 @@ def unsubscribe_speaking(self, channel_id: str) -> bool: self.discord_client.unsubscribe(commands.SPEAKING_STOP, args) return True + # ------------------------------------------------------------------ + # CDN fetch helpers — called from the frontend's thread pool so the + # HTTP request happens off the main thread without blocking rpyc. + # ------------------------------------------------------------------ + + def fetch_avatar(self, user_id: str, avatar_hash: str) -> bytes | None: + """Fetch a Discord user avatar from the CDN and return the raw bytes.""" + if avatar_hash: + url = f"https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png?size=64" + else: + url = "https://cdn.discordapp.com/embed/avatars/0.png" + try: + resp = requests.get(url, timeout=10) + resp.raise_for_status() + return resp.content + except Exception as ex: + log.error(f"Failed to fetch avatar for {user_id}: {ex}") + return None + + def fetch_guild_icon(self, icon_url: str) -> bytes | None: + """Fetch a guild icon from the CDN and return the raw bytes.""" + try: + resp = requests.get(icon_url, timeout=10) + resp.raise_for_status() + return resp.content + except Exception as ex: + log.error(f"Failed to fetch guild icon: {ex}") + return None + def close(self): if self.discord_client: try: diff --git a/main.py b/main.py index 0d0fc41..ebc37da 100644 --- a/main.py +++ b/main.py @@ -23,7 +23,12 @@ from .actions.UserVolume import UserVolume # Import event IDs -from .discordrpc.commands import VOICE_CHANNEL_SELECT, VOICE_SETTINGS_UPDATE, GET_CHANNEL, GET_GUILD, SPEAKING_START, SPEAKING_STOP, VOICE_STATE_CREATE, VOICE_STATE_DELETE +from .discordrpc.commands import ( + VOICE_CHANNEL_SELECT, VOICE_SETTINGS_UPDATE, + GET_CHANNEL, GET_GUILD, + SPEAKING_START, SPEAKING_STOP, + VOICE_STATE_CREATE, VOICE_STATE_DELETE, +) class PluginTemplate(PluginBase): From acbc98f53cdd4d61cb4ecf76cdce1e8456eaf8ee Mon Sep 17 00:00:00 2001 From: Grant Abell Date: Fri, 3 Apr 2026 12:30:23 -0400 Subject: [PATCH 4/8] add avatar_utils.py module for avatar related methods --- actions/ChangeVoiceChannel.py | 81 +++++++++-------- actions/UserVolume.py | 165 ++++++++++++++++++++++++++++++++-- actions/avatar_utils.py | 87 ++++++++++++++++++ 3 files changed, 289 insertions(+), 44 deletions(-) create mode 100644 actions/avatar_utils.py diff --git a/actions/ChangeVoiceChannel.py b/actions/ChangeVoiceChannel.py index d4bf560..1fbd4b1 100644 --- a/actions/ChangeVoiceChannel.py +++ b/actions/ChangeVoiceChannel.py @@ -6,6 +6,14 @@ from PIL import Image, ImageDraw from .DiscordCore import DiscordCore +from .avatar_utils import ( + BUTTON_SIZE, + SPEAKING_COLOR, + RING_WIDTH, + make_circle_avatar, + draw_speaking_ring, + make_placeholder_avatar, +) from src.backend.PluginManager.EventAssigner import EventAssigner from src.backend.PluginManager.InputBases import Input @@ -24,12 +32,14 @@ from GtkHelper.GenerativeUI.ComboRow import ComboRow from GtkHelper.GenerativeUI.SwitchRow import SwitchRow -# Button canvas size (Stream Deck key render size) -_BUTTON_SIZE = 72 +# Button canvas size (Stream Deck key render size) — canonical value lives in +# avatar_utils; this alias keeps the rest of the file unchanged. +_BUTTON_SIZE = BUTTON_SIZE -# Speaking indicator ring colour (Discord green) -_SPEAKING_COLOR = (88, 201, 96, 255) -_RING_WIDTH = 3 +# Speaking indicator constants re-exported for any code that still imports them +# from this module. Actual values live in avatar_utils. +_SPEAKING_COLOR = SPEAKING_COLOR +_RING_WIDTH = RING_WIDTH # User-count badge colours / margin _BADGE_BG = (32, 34, 37, 230) @@ -84,29 +94,14 @@ class Icons(StrEnum): VOICE_CHANNEL_INACTIVE = "voice-inactive" +# Keep these module-level names so that any code importing them from here +# (e.g. old code or tests) continues to work without changes. def _make_circle_avatar(img: Image.Image, size: int) -> Image.Image: - """Resize *img* to *size*×*size* and clip it to a circle.""" - img = img.convert("RGBA").resize((size, size), Image.LANCZOS) - mask = Image.new("L", (size, size), 0) - ImageDraw.Draw(mask).ellipse((0, 0, size - 1, size - 1), fill=255) - result = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - result.paste(img, mask=mask) - return result + return make_circle_avatar(img, size) def _draw_speaking_ring(img: Image.Image, size: int) -> Image.Image: - """Draw a green ring around an avatar image.""" - overlay = Image.new("RGBA", (size, size), (0, 0, 0, 0)) - draw = ImageDraw.Draw(overlay) - half = _RING_WIDTH // 2 - draw.ellipse( - (half, half, size - 1 - half, size - 1 - half), - outline=_SPEAKING_COLOR, - width=_RING_WIDTH, - ) - result = img.copy() - result.paste(overlay, mask=overlay) - return result + return draw_speaking_ring(img, size) def _compose_avatars(avatars: list[tuple[Image.Image, bool]]) -> Image.Image: @@ -114,26 +109,26 @@ def _compose_avatars(avatars: list[tuple[Image.Image, bool]]) -> Image.Image: *avatars* is a list of ``(image, is_speaking)`` tuples. """ - canvas = Image.new("RGBA", (_BUTTON_SIZE, _BUTTON_SIZE), (0, 0, 0, 255)) + canvas = Image.new("RGBA", (BUTTON_SIZE, BUTTON_SIZE), (0, 0, 0, 255)) n = min(len(avatars), 4) if n == 0: return canvas # Determine grid: 1→full, 2→side-by-side, 3-4→2×2 if n == 1: - size = _BUTTON_SIZE + size = BUTTON_SIZE positions = [(0, 0)] elif n == 2: - size = _BUTTON_SIZE // 2 + size = BUTTON_SIZE // 2 positions = [(0, size // 2), (size, size // 2)] # centred vertically else: - size = _BUTTON_SIZE // 2 + size = BUTTON_SIZE // 2 positions = [(0, 0), (size, 0), (0, size), (size, size)] for i, (img, speaking) in enumerate(avatars[:n]): - avatar = _make_circle_avatar(img, size) + avatar = make_circle_avatar(img, size) if speaking: - avatar = _draw_speaking_ring(avatar, size) + avatar = draw_speaking_ring(avatar, size) x, y = positions[i] canvas.paste(avatar, (x, y), avatar) @@ -416,6 +411,8 @@ def _submit_avatar_fetch(self, user_id: str): user = self._users.get(user_id) if not user or user.get("avatar_img") is not None: return + if not user.get("avatar_hash"): # No real avatar; placeholder renders immediately + return if user_id in self._fetching_avatars: return self._fetching_avatars.add(user_id) @@ -425,9 +422,15 @@ def _fetch_avatar(self, user_id: str): user = self._users.get(user_id) if not user: return - image_bytes = None + avatar_hash = user.get("avatar_hash") + if not avatar_hash: + self._fetching_avatars.discard(user_id) + return + url = f"https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png?size=64" try: - image_bytes = self.backend.fetch_avatar(user_id, user.get("avatar_hash")) + resp = requests.get(url, timeout=10) + resp.raise_for_status() + user["avatar_img"] = Image.open(io.BytesIO(resp.content)).convert("RGBA") except Exception as ex: log.error(f"Failed to fetch avatar for {user_id}: {ex}") if image_bytes: @@ -460,12 +463,14 @@ def _render_button(self): self._submit_avatar_fetch(uid) show_self = self._show_self_row.get_value() current_user_id = self.backend.current_user_id if self.backend else None - avatars = [ - (u["avatar_img"], uid in self._speaking) - for uid, u in list(self._users.items()) - if u.get("avatar_img") is not None - and (show_self or uid != current_user_id) - ] + avatars = [] + for uid, u in list(self._users.items()): + if not show_self and uid == current_user_id: + continue + avatar_img = u.get("avatar_img") + if avatar_img is None: + avatar_img = make_placeholder_avatar(u.get("username", "?"), uid, BUTTON_SIZE) + avatars.append((avatar_img, uid in self._speaking)) if avatars: self.set_media(image=_compose_avatars(avatars)) elif self._users: diff --git a/actions/UserVolume.py b/actions/UserVolume.py index ba60db7..00710da 100644 --- a/actions/UserVolume.py +++ b/actions/UserVolume.py @@ -1,15 +1,29 @@ +import io + +import requests from loguru import logger as log +from PIL import Image from .DiscordCore import DiscordCore +from .avatar_utils import ( + BUTTON_SIZE, + make_circle_avatar, + draw_speaking_ring, + make_placeholder_avatar, +) from src.backend.PluginManager.EventAssigner import EventAssigner from src.backend.PluginManager.InputBases import Input +from GtkHelper.GenerativeUI.SwitchRow import SwitchRow + from ..discordrpc.commands import ( VOICE_STATE_CREATE, VOICE_STATE_DELETE, VOICE_STATE_UPDATE, VOICE_CHANNEL_SELECT, GET_CHANNEL, + SPEAKING_START, + SPEAKING_STOP, ) @@ -28,18 +42,35 @@ class UserVolume(DiscordCore): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.has_configuration = False + self.has_configuration = True # Current state - self._users: list = [] # List of user dicts [{id, username, nick, volume, muted}, ...] + self._users: list = [] # List of user dicts [{id, username, nick, volume, muted, avatar_hash, avatar_img}, ...] self._current_user_index: int = 0 self._current_channel_id: str = None self._current_channel_name: str = "" self._in_voice_channel: bool = False + self._speaking: set = set() # user_ids currently speaking + self._fetching_avatars: set = set() # user_ids with in-flight avatar fetches + self._self_input_volume: int = 100 # Tracked locally; mic input volume is write-only via RPC # Volume adjustment step (percentage points per dial tick) self.VOLUME_STEP = 5 + def create_generative_ui(self): + self._control_self_row = SwitchRow( + action_core=self, + var_name="user_volume.control_self", + default_value=False, + title="Control my mic volume", + subtitle="Include yourself so the dial adjusts your microphone input volume", + auto_add=False, + complex_var_name=True, + ) + + def get_config_rows(self): + return [self._control_self_row._widget] + def on_ready(self): super().on_ready() @@ -51,6 +82,14 @@ def on_ready(self): event_id=f"{self.plugin_base.get_plugin_id()}::{GET_CHANNEL}", callback=self._on_get_channel, ) + self.plugin_base.connect_to_event( + event_id=f"{self.plugin_base.get_plugin_id()}::{SPEAKING_START}", + callback=self._on_speaking_start, + ) + self.plugin_base.connect_to_event( + event_id=f"{self.plugin_base.get_plugin_id()}::{SPEAKING_STOP}", + callback=self._on_speaking_stop, + ) # Initialize display self._update_display() @@ -121,6 +160,19 @@ def _adjust_volume(self, delta: int): user = self._users[self._current_user_index] current_volume = user.get("volume", 100) + + if user.get("is_self"): + new_volume = max(0, min(100, current_volume + delta)) + try: + self.backend.set_input_volume(new_volume) + user["volume"] = new_volume + self._self_input_volume = new_volume + self._update_display() + except Exception as ex: + log.error(f"Failed to set input volume: {ex}") + self.show_error(3) + return + new_volume = max(0, min(200, current_volume + delta)) try: @@ -141,11 +193,15 @@ def _on_voice_channel_select(self, *args, **kwargs): # Left voice channel - unsubscribe from previous channel if self._current_channel_id: self.backend.unsubscribe_voice_states(self._current_channel_id) + self.backend.unsubscribe_speaking(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._speaking.clear() + self._fetching_avatars.clear() + self._self_input_volume = 100 self.backend.clear_voice_channel_users() else: # Joined voice channel @@ -154,8 +210,12 @@ def _on_voice_channel_select(self, *args, **kwargs): # 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.backend.unsubscribe_speaking(self._current_channel_id) self._users.clear() self._current_user_index = 0 + self._speaking.clear() + self._fetching_avatars.clear() + self._self_input_volume = 100 self._in_voice_channel = True self._current_channel_id = new_channel_id @@ -166,8 +226,9 @@ def _on_voice_channel_select(self, *args, **kwargs): 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) + # Subscribe to voice state and speaking events via backend (with channel_id) self.backend.subscribe_voice_states(self._current_channel_id) + self.backend.subscribe_speaking(self._current_channel_id) # Fetch initial user list self.backend.get_channel(self._current_channel_id) @@ -202,8 +263,21 @@ def _on_get_channel(self, *args, **kwargs): if not user_id: continue - # Filter out self + # Self: inject as first entry when the toggle is enabled if user_id == current_user_id: + if self._control_self_row.get_value() and not any(u.get("is_self") for u in self._users): + self_info = { + "id": user_id, + "username": user_data.get("username", "Me"), + "nick": vs.get("nick"), + "volume": self._self_input_volume, + "muted": False, + "avatar_hash": user_data.get("avatar"), + "avatar_img": None, + "is_self": True, + } + self._users.insert(0, self_info) + self._submit_avatar_fetch(user_id) continue user_info = { @@ -212,11 +286,14 @@ def _on_get_channel(self, *args, **kwargs): "nick": vs.get("nick"), "volume": vs.get("volume", 100), "muted": vs.get("mute", False), + "avatar_hash": user_data.get("avatar"), + "avatar_img": None, } # Add if not already present (idempotent) if not any(u["id"] == user_id for u in self._users): self._users.append(user_info) + self._submit_avatar_fetch(user_id) # Update backend cache self.backend.update_voice_channel_user( @@ -249,11 +326,14 @@ def _on_voice_state_create(self, data: dict): "nick": data.get("nick"), "volume": data.get("volume", 100), "muted": data.get("mute", False), + "avatar_hash": user_data.get("avatar"), + "avatar_img": None, } # Add to local list (avoid duplicates) if not any(u["id"] == user_id for u in self._users): self._users.append(user_info) + self._submit_avatar_fetch(user_id) # Update backend cache self.backend.update_voice_channel_user( @@ -278,6 +358,8 @@ def _on_voice_state_delete(self, data: dict): # Remove from local list self._users = [u for u in self._users if u["id"] != user_id] + self._speaking.discard(user_id) + self._fetching_avatars.discard(user_id) # Adjust current index if needed if self._current_user_index >= len(self._users): @@ -311,6 +393,63 @@ def _on_voice_state_update(self, data: dict): self._update_display() + # === Speaking === + + def _on_speaking_start(self, *args, **kwargs): + """Handle user starting to speak.""" + data = args[1] if len(args) > 1 else None + if not data: + return + user_id = str(data.get("user_id", "")) + if not user_id: + return + self._speaking.add(user_id) + self._update_display() + + def _on_speaking_stop(self, *args, **kwargs): + """Handle user stopping speaking.""" + data = args[1] if len(args) > 1 else None + if not data: + return + user_id = str(data.get("user_id", "")) + if not user_id: + return + self._speaking.discard(user_id) + self._update_display() + + # === Avatar fetching === + + def _submit_avatar_fetch(self, user_id: str): + """Submit an avatar fetch task if not already cached or in-progress.""" + user = next((u for u in self._users if u["id"] == user_id), None) + if not user or user.get("avatar_img") is not None: + return + if not user.get("avatar_hash"): # No real avatar; placeholder renders immediately + return + if user_id in self._fetching_avatars: + return + self._fetching_avatars.add(user_id) + self.plugin_base._thread_pool.submit(self._fetch_avatar, user_id) + + def _fetch_avatar(self, user_id: str): + user = next((u for u in self._users if u["id"] == user_id), None) + if not user: + self._fetching_avatars.discard(user_id) + return + avatar_hash = user.get("avatar_hash") + if not avatar_hash: + self._fetching_avatars.discard(user_id) + return + url = f"https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png?size=64" + try: + resp = requests.get(url, timeout=10) + resp.raise_for_status() + user["avatar_img"] = Image.open(io.BytesIO(resp.content)).convert("RGBA") + except Exception as ex: + log.error(f"Failed to fetch avatar for {user_id}: {ex}") + self._fetching_avatars.discard(user_id) + self._update_display() + # === Display === def _update_display(self): @@ -319,6 +458,8 @@ def _update_display(self): 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 "") + # Clear any lingering avatar image so the display resets cleanly + self.set_media(image=Image.new("RGBA", (BUTTON_SIZE, BUTTON_SIZE), (0, 0, 0, 255))) return # Truncate channel name for space @@ -329,10 +470,22 @@ def _update_display(self): user = self._users[self._current_user_index] display_name = user.get("nick") or user.get("username", "Unknown") volume = user.get("volume", 100) + is_speaking = user["id"] in self._speaking - # Truncate name for display + # Build avatar image + avatar_src = user.get("avatar_img") + if avatar_src is not None: + avatar = make_circle_avatar(avatar_src, BUTTON_SIZE) + else: + avatar = make_placeholder_avatar(display_name, user["id"], BUTTON_SIZE) + # Kick off a fetch if not already in-flight + self._submit_avatar_fetch(user["id"]) + if is_speaking: + avatar = draw_speaking_ring(avatar, BUTTON_SIZE) + self.set_media(image=avatar) + + # Truncate name for label overlay 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: diff --git a/actions/avatar_utils.py b/actions/avatar_utils.py new file mode 100644 index 0000000..15dcc20 --- /dev/null +++ b/actions/avatar_utils.py @@ -0,0 +1,87 @@ +""" +Shared avatar-rendering utilities for Discord plugin actions. + +Both ChangeVoiceChannel and UserVolume import from here so that colour +choices, rendering logic, and constants stay in one place. +""" + +from PIL import Image, ImageDraw, ImageFont + +# Canvas size used for all Stream Deck key renders in this plugin. +BUTTON_SIZE = 72 + +# Speaking-indicator ring colour (Discord green) and ring thickness. +SPEAKING_COLOR = (88, 201, 96, 255) +RING_WIDTH = 3 + +# Ordered placeholder colours assigned to users who have no profile picture. +# Colour is chosen deterministically from the Discord user ID so the same +# user always gets the same colour. The list repeats when there are more +# users than colours. +PLACEHOLDER_COLORS = [ + (88, 101, 242, 255), # Discord blurple + (87, 242, 135, 255), # Discord green + (254, 231, 92, 255), # Discord yellow + (235, 69, 158, 255), # Discord fuchsia + (237, 66, 69, 255), # Discord red + (52, 152, 219, 255), # Steel blue + (155, 89, 182, 255), # Purple + (230, 126, 34, 255), # Orange +] + +try: + _placeholder_font = ImageFont.load_default(size=28) +except Exception: + _placeholder_font = ImageFont.load_default() + + +def placeholder_color(user_id: str) -> tuple: + """Return a deterministic placeholder colour for *user_id*.""" + try: + idx = int(user_id) % len(PLACEHOLDER_COLORS) + except (ValueError, TypeError): + idx = abs(hash(user_id)) % len(PLACEHOLDER_COLORS) + return PLACEHOLDER_COLORS[idx] + + +def make_placeholder_avatar(display_name: str, user_id: str, size: int) -> Image.Image: + """Return a circular avatar with a solid colour background and the user's initial.""" + color = placeholder_color(user_id) + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + draw.ellipse((0, 0, size - 1, size - 1), fill=color) + initial = display_name[0].upper() if display_name else "?" + bbox = draw.textbbox((0, 0), initial, font=_placeholder_font) + tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] + draw.text( + ((size - tw) // 2 - bbox[0], (size - th) // 2 - bbox[1]), + initial, + fill=(255, 255, 255, 255), + font=_placeholder_font, + ) + return img + + +def make_circle_avatar(img: Image.Image, size: int) -> Image.Image: + """Resize *img* to *size*×*size* and clip it to a circle.""" + img = img.convert("RGBA").resize((size, size), Image.LANCZOS) + mask = Image.new("L", (size, size), 0) + ImageDraw.Draw(mask).ellipse((0, 0, size - 1, size - 1), fill=255) + result = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + result.paste(img, mask=mask) + return result + + +def draw_speaking_ring(img: Image.Image, size: int) -> Image.Image: + """Overlay a green speaking-indicator ring onto *img*.""" + overlay = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + half = RING_WIDTH // 2 + draw.ellipse( + (half, half, size - 1 - half, size - 1 - half), + outline=SPEAKING_COLOR, + width=RING_WIDTH, + ) + result = img.copy() + result.paste(overlay, mask=overlay) + return result From f6673f6c1ce64dec53cedefc3cfc0b64a9f1f48d Mon Sep 17 00:00:00 2001 From: Grant Abell Date: Sat, 4 Apr 2026 00:22:59 -0400 Subject: [PATCH 5/8] feat(UserVolume): tap-to-mute, mute overlay, and overlapping avatar display - Tap touchbar to toggle mute on the displayed user (self-mute uses mic mute; other users use local per-user mute) - Mute state shown as dimmed overlay with red slash on avatar - Both UserVolume and ChangeVoiceChannel now render avatars in an overlapping stack instead of a grid, with the active user (selected or speaking) moved to the centre front - Detect current voice channel on startup so actions reflect state when StreamController launches mid-session - Expose current user avatar hash from backend auth response so self avatars render correctly for users with profile pictures --- actions/ChangeVoiceChannel.py | 41 +++++-------- actions/UserVolume.py | 89 ++++++++++++++++++++------- actions/avatar_utils.py | 110 ++++++++++++++++++++++++++++++++++ backend.py | 16 ++++- main.py | 12 +++- settings.py | 2 +- 6 files changed, 214 insertions(+), 56 deletions(-) diff --git a/actions/ChangeVoiceChannel.py b/actions/ChangeVoiceChannel.py index 1fbd4b1..f46ed0f 100644 --- a/actions/ChangeVoiceChannel.py +++ b/actions/ChangeVoiceChannel.py @@ -13,6 +13,7 @@ make_circle_avatar, draw_speaking_ring, make_placeholder_avatar, + compose_overlapping_avatars, ) from src.backend.PluginManager.EventAssigner import EventAssigner from src.backend.PluginManager.InputBases import Input @@ -105,34 +106,15 @@ def _draw_speaking_ring(img: Image.Image, size: int) -> Image.Image: def _compose_avatars(avatars: list[tuple[Image.Image, bool]]) -> Image.Image: - """Compose up to 4 avatar images (with optional speaking ring) onto a button canvas. - - *avatars* is a list of ``(image, is_speaking)`` tuples. - """ - canvas = Image.new("RGBA", (BUTTON_SIZE, BUTTON_SIZE), (0, 0, 0, 255)) - n = min(len(avatars), 4) - if n == 0: - return canvas - - # Determine grid: 1→full, 2→side-by-side, 3-4→2×2 - if n == 1: - size = BUTTON_SIZE - positions = [(0, 0)] - elif n == 2: - size = BUTTON_SIZE // 2 - positions = [(0, size // 2), (size, size // 2)] # centred vertically - else: - size = BUTTON_SIZE // 2 - positions = [(0, 0), (size, 0), (0, size), (size, size)] - - for i, (img, speaking) in enumerate(avatars[:n]): - avatar = make_circle_avatar(img, size) + """Compose avatar images in an overlapping stack, speaking user in front.""" + # Find the last speaking user to bring to front + front = -1 + avatars_3 = [] + for i, (img, speaking) in enumerate(avatars): if speaking: - avatar = draw_speaking_ring(avatar, size) - x, y = positions[i] - canvas.paste(avatar, (x, y), avatar) - - return canvas + front = i + avatars_3.append((img, speaking, False)) + return compose_overlapping_avatars(avatars_3, BUTTON_SIZE, front_index=front) class ChangeVoiceChannel(DiscordCore): @@ -190,6 +172,11 @@ def on_ready(self): # Subscribe to the configured channel and fetch initial state self._start_watching_configured_channel() + # Request current voice channel so _connected_channel_id is set if + # we're already in a channel when StreamController starts. + if self.backend: + self.backend.request_current_voice_channel() + def _start_watching_configured_channel(self): """Subscribe to voice state events and fetch fresh data for the configured channel. diff --git a/actions/UserVolume.py b/actions/UserVolume.py index 00710da..afc3c85 100644 --- a/actions/UserVolume.py +++ b/actions/UserVolume.py @@ -9,7 +9,9 @@ BUTTON_SIZE, make_circle_avatar, draw_speaking_ring, + draw_mute_overlay, make_placeholder_avatar, + compose_overlapping_avatars, ) from src.backend.PluginManager.EventAssigner import EventAssigner from src.backend.PluginManager.InputBases import Input @@ -82,6 +84,18 @@ def on_ready(self): event_id=f"{self.plugin_base.get_plugin_id()}::{GET_CHANNEL}", callback=self._on_get_channel, ) + self.plugin_base.connect_to_event( + event_id=f"{self.plugin_base.get_plugin_id()}::{VOICE_STATE_CREATE}", + callback=self._on_voice_state_create, + ) + self.plugin_base.connect_to_event( + event_id=f"{self.plugin_base.get_plugin_id()}::{VOICE_STATE_DELETE}", + callback=self._on_voice_state_delete, + ) + self.plugin_base.connect_to_event( + event_id=f"{self.plugin_base.get_plugin_id()}::{VOICE_STATE_UPDATE}", + callback=self._on_voice_state_update, + ) self.plugin_base.connect_to_event( event_id=f"{self.plugin_base.get_plugin_id()}::{SPEAKING_START}", callback=self._on_speaking_start, @@ -95,7 +109,8 @@ def on_ready(self): self._update_display() # Request current voice channel state (in case we're already in a channel) - self.backend.request_current_voice_channel() + if self.backend: + self.backend.request_current_voice_channel() def create_event_assigners(self): # Dial rotation: adjust volume @@ -136,6 +151,16 @@ def create_event_assigners(self): ) ) + # Touchscreen tap: toggle mute on current user + self.event_manager.add_event_assigner( + EventAssigner( + id="toggle-mute", + ui_label="toggle-mute", + default_event=Input.Dial.Events.SHORT_TOUCH_PRESS, + callback=self._on_toggle_mute, + ) + ) + # === Event Handlers === def _on_volume_up(self, _): @@ -153,6 +178,24 @@ def _on_cycle_user(self, _): self._current_user_index = (self._current_user_index + 1) % len(self._users) self._update_display() + def _on_toggle_mute(self, _): + """Toggle mute on the currently displayed user.""" + if not self._users or self._current_user_index >= len(self._users): + return + user = self._users[self._current_user_index] + new_muted = not user.get("muted", False) + try: + if user.get("is_self"): + self.backend.set_mute(new_muted) + else: + if not self.backend.set_user_mute(user["id"], new_muted): + return + user["muted"] = new_muted + self._update_display() + except Exception as ex: + log.error(f"Failed to toggle mute for {user['id']}: {ex}") + self.show_error(3) + 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): @@ -221,11 +264,6 @@ def _on_voice_channel_select(self, *args, **kwargs): 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 and speaking events via backend (with channel_id) self.backend.subscribe_voice_states(self._current_channel_id) self.backend.subscribe_speaking(self._current_channel_id) @@ -272,7 +310,7 @@ def _on_get_channel(self, *args, **kwargs): "nick": vs.get("nick"), "volume": self._self_input_volume, "muted": False, - "avatar_hash": user_data.get("avatar"), + "avatar_hash": user_data.get("avatar") or self.backend.current_user_avatar, "avatar_img": None, "is_self": True, } @@ -306,8 +344,9 @@ def _on_get_channel(self, *args, **kwargs): self._update_display() - def _on_voice_state_create(self, data: dict): + def _on_voice_state_create(self, *args, **kwargs): """Handle user joining voice channel.""" + data = args[1] if len(args) > 1 else None if not data: return @@ -346,8 +385,9 @@ def _on_voice_state_create(self, data: dict): self._update_display() - def _on_voice_state_delete(self, data: dict): + def _on_voice_state_delete(self, *args, **kwargs): """Handle user leaving voice channel.""" + data = args[1] if len(args) > 1 else None if not data: return @@ -370,8 +410,9 @@ def _on_voice_state_delete(self, data: dict): self._update_display() - def _on_voice_state_update(self, data: dict): + def _on_voice_state_update(self, *args, **kwargs): """Handle user voice state change (volume, mute, etc).""" + data = args[1] if len(args) > 1 else None if not data: return @@ -385,7 +426,7 @@ def _on_voice_state_update(self, data: dict): if user["id"] == user_id: if "volume" in data: user["volume"] = data.get("volume") - if "mute" in data: + if "mute" in data and not user.get("is_self"): user["muted"] = data.get("mute") if "nick" in data: user["nick"] = data.get("nick") @@ -470,19 +511,21 @@ def _update_display(self): user = self._users[self._current_user_index] display_name = user.get("nick") or user.get("username", "Unknown") volume = user.get("volume", 100) - is_speaking = user["id"] in self._speaking - # Build avatar image - avatar_src = user.get("avatar_img") - if avatar_src is not None: - avatar = make_circle_avatar(avatar_src, BUTTON_SIZE) - else: - avatar = make_placeholder_avatar(display_name, user["id"], BUTTON_SIZE) - # Kick off a fetch if not already in-flight - self._submit_avatar_fetch(user["id"]) - if is_speaking: - avatar = draw_speaking_ring(avatar, BUTTON_SIZE) - self.set_media(image=avatar) + # Build overlapping avatar stack with selected user in front + avatar_list = [] + for u in self._users: + src = u.get("avatar_img") + name = u.get("nick") or u.get("username", "?") + if src is not None: + img = src + else: + img = make_placeholder_avatar(name, u["id"], BUTTON_SIZE) + self._submit_avatar_fetch(u["id"]) + avatar_list.append((img, u["id"] in self._speaking, u.get("muted", False))) + self.set_media(image=compose_overlapping_avatars( + avatar_list, BUTTON_SIZE, front_index=self._current_user_index, + )) # Truncate name for label overlay display_name = display_name[:10] if len(display_name) > 10 else display_name diff --git a/actions/avatar_utils.py b/actions/avatar_utils.py index 15dcc20..1c697b7 100644 --- a/actions/avatar_utils.py +++ b/actions/avatar_utils.py @@ -85,3 +85,113 @@ def draw_speaking_ring(img: Image.Image, size: int) -> Image.Image: result = img.copy() result.paste(overlay, mask=overlay) return result + + +# Mute indicator: semi-transparent dark overlay with a red diagonal slash. +MUTE_OVERLAY_COLOR = (0, 0, 0, 140) +MUTE_SLASH_COLOR = (237, 66, 69, 255) # Discord red +MUTE_SLASH_WIDTH = 4 + + +def draw_mute_overlay(img: Image.Image, size: int) -> Image.Image: + """Overlay a mute indicator (dimmed circle + red slash) onto *img*.""" + overlay = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + draw.ellipse((0, 0, size - 1, size - 1), fill=MUTE_OVERLAY_COLOR) + pad = size // 6 + draw.line( + (pad, pad, size - 1 - pad, size - 1 - pad), + fill=MUTE_SLASH_COLOR, + width=MUTE_SLASH_WIDTH, + ) + result = img.copy() + result.paste(overlay, mask=overlay) + return result + + +# Border drawn around each avatar in the overlapping stack so circles +# are visually distinct against each other. +_AVATAR_BORDER_COLOR = (0, 0, 0, 255) +_AVATAR_BORDER_WIDTH = 2 + + +def _avatar_with_border(img: Image.Image, size: int) -> Image.Image: + """Add a thin black circular border around a circle-clipped avatar.""" + result = img.copy() + draw = ImageDraw.Draw(result) + half = _AVATAR_BORDER_WIDTH // 2 + draw.ellipse( + (half, half, size - 1 - half, size - 1 - half), + outline=_AVATAR_BORDER_COLOR, + width=_AVATAR_BORDER_WIDTH, + ) + return result + + +def compose_overlapping_avatars( + avatars: list[tuple[Image.Image, bool, bool]], + canvas_size: int, + front_index: int = -1, +) -> Image.Image: + """Compose avatars in an overlapping stack, with *front_index* on top. + + *avatars* is a list of ``(image, is_speaking, is_muted)`` tuples. + *front_index* is the index of the avatar to place in front. When -1 + (the default), no reordering is done and the last avatar is on top. + """ + canvas = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 255)) + n = len(avatars) + if n == 0: + return canvas + + # Single avatar — render full size, centred. + if n == 1: + img, speaking, muted = avatars[0] + av = make_circle_avatar(img, canvas_size) + if speaking: + av = draw_speaking_ring(av, canvas_size) + if muted: + av = draw_mute_overlay(av, canvas_size) + canvas.paste(av, (0, 0), av) + return canvas + + # Avatar diameter: large enough to be readable, small enough to overlap. + avatar_size = int(canvas_size * 0.65) + + # Build a display order where the front avatar is placed at the centre + # position and the remaining avatars fill the other slots, preserving + # their relative order. The front avatar is painted last (on top). + if 0 <= front_index < n: + others = [i for i in range(n) if i != front_index] + centre = n // 2 + display_order = others[:centre] + [front_index] + others[centre:] + else: + display_order = list(range(n)) + + # Spread slots horizontally across the canvas with even overlap. + total_width = avatar_size + (n - 1) * (avatar_size // 2) + x_start = (canvas_size - total_width) // 2 + y = (canvas_size - avatar_size) // 2 + + # Map each original avatar index to the x position of its assigned slot. + positions = {} + for slot, orig_idx in enumerate(display_order): + positions[orig_idx] = x_start + slot * (avatar_size // 2) + + # Paint order: everything except front first, then front on top. + paint_order = [i for i in display_order if i != front_index] + ( + [front_index] if 0 <= front_index < n else [] + ) + if not paint_order: + paint_order = display_order + + for idx in paint_order: + img, speaking, muted = avatars[idx] + av = make_circle_avatar(img, avatar_size) + if speaking: + av = draw_speaking_ring(av, avatar_size) + if muted: + av = draw_mute_overlay(av, avatar_size) + canvas.paste(av, (positions[idx], y), av) + + return canvas diff --git a/backend.py b/backend.py index ec9b997..861caa7 100644 --- a/backend.py +++ b/backend.py @@ -22,6 +22,7 @@ def __init__(self): 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) + self._current_user_avatar: str = None # Current user's avatar hash def discord_callback(self, code, event): if code == 0: @@ -67,15 +68,20 @@ def discord_callback(self, code, event): user = data.get("user", {}) self._register_callbacks() self._current_user_id = user.get("id") + self._current_user_avatar = user.get("avatar") self._get_current_voice_channel() case commands.DISPATCH: evt = event.get("evt") self.frontend.trigger_event(evt, event.get("data")) case commands.GET_SELECTED_VOICE_CHANNEL: - self._current_voice_channel = ( - event.get("data").get("channel_id") if event.get("data") else None + data = event.get("data") + channel_id = data.get("id") if data else None + self._current_voice_channel = channel_id + # Normalize to match VOICE_CHANNEL_SELECT dispatch format + self.frontend.trigger_event( + commands.VOICE_CHANNEL_SELECT, + {"channel_id": channel_id}, ) - self.frontend.trigger_event(commands.VOICE_CHANNEL_SELECT, event.get("data")) case commands.GET_CHANNEL: self.frontend.trigger_event(commands.GET_CHANNEL, event.get("data")) case commands.GET_GUILD: @@ -183,6 +189,10 @@ def current_voice_channel(self): def current_user_id(self): return self._current_user_id + @property + def current_user_avatar(self): + return self._current_user_avatar + def _get_current_voice_channel(self): if not self._ensure_connected(): log.warning( diff --git a/main.py b/main.py index ebc37da..27539eb 100644 --- a/main.py +++ b/main.py @@ -27,7 +27,7 @@ VOICE_CHANNEL_SELECT, VOICE_SETTINGS_UPDATE, GET_CHANNEL, GET_GUILD, SPEAKING_START, SPEAKING_STOP, - VOICE_STATE_CREATE, VOICE_STATE_DELETE, + VOICE_STATE_CREATE, VOICE_STATE_DELETE, VOICE_STATE_UPDATE, ) @@ -121,6 +121,11 @@ def _create_event_holders(self): event_id_suffix=VOICE_STATE_DELETE, ) + voice_state_update = EventHolder( + plugin_base=self, + event_id_suffix=VOICE_STATE_UPDATE, + ) + self.add_event_holders([ voice_channel_select, voice_settings_update, @@ -130,6 +135,7 @@ def _create_event_holders(self): speaking_stop, voice_state_create, voice_state_delete, + voice_state_update, ]) @@ -224,7 +230,9 @@ def _register_actions(self): self.add_action_holder(user_volume) def setup_backend(self): - if self.backend and self.backend.is_authed(): + if not self.backend: + return + if self.backend.is_authed(): return settings = self.get_settings() client_id = settings.get("client_id", "") diff --git a/settings.py b/settings.py index a069a99..12d823f 100644 --- a/settings.py +++ b/settings.py @@ -23,7 +23,7 @@ def __init__(self, plugin_base: PluginBase): self._settings_cache = None def get_settings_area(self) -> Adw.PreferencesGroup: - if not self._plugin_base.backend.is_authed(): + if not self._plugin_base.backend or not self._plugin_base.backend.is_authed(): self._status_label = Gtk.Label( label=self._plugin_base.lm.get("actions.base.credentials.failed"), css_classes=["discord-controller-red"], From ca205cda6f39d4aec588925d7aacf227f6684887 Mon Sep 17 00:00:00 2001 From: Grant Abell <82414202+GrantAbell@users.noreply.github.com> Date: Sat, 4 Apr 2026 00:42:54 -0400 Subject: [PATCH 6/8] Update backend.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend.py b/backend.py index 861caa7..177e89f 100644 --- a/backend.py +++ b/backend.py @@ -1,4 +1,3 @@ -import io import json import requests From c852ab215041b53f085126d89f8dce9a3dc54d4c Mon Sep 17 00:00:00 2001 From: Grant Abell Date: Sat, 4 Apr 2026 00:58:33 -0400 Subject: [PATCH 7/8] fix: dead code, subscription leak, and missing backend method - Remove dead image_bytes branch and use backend.fetch_avatar() in ChangeVoiceChannel._fetch_avatar - Clean up _fetching_avatars on all early-return paths - Unsubscribe voice states and speaking before clearing state in _on_channel_id_changed - Remove unused _current_channel and show_self assignments - Implement Backend.set_input_volume for self mic volume control --- actions/ChangeVoiceChannel.py | 25 ++++++++++++------------- backend.py | 7 +++++++ 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/actions/ChangeVoiceChannel.py b/actions/ChangeVoiceChannel.py index f46ed0f..ef88989 100644 --- a/actions/ChangeVoiceChannel.py +++ b/actions/ChangeVoiceChannel.py @@ -121,7 +121,6 @@ class ChangeVoiceChannel(DiscordCore): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.has_configuration = True - self._current_channel: str = "" self.icon_keys = [Icons.VOICE_CHANNEL_ACTIVE, Icons.VOICE_CHANNEL_INACTIVE] self.current_icon = self.get_icon(Icons.VOICE_CHANNEL_INACTIVE) self.icon_name = Icons.VOICE_CHANNEL_INACTIVE @@ -318,10 +317,9 @@ def _on_get_channel(self, *args, **kwargs): connected = self._connected_channel_id == configured_channel current_user_id = self.backend.current_user_id - show_self = self._show_self_row.get_value() # Reconcile user list against the authoritative voice_states snapshot. - # Self is excluded only in observer mode (not in the channel); show_self + # Self is excluded only in observer mode (not in the channel); # controls avatar *display* only and is handled in _render_button. new_user_ids = set() for vs in data.get("voice_states", []): @@ -408,23 +406,18 @@ def _submit_avatar_fetch(self, user_id: str): def _fetch_avatar(self, user_id: str): user = self._users.get(user_id) if not user: + self._fetching_avatars.discard(user_id) return avatar_hash = user.get("avatar_hash") if not avatar_hash: self._fetching_avatars.discard(user_id) return - url = f"https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png?size=64" try: - resp = requests.get(url, timeout=10) - resp.raise_for_status() - user["avatar_img"] = Image.open(io.BytesIO(resp.content)).convert("RGBA") + image_bytes = self.backend.fetch_avatar(user_id, avatar_hash) + if image_bytes: + user["avatar_img"] = Image.open(io.BytesIO(image_bytes)).convert("RGBA") except Exception as ex: log.error(f"Failed to fetch avatar for {user_id}: {ex}") - if image_bytes: - try: - user["avatar_img"] = Image.open(io.BytesIO(image_bytes)).convert("RGBA") - except Exception as ex: - log.error(f"Failed to decode avatar for {user_id}: {ex}") self._fetching_avatars.discard(user_id) self._render_button() @@ -545,11 +538,17 @@ def create_generative_ui(self): def _on_channel_id_changed(self, widget, new_value, old_value): """Invalidate all cached state and re-subscribe when the channel ID is changed.""" + if self._watching_channel_id: + try: + self.backend.unsubscribe_voice_states(self._watching_channel_id) + self.backend.unsubscribe_speaking(self._watching_channel_id) + except Exception: + pass self._guild_channel_id = None self._guild_icon_image = None self._guild_name = None self._guild_id = None - self._watching_channel_id = None # Force _start_watching to re-subscribe + self._watching_channel_id = None self._users.clear() self._speaking.clear() self._fetching_avatars.clear() diff --git a/backend.py b/backend.py index 177e89f..8a9e155 100644 --- a/backend.py +++ b/backend.py @@ -159,6 +159,13 @@ def set_deafen(self, muted: bool): return self.discord_client.set_voice_settings({"deaf": muted}) + def set_input_volume(self, volume: int): + """Set microphone input volume (0-100).""" + if not self._ensure_connected(): + log.warning("Discord client not connected, cannot set input volume") + return + self.discord_client.set_voice_settings({"input": {"volume": volume}}) + def change_voice_channel(self, channel_id: str = None) -> bool: if not self._ensure_connected(): log.warning("Discord client not connected, cannot change voice channel") From 1a286dd539e3a5db24f42097c8e036a8d7ddf38e Mon Sep 17 00:00:00 2001 From: Grant Abell Date: Sat, 4 Apr 2026 01:15:08 -0400 Subject: [PATCH 8/8] Removed unused method for avatar borders --- actions/avatar_utils.py | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/actions/avatar_utils.py b/actions/avatar_utils.py index 1c697b7..558fc29 100644 --- a/actions/avatar_utils.py +++ b/actions/avatar_utils.py @@ -109,25 +109,6 @@ def draw_mute_overlay(img: Image.Image, size: int) -> Image.Image: return result -# Border drawn around each avatar in the overlapping stack so circles -# are visually distinct against each other. -_AVATAR_BORDER_COLOR = (0, 0, 0, 255) -_AVATAR_BORDER_WIDTH = 2 - - -def _avatar_with_border(img: Image.Image, size: int) -> Image.Image: - """Add a thin black circular border around a circle-clipped avatar.""" - result = img.copy() - draw = ImageDraw.Draw(result) - half = _AVATAR_BORDER_WIDTH // 2 - draw.ellipse( - (half, half, size - 1 - half, size - 1 - half), - outline=_AVATAR_BORDER_COLOR, - width=_AVATAR_BORDER_WIDTH, - ) - return result - - def compose_overlapping_avatars( avatars: list[tuple[Image.Image, bool, bool]], canvas_size: int,