Skip to content

Commit d4b1aa2

Browse files
authored
Merge pull request #65 from hatzibod/feature/AdjustUserVolume
feat(action): add per-user volume control via dial
2 parents bea6afd + f6f34af commit d4b1aa2

5 files changed

Lines changed: 463 additions & 1 deletion

File tree

actions/UserVolume.py

Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
from loguru import logger as log
2+
3+
from .DiscordCore import DiscordCore
4+
from src.backend.PluginManager.EventAssigner import EventAssigner
5+
from src.backend.PluginManager.InputBases import Input
6+
7+
from ..discordrpc.commands import (
8+
VOICE_STATE_CREATE,
9+
VOICE_STATE_DELETE,
10+
VOICE_STATE_UPDATE,
11+
VOICE_CHANNEL_SELECT,
12+
GET_CHANNEL,
13+
)
14+
15+
16+
class UserVolume(DiscordCore):
17+
"""Action for controlling per-user volume via dial.
18+
19+
Dial behavior:
20+
- Rotate: Adjust volume of selected user (+/- 5% per tick)
21+
- Press: Cycle to next user in voice channel
22+
23+
Display:
24+
- Top label: Current voice channel name (or "Not in voice")
25+
- Center label: Username/nick
26+
- Bottom label: Volume percentage
27+
"""
28+
29+
def __init__(self, *args, **kwargs):
30+
super().__init__(*args, **kwargs)
31+
self.has_configuration = False
32+
33+
# Current state
34+
self._users: list = [] # List of user dicts [{id, username, nick, volume, muted}, ...]
35+
self._current_user_index: int = 0
36+
self._current_channel_id: str = None
37+
self._current_channel_name: str = ""
38+
self._in_voice_channel: bool = False
39+
40+
# Volume adjustment step (percentage points per dial tick)
41+
self.VOLUME_STEP = 5
42+
43+
def on_ready(self):
44+
super().on_ready()
45+
46+
# Subscribe to voice channel changes (doesn't need channel_id)
47+
self.register_backend_callback(VOICE_CHANNEL_SELECT, self._on_voice_channel_select)
48+
49+
# Subscribe to GET_CHANNEL responses
50+
self.register_backend_callback(GET_CHANNEL, self._on_get_channel)
51+
52+
# Initialize display
53+
self._update_display()
54+
55+
# Request current voice channel state (in case we're already in a channel)
56+
self.backend.request_current_voice_channel()
57+
58+
def create_event_assigners(self):
59+
# Dial rotation: adjust volume
60+
self.event_manager.add_event_assigner(
61+
EventAssigner(
62+
id="volume-up",
63+
ui_label="volume-up",
64+
default_event=Input.Dial.Events.TURN_CW,
65+
callback=self._on_volume_up,
66+
)
67+
)
68+
self.event_manager.add_event_assigner(
69+
EventAssigner(
70+
id="volume-down",
71+
ui_label="volume-down",
72+
default_event=Input.Dial.Events.TURN_CCW,
73+
callback=self._on_volume_down,
74+
)
75+
)
76+
77+
# Dial press: cycle user
78+
self.event_manager.add_event_assigner(
79+
EventAssigner(
80+
id="cycle-user",
81+
ui_label="cycle-user",
82+
default_event=Input.Dial.Events.DOWN,
83+
callback=self._on_cycle_user,
84+
)
85+
)
86+
87+
# Also support key press for cycling (for key-based assignment)
88+
self.event_manager.add_event_assigner(
89+
EventAssigner(
90+
id="cycle-user-key",
91+
ui_label="cycle-user-key",
92+
default_event=Input.Key.Events.DOWN,
93+
callback=self._on_cycle_user,
94+
)
95+
)
96+
97+
# === Event Handlers ===
98+
99+
def _on_volume_up(self, _):
100+
"""Increase current user's volume."""
101+
self._adjust_volume(self.VOLUME_STEP)
102+
103+
def _on_volume_down(self, _):
104+
"""Decrease current user's volume."""
105+
self._adjust_volume(-self.VOLUME_STEP)
106+
107+
def _on_cycle_user(self, _):
108+
"""Cycle to next user in voice channel."""
109+
if not self._users:
110+
return
111+
self._current_user_index = (self._current_user_index + 1) % len(self._users)
112+
self._update_display()
113+
114+
def _adjust_volume(self, delta: int):
115+
"""Adjust current user's volume by delta."""
116+
if not self._users or self._current_user_index >= len(self._users):
117+
return
118+
119+
user = self._users[self._current_user_index]
120+
current_volume = user.get("volume", 100)
121+
new_volume = max(0, min(200, current_volume + delta))
122+
123+
try:
124+
if self.backend.set_user_volume(user["id"], new_volume):
125+
user["volume"] = new_volume
126+
self._update_display()
127+
except Exception as ex:
128+
log.error(f"Failed to set user volume: {ex}")
129+
self.show_error(3)
130+
131+
# === Discord Event Callbacks ===
132+
133+
def _on_voice_channel_select(self, data: dict):
134+
"""Handle user joining/leaving voice channel."""
135+
try:
136+
if data is None or data.get("channel_id") is None:
137+
# Left voice channel - unsubscribe from previous channel
138+
if self._current_channel_id:
139+
self.backend.unsubscribe_voice_states(self._current_channel_id)
140+
self._in_voice_channel = False
141+
self._current_channel_id = None
142+
self._current_channel_name = ""
143+
self._users.clear()
144+
self._current_user_index = 0
145+
self.backend.clear_voice_channel_users()
146+
else:
147+
# Joined voice channel
148+
new_channel_id = data.get("channel_id")
149+
150+
# If switching channels, unsubscribe from old channel first
151+
if self._current_channel_id and self._current_channel_id != new_channel_id:
152+
self.backend.unsubscribe_voice_states(self._current_channel_id)
153+
self._users.clear()
154+
self._current_user_index = 0
155+
156+
self._in_voice_channel = True
157+
self._current_channel_id = new_channel_id
158+
self._current_channel_name = data.get("name", "Voice")
159+
160+
# Register frontend callbacks for voice state events
161+
self.plugin_base.add_callback(VOICE_STATE_CREATE, self._on_voice_state_create)
162+
self.plugin_base.add_callback(VOICE_STATE_DELETE, self._on_voice_state_delete)
163+
self.plugin_base.add_callback(VOICE_STATE_UPDATE, self._on_voice_state_update)
164+
165+
# Subscribe to voice state events via backend (with channel_id)
166+
self.backend.subscribe_voice_states(self._current_channel_id)
167+
168+
# Fetch initial user list
169+
self.backend.get_channel(self._current_channel_id)
170+
171+
self._update_display()
172+
except Exception as ex:
173+
log.error(f"UserVolume[{id(self)}]: Error in _on_voice_channel_select: {ex}")
174+
175+
def _on_get_channel(self, data: dict):
176+
"""Handle GET_CHANNEL response with initial user list."""
177+
if not data:
178+
return
179+
180+
# Check if this is for our current channel
181+
channel_id = data.get("id")
182+
if channel_id != self._current_channel_id:
183+
return
184+
185+
# Update channel name if available
186+
if data.get("name"):
187+
self._current_channel_name = data.get("name")
188+
189+
# Process voice_states array
190+
voice_states = data.get("voice_states", [])
191+
current_user_id = self.backend.current_user_id
192+
193+
for vs in voice_states:
194+
user_data = vs.get("user", {})
195+
user_id = user_data.get("id")
196+
197+
if not user_id:
198+
continue
199+
200+
# Filter out self
201+
if user_id == current_user_id:
202+
continue
203+
204+
user_info = {
205+
"id": user_id,
206+
"username": user_data.get("username", "Unknown"),
207+
"nick": vs.get("nick"),
208+
"volume": vs.get("volume", 100),
209+
"muted": vs.get("mute", False),
210+
}
211+
212+
# Add if not already present (idempotent)
213+
if not any(u["id"] == user_id for u in self._users):
214+
self._users.append(user_info)
215+
216+
# Update backend cache
217+
self.backend.update_voice_channel_user(
218+
user_id,
219+
user_info["username"],
220+
user_info["nick"],
221+
user_info["volume"],
222+
user_info["muted"]
223+
)
224+
225+
self._update_display()
226+
227+
def _on_voice_state_create(self, data: dict):
228+
"""Handle user joining voice channel."""
229+
if not data:
230+
return
231+
232+
user_data = data.get("user", {})
233+
user_id = user_data.get("id")
234+
if not user_id:
235+
return
236+
237+
# Filter out self
238+
if user_id == self.backend.current_user_id:
239+
return
240+
241+
user_info = {
242+
"id": user_id,
243+
"username": user_data.get("username", "Unknown"),
244+
"nick": data.get("nick"),
245+
"volume": data.get("volume", 100),
246+
"muted": data.get("mute", False),
247+
}
248+
249+
# Add to local list (avoid duplicates)
250+
if not any(u["id"] == user_id for u in self._users):
251+
self._users.append(user_info)
252+
253+
# Update backend cache
254+
self.backend.update_voice_channel_user(
255+
user_id,
256+
user_info["username"],
257+
user_info["nick"],
258+
user_info["volume"],
259+
user_info["muted"]
260+
)
261+
262+
self._update_display()
263+
264+
def _on_voice_state_delete(self, data: dict):
265+
"""Handle user leaving voice channel."""
266+
if not data:
267+
return
268+
269+
user_data = data.get("user", {})
270+
user_id = user_data.get("id")
271+
if not user_id:
272+
return
273+
274+
# Remove from local list
275+
self._users = [u for u in self._users if u["id"] != user_id]
276+
277+
# Adjust current index if needed
278+
if self._current_user_index >= len(self._users):
279+
self._current_user_index = max(0, len(self._users) - 1)
280+
281+
# Update backend cache
282+
self.backend.remove_voice_channel_user(user_id)
283+
284+
self._update_display()
285+
286+
def _on_voice_state_update(self, data: dict):
287+
"""Handle user voice state change (volume, mute, etc)."""
288+
if not data:
289+
return
290+
291+
user_data = data.get("user", {})
292+
user_id = user_data.get("id")
293+
if not user_id:
294+
return
295+
296+
# Find and update user
297+
for user in self._users:
298+
if user["id"] == user_id:
299+
if "volume" in data:
300+
user["volume"] = data.get("volume")
301+
if "mute" in data:
302+
user["muted"] = data.get("mute")
303+
if "nick" in data:
304+
user["nick"] = data.get("nick")
305+
break
306+
307+
self._update_display()
308+
309+
# === Display ===
310+
311+
def _update_display(self):
312+
"""Update the dial display with current user info."""
313+
if not self._in_voice_channel or not self._users:
314+
self.set_top_label("Not in voice" if not self._in_voice_channel else self._current_channel_name[:12])
315+
self.set_center_label("")
316+
self.set_bottom_label("No users" if self._in_voice_channel else "")
317+
return
318+
319+
# Truncate channel name for space
320+
channel_display = self._current_channel_name[:12] if len(self._current_channel_name) > 12 else self._current_channel_name
321+
self.set_top_label(channel_display)
322+
323+
if self._current_user_index < len(self._users):
324+
user = self._users[self._current_user_index]
325+
display_name = user.get("nick") or user.get("username", "Unknown")
326+
volume = user.get("volume", 100)
327+
328+
# Truncate name for display
329+
display_name = display_name[:10] if len(display_name) > 10 else display_name
330+
331+
self.set_center_label(display_name)
332+
self.set_bottom_label(f"{volume}%")
333+
else:
334+
self.set_center_label("")
335+
self.set_bottom_label("No selection")

0 commit comments

Comments
 (0)