Skip to content

Dynamic voice channel#73

Open
GrantAbell wants to merge 15 commits intoImDevinC:mainfrom
GrantAbell:dynamic_voice_channel
Open

Dynamic voice channel#73
GrantAbell wants to merge 15 commits intoImDevinC:mainfrom
GrantAbell:dynamic_voice_channel

Conversation

@GrantAbell
Copy link
Copy Markdown

@GrantAbell GrantAbell commented Apr 2, 2026

feat(voice): Live voice channel display, avatars, speaking rings & user volume controls

Summary

Enhances the Change Voice Channel and User Volume actions with live visual feedback — overlapping avatar stacks, speaking indicators, mute overlays, and real-time user tracking — similar to native Discord Stream Deck integrations.


Change Voice Channel

Server thumbnail / name

  • The button shows the Discord server's guild icon fetched via GET_GUILD.
  • If no guild icon is set, the server name is rendered as a label in a configurable position (top / center / bottom).

Connected mode

  • When you join the configured channel, avatars are overlapped with the currently speaking user moved to the centre front.
  • A green speaking ring animates around each avatar in real time using SPEAKING_START / SPEAKING_STOP events.
  • A "Show my own avatar" toggle controls whether your avatar appears in the stack. You are always included in the user count regardless of this setting.

Observer mode — user count badge

  • When not connected, a pill badge showing the number of users currently in the channel is drawn over the guild icon (or fallback icon).
  • The badge corner is configurable: top-left, top-right, bottom-left, bottom-right.

Startup detection

  • Actions now detect the current voice channel on startup via GET_SELECTED_VOICE_CHANNEL, so the display reflects state correctly when StreamController launches mid-session.

User Volume

Overlapping avatar display

  • The selected user's avatar is rendered with the same overlapping stack style, with the currently controlled user moved to the centre front.
  • Placeholder avatars with colour-coded initials are shown for users without profile pictures.

Tap-to-mute

  • Tapping the touchbar toggles mute on the displayed user.
  • When viewing yourself, tap toggles your own microphone mute.
  • When viewing another user, tap toggles local per-user mute.

Mute overlay

  • Muted users display a dimmed avatar with a red diagonal slash indicator.
  • Self-mute state stays in sync with Discord voice settings events.

Self-volume control

  • Optional "Control my mic volume" toggle adds yourself as the first entry in the user cycle, allowing the dial to adjust your microphone input volume.

Shared avatar utilities (avatar_utils.py)

Rendering logic extracted into a shared module used by both actions:

  • Circle-clipped avatars, speaking rings, mute overlays, placeholder generation
  • compose_overlapping_avatars() — stacks avatars with configurable front-user positioning

Correctness

  • Cross-button contamination: VOICE_STATE_CREATE/DELETE event data from Discord contains no channel_id, so naively updating _users on every event caused all buttons to show the wrong count. Events now trigger a GET_CHANNEL re-fetch for each button's own watched channel; _on_get_channel fully reconciles the user list from the authoritative voice_states snapshot.
  • Post-leave subscription loss: Discord silently drops VOICE_STATE_* subscriptions when the local user leaves a channel. The button now resets _watching_channel_id on leave and re-subscribes immediately, keeping the observer-mode count live.
  • Startup channel detection: GET_SELECTED_VOICE_CHANNEL response data is normalised to match VOICE_CHANNEL_SELECT event format, so actions initialise correctly on launch.

New backend / event infrastructure

File Change
discordrpc/asyncdiscord.py Added get_guild(guild_id)
backend.py get_guild(), subscribe_speaking() / unsubscribe_speaking(), set_user_mute(), current_user_avatar property, dispatch routing for GET_GUILD, SPEAKING_START/STOP, VOICE_STATE_*
main.py EventHolders for GET_GUILD, SPEAKING_START, SPEAKING_STOP, VOICE_STATE_CREATE, VOICE_STATE_DELETE

Configuration options

Setting Action Type Default
Channel ID ChangeVoiceChannel Entry
Server name label position ChangeVoiceChannel Combo (top / center / bottom) bottom
Show my own avatar ChangeVoiceChannel Switch on
User count badge corner ChangeVoiceChannel Combo (top-left / top-right / bottom-left / bottom-right) bottom-right
Control my mic volume UserVolume Switch off

… 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.
Copy link
Copy Markdown
Owner

@ImDevinC ImDevinC left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR. To help keep things organized, I think it would be best to move most of the logic in these actions to a helper file of some kind as it really muddies up the logic of "the action" vs everything else

- 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.
@GrantAbell
Copy link
Copy Markdown
Author

Thanks for the PR. To help keep things organized, I think it would be best to move most of the logic in these actions to a helper file of some kind as it really muddies up the logic of "the action" vs everything else

Done
Let me know if this is up to snuff

…isplay

- 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
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a “dynamic voice channel” button experience by rendering live Discord voice channel state (guild thumbnail/name, user count, avatars, and speaking indicators) directly onto the Stream Deck key face.

Changes:

  • Introduces new RPC/event plumbing for guild fetches and speaking + voice-state events.
  • Enhances ChangeVoiceChannel to render guild icon/name, live user-count badge (observer mode), and avatar/speaking overlays (connected mode).
  • Updates UserVolume to show avatar stacks + speaking state and adds new configuration/UI options.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 11 comments.

Show a summary per file
File Description
settings.py Guards settings UI when backend is not yet initialized/authenticated.
main.py Adds new EventHolders for GET_GUILD, speaking, and voice-state events; hardens backend setup.
discordrpc/asyncdiscord.py Adds get_guild(guild_id) RPC command helper.
backend.py Adds guild fetch, speaking subscribe/unsubscribe, current-user avatar tracking, and CDN fetch helpers.
actions/UserVolume.py Adds avatar/speaking rendering and a new “control self mic volume” toggle (plus mute toggle).
actions/ChangeVoiceChannel.py Major UI/rendering upgrade for live channel display, guild icon/name, speaking rings, and user-count badge.
actions/avatar_utils.py New shared image/placeholder/ring/badge composition utilities for avatar rendering.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

GrantAbell and others added 3 commits April 4, 2026 00:42
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
- 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
Copy link
Copy Markdown
Owner

@ImDevinC ImDevinC left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple things to help keep code clean

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@ImDevinC
Copy link
Copy Markdown
Owner

ImDevinC commented Apr 7, 2026

I just had a chance to test this today, and when I joined a voice channel, it immediately crashed StreamController. My setup was as follows:

  • Single User Volume button
  • Control my mic volume not enabled
  • Joined a voice channel in Discord
  • Crash

…ptions

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
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.
@GrantAbell
Copy link
Copy Markdown
Author

I just had a chance to test this today, and when I joined a voice channel, it immediately crashed StreamController. My setup was as follows:

  • Single User Volume button
  • Control my mic volume not enabled
  • Joined a voice channel in Discord
  • Crash

Yea looks like some of the refactors or new features made it a lot less stable. I tried to address these in the last 2 commits. It's working for me now with the FlatPak version of StreamController. If it still happens for you, let me know if you're on the FlatPak or just running it natively.

… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants