Skip to content

Commit f6673f6

Browse files
committed
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
1 parent acbc98f commit f6673f6

6 files changed

Lines changed: 214 additions & 56 deletions

File tree

actions/ChangeVoiceChannel.py

Lines changed: 14 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
make_circle_avatar,
1414
draw_speaking_ring,
1515
make_placeholder_avatar,
16+
compose_overlapping_avatars,
1617
)
1718
from src.backend.PluginManager.EventAssigner import EventAssigner
1819
from src.backend.PluginManager.InputBases import Input
@@ -105,34 +106,15 @@ def _draw_speaking_ring(img: Image.Image, size: int) -> Image.Image:
105106

106107

107108
def _compose_avatars(avatars: list[tuple[Image.Image, bool]]) -> Image.Image:
108-
"""Compose up to 4 avatar images (with optional speaking ring) onto a button canvas.
109-
110-
*avatars* is a list of ``(image, is_speaking)`` tuples.
111-
"""
112-
canvas = Image.new("RGBA", (BUTTON_SIZE, BUTTON_SIZE), (0, 0, 0, 255))
113-
n = min(len(avatars), 4)
114-
if n == 0:
115-
return canvas
116-
117-
# Determine grid: 1→full, 2→side-by-side, 3-4→2×2
118-
if n == 1:
119-
size = BUTTON_SIZE
120-
positions = [(0, 0)]
121-
elif n == 2:
122-
size = BUTTON_SIZE // 2
123-
positions = [(0, size // 2), (size, size // 2)] # centred vertically
124-
else:
125-
size = BUTTON_SIZE // 2
126-
positions = [(0, 0), (size, 0), (0, size), (size, size)]
127-
128-
for i, (img, speaking) in enumerate(avatars[:n]):
129-
avatar = make_circle_avatar(img, size)
109+
"""Compose avatar images in an overlapping stack, speaking user in front."""
110+
# Find the last speaking user to bring to front
111+
front = -1
112+
avatars_3 = []
113+
for i, (img, speaking) in enumerate(avatars):
130114
if speaking:
131-
avatar = draw_speaking_ring(avatar, size)
132-
x, y = positions[i]
133-
canvas.paste(avatar, (x, y), avatar)
134-
135-
return canvas
115+
front = i
116+
avatars_3.append((img, speaking, False))
117+
return compose_overlapping_avatars(avatars_3, BUTTON_SIZE, front_index=front)
136118

137119

138120
class ChangeVoiceChannel(DiscordCore):
@@ -190,6 +172,11 @@ def on_ready(self):
190172
# Subscribe to the configured channel and fetch initial state
191173
self._start_watching_configured_channel()
192174

175+
# Request current voice channel so _connected_channel_id is set if
176+
# we're already in a channel when StreamController starts.
177+
if self.backend:
178+
self.backend.request_current_voice_channel()
179+
193180

194181
def _start_watching_configured_channel(self):
195182
"""Subscribe to voice state events and fetch fresh data for the configured channel.

actions/UserVolume.py

Lines changed: 66 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
BUTTON_SIZE,
1010
make_circle_avatar,
1111
draw_speaking_ring,
12+
draw_mute_overlay,
1213
make_placeholder_avatar,
14+
compose_overlapping_avatars,
1315
)
1416
from src.backend.PluginManager.EventAssigner import EventAssigner
1517
from src.backend.PluginManager.InputBases import Input
@@ -82,6 +84,18 @@ def on_ready(self):
8284
event_id=f"{self.plugin_base.get_plugin_id()}::{GET_CHANNEL}",
8385
callback=self._on_get_channel,
8486
)
87+
self.plugin_base.connect_to_event(
88+
event_id=f"{self.plugin_base.get_plugin_id()}::{VOICE_STATE_CREATE}",
89+
callback=self._on_voice_state_create,
90+
)
91+
self.plugin_base.connect_to_event(
92+
event_id=f"{self.plugin_base.get_plugin_id()}::{VOICE_STATE_DELETE}",
93+
callback=self._on_voice_state_delete,
94+
)
95+
self.plugin_base.connect_to_event(
96+
event_id=f"{self.plugin_base.get_plugin_id()}::{VOICE_STATE_UPDATE}",
97+
callback=self._on_voice_state_update,
98+
)
8599
self.plugin_base.connect_to_event(
86100
event_id=f"{self.plugin_base.get_plugin_id()}::{SPEAKING_START}",
87101
callback=self._on_speaking_start,
@@ -95,7 +109,8 @@ def on_ready(self):
95109
self._update_display()
96110

97111
# Request current voice channel state (in case we're already in a channel)
98-
self.backend.request_current_voice_channel()
112+
if self.backend:
113+
self.backend.request_current_voice_channel()
99114

100115
def create_event_assigners(self):
101116
# Dial rotation: adjust volume
@@ -136,6 +151,16 @@ def create_event_assigners(self):
136151
)
137152
)
138153

154+
# Touchscreen tap: toggle mute on current user
155+
self.event_manager.add_event_assigner(
156+
EventAssigner(
157+
id="toggle-mute",
158+
ui_label="toggle-mute",
159+
default_event=Input.Dial.Events.SHORT_TOUCH_PRESS,
160+
callback=self._on_toggle_mute,
161+
)
162+
)
163+
139164
# === Event Handlers ===
140165

141166
def _on_volume_up(self, _):
@@ -153,6 +178,24 @@ def _on_cycle_user(self, _):
153178
self._current_user_index = (self._current_user_index + 1) % len(self._users)
154179
self._update_display()
155180

181+
def _on_toggle_mute(self, _):
182+
"""Toggle mute on the currently displayed user."""
183+
if not self._users or self._current_user_index >= len(self._users):
184+
return
185+
user = self._users[self._current_user_index]
186+
new_muted = not user.get("muted", False)
187+
try:
188+
if user.get("is_self"):
189+
self.backend.set_mute(new_muted)
190+
else:
191+
if not self.backend.set_user_mute(user["id"], new_muted):
192+
return
193+
user["muted"] = new_muted
194+
self._update_display()
195+
except Exception as ex:
196+
log.error(f"Failed to toggle mute for {user['id']}: {ex}")
197+
self.show_error(3)
198+
156199
def _adjust_volume(self, delta: int):
157200
"""Adjust current user's volume by delta."""
158201
if not self._users or self._current_user_index >= len(self._users):
@@ -221,11 +264,6 @@ def _on_voice_channel_select(self, *args, **kwargs):
221264
self._current_channel_id = new_channel_id
222265
self._current_channel_name = data.get("name", "Voice")
223266

224-
# Register frontend callbacks for voice state events
225-
self.plugin_base.add_callback(VOICE_STATE_CREATE, self._on_voice_state_create)
226-
self.plugin_base.add_callback(VOICE_STATE_DELETE, self._on_voice_state_delete)
227-
self.plugin_base.add_callback(VOICE_STATE_UPDATE, self._on_voice_state_update)
228-
229267
# Subscribe to voice state and speaking events via backend (with channel_id)
230268
self.backend.subscribe_voice_states(self._current_channel_id)
231269
self.backend.subscribe_speaking(self._current_channel_id)
@@ -272,7 +310,7 @@ def _on_get_channel(self, *args, **kwargs):
272310
"nick": vs.get("nick"),
273311
"volume": self._self_input_volume,
274312
"muted": False,
275-
"avatar_hash": user_data.get("avatar"),
313+
"avatar_hash": user_data.get("avatar") or self.backend.current_user_avatar,
276314
"avatar_img": None,
277315
"is_self": True,
278316
}
@@ -306,8 +344,9 @@ def _on_get_channel(self, *args, **kwargs):
306344

307345
self._update_display()
308346

309-
def _on_voice_state_create(self, data: dict):
347+
def _on_voice_state_create(self, *args, **kwargs):
310348
"""Handle user joining voice channel."""
349+
data = args[1] if len(args) > 1 else None
311350
if not data:
312351
return
313352

@@ -346,8 +385,9 @@ def _on_voice_state_create(self, data: dict):
346385

347386
self._update_display()
348387

349-
def _on_voice_state_delete(self, data: dict):
388+
def _on_voice_state_delete(self, *args, **kwargs):
350389
"""Handle user leaving voice channel."""
390+
data = args[1] if len(args) > 1 else None
351391
if not data:
352392
return
353393

@@ -370,8 +410,9 @@ def _on_voice_state_delete(self, data: dict):
370410

371411
self._update_display()
372412

373-
def _on_voice_state_update(self, data: dict):
413+
def _on_voice_state_update(self, *args, **kwargs):
374414
"""Handle user voice state change (volume, mute, etc)."""
415+
data = args[1] if len(args) > 1 else None
375416
if not data:
376417
return
377418

@@ -385,7 +426,7 @@ def _on_voice_state_update(self, data: dict):
385426
if user["id"] == user_id:
386427
if "volume" in data:
387428
user["volume"] = data.get("volume")
388-
if "mute" in data:
429+
if "mute" in data and not user.get("is_self"):
389430
user["muted"] = data.get("mute")
390431
if "nick" in data:
391432
user["nick"] = data.get("nick")
@@ -470,19 +511,21 @@ def _update_display(self):
470511
user = self._users[self._current_user_index]
471512
display_name = user.get("nick") or user.get("username", "Unknown")
472513
volume = user.get("volume", 100)
473-
is_speaking = user["id"] in self._speaking
474514

475-
# Build avatar image
476-
avatar_src = user.get("avatar_img")
477-
if avatar_src is not None:
478-
avatar = make_circle_avatar(avatar_src, BUTTON_SIZE)
479-
else:
480-
avatar = make_placeholder_avatar(display_name, user["id"], BUTTON_SIZE)
481-
# Kick off a fetch if not already in-flight
482-
self._submit_avatar_fetch(user["id"])
483-
if is_speaking:
484-
avatar = draw_speaking_ring(avatar, BUTTON_SIZE)
485-
self.set_media(image=avatar)
515+
# Build overlapping avatar stack with selected user in front
516+
avatar_list = []
517+
for u in self._users:
518+
src = u.get("avatar_img")
519+
name = u.get("nick") or u.get("username", "?")
520+
if src is not None:
521+
img = src
522+
else:
523+
img = make_placeholder_avatar(name, u["id"], BUTTON_SIZE)
524+
self._submit_avatar_fetch(u["id"])
525+
avatar_list.append((img, u["id"] in self._speaking, u.get("muted", False)))
526+
self.set_media(image=compose_overlapping_avatars(
527+
avatar_list, BUTTON_SIZE, front_index=self._current_user_index,
528+
))
486529

487530
# Truncate name for label overlay
488531
display_name = display_name[:10] if len(display_name) > 10 else display_name

actions/avatar_utils.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,113 @@ def draw_speaking_ring(img: Image.Image, size: int) -> Image.Image:
8585
result = img.copy()
8686
result.paste(overlay, mask=overlay)
8787
return result
88+
89+
90+
# Mute indicator: semi-transparent dark overlay with a red diagonal slash.
91+
MUTE_OVERLAY_COLOR = (0, 0, 0, 140)
92+
MUTE_SLASH_COLOR = (237, 66, 69, 255) # Discord red
93+
MUTE_SLASH_WIDTH = 4
94+
95+
96+
def draw_mute_overlay(img: Image.Image, size: int) -> Image.Image:
97+
"""Overlay a mute indicator (dimmed circle + red slash) onto *img*."""
98+
overlay = Image.new("RGBA", (size, size), (0, 0, 0, 0))
99+
draw = ImageDraw.Draw(overlay)
100+
draw.ellipse((0, 0, size - 1, size - 1), fill=MUTE_OVERLAY_COLOR)
101+
pad = size // 6
102+
draw.line(
103+
(pad, pad, size - 1 - pad, size - 1 - pad),
104+
fill=MUTE_SLASH_COLOR,
105+
width=MUTE_SLASH_WIDTH,
106+
)
107+
result = img.copy()
108+
result.paste(overlay, mask=overlay)
109+
return result
110+
111+
112+
# Border drawn around each avatar in the overlapping stack so circles
113+
# are visually distinct against each other.
114+
_AVATAR_BORDER_COLOR = (0, 0, 0, 255)
115+
_AVATAR_BORDER_WIDTH = 2
116+
117+
118+
def _avatar_with_border(img: Image.Image, size: int) -> Image.Image:
119+
"""Add a thin black circular border around a circle-clipped avatar."""
120+
result = img.copy()
121+
draw = ImageDraw.Draw(result)
122+
half = _AVATAR_BORDER_WIDTH // 2
123+
draw.ellipse(
124+
(half, half, size - 1 - half, size - 1 - half),
125+
outline=_AVATAR_BORDER_COLOR,
126+
width=_AVATAR_BORDER_WIDTH,
127+
)
128+
return result
129+
130+
131+
def compose_overlapping_avatars(
132+
avatars: list[tuple[Image.Image, bool, bool]],
133+
canvas_size: int,
134+
front_index: int = -1,
135+
) -> Image.Image:
136+
"""Compose avatars in an overlapping stack, with *front_index* on top.
137+
138+
*avatars* is a list of ``(image, is_speaking, is_muted)`` tuples.
139+
*front_index* is the index of the avatar to place in front. When -1
140+
(the default), no reordering is done and the last avatar is on top.
141+
"""
142+
canvas = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 255))
143+
n = len(avatars)
144+
if n == 0:
145+
return canvas
146+
147+
# Single avatar — render full size, centred.
148+
if n == 1:
149+
img, speaking, muted = avatars[0]
150+
av = make_circle_avatar(img, canvas_size)
151+
if speaking:
152+
av = draw_speaking_ring(av, canvas_size)
153+
if muted:
154+
av = draw_mute_overlay(av, canvas_size)
155+
canvas.paste(av, (0, 0), av)
156+
return canvas
157+
158+
# Avatar diameter: large enough to be readable, small enough to overlap.
159+
avatar_size = int(canvas_size * 0.65)
160+
161+
# Build a display order where the front avatar is placed at the centre
162+
# position and the remaining avatars fill the other slots, preserving
163+
# their relative order. The front avatar is painted last (on top).
164+
if 0 <= front_index < n:
165+
others = [i for i in range(n) if i != front_index]
166+
centre = n // 2
167+
display_order = others[:centre] + [front_index] + others[centre:]
168+
else:
169+
display_order = list(range(n))
170+
171+
# Spread slots horizontally across the canvas with even overlap.
172+
total_width = avatar_size + (n - 1) * (avatar_size // 2)
173+
x_start = (canvas_size - total_width) // 2
174+
y = (canvas_size - avatar_size) // 2
175+
176+
# Map each original avatar index to the x position of its assigned slot.
177+
positions = {}
178+
for slot, orig_idx in enumerate(display_order):
179+
positions[orig_idx] = x_start + slot * (avatar_size // 2)
180+
181+
# Paint order: everything except front first, then front on top.
182+
paint_order = [i for i in display_order if i != front_index] + (
183+
[front_index] if 0 <= front_index < n else []
184+
)
185+
if not paint_order:
186+
paint_order = display_order
187+
188+
for idx in paint_order:
189+
img, speaking, muted = avatars[idx]
190+
av = make_circle_avatar(img, avatar_size)
191+
if speaking:
192+
av = draw_speaking_ring(av, avatar_size)
193+
if muted:
194+
av = draw_mute_overlay(av, avatar_size)
195+
canvas.paste(av, (positions[idx], y), av)
196+
197+
return canvas

backend.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def __init__(self):
2222
self._is_reconnecting: bool = False
2323
self._voice_channel_users: dict = {} # {user_id: {username, nick, volume, muted}}
2424
self._current_user_id: str = None # Current user's ID (for filtering)
25+
self._current_user_avatar: str = None # Current user's avatar hash
2526

2627
def discord_callback(self, code, event):
2728
if code == 0:
@@ -67,15 +68,20 @@ def discord_callback(self, code, event):
6768
user = data.get("user", {})
6869
self._register_callbacks()
6970
self._current_user_id = user.get("id")
71+
self._current_user_avatar = user.get("avatar")
7072
self._get_current_voice_channel()
7173
case commands.DISPATCH:
7274
evt = event.get("evt")
7375
self.frontend.trigger_event(evt, event.get("data"))
7476
case commands.GET_SELECTED_VOICE_CHANNEL:
75-
self._current_voice_channel = (
76-
event.get("data").get("channel_id") if event.get("data") else None
77+
data = event.get("data")
78+
channel_id = data.get("id") if data else None
79+
self._current_voice_channel = channel_id
80+
# Normalize to match VOICE_CHANNEL_SELECT dispatch format
81+
self.frontend.trigger_event(
82+
commands.VOICE_CHANNEL_SELECT,
83+
{"channel_id": channel_id},
7784
)
78-
self.frontend.trigger_event(commands.VOICE_CHANNEL_SELECT, event.get("data"))
7985
case commands.GET_CHANNEL:
8086
self.frontend.trigger_event(commands.GET_CHANNEL, event.get("data"))
8187
case commands.GET_GUILD:
@@ -183,6 +189,10 @@ def current_voice_channel(self):
183189
def current_user_id(self):
184190
return self._current_user_id
185191

192+
@property
193+
def current_user_avatar(self):
194+
return self._current_user_avatar
195+
186196
def _get_current_voice_channel(self):
187197
if not self._ensure_connected():
188198
log.warning(

0 commit comments

Comments
 (0)