diff --git a/src/abaddon.cpp b/src/abaddon.cpp index b0a1a56..653327c 100644 --- a/src/abaddon.cpp +++ b/src/abaddon.cpp @@ -19,7 +19,7 @@ #include "windows/profilewindow.hpp" #include "windows/pinnedwindow.hpp" #include "windows/threadswindow.hpp" -#include "windows/voicewindow.hpp" +#include "windows/voice/voicewindow.hpp" #include "startup.hpp" #include "notifications/notifications.hpp" #include "remoteauth/remoteauthdialog.hpp" diff --git a/src/components/channellist/cellrendererchannels.cpp b/src/components/channellist/cellrendererchannels.cpp index 8a6097e..af9109a 100644 --- a/src/components/channellist/cellrendererchannels.cpp +++ b/src/components/channellist/cellrendererchannels.cpp @@ -123,6 +123,7 @@ void CellRendererChannels::get_preferred_width_vfunc(Gtk::Widget &widget, int &m case RenderType::Thread: return get_preferred_width_vfunc_thread(widget, minimum_width, natural_width); case RenderType::VoiceChannel: + case RenderType::VoiceStage: return get_preferred_width_vfunc_voice_channel(widget, minimum_width, natural_width); case RenderType::VoiceParticipant: return get_preferred_width_vfunc_voice_participant(widget, minimum_width, natural_width); @@ -146,6 +147,7 @@ void CellRendererChannels::get_preferred_width_for_height_vfunc(Gtk::Widget &wid case RenderType::Thread: return get_preferred_width_for_height_vfunc_thread(widget, height, minimum_width, natural_width); case RenderType::VoiceChannel: + case RenderType::VoiceStage: return get_preferred_width_for_height_vfunc_voice_channel(widget, height, minimum_width, natural_width); case RenderType::VoiceParticipant: return get_preferred_width_for_height_vfunc_voice_participant(widget, height, minimum_width, natural_width); @@ -169,6 +171,7 @@ void CellRendererChannels::get_preferred_height_vfunc(Gtk::Widget &widget, int & case RenderType::Thread: return get_preferred_height_vfunc_thread(widget, minimum_height, natural_height); case RenderType::VoiceChannel: + case RenderType::VoiceStage: return get_preferred_height_vfunc_voice_channel(widget, minimum_height, natural_height); case RenderType::VoiceParticipant: return get_preferred_height_vfunc_voice_participant(widget, minimum_height, natural_height); @@ -192,6 +195,7 @@ void CellRendererChannels::get_preferred_height_for_width_vfunc(Gtk::Widget &wid case RenderType::Thread: return get_preferred_height_for_width_vfunc_thread(widget, width, minimum_height, natural_height); case RenderType::VoiceChannel: + case RenderType::VoiceStage: return get_preferred_height_for_width_vfunc_voice_channel(widget, width, minimum_height, natural_height); case RenderType::VoiceParticipant: return get_preferred_height_for_width_vfunc_voice_participant(widget, width, minimum_height, natural_height); @@ -215,7 +219,9 @@ void CellRendererChannels::render_vfunc(const Cairo::RefPtr &cr, case RenderType::Thread: return render_vfunc_thread(cr, widget, background_area, cell_area, flags); case RenderType::VoiceChannel: - return render_vfunc_voice_channel(cr, widget, background_area, cell_area, flags); + return render_vfunc_voice_channel(cr, widget, background_area, cell_area, flags, "\U0001F50A"); + case RenderType::VoiceStage: + return render_vfunc_voice_channel(cr, widget, background_area, cell_area, flags, "\U0001F4E1"); case RenderType::VoiceParticipant: return render_vfunc_voice_participant(cr, widget, background_area, cell_area, flags); case RenderType::DMHeader: @@ -571,7 +577,7 @@ void CellRendererChannels::get_preferred_height_for_width_vfunc_voice_channel(Gt m_renderer_text.get_preferred_height_for_width(widget, width, minimum_height, natural_height); } -void CellRendererChannels::render_vfunc_voice_channel(const Cairo::RefPtr &cr, Gtk::Widget &widget, const Gdk::Rectangle &background_area, const Gdk::Rectangle &cell_area, Gtk::CellRendererState flags) { +void CellRendererChannels::render_vfunc_voice_channel(const Cairo::RefPtr &cr, Gtk::Widget &widget, const Gdk::Rectangle &background_area, const Gdk::Rectangle &cell_area, Gtk::CellRendererState flags, const char *emoji) { // channel name text Gtk::Requisition minimum_size, natural_size; m_renderer_text.get_preferred_size(widget, minimum_size, natural_size); @@ -588,7 +594,7 @@ void CellRendererChannels::render_vfunc_voice_channel(const Cairo::RefPtrset_font_description(font); layout->set_alignment(Pango::ALIGN_LEFT); cr->set_source_rgba(1.0, 1.0, 1.0, 1.0); diff --git a/src/components/channellist/cellrendererchannels.hpp b/src/components/channellist/cellrendererchannels.hpp index ebe4957..7059c6f 100644 --- a/src/components/channellist/cellrendererchannels.hpp +++ b/src/components/channellist/cellrendererchannels.hpp @@ -6,7 +6,7 @@ #include #include #include "discord/snowflake.hpp" -#include "discord/voicestateflags.hpp" +#include "discord/voicestate.hpp" #include "misc/bitwise.hpp" enum class RenderType : uint8_t { @@ -16,6 +16,7 @@ enum class RenderType : uint8_t { TextChannel, Thread, VoiceChannel, + VoiceStage, // identical to non-stage except for icon VoiceParticipant, DMHeader, @@ -112,7 +113,8 @@ protected: Gtk::Widget &widget, const Gdk::Rectangle &background_area, const Gdk::Rectangle &cell_area, - Gtk::CellRendererState flags); + Gtk::CellRendererState flags, + const char *emoji); // voice participant void get_preferred_width_vfunc_voice_participant(Gtk::Widget &widget, int &minimum_width, int &natural_width) const; diff --git a/src/components/channellist/channellisttree.cpp b/src/components/channellist/channellisttree.cpp index d673060..e824933 100644 --- a/src/components/channellist/channellisttree.cpp +++ b/src/components/channellist/channellisttree.cpp @@ -28,6 +28,8 @@ ChannelListTree::ChannelListTree() #endif , m_menu_voice_channel_join("_Join", true) , m_menu_voice_channel_disconnect("_Disconnect", true) + , m_menu_voice_stage_join("_Join", true) + , m_menu_voice_stage_disconnect("_Disconnect", true) , m_menu_voice_channel_mark_as_read("Mark as _Read", true) , m_menu_voice_open_chat("Open _Chat", true) , m_menu_dm_copy_id("_Copy ID", true) @@ -225,6 +227,21 @@ ChannelListTree::ChannelListTree() m_menu_voice_channel.append(m_menu_voice_open_chat); m_menu_voice_channel.show_all(); +#ifdef WITH_VOICE + m_menu_voice_stage_join.signal_activate().connect([this]() { + const auto id = static_cast((*m_model->get_iter(m_path_for_menu))[m_columns.m_id]); + m_signal_action_join_voice_channel.emit(id); + }); + + m_menu_voice_stage_disconnect.signal_activate().connect([this]() { + m_signal_action_disconnect_voice.emit(); + }); +#endif + + m_menu_voice_stage.append(m_menu_voice_stage_join); + m_menu_voice_stage.append(m_menu_voice_stage_disconnect); + m_menu_voice_stage.show_all(); + m_menu_dm_copy_id.signal_activate().connect([this] { Gtk::Clipboard::get()->set_text(std::to_string((*m_model->get_iter(m_path_for_menu))[m_columns.m_id])); }); @@ -366,8 +383,8 @@ int ChannelListTree::SortFunc(const Gtk::TreeModel::iterator &a, const Gtk::Tree const int64_t b_sort = (*b)[m_columns.m_sort]; if (a_type == RenderType::DMHeader) return -1; if (b_type == RenderType::DMHeader) return 1; - if (a_type == RenderType::TextChannel && b_type == RenderType::VoiceChannel) return -1; - if (b_type == RenderType::TextChannel && a_type == RenderType::VoiceChannel) return 1; + if (a_type == RenderType::TextChannel && (b_type == RenderType::VoiceChannel || b_type == RenderType::VoiceStage)) return -1; + if (b_type == RenderType::TextChannel && (a_type == RenderType::VoiceChannel || a_type == RenderType::VoiceStage)) return 1; return static_cast(std::clamp(a_sort - b_sort, int64_t(-1), int64_t(1))); } @@ -634,6 +651,7 @@ void ChannelListTree::OnThreadListSync(const ThreadListSyncData &data) { void ChannelListTree::OnVoiceUserConnect(Snowflake user_id, Snowflake channel_id) { auto parent_iter = GetIteratorForRowFromIDOfType(channel_id, RenderType::VoiceChannel); + if (!parent_iter) parent_iter = GetIteratorForRowFromIDOfType(channel_id, RenderType::VoiceStage); if (!parent_iter) parent_iter = GetIteratorForRowFromIDOfType(channel_id, RenderType::DM); if (!parent_iter) return; const auto user = Abaddon::Get().GetDiscordClient().GetUser(user_id); @@ -914,7 +932,7 @@ Gtk::TreeModel::iterator ChannelListTree::AddGuild(const GuildData &guild, const for (const auto &channel_ : *guild.Channels) { const auto channel = discord.GetChannel(channel_.ID); if (!channel.has_value()) continue; - if (channel->Type == ChannelType::GUILD_TEXT || channel->Type == ChannelType::GUILD_NEWS || channel->Type == ChannelType::GUILD_VOICE) { + if (channel->Type == ChannelType::GUILD_TEXT || channel->Type == ChannelType::GUILD_NEWS || channel->Type == ChannelType::GUILD_VOICE || channel->Type == ChannelType::GUILD_STAGE_VOICE) { if (channel->ParentID.has_value()) categories[*channel->ParentID].push_back(*channel); else @@ -954,6 +972,10 @@ Gtk::TreeModel::iterator ChannelListTree::AddGuild(const GuildData &guild, const if (IsTextChannel(channel.Type)) { channel_row[m_columns.m_type] = RenderType::TextChannel; channel_row[m_columns.m_name] = "#" + Glib::Markup::escape_text(*channel.Name); + } else if (channel.Type == ChannelType::GUILD_STAGE_VOICE) { + channel_row[m_columns.m_type] = RenderType::VoiceStage; + channel_row[m_columns.m_name] = Glib::Markup::escape_text(*channel.Name); + add_voice_participants(channel, channel_row->children()); } else { channel_row[m_columns.m_type] = RenderType::VoiceChannel; channel_row[m_columns.m_name] = Glib::Markup::escape_text(*channel.Name); @@ -983,6 +1005,10 @@ Gtk::TreeModel::iterator ChannelListTree::AddGuild(const GuildData &guild, const if (IsTextChannel(channel.Type)) { channel_row[m_columns.m_type] = RenderType::TextChannel; channel_row[m_columns.m_name] = "#" + Glib::Markup::escape_text(*channel.Name); + } else if (channel.Type == ChannelType::GUILD_STAGE_VOICE) { + channel_row[m_columns.m_type] = RenderType::VoiceStage; + channel_row[m_columns.m_name] = Glib::Markup::escape_text(*channel.Name); + add_voice_participants(channel, channel_row->children()); } else { channel_row[m_columns.m_type] = RenderType::VoiceChannel; channel_row[m_columns.m_name] = Glib::Markup::escape_text(*channel.Name); @@ -1033,7 +1059,7 @@ Gtk::TreeModel::iterator ChannelListTree::CreateVoiceParticipantRow(const UserDa const auto voice_state = Abaddon::Get().GetDiscordClient().GetVoiceState(user.ID); if (voice_state.has_value()) { - row[m_columns.m_voice_flags] = voice_state->second; + row[m_columns.m_voice_flags] = voice_state->second.Flags; } auto &img = Abaddon::Get().GetImageManager(); @@ -1331,6 +1357,10 @@ bool ChannelListTree::OnButtonPressEvent(GdkEventButton *ev) { OnVoiceChannelSubmenuPopup(); m_menu_voice_channel.popup_at_pointer(reinterpret_cast(ev)); break; + case RenderType::VoiceStage: + OnVoiceStageSubmenuPopup(); + m_menu_voice_stage.popup_at_pointer(reinterpret_cast(ev)); + break; case RenderType::DM: { OnDMSubmenuPopup(); const auto channel = Abaddon::Get().GetDiscordClient().GetChannel(static_cast(row[m_columns.m_id])); @@ -1442,6 +1472,25 @@ void ChannelListTree::OnVoiceChannelSubmenuPopup() { #endif } +void ChannelListTree::OnVoiceStageSubmenuPopup() { +#ifdef WITH_VOICE + const auto iter = m_model->get_iter(m_path_for_menu); + if (!iter) return; + const auto id = static_cast((*iter)[m_columns.m_id]); + auto &discord = Abaddon::Get().GetDiscordClient(); + if (discord.IsVoiceConnected() || discord.IsVoiceConnecting()) { + m_menu_voice_stage_join.set_sensitive(false); + m_menu_voice_stage_disconnect.set_sensitive(discord.GetVoiceChannelID() == id); + } else { + m_menu_voice_stage_join.set_sensitive(true); + m_menu_voice_stage_disconnect.set_sensitive(false); + } +#else + m_menu_voice_stage_join.set_sensitive(false); + m_menu_voice_stage_disconnect.set_sensitive(false); +#endif +} + void ChannelListTree::OnDMSubmenuPopup() { auto iter = m_model->get_iter(m_path_for_menu); if (!iter) return; diff --git a/src/components/channellist/channellisttree.hpp b/src/components/channellist/channellisttree.hpp index 9e2c544..323f843 100644 --- a/src/components/channellist/channellisttree.hpp +++ b/src/components/channellist/channellisttree.hpp @@ -162,6 +162,10 @@ protected: Gtk::Menu m_menu_voice_channel; Gtk::MenuItem m_menu_voice_channel_join; Gtk::MenuItem m_menu_voice_channel_disconnect; + + Gtk::Menu m_menu_voice_stage; + Gtk::MenuItem m_menu_voice_stage_join; + Gtk::MenuItem m_menu_voice_stage_disconnect; Gtk::MenuItem m_menu_voice_channel_mark_as_read; Gtk::MenuItem m_menu_voice_open_chat; @@ -192,6 +196,7 @@ protected: void OnDMSubmenuPopup(); void OnThreadSubmenuPopup(); void OnVoiceChannelSubmenuPopup(); + void OnVoiceStageSubmenuPopup(); bool m_updating_listing = false; diff --git a/src/discord/channel.hpp b/src/discord/channel.hpp index cac8b4c..ebf67b0 100644 --- a/src/discord/channel.hpp +++ b/src/discord/channel.hpp @@ -27,22 +27,6 @@ enum class ChannelType : int { GUILD_MEDIA = 16, }; -enum class StagePrivacy { - PUBLIC = 1, - GUILD_ONLY = 2, -}; - -constexpr const char *GetStagePrivacyDisplayString(StagePrivacy e) { - switch (e) { - case StagePrivacy::PUBLIC: - return "Public"; - case StagePrivacy::GUILD_ONLY: - return "Guild Only"; - default: - return "Unknown"; - } -} - // should be moved somewhere? struct ThreadMetadataData { diff --git a/src/discord/discord.cpp b/src/discord/discord.cpp index 062a871..a7712d6 100644 --- a/src/discord/discord.cpp +++ b/src/discord/discord.cpp @@ -360,6 +360,14 @@ std::optional DiscordClient::GetWebhookMessageData(Snowflake return m_store.GetWebhookMessage(message_id); } +std::optional DiscordClient::GetStageInstanceFromChannel(Snowflake channel_id) const { + const auto iter1 = m_channel_to_stage_instance.find(channel_id); + if (iter1 == m_channel_to_stage_instance.end()) return {}; + const auto iter2 = m_stage_instances.find(iter1->second); + if (iter2 == m_stage_instances.end()) return {}; + return iter2->second; +} + bool DiscordClient::IsThreadJoined(Snowflake thread_id) const { return std::find(m_joined_threads.begin(), m_joined_threads.end(), thread_id) != m_joined_threads.end(); } @@ -462,6 +470,10 @@ bool DiscordClient::CanManageMember(Snowflake guild_id, Snowflake actor, Snowfla return actor_highest->Position > target_highest->Position; } +bool DiscordClient::IsStageModerator(Snowflake user_id, Snowflake channel_id) const { + return HasChannelPermission(user_id, channel_id, Permission::MANAGE_CHANNELS | Permission::MOVE_MEMBERS | Permission::MUTE_MEMBERS); +} + void DiscordClient::ChatMessageCallback(const std::string &nonce, const http::response_type &response, const sigc::slot &callback) { if (!CheckCode(response)) { if (response.status_code == http::TooManyRequests) { @@ -1288,6 +1300,78 @@ std::optional DiscordClient::GetSSRCOfUser(Snowflake id) const { return m_voice.GetSSRCOfUser(id); } +bool DiscordClient::IsUserSpeaker(Snowflake user_id) const { + const auto state = GetVoiceState(user_id); + return state.has_value() && state->second.IsSpeaker(); +} + +bool DiscordClient::HasUserRequestedToSpeak(Snowflake user_id) const { + const auto state = GetVoiceState(user_id); + return state.has_value() && state->second.RequestToSpeakTimestamp.has_value() && util::FlagSet(state->second.Flags, VoiceStateFlags::Suppressed); +} + +bool DiscordClient::IsUserInvitedToSpeak(Snowflake user_id) const { + const auto state = GetVoiceState(user_id); + return state.has_value() && state->second.RequestToSpeakTimestamp.has_value() && !util::FlagSet(state->second.Flags, VoiceStateFlags::Suppressed); +} + +void DiscordClient::RequestToSpeak(Snowflake channel_id, bool want, const sigc::slot &callback) { + if (want && !HasSelfChannelPermission(channel_id, Permission::REQUEST_TO_SPEAK)) return; + const auto channel = GetChannel(channel_id); + if (!channel.has_value() || !channel->GuildID.has_value()) return; + + ModifyCurrentUserVoiceStateObject d; + d.ChannelID = channel_id; + if (want) { + d.RequestToSpeakTimestamp = Glib::DateTime::create_now_utc().format_iso8601(); + } else { + d.RequestToSpeakTimestamp = ""; + } + m_http.MakePATCH("/guilds/" + std::to_string(*channel->GuildID) + "/voice-states/@me", nlohmann::json(d).dump(), [callback](const http::response_type &response) { + if (CheckCode(response, 204)) { + callback(DiscordError::NONE); + } else { + callback(GetCodeFromResponse(response)); + } + }); +} + +void DiscordClient::SetStageSpeaking(Snowflake channel_id, bool want, const sigc::slot &callback) { + const auto channel = GetChannel(channel_id); + if (!channel.has_value() || !channel->GuildID.has_value()) return; + + ModifyCurrentUserVoiceStateObject d; + d.ChannelID = channel_id; + d.Suppress = !want; + if (want) { + d.RequestToSpeakTimestamp = ""; + } + m_http.MakePATCH("/guilds/" + std::to_string(*channel->GuildID) + "/voice-states/@me", nlohmann::json(d).dump(), [callback](const http::response_type &response) { + if (CheckCode(response, 204)) { + callback(DiscordError::NONE); + } else { + callback(GetCodeFromResponse(response)); + } + }); +} + +void DiscordClient::DeclineInviteToSpeak(Snowflake channel_id, const sigc::slot &callback) { + const auto channel = GetChannel(channel_id); + if (!channel.has_value() || !channel->GuildID.has_value()) return; + + ModifyCurrentUserVoiceStateObject d; + d.ChannelID = channel_id; + d.Suppress = true; + d.RequestToSpeakTimestamp = ""; + m_http.MakePATCH("/guilds/" + std::to_string(*channel->GuildID) + "/voice-states/@me", nlohmann::json(d).dump(), [callback](const http::response_type &response) { + if (CheckCode(response, 204)) { + callback(DiscordError::NONE); + } else { + callback(GetCodeFromResponse(response)); + } + }); +} + DiscordVoiceClient &DiscordClient::GetVoiceClient() { return m_voice; } @@ -1303,7 +1387,7 @@ void DiscordClient::SetVoiceDeafened(bool is_deaf) { } #endif -std::optional> DiscordClient::GetVoiceState(Snowflake user_id) const { +std::optional> DiscordClient::GetVoiceState(Snowflake user_id) const { if (const auto it = m_voice_states.find(user_id); it != m_voice_states.end()) { return it->second; } @@ -1652,6 +1736,15 @@ void DiscordClient::HandleGatewayMessage(std::string str) { case GatewayEvent::GUILD_MEMBERS_CHUNK: { HandleGatewayGuildMembersChunk(m); } break; + case GatewayEvent::STAGE_INSTANCE_CREATE: { + HandleGatewayStageInstanceCreate(m); + } break; + case GatewayEvent::STAGE_INSTANCE_UPDATE: { + HandleGatewayStageInstanceUpdate(m); + } break; + case GatewayEvent::STAGE_INSTANCE_DELETE: { + HandleGatewayStageInstanceDelete(m); + } break; case GatewayEvent::VOICE_STATE_UPDATE: { HandleGatewayVoiceStateUpdate(m); } break; @@ -1712,6 +1805,14 @@ void DiscordClient::ProcessNewGuild(GuildData &guild) { return; } + if (guild.StageInstances.has_value()) { + for (const auto &stage : *guild.StageInstances) { + spdlog::get("discord")->debug("storing stage {} in channel {}", stage.ID, stage.ChannelID); + m_stage_instances[stage.ID] = stage; + m_channel_to_stage_instance[stage.ChannelID] = stage.ID; + } + } + m_store.BeginTransaction(); m_store.SetGuild(guild.ID, guild); @@ -2302,6 +2403,29 @@ void DiscordClient::HandleGatewayGuildMembersChunk(const GatewayMessage &msg) { m_store.EndTransaction(); } +void DiscordClient::HandleGatewayStageInstanceCreate(const GatewayMessage &msg) { + StageInstance data = msg.Data; + spdlog::get("discord")->debug("STAGE_INSTANCE_CREATE: {} in {}", data.ID, data.ChannelID); + m_stage_instances[data.ID] = data; + m_channel_to_stage_instance[data.ChannelID] = data.ID; + m_signal_stage_instance_create.emit(data); +} + +void DiscordClient::HandleGatewayStageInstanceUpdate(const GatewayMessage &msg) { + StageInstance data = msg.Data; + spdlog::get("discord")->debug("STAGE_INSTANCE_UPDATE: {} in {}", data.ID, data.ChannelID); + m_stage_instances[data.ID] = data; + m_signal_stage_instance_update.emit(data); +} + +void DiscordClient::HandleGatewayStageInstanceDelete(const GatewayMessage &msg) { + StageInstance data = msg.Data; + spdlog::get("discord")->debug("STAGE_INSTANCE_DELETE: {} in {}", data.ID, data.ChannelID); + m_stage_instances.erase(data.ID); + m_channel_to_stage_instance.erase(data.ChannelID); + m_signal_stage_instance_delete.emit(data); +} + #ifdef WITH_VOICE /* @@ -2399,9 +2523,14 @@ void DiscordClient::CheckVoiceState(const VoiceState &data) { if (data.ChannelID.has_value()) { const auto old_state = GetVoiceState(data.UserID); SetVoiceState(data.UserID, data); - if (old_state.has_value() && old_state->first != *data.ChannelID) { - m_signal_voice_user_disconnect.emit(data.UserID, old_state->first); - m_signal_voice_user_connect.emit(data.UserID, *data.ChannelID); + const auto new_state = GetVoiceState(data.UserID); + if (old_state.has_value()) { + if (old_state->first != *data.ChannelID) { + m_signal_voice_user_disconnect.emit(data.UserID, old_state->first); + m_signal_voice_user_connect.emit(data.UserID, *data.ChannelID); + } else if (old_state->second.IsSpeaker() != new_state.value().second.IsSpeaker()) { + m_signal_voice_speaker_state_changed.emit(*data.ChannelID, data.UserID, new_state->second.IsSpeaker()); + } } else if (!old_state.has_value()) { m_signal_voice_user_connect.emit(data.UserID, *data.ChannelID); } @@ -2954,8 +3083,9 @@ void DiscordClient::SetVoiceState(Snowflake user_id, const VoiceState &state) { if (state.IsDeafened) flags |= VoiceStateFlags::Deaf; if (state.IsSelfStream) flags |= VoiceStateFlags::SelfStream; if (state.IsSelfVideo) flags |= VoiceStateFlags::SelfVideo; + if (state.IsSuppressed) flags |= VoiceStateFlags::Suppressed; - m_voice_states[user_id] = std::make_pair(*state.ChannelID, flags); + m_voice_states[user_id] = std::make_pair(*state.ChannelID, PackedVoiceState { flags, state.RequestToSpeakTimestamp }); m_voice_state_channel_users[*state.ChannelID].insert(user_id); m_signal_voice_state_set.emit(user_id, *state.ChannelID, flags); @@ -3018,6 +3148,9 @@ void DiscordClient::LoadEventMap() { m_event_map["VOICE_STATE_UPDATE"] = GatewayEvent::VOICE_STATE_UPDATE; m_event_map["VOICE_SERVER_UPDATE"] = GatewayEvent::VOICE_SERVER_UPDATE; m_event_map["CALL_CREATE"] = GatewayEvent::CALL_CREATE; + m_event_map["STAGE_INSTANCE_CREATE"] = GatewayEvent::STAGE_INSTANCE_CREATE; + m_event_map["STAGE_INSTANCE_UPDATE"] = GatewayEvent::STAGE_INSTANCE_UPDATE; + m_event_map["STAGE_INSTANCE_DELETE"] = GatewayEvent::STAGE_INSTANCE_DELETE; } DiscordClient::type_signal_gateway_ready DiscordClient::signal_gateway_ready() { @@ -3196,6 +3329,18 @@ DiscordClient::type_signal_guild_members_chunk DiscordClient::signal_guild_membe return m_signal_guild_members_chunk; } +DiscordClient::type_signal_stage_instance_create DiscordClient::signal_stage_instance_create() { + return m_signal_stage_instance_create; +} + +DiscordClient::type_signal_stage_instance_update DiscordClient::signal_stage_instance_update() { + return m_signal_stage_instance_update; +} + +DiscordClient::type_signal_stage_instance_delete DiscordClient::signal_stage_instance_delete() { + return m_signal_stage_instance_delete; +} + DiscordClient::type_signal_added_to_thread DiscordClient::signal_added_to_thread() { return m_signal_added_to_thread; } @@ -3273,3 +3418,7 @@ DiscordClient::type_signal_voice_user_connect DiscordClient::signal_voice_user_c DiscordClient::type_signal_voice_state_set DiscordClient::signal_voice_state_set() { return m_signal_voice_state_set; } + +DiscordClient::type_signal_voice_speaker_state_changed DiscordClient::signal_voice_speaker_state_changed() { + return m_signal_voice_speaker_state_changed; +} \ No newline at end of file diff --git a/src/discord/discord.hpp b/src/discord/discord.hpp index 4e898dc..483abf0 100644 --- a/src/discord/discord.hpp +++ b/src/discord/discord.hpp @@ -5,7 +5,7 @@ #include "objects.hpp" #include "store.hpp" #include "voiceclient.hpp" -#include "voicestateflags.hpp" +#include "voicestate.hpp" #include "websocket.hpp" #include #include @@ -65,6 +65,7 @@ public: void GetArchivedPrivateThreads(Snowflake channel_id, const sigc::slot &callback); std::vector GetChildChannelIDs(Snowflake parent_id) const; std::optional GetWebhookMessageData(Snowflake message_id) const; + std::optional GetStageInstanceFromChannel(Snowflake channel_id) const; // get ids of given list of members for who we do not have the member data template @@ -86,6 +87,7 @@ public: Permission ComputePermissions(Snowflake member_id, Snowflake guild_id) const; Permission ComputeOverwrites(Permission base, Snowflake member_id, Snowflake channel_id) const; bool CanManageMember(Snowflake guild_id, Snowflake actor, Snowflake target) const; // kick, ban, edit nickname (cant think of a better name) + bool IsStageModerator(Snowflake user_id, Snowflake channel_id) const; void ChatMessageCallback(const std::string &nonce, const http::response_type &response, const sigc::slot &callback); void SendChatMessageNoAttachments(const ChatSubmitParams ¶ms, const sigc::slot &callback); @@ -201,6 +203,13 @@ public: [[nodiscard]] bool IsVoiceConnecting() const noexcept; [[nodiscard]] Snowflake GetVoiceChannelID() const noexcept; [[nodiscard]] std::optional GetSSRCOfUser(Snowflake id) const; + [[nodiscard]] bool IsUserSpeaker(Snowflake user_id) const; + [[nodiscard]] bool HasUserRequestedToSpeak(Snowflake user_id) const; + [[nodiscard]] bool IsUserInvitedToSpeak(Snowflake user_id) const; + + void RequestToSpeak(Snowflake channel_id, bool want, const sigc::slot &callback); + void SetStageSpeaking(Snowflake channel_id, bool want, const sigc::slot &callback); + void DeclineInviteToSpeak(Snowflake channel_id, const sigc::slot &callback); DiscordVoiceClient &GetVoiceClient(); @@ -208,7 +217,7 @@ public: void SetVoiceDeafened(bool is_deaf); #endif - [[nodiscard]] std::optional> GetVoiceState(Snowflake user_id) const; + [[nodiscard]] std::optional> GetVoiceState(Snowflake user_id) const; [[nodiscard]] std::unordered_set GetUsersInVoiceChannel(Snowflake channel_id); void SetReferringChannel(Snowflake id); @@ -295,6 +304,9 @@ private: void HandleGatewayMessageAck(const GatewayMessage &msg); void HandleGatewayUserGuildSettingsUpdate(const GatewayMessage &msg); void HandleGatewayGuildMembersChunk(const GatewayMessage &msg); + void HandleGatewayStageInstanceCreate(const GatewayMessage &msg); + void HandleGatewayStageInstanceUpdate(const GatewayMessage &msg); + void HandleGatewayStageInstanceDelete(const GatewayMessage &msg); void HandleGatewayReadySupplemental(const GatewayMessage &msg); void HandleGatewayReconnect(const GatewayMessage &msg); void HandleGatewayInvalidSession(const GatewayMessage &msg); @@ -345,6 +357,8 @@ private: std::unordered_set m_muted_channels; std::unordered_map m_unread; std::unordered_set m_channel_muted_parent; + std::map m_stage_instances; + std::map m_channel_to_stage_instance; UserData m_user_data; UserSettings m_user_settings; @@ -388,7 +402,7 @@ private: void ClearVoiceState(Snowflake user_id); // todo sql i guess - std::unordered_map> m_voice_states; + std::unordered_map> m_voice_states; std::unordered_map> m_voice_state_channel_users; mutable std::mutex m_msg_mutex; @@ -446,6 +460,9 @@ public: typedef sigc::signal type_signal_thread_member_list_update; typedef sigc::signal type_signal_message_ack; typedef sigc::signal type_signal_guild_members_chunk; + typedef sigc::signal type_signal_stage_instance_create; + typedef sigc::signal type_signal_stage_instance_update; + typedef sigc::signal type_signal_stage_instance_delete; // not discord dispatch events typedef sigc::signal type_signal_added_to_thread; @@ -477,6 +494,7 @@ public: using type_signal_voice_user_disconnect = sigc::signal; using type_signal_voice_user_connect = sigc::signal; using type_signal_voice_state_set = sigc::signal; + using type_signal_voice_speaker_state_changed = sigc::signal; type_signal_gateway_ready signal_gateway_ready(); type_signal_gateway_ready_supplemental signal_gateway_ready_supplemental(); @@ -519,6 +537,9 @@ public: type_signal_thread_member_list_update signal_thread_member_list_update(); type_signal_message_ack signal_message_ack(); type_signal_guild_members_chunk signal_guild_members_chunk(); + type_signal_stage_instance_create signal_stage_instance_create(); + type_signal_stage_instance_update signal_stage_instance_update(); + type_signal_stage_instance_delete signal_stage_instance_delete(); type_signal_added_to_thread signal_added_to_thread(); type_signal_removed_from_thread signal_removed_from_thread(); @@ -546,6 +567,7 @@ public: type_signal_voice_user_disconnect signal_voice_user_disconnect(); type_signal_voice_user_connect signal_voice_user_connect(); type_signal_voice_state_set signal_voice_state_set(); + type_signal_voice_speaker_state_changed signal_voice_speaker_state_changed(); protected: type_signal_gateway_ready m_signal_gateway_ready; @@ -589,6 +611,9 @@ protected: type_signal_thread_member_list_update m_signal_thread_member_list_update; type_signal_message_ack m_signal_message_ack; type_signal_guild_members_chunk m_signal_guild_members_chunk; + type_signal_stage_instance_create m_signal_stage_instance_create; + type_signal_stage_instance_update m_signal_stage_instance_update; + type_signal_stage_instance_delete m_signal_stage_instance_delete; type_signal_removed_from_thread m_signal_removed_from_thread; type_signal_added_to_thread m_signal_added_to_thread; @@ -616,4 +641,5 @@ protected: type_signal_voice_user_disconnect m_signal_voice_user_disconnect; type_signal_voice_user_connect m_signal_voice_user_connect; type_signal_voice_state_set m_signal_voice_state_set; + type_signal_voice_speaker_state_changed m_signal_voice_speaker_state_changed; }; diff --git a/src/discord/guild.cpp b/src/discord/guild.cpp index 06c4acf..9cf94c2 100644 --- a/src/discord/guild.cpp +++ b/src/discord/guild.cpp @@ -54,6 +54,7 @@ void from_json(const nlohmann::json &j, GuildData &m) { JS_O("preferred_locale", m.PreferredLocale); JS_ON("public_updates_channel_id", m.PublicUpdatesChannelID); JS_O("max_video_channel_users", m.MaxVideoChannelUsers); + JS_ON("stage_instances", m.StageInstances); JS_O("approximate_member_count", tmp); if (tmp.has_value()) m.ApproximateMemberCount = std::stol(*tmp); diff --git a/src/discord/guild.hpp b/src/discord/guild.hpp index 4895d30..1ea858d 100644 --- a/src/discord/guild.hpp +++ b/src/discord/guild.hpp @@ -4,6 +4,7 @@ #include "role.hpp" #include "channel.hpp" #include "emoji.hpp" +#include "stage.hpp" #include #include #include @@ -90,6 +91,7 @@ struct GuildData { std::optional ApproximateMemberCount; std::optional ApproximatePresenceCount; std::optional> Threads; // only with permissions to view, id only + std::optional> StageInstances; // undocumented // std::map GuildHashes; diff --git a/src/discord/objects.cpp b/src/discord/objects.cpp index 804f10d..e6b7675 100644 --- a/src/discord/objects.cpp +++ b/src/discord/objects.cpp @@ -699,6 +699,18 @@ void from_json(const nlohmann::json &j, CallCreateData &m) { JS_D("channel_id", m.ChannelID); JS_ON("voice_states", m.VoiceStates); } + +void to_json(nlohmann::json &j, const ModifyCurrentUserVoiceStateObject &m) { + JS_IF("channel_id", m.ChannelID); + JS_IF("suppress", m.Suppress); + if (m.RequestToSpeakTimestamp.has_value()) { + if (m.RequestToSpeakTimestamp->empty()) { + j["request_to_speak_timestamp"] = nullptr; + } else { + j["request_to_speak_timestamp"] = *m.RequestToSpeakTimestamp; + } + } +} #endif void from_json(const nlohmann::json &j, VoiceState &m) { @@ -714,4 +726,5 @@ void from_json(const nlohmann::json &j, VoiceState &m) { JS_D("user_id", m.UserID); JS_ON("member", m.Member); JS_D("session_id", m.SessionID); + JS_ON("request_to_speak_timestamp", m.RequestToSpeakTimestamp); } diff --git a/src/discord/objects.hpp b/src/discord/objects.hpp index dfe99f0..44afe8d 100644 --- a/src/discord/objects.hpp +++ b/src/discord/objects.hpp @@ -20,6 +20,7 @@ #include "auditlog.hpp" #include "relationship.hpp" #include "errors.hpp" +#include "stage.hpp" // most stuff below should just be objects that get processed and thrown away immediately @@ -110,6 +111,9 @@ enum class GatewayEvent : int { VOICE_STATE_UPDATE, VOICE_SERVER_UPDATE, CALL_CREATE, + STAGE_INSTANCE_CREATE, + STAGE_INSTANCE_UPDATE, + STAGE_INSTANCE_DELETE, }; enum class GatewayCloseCode : uint16_t { @@ -917,6 +921,7 @@ struct VoiceState { std::string SessionID; bool IsSuppressed; Snowflake UserID; + std::optional RequestToSpeakTimestamp; friend void from_json(const nlohmann::json &j, VoiceState &m); }; @@ -952,4 +957,12 @@ struct CallCreateData { friend void from_json(const nlohmann::json &j, CallCreateData &m); }; + +struct ModifyCurrentUserVoiceStateObject { + std::optional ChannelID; + std::optional Suppress; + std::optional RequestToSpeakTimestamp; + + friend void to_json(nlohmann::json &j, const ModifyCurrentUserVoiceStateObject &m); +}; #endif diff --git a/src/discord/stage.cpp b/src/discord/stage.cpp new file mode 100644 index 0000000..428e1f3 --- /dev/null +++ b/src/discord/stage.cpp @@ -0,0 +1,12 @@ +#include "stage.hpp" + +#include "json.hpp" + +void from_json(const nlohmann::json &j, StageInstance &m) { + JS_D("id", m.ID); + JS_D("guild_id", m.GuildID); + JS_D("channel_id", m.ChannelID); + JS_N("topic", m.Topic); + JS_N("privacy_level", m.PrivacyLevel); + JS_N("guild_scheduled_event_id", m.GuildScheduledEventID); +} diff --git a/src/discord/stage.hpp b/src/discord/stage.hpp new file mode 100644 index 0000000..3df4433 --- /dev/null +++ b/src/discord/stage.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include "snowflake.hpp" + +enum class StagePrivacy { + PUBLIC = 1, + GUILD_ONLY = 2, +}; + +constexpr const char *GetStagePrivacyDisplayString(StagePrivacy e) { + switch (e) { + case StagePrivacy::PUBLIC: + return "Public"; + case StagePrivacy::GUILD_ONLY: + return "Guild Only"; + default: + return "Unknown"; + } +} + +struct StageInstance { + Snowflake ID; + Snowflake GuildID; + Snowflake ChannelID; + std::string Topic; + StagePrivacy PrivacyLevel; + Snowflake GuildScheduledEventID; + + friend void from_json(const nlohmann::json &j, StageInstance &m); +}; diff --git a/src/discord/voiceclient.cpp b/src/discord/voiceclient.cpp index e3e83f1..f021e49 100644 --- a/src/discord/voiceclient.cpp +++ b/src/discord/voiceclient.cpp @@ -250,6 +250,7 @@ bool DiscordVoiceClient::IsConnecting() const noexcept { } void DiscordVoiceClient::OnGatewayMessage(const std::string &str) { + m_log->trace("IN: {}", str); VoiceGatewayMessage msg = nlohmann::json::parse(str); switch (msg.Opcode) { case VoiceGatewayOp::Hello: diff --git a/src/discord/voiceclient.hpp b/src/discord/voiceclient.hpp index 0112749..aa1014c 100644 --- a/src/discord/voiceclient.hpp +++ b/src/discord/voiceclient.hpp @@ -43,6 +43,23 @@ enum class VoiceGatewayOp : int { Hello = 8, Resumed = 9, ClientDisconnect = 13, + SessionUpdate = 14, + MediaSinkWants = 15, + VoiceBackendVersion = 16, + ChannelOptionsUpdate = 17, + Flags = 18, + SpeedTest = 19, + Platform = 20, + SecureFramesPrepareProtocolTransition = 21, + SecureFramesExecuteTransition = 22, + SecureFramesReadyForTransition = 23, + SecureFramesPrepareEpoch = 24, + MlsExternalSenderPackage = 25, + MlsKeyPackage = 26, + MlsProposals = 27, + MlsCommitWelcome = 28, + MlsPrepareCommitTransition = 29, + MlsWelcome = 30, }; struct VoiceGatewayMessage { @@ -156,11 +173,11 @@ public: private: void ReadThread(); - #ifdef _WIN32 +#ifdef _WIN32 SOCKET m_socket; - #else +#else int m_socket; - #endif +#endif sockaddr_in m_server; std::atomic m_running = false; diff --git a/src/discord/voicestate.cpp b/src/discord/voicestate.cpp new file mode 100644 index 0000000..05c050d --- /dev/null +++ b/src/discord/voicestate.cpp @@ -0,0 +1,5 @@ +#include "voicestate.hpp" + +bool PackedVoiceState::IsSpeaker() const noexcept { + return ((Flags & VoiceStateFlags::Suppressed) != VoiceStateFlags::Suppressed) && !RequestToSpeakTimestamp.has_value(); +} diff --git a/src/discord/voicestateflags.hpp b/src/discord/voicestate.hpp similarity index 52% rename from src/discord/voicestateflags.hpp rename to src/discord/voicestate.hpp index 01fb762..cc75b0c 100644 --- a/src/discord/voicestateflags.hpp +++ b/src/discord/voicestate.hpp @@ -1,7 +1,10 @@ #pragma once #include +#include +#include #include "misc/bitwise.hpp" +// this is packed into a enum cuz it makes implementing tree models easier enum class VoiceStateFlags : uint8_t { Clear = 0, Deaf = 1 << 0, @@ -10,6 +13,14 @@ enum class VoiceStateFlags : uint8_t { SelfMute = 1 << 3, SelfStream = 1 << 4, SelfVideo = 1 << 5, + Suppressed = 1 << 6, +}; + +struct PackedVoiceState { + VoiceStateFlags Flags; + std::optional RequestToSpeakTimestamp; + + [[nodiscard]] bool IsSpeaker() const noexcept; }; template<> diff --git a/src/misc/bitwise.hpp b/src/misc/bitwise.hpp index ecce333..4d4cf8f 100644 --- a/src/misc/bitwise.hpp +++ b/src/misc/bitwise.hpp @@ -1,6 +1,13 @@ #pragma once #include +namespace util { +template +bool FlagSet(T flags, T value) { + return (flags & value) == value; +} +} // namespace util + template struct Bitwise { static const bool enable = false; diff --git a/src/windows/voicewindow.cpp b/src/windows/voice/voicewindow.cpp similarity index 59% rename from src/windows/voicewindow.cpp rename to src/windows/voice/voicewindow.cpp index a005e79..e59705a 100644 --- a/src/windows/voicewindow.cpp +++ b/src/windows/voice/voicewindow.cpp @@ -1,89 +1,19 @@ +#include "util.hpp" #ifdef WITH_VOICE // clang-format off +#include "voicewindow.hpp" + #include "abaddon.hpp" #include "audio/manager.hpp" #include "components/lazyimage.hpp" -#include "voicesettingswindow.hpp" -#include "voicewindow.hpp" +#include "voicewindowaudiencelistentry.hpp" +#include "voicewindowspeakerlistentry.hpp" +#include "windows/voicesettingswindow.hpp" // clang-format on -class VoiceWindowUserListEntry : public Gtk::ListBoxRow { -public: - VoiceWindowUserListEntry(Snowflake id) - : m_main(Gtk::ORIENTATION_VERTICAL) - , m_horz(Gtk::ORIENTATION_HORIZONTAL) - , m_avatar(32, 32) - , m_mute("Mute") { - m_name.set_halign(Gtk::ALIGN_START); - m_name.set_hexpand(true); - m_mute.set_halign(Gtk::ALIGN_END); - - m_volume.set_range(0.0, 200.0); - m_volume.set_value_pos(Gtk::POS_LEFT); - m_volume.set_value(100.0); - m_volume.signal_value_changed().connect([this]() { - m_signal_volume.emit(m_volume.get_value() * 0.01); - }); - - m_horz.add(m_avatar); - m_horz.add(m_name); - m_horz.add(m_mute); - m_main.add(m_horz); - m_main.add(m_volume); - m_main.add(m_meter); - add(m_main); - show_all_children(); - - auto &discord = Abaddon::Get().GetDiscordClient(); - const auto user = discord.GetUser(id); - if (user.has_value()) { - m_name.set_text(user->GetUsername()); - m_avatar.SetURL(user->GetAvatarURL("png", "32")); - } else { - m_name.set_text("Unknown user"); - } - - m_mute.signal_toggled().connect([this]() { - m_signal_mute_cs.emit(m_mute.get_active()); - }); - } - - void SetVolumeMeter(double frac) { - m_meter.SetVolume(frac); - } - - void RestoreGain(double frac) { - m_volume.set_value(frac * 100.0); - } - -private: - Gtk::Box m_main; - Gtk::Box m_horz; - LazyImage m_avatar; - Gtk::Label m_name; - Gtk::CheckButton m_mute; - Gtk::Scale m_volume; - VolumeMeter m_meter; - -public: - using type_signal_mute_cs = sigc::signal; - using type_signal_volume = sigc::signal; - type_signal_mute_cs signal_mute_cs() { - return m_signal_mute_cs; - } - - type_signal_volume signal_volume() { - return m_signal_volume; - } - -private: - type_signal_mute_cs m_signal_mute_cs; - type_signal_volume m_signal_volume; -}; - VoiceWindow::VoiceWindow(Snowflake channel_id) : m_main(Gtk::ORIENTATION_VERTICAL) , m_controls(Gtk::ORIENTATION_HORIZONTAL) @@ -91,7 +21,11 @@ VoiceWindow::VoiceWindow(Snowflake channel_id) , m_deafen("Deafen") , m_noise_suppression("Suppress Noise") , m_mix_mono("Mix Mono") + , m_stage_command("Request to Speak") , m_disconnect("Disconnect") + , m_stage_invite_lbl("You've been invited to speak") + , m_stage_accept("Accept") + , m_stage_decline("Decline") , m_channel_id(channel_id) , m_menu_view("View") , m_menu_view_settings("More _Settings", true) { @@ -102,14 +36,19 @@ VoiceWindow::VoiceWindow(Snowflake channel_id) auto &discord = Abaddon::Get().GetDiscordClient(); auto &audio = Abaddon::Get().GetAudio(); + const auto channel = discord.GetChannel(m_channel_id); + m_is_stage = channel.has_value() && channel->Type == ChannelType::GUILD_STAGE_VOICE; + SetUsers(discord.GetUsersInVoiceChannel(m_channel_id)); discord.signal_voice_user_disconnect().connect(sigc::mem_fun(*this, &VoiceWindow::OnUserDisconnect)); discord.signal_voice_user_connect().connect(sigc::mem_fun(*this, &VoiceWindow::OnUserConnect)); + discord.signal_voice_speaker_state_changed().connect(sigc::mem_fun(*this, &VoiceWindow::OnSpeakerStateChanged)); + discord.signal_voice_state_set().connect(sigc::mem_fun(*this, &VoiceWindow::OnVoiceStateUpdate)); if (const auto self_state = discord.GetVoiceState(discord.GetUserData().ID); self_state.has_value()) { - m_mute.set_active((self_state->second & VoiceStateFlags::SelfMute) == VoiceStateFlags::SelfMute); - m_deafen.set_active((self_state->second & VoiceStateFlags::SelfDeaf) == VoiceStateFlags::SelfDeaf); + m_mute.set_active(util::FlagSet(self_state->second.Flags, VoiceStateFlags::SelfMute)); + m_deafen.set_active(util::FlagSet(self_state->second.Flags, VoiceStateFlags::SelfDeaf)); } m_mute.signal_toggled().connect(sigc::mem_fun(*this, &VoiceWindow::OnMuteChanged)); @@ -253,35 +192,97 @@ VoiceWindow::VoiceWindow(Snowflake channel_id) combos_combos->pack_start(m_playback_combo); combos_combos->pack_start(m_capture_combo); - m_scroll.add(m_user_list); + if (const auto instance = discord.GetStageInstanceFromChannel(channel_id); instance.has_value()) { + m_stage_topic_label.show(); + UpdateStageTopicLabel(instance->Topic); + } else { + m_stage_topic_label.hide(); + } + + discord.signal_stage_instance_create().connect(sigc::mem_fun(*this, &VoiceWindow::OnStageInstanceCreate)); + discord.signal_stage_instance_update().connect(sigc::mem_fun(*this, &VoiceWindow::OnStageInstanceUpdate)); + discord.signal_stage_instance_delete().connect(sigc::mem_fun(*this, &VoiceWindow::OnStageInstanceDelete)); + + m_stage_command.signal_clicked().connect([this]() { + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto user_id = discord.GetUserData().ID; + const bool is_moderator = discord.IsStageModerator(user_id, m_channel_id); + const bool is_speaker = discord.IsUserSpeaker(user_id); + const bool is_invited_to_speak = discord.IsUserInvitedToSpeak(user_id); + + if (is_speaker) { + discord.SetStageSpeaking(m_channel_id, false, NOOP_CALLBACK); + } else if (is_moderator) { + discord.SetStageSpeaking(m_channel_id, true, NOOP_CALLBACK); + } else if (is_invited_to_speak) { + discord.DeclineInviteToSpeak(m_channel_id, NOOP_CALLBACK); + } else { + const bool requested = discord.HasUserRequestedToSpeak(user_id); + discord.RequestToSpeak(m_channel_id, !requested, NOOP_CALLBACK); + } + }); + + m_stage_accept.signal_clicked().connect([this]() { + Abaddon::Get().GetDiscordClient().SetStageSpeaking(m_channel_id, true, NOOP_CALLBACK); + }); + + m_stage_decline.signal_clicked().connect([this]() { + Abaddon::Get().GetDiscordClient().DeclineInviteToSpeak(m_channel_id, NOOP_CALLBACK); + }); + + m_speakers_label.set_markup("Speakers"); + if (m_is_stage) m_listing.pack_start(m_speakers_label, false, true); + m_listing.pack_start(m_speakers_list, false, true); + m_audience_label.set_markup("Audience"); + if (m_is_stage) m_listing.pack_start(m_audience_label, false, true); + if (m_is_stage) m_listing.pack_start(m_audience_list, false, true); + m_scroll.add(m_listing); m_controls.add(m_mute); m_controls.add(m_deafen); m_controls.add(m_noise_suppression); m_controls.add(m_mix_mono); - m_controls.pack_end(m_disconnect, false, true); + m_buttons.set_halign(Gtk::ALIGN_CENTER); + if (m_is_stage) m_buttons.pack_start(m_stage_command, false, true); + m_buttons.pack_start(m_disconnect, false, true); + m_stage_invite_box.pack_start(m_stage_invite_lbl, false, true); + m_stage_invite_box.pack_start(m_stage_invite_btns); + m_stage_invite_btns.set_halign(Gtk::ALIGN_CENTER); + m_stage_invite_btns.pack_start(m_stage_accept, false, true); + m_stage_invite_btns.pack_start(m_stage_decline, false, true); m_main.pack_start(m_menu_bar, false, true); m_main.pack_start(m_controls, false, true); + m_main.pack_start(m_buttons, false, true); + m_main.pack_start(m_stage_invite_box, false, true); m_main.pack_start(m_vad_value, false, true); m_main.pack_start(*Gtk::make_managed("Input Settings"), false, true); m_main.pack_start(*sliders_container, false, true); m_main.pack_start(m_scroll); + m_stage_topic_label.set_ellipsize(Pango::ELLIPSIZE_END); + m_stage_topic_label.set_halign(Gtk::ALIGN_CENTER); + m_main.pack_start(m_stage_topic_label, false, true); m_main.pack_start(*combos_container, false, true, 2); add(m_main); show_all_children(); Glib::signal_timeout().connect(sigc::mem_fun(*this, &VoiceWindow::UpdateVoiceMeters), 40); + + UpdateStageCommand(); } void VoiceWindow::SetUsers(const std::unordered_set &user_ids) { - const auto me = Abaddon::Get().GetDiscordClient().GetUserData().ID; + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto me = discord.GetUserData().ID; for (auto id : user_ids) { - if (id == me) continue; - m_user_list.add(*CreateRow(id)); + if (!m_is_stage || discord.IsUserSpeaker(id)) { + if (id != me) m_speakers_list.add(*CreateSpeakerRow(id)); + } else { + m_audience_list.add(*CreateAudienceRow(id)); + } } } -Gtk::ListBoxRow *VoiceWindow::CreateRow(Snowflake id) { - auto *row = Gtk::make_managed(id); +Gtk::ListBoxRow *VoiceWindow::CreateSpeakerRow(Snowflake id) { + auto *row = Gtk::make_managed(id); m_rows[id] = row; auto &vc = Abaddon::Get().GetDiscordClient().GetVoiceClient(); row->RestoreGain(vc.GetUserVolume(id)); @@ -291,7 +292,14 @@ Gtk::ListBoxRow *VoiceWindow::CreateRow(Snowflake id) { row->signal_volume().connect([this, id](double volume) { m_signal_user_volume_changed.emit(id, volume); }); - row->show_all(); + row->show(); + return row; +} + +Gtk::ListBoxRow *VoiceWindow::CreateAudienceRow(Snowflake id) { + auto *row = Gtk::make_managed(id); + m_rows[id] = row; + row->show(); return row; } @@ -303,6 +311,13 @@ void VoiceWindow::OnDeafenChanged() { m_signal_deafen.emit(m_deafen.get_active()); } +void VoiceWindow::TryDeleteRow(Snowflake id) { + if (auto it = m_rows.find(id); it != m_rows.end()) { + delete it->second; + m_rows.erase(it); + } +} + bool VoiceWindow::UpdateVoiceMeters() { auto &audio = Abaddon::Get().GetAudio(); switch (audio.GetVADMethod()) { @@ -319,7 +334,9 @@ bool VoiceWindow::UpdateVoiceMeters() { for (auto [id, row] : m_rows) { const auto ssrc = Abaddon::Get().GetDiscordClient().GetSSRCOfUser(id); if (ssrc.has_value()) { - row->SetVolumeMeter(audio.GetSSRCVolumeLevel(*ssrc)); + if (auto *speaker_row = dynamic_cast(row)) { + speaker_row->SetVolumeMeter(audio.GetSSRCVolumeLevel(*ssrc)); + } } } return true; @@ -339,23 +356,80 @@ void VoiceWindow::UpdateVADParamValue() { } } +void VoiceWindow::UpdateStageCommand() { + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto user_id = discord.GetUserData().ID; + + m_has_requested_to_speak = discord.HasUserRequestedToSpeak(user_id); + const bool is_moderator = discord.IsStageModerator(user_id, m_channel_id); + const bool is_speaker = discord.IsUserSpeaker(user_id); + const bool is_invited_to_speak = discord.IsUserInvitedToSpeak(user_id); + + m_stage_invite_box.set_visible(is_invited_to_speak); + + if (is_speaker) { + m_stage_command.set_label("Leave the Stage"); + } else if (is_moderator) { + m_stage_command.set_label("Speak on Stage"); + } else if (m_has_requested_to_speak) { + m_stage_command.set_label("Cancel Request"); + } else if (is_invited_to_speak) { + m_stage_command.set_label("Decline Invite"); + } else { + m_stage_command.set_label("Request to Speak"); + } +} + +void VoiceWindow::UpdateStageTopicLabel(const std::string &topic) { + m_stage_topic_label.set_markup("Topic: " + topic); +} + void VoiceWindow::OnUserConnect(Snowflake user_id, Snowflake to_channel_id) { if (m_channel_id == to_channel_id) { if (auto it = m_rows.find(user_id); it == m_rows.end()) { - m_user_list.add(*CreateRow(user_id)); + if (Abaddon::Get().GetDiscordClient().IsUserSpeaker(user_id)) { + m_speakers_list.add(*CreateSpeakerRow(user_id)); + } else { + m_audience_list.add(*CreateAudienceRow(user_id)); + } } } } void VoiceWindow::OnUserDisconnect(Snowflake user_id, Snowflake from_channel_id) { - if (m_channel_id == from_channel_id) { - if (auto it = m_rows.find(user_id); it != m_rows.end()) { - delete it->second; - m_rows.erase(it); - } + if (m_channel_id == from_channel_id) TryDeleteRow(user_id); +} + +void VoiceWindow::OnSpeakerStateChanged(Snowflake channel_id, Snowflake user_id, bool is_speaker) { + if (m_channel_id != channel_id) return; + TryDeleteRow(user_id); + if (is_speaker) { + m_speakers_list.add(*CreateSpeakerRow(user_id)); + } else { + m_audience_list.add(*CreateAudienceRow(user_id)); } } +void VoiceWindow::OnVoiceStateUpdate(Snowflake user_id, Snowflake channel_id, VoiceStateFlags flags) { + auto &discord = Abaddon::Get().GetDiscordClient(); + if (user_id != discord.GetUserData().ID) return; + + UpdateStageCommand(); +} + +void VoiceWindow::OnStageInstanceCreate(const StageInstance &instance) { + m_stage_topic_label.show(); + UpdateStageTopicLabel(instance.Topic); +} + +void VoiceWindow::OnStageInstanceUpdate(const StageInstance &instance) { + UpdateStageTopicLabel(instance.Topic); +} + +void VoiceWindow::OnStageInstanceDelete(const StageInstance &instance) { + m_stage_topic_label.hide(); +} + VoiceWindow::type_signal_mute VoiceWindow::signal_mute() { return m_signal_mute; } diff --git a/src/windows/voicewindow.hpp b/src/windows/voice/voicewindow.hpp similarity index 65% rename from src/windows/voicewindow.hpp rename to src/windows/voice/voicewindow.hpp index fb64010..05033d9 100644 --- a/src/windows/voicewindow.hpp +++ b/src/windows/voice/voicewindow.hpp @@ -1,4 +1,6 @@ #pragma once +#include "discord/stage.hpp" +#include "discord/voicestate.hpp" #ifdef WITH_VOICE // clang-format off @@ -16,7 +18,6 @@ #include // clang-format on -class VoiceWindowUserListEntry; class VoiceWindow : public Gtk::Window { public: VoiceWindow(Snowflake channel_id); @@ -24,17 +25,25 @@ public: private: void SetUsers(const std::unordered_set &user_ids); - Gtk::ListBoxRow *CreateRow(Snowflake id); + Gtk::ListBoxRow *CreateSpeakerRow(Snowflake id); + Gtk::ListBoxRow *CreateAudienceRow(Snowflake id); void OnUserConnect(Snowflake user_id, Snowflake to_channel_id); void OnUserDisconnect(Snowflake user_id, Snowflake from_channel_id); + void OnSpeakerStateChanged(Snowflake channel_id, Snowflake user_id, bool is_speaker); + void OnVoiceStateUpdate(Snowflake user_id, Snowflake channel_id, VoiceStateFlags flags); + void OnStageInstanceCreate(const StageInstance &instance); + void OnStageInstanceUpdate(const StageInstance &instance); + void OnStageInstanceDelete(const StageInstance &instance); void OnMuteChanged(); void OnDeafenChanged(); + void TryDeleteRow(Snowflake id); bool UpdateVoiceMeters(); - void UpdateVADParamValue(); + void UpdateStageCommand(); + void UpdateStageTopicLabel(const std::string &topic); Gtk::Box m_main; Gtk::Box m_controls; @@ -43,7 +52,9 @@ private: Gtk::CheckButton m_deafen; Gtk::ScrolledWindow m_scroll; - Gtk::ListBox m_user_list; + Gtk::VBox m_listing; + Gtk::ListBox m_speakers_list; + Gtk::ListBox m_audience_list; // Shows volume for gate VAD method // Shows probability for RNNoise VAD method @@ -56,21 +67,36 @@ private: Gtk::CheckButton m_noise_suppression; Gtk::CheckButton m_mix_mono; + Gtk::HBox m_buttons; Gtk::Button m_disconnect; + Gtk::Button m_stage_command; + + Gtk::VBox m_stage_invite_box; + Gtk::Label m_stage_invite_lbl; + Gtk::HBox m_stage_invite_btns; + Gtk::Button m_stage_accept; + Gtk::Button m_stage_decline; + + bool m_has_requested_to_speak = false; Gtk::ComboBoxText m_vad_combo; Gtk::ComboBox m_playback_combo; Gtk::ComboBox m_capture_combo; Snowflake m_channel_id; + bool m_is_stage; - std::unordered_map m_rows; + std::unordered_map m_rows; Gtk::MenuBar m_menu_bar; Gtk::MenuItem m_menu_view; Gtk::Menu m_menu_view_sub; Gtk::MenuItem m_menu_view_settings; + Gtk::Label m_stage_topic_label; + Gtk::Label m_speakers_label; + Gtk::Label m_audience_label; + public: using type_signal_mute = sigc::signal; using type_signal_deafen = sigc::signal; diff --git a/src/windows/voice/voicewindowaudiencelistentry.cpp b/src/windows/voice/voicewindowaudiencelistentry.cpp new file mode 100644 index 0000000..cf93343 --- /dev/null +++ b/src/windows/voice/voicewindowaudiencelistentry.cpp @@ -0,0 +1,23 @@ +#include "voicewindowaudiencelistentry.hpp" +#include "abaddon.hpp" + +VoiceWindowAudienceListEntry::VoiceWindowAudienceListEntry(Snowflake id) + : m_main(Gtk::ORIENTATION_HORIZONTAL) + , m_avatar(32, 32) { + m_name.set_halign(Gtk::ALIGN_START); + m_name.set_hexpand(true); + + m_main.add(m_avatar); + m_main.add(m_name); + add(m_main); + show_all_children(); + + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto user = discord.GetUser(id); + if (user.has_value()) { + m_name.set_text(user->GetUsername()); + m_avatar.SetURL(user->GetAvatarURL("png", "32")); + } else { + m_name.set_text("Unknown user"); + } +} diff --git a/src/windows/voice/voicewindowaudiencelistentry.hpp b/src/windows/voice/voicewindowaudiencelistentry.hpp new file mode 100644 index 0000000..e7bdbb1 --- /dev/null +++ b/src/windows/voice/voicewindowaudiencelistentry.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include "components/lazyimage.hpp" +#include "discord/snowflake.hpp" + +#include +#include +#include + +class VoiceWindowAudienceListEntry : public Gtk::ListBoxRow { +public: + VoiceWindowAudienceListEntry(Snowflake id); + +private: + Gtk::Box m_main; + LazyImage m_avatar; + Gtk::Label m_name; +}; diff --git a/src/windows/voice/voicewindowspeakerlistentry.cpp b/src/windows/voice/voicewindowspeakerlistentry.cpp new file mode 100644 index 0000000..a7bf2b8 --- /dev/null +++ b/src/windows/voice/voicewindowspeakerlistentry.cpp @@ -0,0 +1,58 @@ +#include "voicewindowspeakerlistentry.hpp" + +#include "abaddon.hpp" + +VoiceWindowSpeakerListEntry::VoiceWindowSpeakerListEntry(Snowflake id) + : m_main(Gtk::ORIENTATION_VERTICAL) + , m_horz(Gtk::ORIENTATION_HORIZONTAL) + , m_avatar(32, 32) + , m_mute("Mute") { + m_name.set_halign(Gtk::ALIGN_START); + m_name.set_hexpand(true); + m_mute.set_halign(Gtk::ALIGN_END); + + m_volume.set_range(0.0, 200.0); + m_volume.set_value_pos(Gtk::POS_LEFT); + m_volume.set_value(100.0); + m_volume.signal_value_changed().connect([this]() { + m_signal_volume.emit(m_volume.get_value() * 0.01); + }); + + m_horz.add(m_avatar); + m_horz.add(m_name); + m_horz.add(m_mute); + m_main.add(m_horz); + m_main.add(m_volume); + m_main.add(m_meter); + add(m_main); + show_all_children(); + + auto &discord = Abaddon::Get().GetDiscordClient(); + const auto user = discord.GetUser(id); + if (user.has_value()) { + m_name.set_text(user->GetUsername()); + m_avatar.SetURL(user->GetAvatarURL("png", "32")); + } else { + m_name.set_text("Unknown user"); + } + + m_mute.signal_toggled().connect([this]() { + m_signal_mute_cs.emit(m_mute.get_active()); + }); +} + +void VoiceWindowSpeakerListEntry::SetVolumeMeter(double frac) { + m_meter.SetVolume(frac); +} + +void VoiceWindowSpeakerListEntry::RestoreGain(double frac) { + m_volume.set_value(frac * 100.0); +} + +VoiceWindowSpeakerListEntry::type_signal_mute_cs VoiceWindowSpeakerListEntry::signal_mute_cs() { + return m_signal_mute_cs; +} + +VoiceWindowSpeakerListEntry::type_signal_volume VoiceWindowSpeakerListEntry::signal_volume() { + return m_signal_volume; +} diff --git a/src/windows/voice/voicewindowspeakerlistentry.hpp b/src/windows/voice/voicewindowspeakerlistentry.hpp new file mode 100644 index 0000000..a3b6429 --- /dev/null +++ b/src/windows/voice/voicewindowspeakerlistentry.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "components/lazyimage.hpp" +#include "components/volumemeter.hpp" +#include "discord/snowflake.hpp" + +#include +#include +#include +#include +#include + +class VoiceWindowSpeakerListEntry : public Gtk::ListBoxRow { +public: + VoiceWindowSpeakerListEntry(Snowflake id); + + void SetVolumeMeter(double frac); + void RestoreGain(double frac); + +private: + Gtk::Box m_main; + Gtk::Box m_horz; + LazyImage m_avatar; + Gtk::Label m_name; + Gtk::CheckButton m_mute; + Gtk::Scale m_volume; + VolumeMeter m_meter; + +public: + using type_signal_mute_cs = sigc::signal; + using type_signal_volume = sigc::signal; + type_signal_mute_cs signal_mute_cs(); + type_signal_volume signal_volume(); + +private: + type_signal_mute_cs m_signal_mute_cs; + type_signal_volume m_signal_volume; +};