diff --git a/Assets/AirConsole/examples/basic/AudioPlayer.cs b/Assets/AirConsole/examples/basic/AudioPlayer.cs
new file mode 100644
index 00000000..f4170267
--- /dev/null
+++ b/Assets/AirConsole/examples/basic/AudioPlayer.cs
@@ -0,0 +1,63 @@
+#if !DISABLE_AIRCONSOLE
+namespace NDream.AirConsole.Examples {
+ using NDream.AirConsole;
+ using UnityEngine;
+
+ ///
+ /// Example Audio Player that starts playing when the game is ready and stops when the game ends.
+ /// It also listens to volume changes and adjusts the audio accordingly.
+ ///
+ [RequireComponent(typeof(AudioSource))]
+ public class AudioPlayer : MonoBehaviour {
+ private AudioSource _audioSource;
+
+ private void Awake() {
+ _audioSource = GetComponent();
+ if (!_audioSource) {
+ AirConsoleLogger.LogError(() => "AudioPlayer requires an AudioSource component.", this);
+ enabled = false;
+ return;
+ }
+
+ SetupAudioSource();
+ AirConsole.instance.onReady += HandleOnReady;
+ AirConsole.instance.onGameEnd += HandleOnGameEnd;
+
+ // Until OnReady is called, we don't want any audio from Unity playing as the Player Lobby overlay will be shown.
+ AudioListener.pause = true;
+ }
+
+ private void SetupAudioSource() {
+ _audioSource.playOnAwake = false;
+ _audioSource.loop = true;
+ _audioSource.volume = 1.0f;
+ if (!_audioSource.clip) {
+ _audioSource.clip = Resources.Load("Audio/Music/Happy_1");
+ }
+ }
+
+ private void HandleOnGameEnd() {
+ // After OnGameEnd is called, we must not play any audio until OnReady is called again. During this time the Player Lobby
+ // overlay will be shown.
+ AudioListener.pause = true;
+ }
+
+ private void HandleOnReady(string code) {
+ AirConsoleLogger.Log(() => $"OnReady for {code}");
+ AudioListener.pause = false;
+ _audioSource.Play();
+ }
+
+ private void HandleAudioVolumeChange(float volume) {
+ AirConsoleLogger.Log(() => $"Setting volume to {volume}");
+ if (volume > 0) {
+ AudioListener.pause = false;
+ AudioListener.volume = volume;
+ _audioSource.Play();
+ } else if (Mathf.Approximately(0, volume)) {
+ AudioListener.pause = true;
+ }
+ }
+ }
+}
+#endif
diff --git a/Assets/AirConsole/examples/basic/AudioPlayer.cs.meta b/Assets/AirConsole/examples/basic/AudioPlayer.cs.meta
new file mode 100644
index 00000000..a2241e10
--- /dev/null
+++ b/Assets/AirConsole/examples/basic/AudioPlayer.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d33481c3f96324d4bba3e5db8826fd6e
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Assets/AirConsole/examples/basic/ExampleBasicLogic.cs b/Assets/AirConsole/examples/basic/ExampleBasicLogic.cs
index e74e56a6..452166d3 100644
--- a/Assets/AirConsole/examples/basic/ExampleBasicLogic.cs
+++ b/Assets/AirConsole/examples/basic/ExampleBasicLogic.cs
@@ -38,9 +38,12 @@ private void Awake() {
}
private void OnReady(string code) {
+ AirConsoleLogger.Log(() => "OnReady: " + code);
//Log to on-screen Console
logWindow.text = "ExampleBasic: AirConsole is ready! \n \n";
+ Time.timeScale = 1.0f;
+
//Mark Buttons as Interactable as soon as AirConsole is ready
Button[] allButtons = (Button[])FindObjectsOfType(typeof(Button));
foreach (Button button in allButtons) {
@@ -114,8 +117,7 @@ private void OnAdComplete(bool adWasShown) {
}
private void OnGameEnd() {
- Debug.Log("OnGameEnd is called");
- Camera.main.enabled = false;
+ AirConsoleLogger.Log(() => "OnGameEnd is called");
Time.timeScale = 0.0f;
}
diff --git a/Assets/AirConsole/examples/basic/basic.unity b/Assets/AirConsole/examples/basic/basic.unity
index aff15d10..5a6e07ed 100644
--- a/Assets/AirConsole/examples/basic/basic.unity
+++ b/Assets/AirConsole/examples/basic/basic.unity
@@ -4467,6 +4467,147 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1064712596}
m_CullTransparentMesh: 0
+--- !u!1 &1070081529
+GameObject:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ serializedVersion: 6
+ m_Component:
+ - component: {fileID: 1070081532}
+ - component: {fileID: 1070081531}
+ - component: {fileID: 1070081530}
+ m_Layer: 0
+ m_Name: AudioPlayer
+ m_TagString: Untagged
+ m_Icon: {fileID: 0}
+ m_NavMeshLayer: 0
+ m_StaticEditorFlags: 0
+ m_IsActive: 1
+--- !u!114 &1070081530
+MonoBehaviour:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1070081529}
+ m_Enabled: 1
+ m_EditorHideFlags: 0
+ m_Script: {fileID: 11500000, guid: d33481c3f96324d4bba3e5db8826fd6e, type: 3}
+ m_Name:
+ m_EditorClassIdentifier:
+--- !u!82 &1070081531
+AudioSource:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1070081529}
+ m_Enabled: 1
+ serializedVersion: 4
+ OutputAudioMixerGroup: {fileID: 0}
+ m_audioClip: {fileID: 8300000, guid: 804632e1e46684476b4ea5f752e8df15, type: 3}
+ m_PlayOnAwake: 0
+ m_Volume: 1
+ m_Pitch: 1
+ Loop: 1
+ Mute: 0
+ Spatialize: 0
+ SpatializePostEffects: 0
+ Priority: 128
+ DopplerLevel: 1
+ MinDistance: 1
+ MaxDistance: 500
+ Pan2D: 0
+ rolloffMode: 0
+ BypassEffects: 0
+ BypassListenerEffects: 0
+ BypassReverbZones: 0
+ rolloffCustomCurve:
+ serializedVersion: 2
+ m_Curve:
+ - serializedVersion: 3
+ time: 0
+ value: 1
+ inSlope: 0
+ outSlope: 0
+ tangentMode: 0
+ weightedMode: 0
+ inWeight: 0.33333334
+ outWeight: 0.33333334
+ - serializedVersion: 3
+ time: 1
+ value: 0
+ inSlope: 0
+ outSlope: 0
+ tangentMode: 0
+ weightedMode: 0
+ inWeight: 0.33333334
+ outWeight: 0.33333334
+ m_PreInfinity: 2
+ m_PostInfinity: 2
+ m_RotationOrder: 4
+ panLevelCustomCurve:
+ serializedVersion: 2
+ m_Curve:
+ - serializedVersion: 3
+ time: 0
+ value: 0
+ inSlope: 0
+ outSlope: 0
+ tangentMode: 0
+ weightedMode: 0
+ inWeight: 0.33333334
+ outWeight: 0.33333334
+ m_PreInfinity: 2
+ m_PostInfinity: 2
+ m_RotationOrder: 4
+ spreadCustomCurve:
+ serializedVersion: 2
+ m_Curve:
+ - serializedVersion: 3
+ time: 0
+ value: 0
+ inSlope: 0
+ outSlope: 0
+ tangentMode: 0
+ weightedMode: 0
+ inWeight: 0.33333334
+ outWeight: 0.33333334
+ m_PreInfinity: 2
+ m_PostInfinity: 2
+ m_RotationOrder: 4
+ reverbZoneMixCustomCurve:
+ serializedVersion: 2
+ m_Curve:
+ - serializedVersion: 3
+ time: 0
+ value: 1
+ inSlope: 0
+ outSlope: 0
+ tangentMode: 0
+ weightedMode: 0
+ inWeight: 0.33333334
+ outWeight: 0.33333334
+ m_PreInfinity: 2
+ m_PostInfinity: 2
+ m_RotationOrder: 4
+--- !u!4 &1070081532
+Transform:
+ m_ObjectHideFlags: 0
+ m_CorrespondingSourceObject: {fileID: 0}
+ m_PrefabInstance: {fileID: 0}
+ m_PrefabAsset: {fileID: 0}
+ m_GameObject: {fileID: 1070081529}
+ serializedVersion: 2
+ m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
+ m_LocalPosition: {x: 0.36577117, y: -0.17775297, z: 0.030780792}
+ m_LocalScale: {x: 1, y: 1, z: 1}
+ m_ConstrainProportionsScale: 0
+ m_Children: []
+ m_Father: {fileID: 0}
+ m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1087830224
GameObject:
m_ObjectHideFlags: 0
@@ -7557,3 +7698,4 @@ SceneRoots:
- {fileID: 607279077}
- {fileID: 1179297379}
- {fileID: 535473225}
+ - {fileID: 1070081532}
diff --git a/Assets/AirConsole/scripts/Runtime/AirConsole.Runtime.asmdef b/Assets/AirConsole/scripts/Runtime/AirConsole.Runtime.asmdef
index 7cacff24..8913fa27 100644
--- a/Assets/AirConsole/scripts/Runtime/AirConsole.Runtime.asmdef
+++ b/Assets/AirConsole/scripts/Runtime/AirConsole.Runtime.asmdef
@@ -4,7 +4,7 @@
"references": [
"Unity.TextMeshPro",
"UnityEngine.UI",
- "unity-webview"
+ "com.airconsole.unity-webview.runtime"
],
"includePlatforms": [
"Android",
diff --git a/Assets/AirConsole/scripts/Runtime/AirConsole.cs b/Assets/AirConsole/scripts/Runtime/AirConsole.cs
index ad73084a..3cf459b4 100644
--- a/Assets/AirConsole/scripts/Runtime/AirConsole.cs
+++ b/Assets/AirConsole/scripts/Runtime/AirConsole.cs
@@ -116,9 +116,9 @@ public class AirConsole : MonoBehaviour {
#if !DISABLE_AIRCONSOLE
#region airconsole api
-
+
// ReSharper disable MemberCanBePrivate.Global UnusedMember.Global
-
+
///
/// Device ID of the screen
///
@@ -1117,7 +1117,6 @@ protected void Awake() {
if (IsAndroidRuntime) {
AirConsoleLogger.Log(() => $"Launching build {Application.version} in Unity v{Application.unityVersion}");
- defaultScreenHeight = Screen.height;
_pluginManager = new PluginManager(this);
}
}
@@ -1255,7 +1254,7 @@ protected void Update() {
}
}
}
-
+
private void ProcessEvents() {
// dispatch event queue on main unity thread
while (eventQueue.Count > 0) {
@@ -1302,25 +1301,25 @@ private void OnDeviceStateChange(JObject msg) {
try {
int deviceId = (int)msg["device_id"];
- AllocateDeviceSlots(deviceId);
JToken deviceData = msg["device_data"];
- if (deviceData != null && deviceData.HasValues) {
- _devices[deviceId] = deviceData;
- } else {
- _devices[deviceId] = null;
- }
- if (onDeviceStateChange != null) {
- eventQueue.Enqueue(delegate() {
- if (onDeviceStateChange != null) {
- onDeviceStateChange(deviceId, GetDevice(_device_id));
- }
- });
- }
+ // Queue all _devices modifications to run on the Unity main thread to avoid race conditions
+ eventQueue.Enqueue(delegate() {
+ AllocateDeviceSlots(deviceId);
+ if (deviceData != null && deviceData.HasValues) {
+ _devices[deviceId] = deviceData;
+ } else {
+ _devices[deviceId] = null;
+ }
- if (Settings.debug.info) {
- AirConsoleLogger.Log(() => $"AirConsole: saved devicestate of {deviceId}");
- }
+ if (onDeviceStateChange != null) {
+ onDeviceStateChange(deviceId, GetDevice(_device_id));
+ }
+
+ if (Settings.debug.info) {
+ AirConsoleLogger.Log(() => $"AirConsole: saved devicestate of {deviceId}");
+ }
+ });
} catch (Exception e) {
if (Settings.debug.error) {
AirConsoleLogger.LogError(() => e.Message);
@@ -1417,52 +1416,51 @@ private void OnMessage(JObject msg) {
}
private void OnReady(JObject msg) {
- if (webViewLoadingCanvas) {
- Destroy(webViewLoadingCanvas.gameObject);
- }
-
- // parse server_time_offset
- _server_time_offset = (int)msg["server_time_offset"];
-
- // parse device_id
- _device_id = (int)msg["device_id"];
-
- // parse location
- _location = (string)msg["location"];
-
- if (msg["translations"] != null) {
- _translations = new Dictionary();
- JObject translationObject = (JObject)msg["translations"];
- if (translationObject != null) {
- foreach (KeyValuePair keyValue in translationObject) {
- string value = (string)keyValue.Value;
- value = value.Replace("\\n", "\n");
- value = value.Replace("<", "<");
- value = value.Replace(">", ">");
- _translations.Add(keyValue.Key, value);
+ // Queue all state modifications to run on the Unity main thread to avoid race conditions
+ eventQueue.Enqueue(delegate() {
+ if (webViewLoadingCanvas) {
+ Destroy(webViewLoadingCanvas.gameObject);
+ }
+
+ // parse server_time_offset
+ _server_time_offset = (int)msg["server_time_offset"];
+
+ // parse device_id
+ _device_id = (int)msg["device_id"];
+
+ // parse location
+ _location = (string)msg["location"];
+
+ if (msg["translations"] != null) {
+ _translations = new Dictionary();
+ JObject translationObject = (JObject)msg["translations"];
+ if (translationObject != null) {
+ foreach (KeyValuePair keyValue in translationObject) {
+ string value = (string)keyValue.Value;
+ value = value.Replace("\\n", "\n");
+ value = value.Replace("<", "<");
+ value = value.Replace(">", ">");
+ _translations.Add(keyValue.Key, value);
+ }
}
}
- }
- // load devices
- _devices.Clear();
- foreach (JToken data in (JToken)msg["devices"]) {
- JToken assign = data;
- if (data != null && !data.HasValues) {
- assign = null;
- }
+ // load devices
+ _devices.Clear();
+ foreach (JToken data in (JToken)msg["devices"]) {
+ JToken assign = data;
+ if (data != null && !data.HasValues) {
+ assign = null;
+ }
- _devices.Add(assign);
- }
+ _devices.Add(assign);
+ }
- if (onReady != null) {
- eventQueue.Enqueue(delegate() {
- if (onReady != null) {
- onReady((string)msg["code"]);
- }
- });
- }
+ if (onReady != null) {
+ onReady((string)msg["code"]);
+ }
+ });
}
private void OnDeviceProfileChange(JObject msg) {
@@ -1537,6 +1535,124 @@ private void OnAdComplete(JObject msg) {
}
}
+ ///
+ /// Resets the caches.
+ ///
+ /// Optional task to queue after the eventQueue gets cleared.
+ private void ResetCaches(Action taskToQueueAfterClear) {
+ AirConsoleLogger.LogDevelopment(() => "Resetting AirConsole caches");
+
+ // Clear device and player data
+ _devices.Clear();
+ _players.Clear();
+
+ // Reset state variables
+ _device_id = 0;
+ _server_time_offset = 0;
+ _location = null;
+ _translations = null;
+
+ // Reset safe area
+ _safeAreaWasSet = false;
+ _lastSafeAreaParameters = null;
+ SafeArea = new Rect(0, 0, Screen.width, Screen.height);
+
+ // Clear event queue
+ eventQueue.Clear();
+ if (taskToQueueAfterClear != null) {
+ eventQueue.Enqueue(taskToQueueAfterClear);
+ }
+
+ AirConsoleLogger.LogDevelopment(() => "AirConsole caches reset complete");
+ }
+
+ private void UnsubscribeWebSocketEvents() {
+ // Unsubscribe all event handlers to prevent stale events
+ wsListener.OnSetSafeArea -= OnSetSafeArea;
+ wsListener.onReady -= OnReady;
+ wsListener.onClose -= OnClose;
+ wsListener.onMessage -= OnMessage;
+ wsListener.onDeviceStateChange -= OnDeviceStateChange;
+ wsListener.onConnect -= OnConnect;
+ wsListener.onDisconnect -= OnDisconnect;
+ wsListener.onCustomDeviceStateChange -= OnCustomDeviceStateChange;
+ wsListener.onDeviceProfileChange -= OnDeviceProfileChange;
+ wsListener.onAdShow -= OnAdShow;
+ wsListener.onAdComplete -= OnAdComplete;
+ wsListener.onGameEnd -= OnGameEnd;
+ wsListener.onHighScores -= OnHighScores;
+ wsListener.onHighScoreStored -= OnHighScoreStored;
+ wsListener.onPersistentDataStored -= OnPersistentDataStored;
+ wsListener.onPersistentDataLoaded -= OnPersistentDataLoaded;
+ wsListener.onPremium -= OnPremium;
+ wsListener.onPause -= OnPause;
+ wsListener.onResume -= OnResume;
+
+ if (IsAndroidOrEditor) {
+ wsListener.onLaunchApp -= OnLaunchApp;
+ wsListener.onUnityWebviewResize -= OnUnityWebviewResize;
+ wsListener.onUnityWebviewPlatformReady -= OnUnityWebviewPlatformReady;
+ wsListener.OnUpdateContentProvider -= OnUpdateContentProvider;
+ wsListener.OnPlatformReady -= HandlePlatformReady;
+ }
+ }
+
+ private void CleanupWebSocketListener() {
+ AirConsoleLogger.LogDevelopment(() => "Cleaning up WebSocket listener");
+
+ if (wsListener != null) {
+ // Unsubscribe all event handlers to prevent stale events
+ UnsubscribeWebSocketEvents();
+ wsListener = null;
+ }
+
+ // Stop websocket server if in editor
+ StopWebsocketServer();
+
+ AirConsoleLogger.LogDevelopment(() => "WebSocket listener cleanup complete");
+ }
+
+ private void RecreateWebView() {
+ if (string.IsNullOrEmpty(_webViewOriginalUrl) || string.IsNullOrEmpty(_webViewConnectionUrl)) {
+ List missingComponents = new();
+ if (string.IsNullOrEmpty(_webViewOriginalUrl)) {
+ missingComponents.Add("original URL");
+ }
+
+ if (string.IsNullOrEmpty(_webViewConnectionUrl)) {
+ missingComponents.Add("connection URL");
+ }
+
+ string missing = string.Join(" and ", missingComponents);
+ AirConsoleLogger.LogDevelopment(() => $"Cannot recreate webview - missing {missing}");
+ return;
+ }
+
+ AirConsoleLogger.LogDevelopment(() => $"Recreating webview with URL: {_webViewOriginalUrl}");
+
+ // Cleanup websocket listener first to prevent stale events
+ CleanupWebSocketListener();
+
+ // Reset webview manager
+ _webViewManager = null;
+
+ // Destroy the old webview
+ if (webViewObject) {
+ if (_pluginManager != null && _reloadWebviewHandler != null) {
+ _pluginManager.OnReloadWebview -= _reloadWebviewHandler;
+ _reloadWebviewHandler = null;
+ }
+
+ webViewObject.Destroy();
+
+ Destroy(webViewObject.gameObject);
+ webViewObject = null;
+ }
+
+ // Recreate the webview with stored connection URL
+ CreateAndroidWebview(_webViewConnectionUrl);
+ }
+
private void OnGameEnd(JObject msg) {
_webViewManager.RequestStateTransition(WebViewManager.WebViewState.FullScreen);
@@ -1552,6 +1668,13 @@ private void OnGameEnd(JObject msg) {
if (Settings.debug.info) {
AirConsoleLogger.Log(() => "AirConsole: onGameEnd");
}
+
+ // Reset all caches and recreate webview on the main thread
+ eventQueue.Enqueue(delegate {
+ // We want to chain RecreateWebView to ensure it happens independent of
+ // the eventQueue getting cleared and related side effects.
+ ResetCaches(RecreateWebView);
+ });
} catch (Exception e) {
if (Settings.debug.error) {
AirConsoleLogger.LogError(() => e.Message);
@@ -1766,6 +1889,7 @@ internal static bool IsAndroidOrEditor {
private int defaultScreenHeight;
private List fixedCanvasScalers = new();
+ private Action _reloadWebviewHandler;
private PluginManager _pluginManager;
private List _devices = new();
@@ -1779,6 +1903,8 @@ internal static bool IsAndroidOrEditor {
private JObject _lastSafeAreaParameters;
private WebViewManager _webViewManager;
private bool _logPlatformMessages;
+ private string _webViewConnectionUrl;
+ private string _webViewOriginalUrl;
private IRuntimeConfigurator _runtimeConfigurator;
@@ -1789,12 +1915,18 @@ private void StopWebsocketServer() {
return;
}
+ // Unregister event handlers before stopping to prevent race conditions
+ UnsubscribeWebSocketEvents();
+
wsServer.Stop();
wsServer = null;
}
private void OnClose() {
- _devices.Clear();
+ // Queue the clear operation to run on the Unity main thread to avoid race conditions
+ eventQueue.Enqueue(delegate() {
+ _devices.Clear();
+ });
}
public static string GetUrl(StartMode mode) {
@@ -1928,7 +2060,7 @@ private void InitWebView() {
private void PrepareWebviewOverlay() {
webViewLoadingCanvas = new GameObject("WebViewLoadingCanvas").AddComponent