diff --git a/actions/ChangeVoiceChannel.py b/actions/ChangeVoiceChannel.py index 74024cf..ef88989 100644 --- a/actions/ChangeVoiceChannel.py +++ b/actions/ChangeVoiceChannel.py @@ -1,14 +1,93 @@ +import io +import math from enum import StrEnum from loguru import logger as log +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, + compose_overlapping_avatars, +) from src.backend.PluginManager.EventAssigner import EventAssigner from src.backend.PluginManager.InputBases import Input 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 +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) +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): @@ -16,36 +95,408 @@ 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 = [] + 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) + + 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 + # 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._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() 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, + ) + # 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 _update_display(self, *args, **kwargs): + + 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}") + + 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 + # 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() + + # 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_speaking(configured) + except Exception as ex: + 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 + + self._connected_channel_id = new_channel + + 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_speaking(new_channel) + self.backend.get_channel(new_channel) + except Exception as ex: + log.error(f"Failed to subscribe after joining channel: {ex}") + self._render_button() + else: + # 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) — 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): + if not self._watching_channel_id: + return + 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): + if not self._watching_channel_id: + return + 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 + 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 + 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() + if channel_id != configured_channel: + return + + connected = self._connected_channel_id == configured_channel + current_user_id = self.backend.current_user_id + + # Reconcile user list against the authoritative voice_states snapshot. + # 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", []): + 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 + 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): + image_bytes = None + try: + 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) + 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 = 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 + try: + 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}") + self._fetching_avatars.discard(user_id) + self._render_button() + + # Rendering + def display_icon(self): + self._render_button() + + def _render_button(self): + configured = self._channel_row.get_value() + connected = ( + 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() + + # 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 + 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 = [] + 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: + # 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: + self.current_icon = self.get_icon(Icons.VOICE_CHANNEL_ACTIVE) + super().display_icon() + else: + # 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 "" + if label_text: + self.set_label( + label_text, position=position, + font_size=8, outline_width=2, + outline_color=[0, 0, 0, 255], + ) + def create_generative_ui(self): self._channel_row = EntryRow( @@ -55,10 +506,62 @@ 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, + ) + 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 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 + self._users.clear() + self._speaking.clear() + self._fetching_avatars.clear() + self._render_button() + self._start_watching_configured_channel() + def get_config_rows(self): - return [self._channel_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( @@ -71,7 +574,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 +587,4 @@ def _on_change_channel(self, _): except Exception as ex: log.error(ex) self.show_error(3) + diff --git a/actions/UserVolume.py b/actions/UserVolume.py index ba60db7..afc3c85 100644 --- a/actions/UserVolume.py +++ b/actions/UserVolume.py @@ -1,15 +1,31 @@ +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, + draw_mute_overlay, + make_placeholder_avatar, + compose_overlapping_avatars, +) 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 +44,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,12 +84,33 @@ 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, + ) + 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() # 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 @@ -97,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, _): @@ -114,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): @@ -121,6 +203,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 +236,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,20 +253,20 @@ 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 self._current_channel_name = data.get("name", "Voice") - # Register frontend callbacks for voice state events - self.plugin_base.add_callback(VOICE_STATE_CREATE, self._on_voice_state_create) - self.plugin_base.add_callback(VOICE_STATE_DELETE, self._on_voice_state_delete) - self.plugin_base.add_callback(VOICE_STATE_UPDATE, self._on_voice_state_update) - - # Subscribe to voice state events via backend (with channel_id) + # 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 +301,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") or self.backend.current_user_avatar, + "avatar_img": None, + "is_self": True, + } + self._users.insert(0, self_info) + self._submit_avatar_fetch(user_id) continue user_info = { @@ -212,11 +324,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( @@ -229,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 @@ -249,11 +365,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( @@ -266,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 @@ -278,6 +398,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): @@ -288,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 @@ -303,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") @@ -311,6 +434,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 +499,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 @@ -330,9 +512,23 @@ def _update_display(self): display_name = user.get("nick") or user.get("username", "Unknown") volume = user.get("volume", 100) - # Truncate name for display + # 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 - 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..558fc29 --- /dev/null +++ b/actions/avatar_utils.py @@ -0,0 +1,178 @@ +""" +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 + + +# 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 + + +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 5bf6241..8a9e155 100644 --- a/backend.py +++ b/backend.py @@ -1,5 +1,6 @@ import json +import requests from streamcontroller_plugin_tools import BackendBase from loguru import logger as log @@ -20,6 +21,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: @@ -65,17 +67,24 @@ 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: + 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 @@ -150,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") @@ -179,6 +195,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( @@ -243,6 +263,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 +292,54 @@ 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 + + # ------------------------------------------------------------------ + # 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/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..27539eb 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 +from .discordrpc.commands import ( + VOICE_CHANNEL_SELECT, VOICE_SETTINGS_UPDATE, + GET_CHANNEL, GET_GUILD, + SPEAKING_START, SPEAKING_STOP, + VOICE_STATE_CREATE, VOICE_STATE_DELETE, VOICE_STATE_UPDATE, +) class PluginTemplate(PluginBase): @@ -91,10 +96,46 @@ 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, + ) + + voice_state_update = EventHolder( + plugin_base=self, + event_id_suffix=VOICE_STATE_UPDATE, + ) + self.add_event_holders([ voice_channel_select, voice_settings_update, get_channel, + get_guild, + speaking_start, + speaking_stop, + voice_state_create, + voice_state_delete, + voice_state_update, ]) @@ -189,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"],