using System;
using System.Collections;
using System.Collections.Generic;
using Epic.OnlineServices;
using Epic.OnlineServices.Lobby;
using Epic.OnlineServices.RTC;
using Epic.OnlineServices.RTCAudio;
using UnityEngine;
///
/// Generic reusable cross-platform class to provide common voice chat functionality based on the EOS Voice Chat service.
/// This class does not know anything about EOS platform initialization or authentication; it just takes the required
/// EOS interfaces and exposes a number of game-related voice functions.
///
public class EOSVoiceChat: IDisposable
{
private const uint DefaultMaxChatPlayers = 16; // "Lobbies that generate conference rooms must have <= 16 max players"
private readonly LobbyInterface lobbyInterface;
private readonly RTCInterface rtcInterface;
private readonly RTCAudioInterface audioInterface;
private readonly Func localUserProvider;
private readonly Dictionary chatUsers = new Dictionary();
private string connectedLobbyId;
private string rtcRoomName;
public bool IsConnected => lobbyInterface != null && rtcInterface != null && audioInterface != null && !string.IsNullOrEmpty(connectedLobbyId);
private string defaultInputDeviceId;
public bool HasInputDevice => !string.IsNullOrEmpty(defaultInputDeviceId);
private ulong? onAudioDevicesChangedCallbackId;
private ulong? onConnectionChangedCallbackId, onParticipantStatusChangedCallbackId, onParticipantUpdatedCallbackId;
public event Action OnChatConnected;
public event Action OnChatConnectionFailed;
public event Action OnChatDisconnected;
public event Action OnChatUserJoined;
public event Action OnChatUserLeft;
///
/// Provide the required interfaces for voice chat. Product User ID is provided through a callback, so that the
/// same instance of this class can remain in use even if the logged in user changes.
///
public EOSVoiceChat(
LobbyInterface lobbyInterface, RTCInterface rtcInterface, RTCAudioInterface audioInterface,
Func localUserProvider)
{
this.lobbyInterface = lobbyInterface;
this.rtcInterface = rtcInterface;
this.audioInterface = audioInterface;
this.localUserProvider = localUserProvider;
SubscribeToAudioDeviceNotifications();
}
public void Dispose()
{
DisconnectChat();
UnsubscribeFromAudioDeviceNotifications();
}
///
/// Join an existing chat lobby or create a new one.
///
public void ConnectToChat(string chatLobbyName, uint maxChatPlayers = DefaultMaxChatPlayers)
{
DisconnectChat(); // Leave any currently connected chat lobby
var connectArgs = new ChatConnectArgs(chatLobbyName, maxChatPlayers);
lobbyInterface.CreateLobbySearch(new CreateLobbySearchOptions { MaxResults = 1 }, out var searchHandle);
searchHandle.SetLobbyId(new LobbySearchSetLobbyIdOptions { LobbyId = chatLobbyName });
var localUserId = localUserProvider.Invoke();
if (localUserId == null)
return;
searchHandle.Find(new LobbySearchFindOptions { LocalUserId = localUserId }, null, findData =>
{
switch (findData.ResultCode)
{
case Result.Success:
searchHandle.CopySearchResultByIndex(new LobbySearchCopySearchResultByIndexOptions { LobbyIndex = 0 }, out var lobbyDetails);
Debug.Log("Found existing chat lobby, joining...");
lobbyInterface.JoinLobby
(
new JoinLobbyOptions
{
LocalUserId = localUserId,
LobbyDetailsHandle = lobbyDetails,
PresenceEnabled = false,
},
connectArgs,
HandleLobbyJoined
);
break;
default:
Debug.Log($"Creating new chat lobby...");
lobbyInterface.CreateLobby
(
new CreateLobbyOptions
{
LocalUserId = localUserId,
AllowInvites = false,
PermissionLevel = LobbyPermissionLevel.Publicadvertised,
PresenceEnabled = false,
MaxLobbyMembers = maxChatPlayers,
DisableHostMigration = false,
LobbyId = chatLobbyName,
BucketId = Application.productName, // TODO: do we need anything more specific than this?
EnableRTCRoom = true,
},
connectArgs,
HandleLobbyCreated
);
break;
}
});
}
private void HandleLobbyCreated(CreateLobbyCallbackInfo data)
{
var connectArgs = (ChatConnectArgs)data.ClientData;
switch (data.ResultCode)
{
case Result.Success:
connectedLobbyId = data.LobbyId;
UpdateRTCRoomName();
Debug.Log($"Chat lobby creation successful, lobby ID = {connectedLobbyId}, RTC room name = {rtcRoomName}");
chatUsers.Clear();
SubscribeToRoomNotifications();
OnChatConnected?.Invoke();
break;
case Result.LobbyLobbyAlreadyExists:
// This can happen if two clients try to create the same lobby at the same time, a classic race condition.
// Try to join the other client's newly created chat lobby instead.
connectedLobbyId = null;
rtcRoomName = null;
Debug.Log("Chat lobby already exists, attempting to join it...");
ConnectToChat(connectArgs.chatLobbyName, connectArgs.maxChatPlayers);
break;
default:
Debug.LogError($"Chat lobby creation failed, result code = {data.ResultCode}");
connectedLobbyId = null;
rtcRoomName = null;
OnChatConnectionFailed?.Invoke();
break;
}
}
private void HandleLobbyJoined(JoinLobbyCallbackInfo data)
{
var connectArgs = (ChatConnectArgs)data.ClientData;
switch (data.ResultCode)
{
case Result.Success:
connectedLobbyId = data.LobbyId;
UpdateRTCRoomName();
Debug.Log($"Chat lobby joined successfully, lobby ID = {connectedLobbyId}, RTC room name = {rtcRoomName}");
chatUsers.Clear();
SubscribeToRoomNotifications();
OnChatConnected?.Invoke();
break;
default:
Debug.LogError($"Chat lobby join failed, result code = {data.ResultCode}");
connectedLobbyId = null;
rtcRoomName = null;
OnChatConnectionFailed?.Invoke();
break;
}
}
private void UpdateRTCRoomName()
{
rtcRoomName = null;
var result = lobbyInterface.GetRTCRoomName(
new GetRTCRoomNameOptions { LocalUserId = localUserProvider.Invoke(), LobbyId = connectedLobbyId },
out string roomName);
if (result != Result.Success)
{
Debug.LogError($"Failed to obtain RTC room name for lobby with ID {connectedLobbyId}");
return;
}
rtcRoomName = roomName;
}
public void DisconnectChat()
{
if (!IsConnected)
return;
// Unsubscribing first means we don't get a Disconnected notification for intentionally leaving the lobby
UnsubscribeFromRoomNotifications();
lobbyInterface.LeaveLobby
(
new LeaveLobbyOptions
{
LocalUserId = localUserProvider.Invoke(),
LobbyId = connectedLobbyId,
},
null,
data => { }
);
connectedLobbyId = null;
rtcRoomName = null;
chatUsers.Clear();
OnChatDisconnected?.Invoke();
}
///
/// Mute or unmute the local player's voice chat. This can be used to implement push-to-talk.
///
public void SetLocalMuted(bool muted)
{
if (!IsConnected)
return;
audioInterface.UpdateSending(new UpdateSendingOptions
{
LocalUserId = localUserProvider.Invoke(),
RoomName = rtcRoomName,
AudioStatus = muted ? RTCAudioStatus.Disabled : RTCAudioStatus.Enabled,
}, null, data => { });
}
///
/// Mute or unmute a specific remove player. This can be used to filter out specific players in the chat lobby,
/// or for manually muting toxic players.
///
public void SetRemoteMuted(ProductUserId remoteUser, bool muted)
{
if (!IsConnected)
return;
audioInterface.UpdateReceiving(new UpdateReceivingOptions
{
LocalUserId = localUserProvider.Invoke(),
ParticipantId = remoteUser,
RoomName = rtcRoomName,
AudioEnabled = !muted,
}, null, data => { });
}
///
/// Set all remote players to muted. This can be used during loading screens, or to initialize the chat lobby
/// when chatting with only a small subset of players.
///
public void MuteAllRemote()
{
if (!IsConnected)
return;
var localUserId = localUserProvider.Invoke();
foreach (var remoteProductUser in chatUsers.Keys)
{
audioInterface.UpdateReceiving(new UpdateReceivingOptions
{
LocalUserId = localUserId,
ParticipantId = remoteProductUser,
RoomName = rtcRoomName,
AudioEnabled = false,
}, null, data => { });
}
}
///
/// Set global voice chat volume. Values range between 0.0 (muted) and 1.0 (full volume).
/// If desired, volume value can be increased up to 2.0 for an overdrive mode.
///
public void SetOutputVolume(float volume)
{
audioInterface.SetAudioOutputSettings(new SetAudioOutputSettingsOptions
{
LocalUserId = localUserProvider.Invoke(),
DeviceId = null, // Default output device
Volume = volume * 50f,
});
}
///
/// Whether the requested user is currently talking or not.
/// This can be either the local player or a remote user.
///
public bool IsUserSpeaking(ProductUserId user)
{
if (chatUsers.TryGetValue(user, out var chatUser))
{
return chatUser.audioStatus == RTCAudioStatus.Enabled && chatUser.isSpeaking;
}
return false;
}
private void SubscribeToAudioDeviceNotifications()
{
if (!onAudioDevicesChangedCallbackId.HasValue)
{
onAudioDevicesChangedCallbackId = audioInterface.AddNotifyAudioDevicesChanged(
new AddNotifyAudioDevicesChangedOptions(), null,
HandleAudioDevicesChanged);
// Call the event handler once to query the audio devices in their initial state
HandleAudioDevicesChanged(null);
}
}
private void UnsubscribeFromAudioDeviceNotifications()
{
if (onAudioDevicesChangedCallbackId.HasValue)
{
audioInterface.RemoveNotifyAudioDevicesChanged(onAudioDevicesChangedCallbackId.Value);
onAudioDevicesChangedCallbackId = null;
}
}
private void HandleAudioDevicesChanged(AudioDevicesChangedCallbackInfo data)
{
defaultInputDeviceId = null;
var sb = new System.Text.StringBuilder();
// Update the default input device, so we know whether we can actually talk or not
uint inputDevicesCount = audioInterface.GetAudioInputDevicesCount(new GetAudioInputDevicesCountOptions());
sb.AppendLine($"Found {inputDevicesCount} audio input device(s):");
for (uint inputDeviceIndex = 0; inputDeviceIndex < inputDevicesCount; ++inputDeviceIndex)
{
var inputDeviceInfo = audioInterface.GetAudioInputDeviceByIndex(
new GetAudioInputDeviceByIndexOptions { DeviceInfoIndex = inputDeviceIndex });
sb.AppendLine($"Input device {inputDeviceIndex}: ID = {inputDeviceInfo.DeviceId}, Name = {inputDeviceInfo.DeviceName}, Default = {inputDeviceInfo.DefaultDevice}");
if (inputDeviceInfo.DefaultDevice)
{
defaultInputDeviceId = inputDeviceInfo.DeviceId;
}
}
uint outputDevicesCount = audioInterface.GetAudioOutputDevicesCount(new GetAudioOutputDevicesCountOptions());
sb.AppendLine($"Found {outputDevicesCount} audio output device(s):");
for (uint outputDeviceIndex = 0; outputDeviceIndex < outputDevicesCount; ++outputDeviceIndex)
{
var outputDeviceInfo = audioInterface.GetAudioOutputDeviceByIndex(
new GetAudioOutputDeviceByIndexOptions { DeviceInfoIndex = outputDeviceIndex });
sb.AppendLine($"Output device {outputDeviceIndex}: ID = {outputDeviceInfo.DeviceId}, Name = {outputDeviceInfo.DeviceName}, Default = {outputDeviceInfo.DefaultDevice}");
}
Debug.Log(sb);
}
private void SubscribeToRoomNotifications()
{
var localUserId = localUserProvider.Invoke();
if (!onConnectionChangedCallbackId.HasValue)
{
onConnectionChangedCallbackId = lobbyInterface.AddNotifyRTCRoomConnectionChanged
(
new AddNotifyRTCRoomConnectionChangedOptions
{
LocalUserId = localUserId,
LobbyId = connectedLobbyId,
},
null,
HandleConnectionChanged
);
}
if (!onParticipantStatusChangedCallbackId.HasValue)
{
onParticipantStatusChangedCallbackId = rtcInterface.AddNotifyParticipantStatusChanged
(
new AddNotifyParticipantStatusChangedOptions
{
LocalUserId = localUserId,
RoomName = rtcRoomName,
},
null,
HandleParticipantStatusChanged
);
}
if (!onParticipantUpdatedCallbackId.HasValue)
{
onParticipantUpdatedCallbackId = audioInterface.AddNotifyParticipantUpdated
(
new AddNotifyParticipantUpdatedOptions
{
LocalUserId = localUserId,
RoomName = rtcRoomName,
},
null,
HandleParticipantUpdated
);
}
}
private void UnsubscribeFromRoomNotifications()
{
if (onConnectionChangedCallbackId.HasValue)
{
rtcInterface.RemoveNotifyDisconnected(onConnectionChangedCallbackId.Value);
onConnectionChangedCallbackId = null;
}
if (onParticipantStatusChangedCallbackId.HasValue)
{
rtcInterface.RemoveNotifyParticipantStatusChanged(onParticipantStatusChangedCallbackId.Value);
onParticipantStatusChangedCallbackId = null;
}
if (onParticipantUpdatedCallbackId.HasValue)
{
audioInterface.RemoveNotifyParticipantUpdated(onParticipantUpdatedCallbackId.Value);
onParticipantUpdatedCallbackId = null;
}
}
private void HandleConnectionChanged(RTCRoomConnectionChangedCallbackInfo data)
{
// Note: reconnecting is handled automatically by the RTC lobby system
if (!data.IsConnected &&
(data.DisconnectReason == Result.UserKicked || data.DisconnectReason == Result.UserBanned))
{
// If we're either kicked or banned then we won't automatically reconnect, so leave the lobby properly
DisconnectChat();
}
}
private void HandleParticipantStatusChanged(ParticipantStatusChangedCallbackInfo data)
{
switch (data.ParticipantStatus)
{
case RTCParticipantStatus.Joined:
if (!chatUsers.ContainsKey(data.ParticipantId))
{
var chatUser = new ChatUser(data.ParticipantId);
chatUsers.Add(data.ParticipantId, chatUser);
OnChatUserJoined?.Invoke(data.ParticipantId);
}
break;
case RTCParticipantStatus.Left:
chatUsers.Remove(data.ParticipantId);
OnChatUserLeft?.Invoke(data.ParticipantId);
break;
}
}
private void HandleParticipantUpdated(ParticipantUpdatedCallbackInfo data)
{
if (chatUsers.TryGetValue(data.ParticipantId, out var chatUser))
{
chatUser.audioStatus = data.AudioStatus;
chatUser.isSpeaking = data.Speaking;
}
}
private class ChatConnectArgs
{
public readonly string chatLobbyName;
public readonly uint maxChatPlayers;
public ChatConnectArgs(string chatLobbyName, uint maxChatPlayers)
{
this.chatLobbyName = chatLobbyName;
this.maxChatPlayers = maxChatPlayers;
}
}
private class ChatUser
{
public readonly ProductUserId productUserId;
public RTCAudioStatus audioStatus;
public bool isSpeaking;
public ChatUser(ProductUserId productUserId)
{
this.productUserId = productUserId;
}
}
}