From 84b746af71d73d5978fbd42a553986e853bfbb50 Mon Sep 17 00:00:00 2001 From: Grant Abell Date: Thu, 2 Apr 2026 12:12:00 -0400 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 06/15] 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 07/15] 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 08/15] 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, From 9e93a545c62fcc98bce29051ed0266906277fc0c Mon Sep 17 00:00:00 2001 From: Grant Abell Date: Mon, 6 Apr 2026 12:22:24 -0400 Subject: [PATCH 09/15] Remove unnecesarry redeclarations and rename confusing variables --- actions/ChangeVoiceChannel.py | 52 ++++++++--------------------------- actions/avatar_utils.py | 14 ++++------ 2 files changed, 18 insertions(+), 48 deletions(-) diff --git a/actions/ChangeVoiceChannel.py b/actions/ChangeVoiceChannel.py index ef88989..9dc882b 100644 --- a/actions/ChangeVoiceChannel.py +++ b/actions/ChangeVoiceChannel.py @@ -1,17 +1,12 @@ import io -import math from enum import StrEnum from loguru import logger as log -from PIL import Image, ImageDraw +from PIL import Image, ImageDraw, ImageFont from .DiscordCore import DiscordCore from .avatar_utils import ( BUTTON_SIZE, - SPEAKING_COLOR, - RING_WIDTH, - make_circle_avatar, - draw_speaking_ring, make_placeholder_avatar, compose_overlapping_avatars, ) @@ -19,6 +14,8 @@ from src.backend.PluginManager.InputBases import Input from GtkHelper.GenerativeUI.EntryRow import EntryRow +from GtkHelper.GenerativeUI.ComboRow import ComboRow +from GtkHelper.GenerativeUI.SwitchRow import SwitchRow from ..discordrpc.commands import ( VOICE_CHANNEL_SELECT, @@ -30,29 +27,15 @@ SPEAKING_STOP, ) -from GtkHelper.GenerativeUI.ComboRow import ComboRow -from GtkHelper.GenerativeUI.SwitchRow import SwitchRow - -# 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 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) _BADGE_FG = (255, 255, 255, 255) _BADGE_MARGIN = 4 try: - from PIL import ImageFont as _ImageFont - _badge_font = _ImageFont.load_default(size=10) + _badge_font = ImageFont.load_default(size=10) except Exception: - from PIL import ImageFont as _ImageFont - _badge_font = _ImageFont.load_default() + _badge_font = ImageFont.load_default() def _draw_counter_badge(base: Image.Image, count: int, corner: str = "bottom-right") -> Image.Image: @@ -60,7 +43,7 @@ def _draw_counter_badge(base: Image.Image, count: int, corner: str = "bottom-rig *corner* is one of: "top-left", "top-right", "bottom-left", "bottom-right". """ - img = base.convert("RGBA").resize((_BUTTON_SIZE, _BUTTON_SIZE), Image.LANCZOS) + 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) @@ -72,13 +55,13 @@ def _draw_counter_badge(base: Image.Image, count: int, corner: str = "bottom-rig right = corner.endswith("right") bottom = corner.startswith("bottom") if right: - x2 = _BUTTON_SIZE - _BADGE_MARGIN + x2 = BUTTON_SIZE - _BADGE_MARGIN x1 = x2 - bw else: x1 = _BADGE_MARGIN x2 = x1 + bw if bottom: - y2 = _BUTTON_SIZE - _BADGE_MARGIN + y2 = BUTTON_SIZE - _BADGE_MARGIN y1 = y2 - bh else: y1 = _BADGE_MARGIN @@ -95,26 +78,15 @@ 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: - return make_circle_avatar(img, size) - - -def _draw_speaking_ring(img: Image.Image, size: int) -> Image.Image: - return draw_speaking_ring(img, size) - - def _compose_avatars(avatars: list[tuple[Image.Image, bool]]) -> Image.Image: """Compose avatar images in an overlapping stack, speaking user in front.""" - # Find the last speaking user to bring to front - front = -1 - avatars_3 = [] + front = None + extended = [] for i, (img, speaking) in enumerate(avatars): if speaking: front = i - avatars_3.append((img, speaking, False)) - return compose_overlapping_avatars(avatars_3, BUTTON_SIZE, front_index=front) + extended.append((img, speaking, False)) + return compose_overlapping_avatars(extended, BUTTON_SIZE, front_index=front) class ChangeVoiceChannel(DiscordCore): diff --git a/actions/avatar_utils.py b/actions/avatar_utils.py index 558fc29..14848b8 100644 --- a/actions/avatar_utils.py +++ b/actions/avatar_utils.py @@ -108,16 +108,15 @@ def draw_mute_overlay(img: Image.Image, size: int) -> Image.Image: result.paste(overlay, mask=overlay) return result - def compose_overlapping_avatars( avatars: list[tuple[Image.Image, bool, bool]], canvas_size: int, - front_index: int = -1, + front_index: int | None = None, ) -> 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 + *front_index* is the index of the avatar to place in front. When ``None`` (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)) @@ -142,7 +141,7 @@ def compose_overlapping_avatars( # 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: + if front_index is not None and 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:] @@ -160,10 +159,9 @@ def compose_overlapping_avatars( 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: + if front_index is not None and 0 <= front_index < n: + paint_order = [i for i in display_order if i != front_index] + [front_index] + else: paint_order = display_order for idx in paint_order: From 2aa5282382cad443cb3997f068217a0cc5ac0a92 Mon Sep 17 00:00:00 2001 From: Grant Abell Date: Mon, 6 Apr 2026 12:28:55 -0400 Subject: [PATCH 10/15] Remove direct requests ussage from _fetch_avatar method in favor of backend --- actions/UserVolume.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/actions/UserVolume.py b/actions/UserVolume.py index afc3c85..2bc9b05 100644 --- a/actions/UserVolume.py +++ b/actions/UserVolume.py @@ -1,6 +1,5 @@ import io -import requests from loguru import logger as log from PIL import Image @@ -481,11 +480,16 @@ def _fetch_avatar(self, user_id: str): 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") + backend = getattr(self, "backend", None) + if backend is None and hasattr(self, "plugin_base"): + backend = getattr(self.plugin_base, "backend", None) + if backend is None or not hasattr(backend, "fetch_avatar"): + raise AttributeError("Backend fetch_avatar helper is not available") + + avatar_bytes = backend.fetch_avatar(user_id, avatar_hash) + if avatar_bytes: + user["avatar_img"] = Image.open(io.BytesIO(avatar_bytes)).convert("RGBA") except Exception as ex: log.error(f"Failed to fetch avatar for {user_id}: {ex}") self._fetching_avatars.discard(user_id) From d383ee9a9e12c52a28bb5a37148c5de3ef659d88 Mon Sep 17 00:00:00 2001 From: Grant Abell Date: Mon, 6 Apr 2026 12:34:17 -0400 Subject: [PATCH 11/15] Reconcile self-user entry in UserVolume when control_self toggle changes --- actions/UserVolume.py | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/actions/UserVolume.py b/actions/UserVolume.py index 2bc9b05..b2318a6 100644 --- a/actions/UserVolume.py +++ b/actions/UserVolume.py @@ -67,11 +67,27 @@ def create_generative_ui(self): subtitle="Include yourself so the dial adjusts your microphone input volume", auto_add=False, complex_var_name=True, + on_change=self._on_control_self_changed, ) def get_config_rows(self): return [self._control_self_row._widget] + def _on_control_self_changed(self, widget, new_value, old_value): + """Add or remove self from the user list when the toggle changes.""" + if not new_value: + # Remove any is_self entry + had_self = any(u.get("is_self") for u in self._users) + self._users = [u for u in self._users if not u.get("is_self")] + if had_self: + if self._current_user_index >= len(self._users): + self._current_user_index = max(0, len(self._users) - 1) + self._update_display() + else: + # Re-fetch channel data to inject self + if self._in_voice_channel and self._current_channel_id and self.backend: + self.backend.get_channel(self._current_channel_id) + def on_ready(self): super().on_ready() @@ -292,6 +308,13 @@ def _on_get_channel(self, *args, **kwargs): # Process voice_states array voice_states = data.get("voice_states", []) current_user_id = self.backend.current_user_id + control_self = self._control_self_row.get_value() + + # Remove stale is_self entry if the toggle was turned off + if not control_self: + self._users = [u for u in self._users if not u.get("is_self")] + if self._current_user_index >= len(self._users): + self._current_user_index = max(0, len(self._users) - 1) for vs in voice_states: user_data = vs.get("user", {}) @@ -302,7 +325,7 @@ def _on_get_channel(self, *args, **kwargs): # 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): + if control_self and not any(u.get("is_self") for u in self._users): self_info = { "id": user_id, "username": user_data.get("username", "Me"), From 017610c5e5ecb4a2dce09d2b0b8b04fd7517f209 Mon Sep 17 00:00:00 2001 From: Grant Abell Date: Tue, 7 Apr 2026 23:27:40 -0400 Subject: [PATCH 12/15] fix: label errors, flickering, clean shutdown, and add label config options UserVolume: - Replace set_top/center/bottom_label() calls with set_label(position=) to avoid ActionCore state lookup errors (state: 1) during voice events - Add _set_label_if_changed() helper that only calls set_label() when the text actually changes, eliminating volume % flickering during speaking updates - Guard on_ready() event subscriptions with _events_connected flag to prevent duplicate callbacks being stacked on subsequent on_ready() calls, which was the cause of repeated state errors on channel join/leave - Guard request_current_voice_channel() with _requested_initial_voice_state so it fires only once per action instance - Preserve self mic input volume in _on_voice_state_update: skip volume field for is_self entries so incoming voice-state data cannot overwrite the locally-managed input volume controlled via set_input_volume ChangeVoiceChannel: - Store channel name from GET_CHANNEL response for use as a button label - Add 'Channel name label position' ComboRow (default: none) to independently position channel name alongside server name - Add 'none' option to server name label position ComboRow - Add 'Server name font size' and 'Channel name font size' SpinRows (6-32pt) - Move label rendering outside the connected/observer branch so guild name is displayed in all states, not only the empty-channel fallback - Add on_change callbacks to all label config rows so position, font size, show-self, and badge corner changes re-render the button immediately - Reset _channel_name on channel ID change alongside other cached guild state main.py: - Mark ThreadPoolExecutor worker threads as daemon threads via initializer so they do not block StreamController's shutdown join loop, restoring the clean exit path and 'Have a nice day' message on Ctrl+C --- actions/ChangeVoiceChannel.py | 96 ++++++++++++++++++++++++++++++----- actions/UserVolume.py | 52 ++++++++++++------- main.py | 5 +- 3 files changed, 122 insertions(+), 31 deletions(-) diff --git a/actions/ChangeVoiceChannel.py b/actions/ChangeVoiceChannel.py index 9dc882b..c898126 100644 --- a/actions/ChangeVoiceChannel.py +++ b/actions/ChangeVoiceChannel.py @@ -16,6 +16,7 @@ from GtkHelper.GenerativeUI.EntryRow import EntryRow from GtkHelper.GenerativeUI.ComboRow import ComboRow from GtkHelper.GenerativeUI.SwitchRow import SwitchRow +from GtkHelper.GenerativeUI.SpinRow import SpinRow from ..discordrpc.commands import ( VOICE_CHANNEL_SELECT, @@ -102,6 +103,7 @@ def __init__(self, *args, **kwargs): self._guild_name: str = None self._guild_icon_image: Image.Image = None self._guild_channel_id: str = None + self._channel_name: str = None # Current channel name for label display # Voice channel / avatar state self._connected_channel_id: str = None # channel we're currently in @@ -316,6 +318,9 @@ def _on_get_channel(self, *args, **kwargs): self._users.pop(uid) self._speaking.discard(uid) + # Store channel name for label display + self._channel_name = data.get("name", "") + # Guild info lookup (only if not yet cached for this channel) if self._guild_channel_id != channel_id: guild_id = data.get("guild_id") @@ -404,10 +409,17 @@ def _render_button(self): and self._connected_channel_id == configured ) - # 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) + # Build label texts based on settings + guild_position = self._label_position_row.get_value() or "bottom" + channel_position = self._channel_name_label_position_row.get_value() or "none" + guild_text = self._guild_name or "" + channel_text = self._channel_name or "" + + # Clear both potential label positions if configured + if guild_position != "none": + self.set_label("", position=guild_position) + if channel_position != "none": + self.set_label("", position=channel_position) if connected: # Trigger fetches for any users who joined before avatars were loaded @@ -459,15 +471,30 @@ def _render_button(self): 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 + # Empty channel, no guild icon — show inactive voice icon. super().display_icon() - label_text = self._guild_name or "" - if label_text: - self.set_label( - label_text, position=position, - font_size=8, outline_width=2, - outline_color=[0, 0, 0, 255], - ) + + # Render guild name label + if guild_text and guild_position != "none": + guild_font_size = int(self._guild_label_font_size_row.get_value()) + self.set_label( + guild_text, + position=guild_position, + font_size=guild_font_size, + outline_width=2, + outline_color=[0, 0, 0, 255], + ) + + # Render channel name label + if channel_text and channel_position != "none": + channel_font_size = int(self._channel_label_font_size_row.get_value()) + self.set_label( + channel_text, + position=channel_position, + font_size=channel_font_size, + outline_width=2, + outline_color=[0, 0, 0, 255], + ) def create_generative_ui(self): @@ -484,10 +511,47 @@ def create_generative_ui(self): action_core=self, var_name="change_voice_channel.label_position", default_value="bottom", - items=["top", "center", "bottom"], + items=["top", "center", "bottom", "none"], title="Server name label position", auto_add=False, complex_var_name=True, + on_change=lambda *_: self._render_button(), + ) + self._guild_label_font_size_row = SpinRow( + action_core=self, + var_name="change_voice_channel.guild_label_font_size", + default_value=8, + min=6, + max=32, + step=1, + digits=0, + title="Server name font size", + auto_add=False, + complex_var_name=True, + on_change=lambda *_: self._render_button(), + ) + self._channel_name_label_position_row = ComboRow( + action_core=self, + var_name="change_voice_channel.channel_label_position", + default_value="none", + items=["none", "top", "center", "bottom"], + title="Channel name label position", + auto_add=False, + complex_var_name=True, + on_change=lambda *_: self._render_button(), + ) + self._channel_label_font_size_row = SpinRow( + action_core=self, + var_name="change_voice_channel.channel_label_font_size", + default_value=8, + min=6, + max=32, + step=1, + digits=0, + title="Channel name font size", + auto_add=False, + complex_var_name=True, + on_change=lambda *_: self._render_button(), ) self._show_self_row = SwitchRow( action_core=self, @@ -497,6 +561,7 @@ def create_generative_ui(self): subtitle="Include yourself in the user grid when connected", auto_add=False, complex_var_name=True, + on_change=lambda *_: self._render_button(), ) self._badge_corner_row = ComboRow( action_core=self, @@ -506,6 +571,7 @@ def create_generative_ui(self): title="User count badge corner", auto_add=False, complex_var_name=True, + on_change=lambda *_: self._render_button(), ) def _on_channel_id_changed(self, widget, new_value, old_value): @@ -520,6 +586,7 @@ def _on_channel_id_changed(self, widget, new_value, old_value): self._guild_icon_image = None self._guild_name = None self._guild_id = None + self._channel_name = None self._watching_channel_id = None self._users.clear() self._speaking.clear() @@ -531,6 +598,9 @@ def get_config_rows(self): return [ self._channel_row._widget, self._label_position_row._widget, + self._guild_label_font_size_row._widget, + self._channel_name_label_position_row._widget, + self._channel_label_font_size_row._widget, self._show_self_row._widget, self._badge_corner_row._widget, ] diff --git a/actions/UserVolume.py b/actions/UserVolume.py index b2318a6..4dd95e4 100644 --- a/actions/UserVolume.py +++ b/actions/UserVolume.py @@ -54,6 +54,9 @@ def __init__(self, *args, **kwargs): 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 + self._label_cache: dict[str, str] = {"top": None, "center": None, "bottom": None} + self._events_connected: bool = False + self._requested_initial_voice_state: bool = False # Volume adjustment step (percentage points per dial tick) self.VOLUME_STEP = 5 @@ -91,41 +94,44 @@ def _on_control_self_changed(self, widget, new_value, old_value): def on_ready(self): super().on_ready() - self.plugin_base.connect_to_event( + if not self._events_connected: + self.plugin_base.connect_to_event( event_id=f"{self.plugin_base.get_plugin_id()}::{VOICE_CHANNEL_SELECT}", callback=self._on_voice_channel_select, ) - self.plugin_base.connect_to_event( + 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( + 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( + 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( + 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( + 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( + self.plugin_base.connect_to_event( event_id=f"{self.plugin_base.get_plugin_id()}::{SPEAKING_STOP}", callback=self._on_speaking_stop, ) + self._events_connected = True # Initialize display self._update_display() # Request current voice channel state (in case we're already in a channel) - if self.backend: + if self.backend and not self._requested_initial_voice_state: self.backend.request_current_voice_channel() + self._requested_initial_voice_state = True def create_event_assigners(self): # Dial rotation: adjust volume @@ -446,7 +452,9 @@ def _on_voice_state_update(self, *args, **kwargs): # Find and update user for user in self._users: if user["id"] == user_id: - if "volume" in data: + # Voice-state volume tracks per-user output volume, not local mic input. + # Preserve self input volume managed through set_input_volume. + if "volume" in data and not user.get("is_self"): user["volume"] = data.get("volume") if "mute" in data and not user.get("is_self"): user["muted"] = data.get("mute") @@ -520,19 +528,29 @@ def _fetch_avatar(self, user_id: str): # === Display === + def _set_label_if_changed(self, text: str, position: str): + """Only update labels when content changes to avoid noisy state lookup logs.""" + if self._label_cache.get(position) == text: + return + self._label_cache[position] = text + self.set_label(text, position=position) + 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 "") + self._set_label_if_changed( + "Not in voice" if not self._in_voice_channel else self._current_channel_name[:12], + position="top", + ) + self._set_label_if_changed("", position="center") + self._set_label_if_changed("No users" if self._in_voice_channel else "", position="bottom") # 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 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) + self._set_label_if_changed(channel_display, position="top") if self._current_user_index < len(self._users): user = self._users[self._current_user_index] @@ -556,8 +574,8 @@ def _update_display(self): # 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}%") + self._set_label_if_changed(display_name, position="center") + self._set_label_if_changed(f"{volume}%", position="bottom") else: - self.set_center_label("") - self.set_bottom_label("No selection") + self._set_label_if_changed("", position="center") + self._set_label_if_changed("No selection", position="bottom") diff --git a/main.py b/main.py index 27539eb..6a6fe59 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,6 @@ import os import json +import threading from concurrent.futures import ThreadPoolExecutor from loguru import logger as log @@ -45,7 +46,9 @@ def __init__(self): self._settings_manager = PluginSettings(self) self.has_plugin_settings = True self._thread_pool = ThreadPoolExecutor( - max_workers=4, thread_name_prefix="discord-" + max_workers=4, + thread_name_prefix="discord-", + initializer=lambda: setattr(threading.current_thread(), "daemon", True), ) self._add_icons() self._register_actions() From 0fe640bfea0869fc2b3a4162042fb4cdd694fc04 Mon Sep 17 00:00:00 2001 From: Grant Abell Date: Wed, 8 Apr 2026 00:19:02 -0400 Subject: [PATCH 13/15] fix: stabilize guild icon callback after clean-shutdown change Follow-up to 017610c. - main.py: remove the ThreadPoolExecutor thread initializer that attempted to set daemon=True at worker startup. - actions/ChangeVoiceChannel.py: guard guild icon task submission in _on_get_guild with try/except and fall back to synchronous fetch when submit fails. This keeps GET_GUILD callbacks from failing hard if the pool cannot accept a new task, while preserving thumbnail rendering behavior. --- actions/ChangeVoiceChannel.py | 6 +++++- main.py | 2 -- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/actions/ChangeVoiceChannel.py b/actions/ChangeVoiceChannel.py index c898126..53f758c 100644 --- a/actions/ChangeVoiceChannel.py +++ b/actions/ChangeVoiceChannel.py @@ -346,7 +346,11 @@ def _on_get_guild(self, *args, **kwargs): 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) + try: + self.plugin_base._thread_pool.submit(self._fetch_guild_icon, icon_url) + except Exception as ex: + log.error(f"Failed to submit guild icon fetch task: {ex}") + self._fetch_guild_icon(icon_url) else: self._guild_icon_image = None self._render_button() diff --git a/main.py b/main.py index 6a6fe59..4e8aaca 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,5 @@ import os import json -import threading from concurrent.futures import ThreadPoolExecutor from loguru import logger as log @@ -48,7 +47,6 @@ def __init__(self): self._thread_pool = ThreadPoolExecutor( max_workers=4, thread_name_prefix="discord-", - initializer=lambda: setattr(threading.current_thread(), "daemon", True), ) self._add_icons() self._register_actions() From 5b9eb4049ce1d38d9318690a4d3d0c79817f5a46 Mon Sep 17 00:00:00 2001 From: Grant Abell Date: Wed, 8 Apr 2026 11:22:42 -0400 Subject: [PATCH 14/15] fix(change-voice): bootstrap channel data on startup and correct join toggle behavior - Call _start_watching_configured_channel() during render to recover from startup event ordering and populate guild/channel labels + thumbnail without requiring an initial button press. - Update _on_change_channel toggle logic to disconnect only when already in the configured channel; if connected elsewhere, pressing now switches/joins the configured channel as expected. --- actions/ChangeVoiceChannel.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/actions/ChangeVoiceChannel.py b/actions/ChangeVoiceChannel.py index 53f758c..cb3f516 100644 --- a/actions/ChangeVoiceChannel.py +++ b/actions/ChangeVoiceChannel.py @@ -407,6 +407,10 @@ def display_icon(self): self._render_button() def _render_button(self): + # Ensure configured-channel metadata and subscriptions are loaded even if + # initial auth/state events were missed during startup ordering. + self._start_watching_configured_channel() + configured = self._channel_row.get_value() connected = ( self._connected_channel_id is not None @@ -620,14 +624,19 @@ def create_event_assigners(self): ) def _on_change_channel(self, _): - if self._connected_channel_id is not None: + configured = self._channel_row.get_value() + + # Toggle behavior only when already connected to this configured channel. + # If connected to another channel, switch to the configured one instead. + if self._connected_channel_id is not None and self._connected_channel_id == configured: try: self.backend.change_voice_channel(None) except Exception as ex: log.error(ex) self.show_error(3) return - channel = self._channel_row.get_value() + + channel = configured try: self.backend.change_voice_channel(channel) except Exception as ex: From 19efeed9d183edf83543f1b82ef9a74607a1e5f9 Mon Sep 17 00:00:00 2001 From: Grant Abell Date: Fri, 10 Apr 2026 00:58:12 -0400 Subject: [PATCH 15/15] Fix UserVolume labels after UI display resets --- actions/UserVolume.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/actions/UserVolume.py b/actions/UserVolume.py index 4dd95e4..662ca1d 100644 --- a/actions/UserVolume.py +++ b/actions/UserVolume.py @@ -54,7 +54,7 @@ def __init__(self, *args, **kwargs): 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 - self._label_cache: dict[str, str] = {"top": None, "center": None, "bottom": None} + self._label_cache: dict[str, str] = {"top": "", "center": "", "bottom": ""} self._events_connected: bool = False self._requested_initial_voice_state: bool = False @@ -529,9 +529,7 @@ def _fetch_avatar(self, user_id: str): # === Display === def _set_label_if_changed(self, text: str, position: str): - """Only update labels when content changes to avoid noisy state lookup logs.""" - if self._label_cache.get(position) == text: - return + """Set labels on every refresh so external UI clears are immediately healed.""" self._label_cache[position] = text self.set_label(text, position=position)