using System;
using System.Collections;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.Networking;
using WebSocketSharp;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace WefLab
{
///
/// Donation data structure
///
[System.Serializable]
public class DonationData
{
public string platform; // afreeca / naver(chzzk) / youtube / twitch / ...
public string donationType; // weflab subtype (SENDBALLOON / chzzk / superchat / bits ...)
public string subtype; // same as donationType, explicit name
public int amount; // normalized amount in KRW
public int rawValue; // raw platform value before KRW conversion (balloon count, cheese raw, bits, ...)
public string message;
public string donorName;
public string donorId;
public long timestamp;
}
///
/// A single emoticon/sticker referenced in a chat message.
///
[System.Serializable]
public class ChatEmoticon
{
public string name; // emoticon key/name as it appears in the message
public string imageUrl; // image URL to render
}
///
/// Chat message data structure. Shares weflab's flat envelope with donations
/// (type/data) but carries chat-specific fields: color, badges, emoticons, warnings.
///
[System.Serializable]
public class ChatData
{
public string platform;
public string type; // "chat" (normal) or "warn" (connection warning)
public string subtype; // platform-specific subtype (e.g. "emoticon")
public string nickname; // display name (data.name, falls back to data.id)
public string userId; // data.id
public string message; // data.msg (may contain emoticon name placeholders)
public string nickColor; // data.color (hex, may be empty)
// Viewer grade flags (from data.grade)
public bool isStreamer;
public bool isManager;
public bool isFan;
public bool isSubscriber;
public bool isVip;
public string[] badges; // data.grade.badge (platform badge ids)
public List emoticons = new List(); // data.emoticon
// Connection warning (type == "warn"): not a real chat message
public bool isWarn;
public string warnType; // subtype when isWarn (e.g. "password", "subonly", "donationoff")
public long timestamp;
}
///
/// Unity event with donation data parameter
///
[System.Serializable]
public class DonationEvent : UnityEvent { }
///
/// Unity event with chat data parameter
///
[System.Serializable]
public class ChatEvent : UnityEvent { }
///
/// Donation event trigger based on amount range
///
[System.Serializable]
public class DonationEventTrigger
{
[Tooltip("Name for this donation trigger (e.g., 'Small Donation', 'Large Donation')")]
public string triggerName = "New Trigger";
[Header("Amount Range")]
[Tooltip("Minimum donation amount (inclusive)")]
public int minAmount = 0;
[Tooltip("Maximum donation amount (inclusive, -1 for unlimited)")]
public int maxAmount = -1;
[Header("Repeat Settings")]
[Tooltip("Use amount-based repeat count (divide amount by amountPerRepeat)")]
public bool useAmountBasedRepeat = false;
[Tooltip("Amount of KRW per repeat (e.g., 1000 = 1 repeat per 1000 KRW)")]
[Min(1)]
public int amountPerRepeat = 1000;
[Tooltip("Minimum repeat count")]
[Min(1)]
public int minRepeatCount = 1;
[Tooltip("Maximum repeat count (0 = unlimited)")]
[Min(0)]
public int maxRepeatCount = 20;
[Tooltip("Fixed repeat count (used when useAmountBasedRepeat is false)")]
[Min(1)]
public int fixedRepeatCount = 1;
[Tooltip("Delay between each repeat in seconds (0 for immediate)")]
[Min(0)]
public float repeatDelay = 0.15f;
[Header("Event")]
[Tooltip("Unity event to trigger when donation amount is in range")]
public DonationEvent onDonation;
///
/// Check if donation amount is within range
///
public bool IsInRange(int amount)
{
if (amount < minAmount)
return false;
if (maxAmount >= 0 && amount > maxAmount)
return false;
return true;
}
///
/// Calculate repeat count based on donation amount
///
public int GetRepeatCount(int amount)
{
if (!useAmountBasedRepeat)
{
return fixedRepeatCount;
}
// Calculate repeat count based on amount
int count = amount / amountPerRepeat;
// Apply min/max limits
count = Mathf.Max(count, minRepeatCount);
if (maxRepeatCount > 0)
{
count = Mathf.Min(count, maxRepeatCount);
}
return count;
}
}
///
/// WefLab WebSocket client for receiving donation events
/// Implements Engine.IO v4 and Socket.IO protocol
///
public class WefLabWebSocketClient : MonoBehaviour
{
[Header("WefLab Settings")]
[Tooltip("WefLab page URL (e.g., https://weflab.com/page/guPK2ODAmmRvYG4)")]
public string pageUrl = "https://weflab.com/page/guPK2ODAmmRvYG4";
[Tooltip("Also connect to the main control socket (ssmain). Donations arrive on the per-platform sockets, so this is usually not needed.")]
public bool connectMainSocket = false;
[Tooltip("Drop duplicate donations with an identical signature within this window in seconds (guards against the same alert arriving on more than one socket). 0 disables.")]
[Min(0f)]
public float duplicateWindow = 3f;
[Header("Donation Event Triggers")]
[Tooltip("Donation event triggers based on amount ranges")]
public DonationEventTrigger[] donationTriggers = Array.Empty();
[Header("Chat (optional - structured for later use)")]
[Tooltip("Receive chat messages. Off by default; turn on when you want to consume chat via onChatReceived.")]
public bool enableChat = false;
[Tooltip("Fired for every incoming chat message (when enableChat is on)")]
public ChatEvent onChatReceived;
[Header("Queue Settings")]
[Tooltip("Enable sequential processing of donations (one alert at a time, like the weflab overlay)")]
public bool enableQueue = true;
[Tooltip("Lead-in delay before the donation event fires after it leaves the queue (seconds). " +
"Approximates the weflab overlay's intro/slide-in animation so the Unity reaction lands when the alert is revealed. " +
"Tune this to match your weflab alert's appearance timing.")]
[Min(0f)]
public float alertLeadTime = 0.8f;
[Tooltip("On-screen display time for each alert before the next one is dequeued (seconds). " +
"Set this close to your weflab alert display duration so spacing between alerts matches the overlay.")]
[Min(0.1f)]
public float alertDelay = 3f;
[Tooltip("Maximum queue size (0 = unlimited)")]
[Min(0)]
public int maxQueueSize = 50;
[Tooltip("Fired immediately when a donation is received from the socket (before the queue/lead-in). " +
"Use for instant side effects like running totals; the amount-range triggers fire later, synced to the alert reveal.")]
public DonationEvent onDonationReceived;
[Header("Precise Timing Sync (match the weflab overlay reveal)")]
[Tooltip("Replicate the overlay timing: when an alert reaches the front of the queue, fetch the SAME TTS audio weflab uses, " +
"measure its length, fire the donation event when the fetch completes (= overlay reveal moment), then hold for the audio length. " +
"Automatically tracks the overlay's synthesis latency. Falls back to alertLeadTime/alertDelay if settings or audio are unavailable.")]
public bool syncWithAlertAudio = true;
[Tooltip("Small constant added on top of the auto-measured TTS fetch time, to compensate server TTS cache skew + audio decode. " +
"The bulk of the reveal offset is measured automatically; fine-tune this against your live overlay (seconds).")]
[Min(0f)]
public float audioStartOffset = 0.15f;
[Tooltip("Gap between one alert's audio ending and the next alert appearing (seconds). Matches the overlay's inter-alert pause (~1s).")]
[Min(0f)]
public float interAlertGap = 1f;
[Tooltip("TTS language code sent to weflab's voice endpoint (affects the measured audio length).")]
public string ttsLang = "ko";
[Header("Debug")]
[Tooltip("Show detailed WefLab logs in the console (raw payloads, every socket event, ping/pong, handshakes, platform-feed keepalives, chat). " +
"Off keeps the console clean - only donations, connection status and errors are logged.")]
public bool verboseLog = false;
/// Console log that only fires when verboseLog (detail logging) is enabled.
private void VLog(string message)
{
if (verboseLog)
UnityEngine.Debug.Log(message);
}
// Queue state (visible in Inspector for debugging)
[Header("Queue Status (Read Only)")]
[SerializeField] private int queueCount = 0;
[SerializeField] private bool isProcessingQueue = false;
// Donation queue
private Queue donationQueue = new Queue();
private Coroutine queueProcessorCoroutine = null;
private Coroutine platformKeepAliveCoroutine = null;
// ---- Precise timing: meta extracted from loginData + settings fetched from /api/ ----
private string userDir = "";
private string userPreset = "0";
private string pageType = "page"; // loginData.type
private string pageId = "alert"; // loginData.pageid
private string verServer = "";
private string verSocket = "";
private string baseDomain = "https://weflab.com";
private string voiceUrlBase = ""; // absolute TTS endpoint (config.url.voiceurl)
private bool settingsLoaded = false;
private float settingPopupTimeCap = 60f; // item_alert_popuptime (seconds) - display upper bound
private float ttsSpeed = 1f; // item_alert_popupsoundspeed
private string ttsTemplate = "{닉네임}님 {종류} {개수}{개} 감사합니다!"; // setup_alert_text
// Hidden connection info
[HideInInspector] public bool isConnected = false;
[HideInInspector] public string currentSid = "";
[HideInInspector] public string extractedUserIdx = "";
///
/// One WebSocket connection to a single weflab socket server (one per linked platform).
///
private class PlatformConnection
{
public string platform; // "afreeca", "naver", "youtube", "main", ...
public string platformId; // the platform account id (loginData.platform[x].id), required for join_platform
public JObject platformData; // the full loginData.platform[x] object, sent in the "platform" feed message
public string page; // subscription page: "alert" (donations) or "chat"
public string wsUrl; // wss://ssafreeca.weflab.com/socket.io/?...
public WebSocket ws;
public string engineSid = "";
public string socketSid = "";
public bool connected = false;
public bool platformStarted = false; // whether the initial "platform" start message was sent
public bool IsPlatform => platform != "main";
public string Label => $"{platform}/{page}";
}
// Active connections (one per linked platform socket)
private readonly List connections = new List();
private string userIdx = ""; // Will be extracted from page (shared by every socket)
private bool isExtracting = false;
// One-time init guard: the page/config extraction runs only on the first enable;
// later enables (after a disable) just re-open the already-built connections.
private bool initialized = false;
// Duplicate-donation guard: signature -> last seen Time.time
private readonly Dictionary recentDonations = new Dictionary();
// Ping/Pong settings (informational, reported by server handshake)
private float pingInterval = 30f;
// Thread-safe action queue for main thread
private Queue mainThreadActions = new Queue();
private object actionLock = new object();
void OnEnable()
{
if (!initialized)
{
// First activation: extract user idx + config from the page, then connect.
initialized = true;
StartCoroutine(ExtractUserIdxAndConnect());
}
else
{
// Re-enabled after a disable: connections/settings are already built,
// so just re-open the sockets. The keepalive + queue processor restart
// themselves once the sockets reconnect (HandleSocketConnect).
Debug.Log("[WefLab] Re-enabled - reconnecting sockets");
ConnectAll();
}
}
void OnDisable()
{
// OnDestroy is NOT called on SetActive(false), so close the sockets here -
// otherwise the background WebSocket threads keep running while disabled.
Disconnect();
// Unity auto-stops this component's coroutines on disable. Reset the queue
// state they leave behind so processing restarts cleanly on re-enable
// (otherwise isProcessingQueue can stay true and stall the queue forever).
donationQueue.Clear();
queueCount = 0;
isProcessingQueue = false;
queueProcessorCoroutine = null;
// Drop any background-thread messages queued but not yet drained by Update(),
// so re-enabling doesn't flood Update() with stale alerts.
lock (actionLock)
{
mainThreadActions.Clear();
}
}
void Update()
{
// Execute queued actions on main thread
lock (actionLock)
{
while (mainThreadActions.Count > 0)
{
mainThreadActions.Dequeue()?.Invoke();
}
}
}
void OnDestroy()
{
Disconnect();
}
///
/// Extract user index from page URL and connect
///
private IEnumerator ExtractUserIdxAndConnect()
{
if (string.IsNullOrEmpty(pageUrl))
{
Debug.LogError("[WefLab] Page URL is empty! Please set the page URL in Inspector.");
yield break;
}
isExtracting = true;
Debug.Log($"[WefLab] Fetching page URL: {pageUrl}");
// Fetch the page HTML
using (UnityWebRequest request = UnityWebRequest.Get(pageUrl))
{
yield return request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogError($"[WefLab] Failed to fetch page: {request.error}");
isExtracting = false;
yield break;
}
string htmlContent = request.downloadHandler.text;
// Extract userIdx from JavaScript in the page
// Look for pattern: loginData = {...}
Match loginDataMatch = Regex.Match(htmlContent, @"loginData\s*=\s*(\{[^;]+\});", RegexOptions.Singleline);
if (loginDataMatch.Success)
{
try
{
string loginDataJson = loginDataMatch.Groups[1].Value;
var loginData = JsonConvert.DeserializeObject(loginDataJson);
// Extract idx from loginData
userIdx = loginData["idx"]?.ToString() ?? "";
if (!string.IsNullOrEmpty(userIdx))
{
extractedUserIdx = userIdx;
Debug.Log($"[WefLab] Successfully extracted userIdx: {userIdx}");
// Capture meta needed for the precise-timing settings fetch
ParseLoginMeta(loginData);
// Build the per-platform socket connection list from the same loginData
BuildConnections(loginData);
}
else
{
Debug.LogError("[WefLab] userIdx not found in loginData");
}
}
catch (Exception ex)
{
Debug.LogError($"[WefLab] Error parsing loginData: {ex.Message}");
}
}
else
{
Debug.LogError("[WefLab] Could not find loginData in page HTML");
}
}
isExtracting = false;
// Connect if we successfully built at least one socket connection
if (connections.Count > 0)
{
ConnectAll();
// Fetch alert design settings (popuptime/tts template/speed) for precise timing
if (syncWithAlertAudio)
{
yield return StartCoroutine(FetchAlertSettings());
}
}
else
{
Debug.LogError("[WefLab] Cannot connect - no socket connections were prepared (userIdx/config extraction failed)");
}
}
///
/// Capture the loginData fields needed to call the /api/ settings endpoint and the TTS endpoint.
///
private void ParseLoginMeta(JObject loginData)
{
userDir = loginData["dir"]?.ToString() ?? "";
userPreset = loginData["preset"]?.ToString() ?? "0";
pageType = loginData["type"]?.ToString() ?? "page";
pageId = loginData["pageid"]?.ToString() ?? "alert";
var config = loginData["config"] as JObject;
verServer = config?["ver"]?["server"]?.ToString() ?? "";
verSocket = config?["ver"]?["socket"]?.ToString() ?? "";
string domain = config?["domain"]?.ToString();
if (!string.IsNullOrEmpty(domain))
baseDomain = "https://" + domain;
string voiceUrl = config?["url"]?["voiceurl"]?.ToString();
if (!string.IsNullOrEmpty(voiceUrl))
voiceUrlBase = voiceUrl.StartsWith("http") ? voiceUrl : baseDomain + voiceUrl;
}
///
/// Fetch the streamer's alert design settings from POST /api/ (type=page_load).
/// Reads the display-time cap, TTS speed and TTS text template used for precise timing.
///
private IEnumerator FetchAlertSettings()
{
if (string.IsNullOrEmpty(userIdx) || string.IsNullOrEmpty(userDir))
{
Debug.LogWarning("[WefLab] Cannot fetch alert settings - missing idx/dir; precise timing will fall back to alertDelay");
yield break;
}
WWWForm form = new WWWForm();
form.AddField("type", pageType + "_load");
form.AddField("pagetype", pageType);
form.AddField("idx", userIdx);
form.AddField("pageid", pageId);
form.AddField("preset", userPreset);
form.AddField("dir", userDir);
form.AddField("ver[server]", verServer);
form.AddField("ver[socket]", verSocket);
using (UnityWebRequest request = UnityWebRequest.Post(baseDomain + "/api/", form))
{
request.SetRequestHeader("Referer", pageUrl);
yield return request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
Debug.LogWarning($"[WefLab] Alert settings fetch failed ({request.error}); precise timing falls back to alertDelay");
yield break;
}
try
{
var root = JsonConvert.DeserializeObject(request.downloadHandler.text);
var data = root?["data"] as JObject;
if (data == null)
{
Debug.LogWarning("[WefLab] Alert settings response had no data; falling back to alertDelay");
yield break;
}
string popup = FindSetting(data, "item_alert_popuptime");
if (!string.IsNullOrEmpty(popup) && float.TryParse(popup, out float pt) && pt > 0f)
settingPopupTimeCap = pt;
string speed = FindSetting(data, "item_alert_popupsoundspeed");
if (!string.IsNullOrEmpty(speed) && float.TryParse(speed, out float sp) && sp > 0f)
ttsSpeed = sp;
string template = FindSetting(data, "setup_alert_text");
if (!string.IsNullOrEmpty(template))
ttsTemplate = template;
settingsLoaded = true;
Debug.Log($"[WefLab] Alert settings loaded - popuptimeCap:{settingPopupTimeCap}s, ttsSpeed:{ttsSpeed}, template:\"{ttsTemplate}\", voiceUrl:{voiceUrlBase}");
}
catch (Exception ex)
{
Debug.LogWarning($"[WefLab] Error parsing alert settings: {ex.Message}; falling back to alertDelay");
}
}
}
///
/// Recursively find a setting value by key within the nested settings object (radio/checkbox/text/...).
///
private static string FindSetting(JObject node, string key)
{
var direct = node[key];
if (direct != null && direct.Type != JTokenType.Object && direct.Type != JTokenType.Array)
return direct.ToString();
foreach (var prop in node.Properties())
{
if (prop.Value is JObject child)
{
string found = FindSetting(child, key);
if (found != null)
return found;
}
}
return null;
}
///
/// Build one PlatformConnection per linked platform from loginData.config.url.
/// Mirrors the weflab web client which opens socket.iop[platform] for each linked platform.
///
private void BuildConnections(JObject loginData)
{
connections.Clear();
var urlConfig = loginData["config"]?["url"] as JObject;
var platforms = loginData["platform"] as JObject;
if (urlConfig == null || platforms == null)
{
Debug.LogError("[WefLab] config.url or platform missing in loginData - cannot build connections");
return;
}
// One donation socket per linked platform (afreeca/naver/youtube/...).
// When chat is enabled, also open a second connection per platform on the chat page,
// since weflab subscribes alert and chat on separate pages.
foreach (var prop in platforms.Properties())
{
string platform = prop.Name;
string host = urlConfig["socket_" + platform]?.ToString();
string platformId = (prop.Value as JObject)?["id"]?.ToString() ?? "";
if (string.IsNullOrEmpty(host))
{
Debug.LogWarning($"[WefLab] No socket server for platform '{platform}' (socket_{platform} not in config.url) - skipping");
continue;
}
JObject platformData = prop.Value as JObject;
connections.Add(new PlatformConnection
{
platform = platform,
platformId = platformId,
platformData = platformData,
page = "alert",
wsUrl = BuildSocketUrl(host, "alert")
});
if (enableChat)
{
connections.Add(new PlatformConnection
{
platform = platform,
platformId = platformId,
platformData = platformData,
page = "chat",
wsUrl = BuildSocketUrl(host, "chat")
});
}
}
// Optionally include the main control socket (ssmain)
if (connectMainSocket)
{
string mainHost = urlConfig["socket"]?.ToString();
if (!string.IsNullOrEmpty(mainHost))
{
connections.Add(new PlatformConnection
{
platform = "main",
page = "alert",
wsUrl = BuildSocketUrl(mainHost, "alert")
});
}
}
Debug.Log($"[WefLab] Prepared {connections.Count} socket connection(s): {string.Join(", ", connections.ConvertAll(c => c.Label))}");
}
///
/// Convert a config host (https://ssafreeca.weflab.com) into a full Socket.IO websocket URL
/// subscribed to the given page ("alert" or "chat").
///
private string BuildSocketUrl(string httpHost, string page)
{
string baseUrl = httpHost
.Replace("https://", "wss://")
.Replace("http://", "ws://")
.TrimEnd('/');
return $"{baseUrl}/socket.io/?idx={userIdx}&type=page&page={page}&EIO=4&transport=websocket";
}
///
/// Open every prepared platform connection.
///
public void ConnectAll()
{
foreach (var conn in connections)
{
ConnectOne(conn);
}
}
///
/// Backwards-compatible alias - connects to all platform sockets.
///
public void Connect()
{
ConnectAll();
}
///
/// Open a single platform connection and wire its event handlers.
///
private void ConnectOne(PlatformConnection conn)
{
if (conn.ws != null && conn.ws.IsAlive)
{
Debug.LogWarning($"[WefLab] ({conn.Label}) Already connected");
return;
}
VLog($"[WefLab] ({conn.Label}) Connecting to: {conn.wsUrl}");
conn.ws = new WebSocket(conn.wsUrl);
// Capture conn in the handlers so each message knows which socket it came from
conn.ws.OnOpen += (sender, e) =>
EnqueueMainThreadAction(() => VLog($"[WefLab] ({conn.Label}) WebSocket connection opened"));
conn.ws.OnMessage += (sender, e) =>
{
string data = e.Data;
EnqueueMainThreadAction(() => ProcessMessage(conn, data));
};
conn.ws.OnError += (sender, e) =>
EnqueueMainThreadAction(() =>
{
Debug.LogError($"[WefLab] ({conn.Label}) WebSocket error: {e.Message}");
if (e.Exception != null)
Debug.LogError($"[WefLab] ({conn.Label}) Exception: {e.Exception}");
});
conn.ws.OnClose += (sender, e) =>
EnqueueMainThreadAction(() =>
{
VLog($"[WefLab] ({conn.Label}) WebSocket closed. Code: {e.Code}, Reason: {e.Reason}");
conn.connected = false;
conn.engineSid = "";
conn.socketSid = "";
UpdateAggregateState();
});
conn.ws.ConnectAsync();
}
///
/// Disconnect every platform connection.
///
public void Disconnect()
{
if (platformKeepAliveCoroutine != null)
{
StopCoroutine(platformKeepAliveCoroutine);
platformKeepAliveCoroutine = null;
}
foreach (var conn in connections)
{
if (conn.ws != null)
{
conn.ws.Close();
conn.ws = null;
}
conn.connected = false;
conn.engineSid = "";
conn.socketSid = "";
conn.platformStarted = false;
}
UpdateAggregateState();
}
///
/// Refresh the aggregate inspector fields (isConnected / currentSid) from all connections.
///
private void UpdateAggregateState()
{
bool any = false;
string firstSid = "";
foreach (var c in connections)
{
if (c.connected)
any = true;
if (string.IsNullOrEmpty(firstSid) && !string.IsNullOrEmpty(c.engineSid))
firstSid = c.engineSid;
}
isConnected = any;
currentSid = firstSid;
}
#region Message Processing
///
/// Process incoming WebSocket message for a given platform connection
///
private void ProcessMessage(PlatformConnection conn, string data)
{
if (string.IsNullOrEmpty(data))
return;
// Get message type (first character or first two characters)
string messageType = data.Length >= 2 && char.IsDigit(data[1])
? data.Substring(0, 2)
: data.Substring(0, 1);
string payload = data.Substring(messageType.Length);
switch (messageType)
{
case "0": // Engine.IO OPEN
HandleEngineOpen(conn, payload);
break;
case "2": // Engine.IO PING
HandlePing(conn);
break;
case "3": // Engine.IO PONG
VLog($"[WefLab] ({conn.Label}) Received PONG");
break;
case "40": // Socket.IO CONNECT
HandleSocketConnect(conn, payload);
break;
case "42": // Socket.IO EVENT
HandleSocketEvent(conn, payload);
break;
default:
VLog($"[WefLab] ({conn.Label}) Unknown message type: {messageType} | Data: {data}");
break;
}
}
///
/// Handle Engine.IO OPEN message (handshake)
///
private void HandleEngineOpen(PlatformConnection conn, string payload)
{
try
{
var openData = JsonConvert.DeserializeObject(payload);
conn.engineSid = openData["sid"]?.ToString() ?? "";
if (openData["pingInterval"] != null)
{
pingInterval = openData["pingInterval"].Value() / 1000f; // Convert to seconds
}
UpdateAggregateState();
VLog($"[WefLab] ({conn.Label}) Engine.IO OPEN - SID: {conn.engineSid}, PingInterval: {pingInterval}s");
VLog($"[WefLab] ({conn.Label}) Full handshake data: {payload}");
// Send Socket.IO CONNECT
SendSocketConnect(conn);
}
catch (Exception ex)
{
Debug.LogError($"[WefLab] ({conn.Label}) Error parsing OPEN message: {ex.Message}");
}
}
///
/// Handle Engine.IO PING
///
private void HandlePing(PlatformConnection conn)
{
VLog($"[WefLab] ({conn.Label}) Received PING, sending PONG");
SendMessage(conn, "3"); // Send PONG
}
///
/// Handle Socket.IO CONNECT response
///
private void HandleSocketConnect(PlatformConnection conn, string payload)
{
try
{
if (!string.IsNullOrEmpty(payload))
{
var connectData = JsonConvert.DeserializeObject(payload);
conn.socketSid = connectData["sid"]?.ToString() ?? "";
VLog($"[WefLab] ({conn.Label}) Socket.IO CONNECT - SID: {conn.socketSid}");
VLog($"[WefLab] ({conn.Label}) Full connect data: {payload}");
}
conn.connected = true;
UpdateAggregateState();
// 1) Register the subscription
SendJoinMessage(conn);
// 2) Platform sockets must also start the platform feed; this is what actually
// triggers donation/chat delivery (join_platform alone is not enough).
if (conn.IsPlatform)
{
SendPlatformMessage(conn);
// 3) Keep the feed alive with periodic re-sends (weflab uses config.time.page_platform = 60s)
if (platformKeepAliveCoroutine == null)
platformKeepAliveCoroutine = StartCoroutine(PlatformKeepAlive());
}
}
catch (Exception ex)
{
Debug.LogError($"[WefLab] ({conn.Label}) Error parsing Socket.IO CONNECT: {ex.Message}");
}
}
///
/// Send the "platform" feed message that starts/maintains donation delivery on a platform socket.
/// Mirrors weflab: socket.send(iop, { type:"platform", start, platform:, use:[] }).
///
private void SendPlatformMessage(PlatformConnection conn)
{
bool start = !conn.platformStarted;
conn.platformStarted = true;
var msg = new
{
type = "platform",
start = start,
platform = conn.platformData,
use = Array.Empty(),
// fields socket.send() auto-appends
page = pageType,
idx = userIdx,
pageid = conn.page,
preset = "0"
};
string fullMessage = "42" + JsonConvert.SerializeObject(new object[] { "msg", msg });
VLog($"[WefLab] ({conn.Label}) Sending PLATFORM feed message (start={start})");
SendMessage(conn, fullMessage);
}
///
/// Periodically re-send the platform feed message to keep the donation feed alive (every 60s).
///
private IEnumerator PlatformKeepAlive()
{
var wait = new WaitForSeconds(60f);
while (true)
{
yield return wait;
foreach (var conn in connections)
{
if (conn.IsPlatform && conn.connected && conn.ws != null && conn.ws.IsAlive)
SendPlatformMessage(conn);
}
}
}
// Subtypes that are NOT monetary donations (subscribe/membership/follow/emoticon).
// value here is a month count or nothing, so they must not trigger amount-based events.
private static readonly HashSet NonMonetarySubtypes = new HashSet(StringComparer.OrdinalIgnoreCase)
{
"subscribe", "vip", "follow", "follow_item", "follow_item_effect", "up", "emoticon", "chat"
};
///
/// Handle Socket.IO EVENT message. The weflab envelope is ["msg", { type, data, ... }]
/// where `type` is "alert" (donation), "chat", "info", etc. and `data` is the flat payload.
///
private void HandleSocketEvent(PlatformConnection conn, string payload)
{
try
{
var eventArray = JsonConvert.DeserializeObject(payload);
if (eventArray == null || eventArray.Count < 1)
return;
string eventName = eventArray[0].ToString();
// The server also emits bare events like ["pong"] - ignore anything that isn't a "msg" payload
if (eventName != "msg")
{
VLog($"[WefLab] ({conn.Label}) Socket event '{eventName}' (ignored)");
return;
}
if (eventArray.Count < 2)
return;
var envelope = eventArray[1] as JObject;
VLog($"[WefLab] ===== EVENT ({conn.Label}) name={eventName} type={envelope?["type"]} =====");
// Verbose: dump the full raw payload so real (non-test) platform structures can be verified
if (verboseLog && envelope != null)
Debug.Log($"[WefLab] RAW ({conn.Label}):\n{envelope.ToString(Formatting.Indented)}");
HandleMessageEnvelope(conn.platform, envelope);
}
catch (Exception ex)
{
Debug.LogError($"[WefLab] ({conn.Label}) Error parsing Socket.IO EVENT: {ex.Message}");
Debug.LogError($"[WefLab] Payload: {payload}");
}
}
///
/// Route a "msg" envelope by its outer type. fallbackPlatform is the socket's platform,
/// used when the flat payload itself does not carry a platform field.
///
private void HandleMessageEnvelope(string fallbackPlatform, JObject envelope)
{
if (envelope == null)
return;
string envType = envelope["type"]?.ToString() ?? "";
// The real donation/chat payload is the flat `data` object.
// (Older/legacy flat messages fall back to the envelope itself.)
JObject data = envelope["data"] as JObject ?? envelope;
switch (envType)
{
case "alert": // donation (all platforms/subtypes)
case "test_donation": // legacy flat test donation
HandleAlert(fallbackPlatform, data);
break;
case "chat": // normal chat message
case "warn": // connection warning (password/subonly/donationoff ...)
HandleChat(fallbackPlatform, envType, data);
break;
default:
VLog($"[WefLab] ({fallbackPlatform}) Ignored envelope type: '{envType}'");
break;
}
}
///
/// Convert a platform's raw donation value to KRW.
/// Unit semantics confirmed by live testing:
/// - afreeca/soop/soopg/twitch: value is an item count (balloon/bit), 1 unit = 100 KRW (x100)
/// - naver(chzzk)/youtube/cime/extdona: value is already the KRW amount (x1, as-is)
///
private static int ConvertToKrw(string platform, JObject data, out int rawValue)
{
rawValue = 0;
if (data["value"] != null)
int.TryParse(data["value"].ToString(), out rawValue);
switch ((platform ?? "").ToLowerInvariant())
{
case "afreeca":
case "soop":
case "soopg":
case "twitch":
return rawValue * 100; // count (balloon / bit) -> KRW
default:
// naver(chzzk), youtube, cime, extdona: value is already KRW
return rawValue;
}
}
///
/// Handle an "alert" payload (a monetary donation on any platform).
///
private void HandleAlert(string fallbackPlatform, JObject data)
{
if (data == null)
return;
string platform = data["platform"]?.ToString() ?? fallbackPlatform;
// Real alerts use "subtype"; dashboard test donations use "type" instead.
string subtype = data["subtype"]?.ToString();
if (string.IsNullOrEmpty(subtype))
subtype = data["type"]?.ToString() ?? "";
int amount = ConvertToKrw(platform, data, out int rawValue);
// Skip non-monetary alerts (subscribe / membership / follow / emoticon, value = 0, ...)
if (NonMonetarySubtypes.Contains(subtype) || amount <= 0)
{
VLog($"[WefLab] ({platform}) Non-monetary alert ignored (subtype:'{subtype}', value:{rawValue}, krw:{amount})");
return;
}
string message = data["msg"]?.ToString() ?? data["message"]?.ToString() ?? "";
// Real alerts use "name"; test donations use "uname".
string donorName = data["name"]?.ToString() ?? data["uname"]?.ToString() ?? data["id"]?.ToString() ?? "Anonymous";
string donorId = data["id"]?.ToString() ?? "";
long timestamp = data["time"]?.Value() ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
DonationData donation = new()
{
platform = platform,
donationType = subtype,
subtype = subtype,
amount = amount,
rawValue = rawValue,
message = message,
donorName = donorName,
donorId = donorId,
timestamp = timestamp
};
Debug.Log($"[WefLab] DONATION ({platform}/{subtype}): {donorName} | raw {rawValue} -> {amount} KRW | {message}");
EnqueueDonation(donation);
}
///
/// Handle a "chat" payload. Structured and wired through onChatReceived for later use;
/// no-op unless enableChat is turned on.
///
private void HandleChat(string fallbackPlatform, string envType, JObject data)
{
if (!enableChat || data == null)
return;
string platform = data["platform"]?.ToString() ?? fallbackPlatform;
string type = data["type"]?.ToString() ?? envType;
string subtype = data["subtype"]?.ToString() ?? "";
bool isWarn = envType == "warn" || type == "warn";
var grade = data["grade"] as JObject;
ChatData chat = new()
{
platform = platform,
type = type,
subtype = subtype,
nickname = data["name"]?.ToString() ?? data["id"]?.ToString() ?? "",
userId = data["id"]?.ToString() ?? "",
message = data["msg"]?.ToString() ?? "",
nickColor = data["color"]?.ToString() ?? "",
isStreamer = HasGrade(grade, "streamer"),
isManager = HasGrade(grade, "manager"),
isFan = HasGrade(grade, "fan"),
isSubscriber = HasGrade(grade, "subscribe"),
isVip = HasGrade(grade, "vip"),
badges = ParseBadges(grade),
emoticons = ParseEmoticons(data["emoticon"]),
isWarn = isWarn,
warnType = isWarn ? subtype : "",
timestamp = data["time"]?.Value() ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
if (isWarn)
VLog($"[WefLab] CHAT WARN ({platform}): {chat.warnType}");
else
VLog($"[WefLab] CHAT ({platform}): {chat.nickname}: {chat.message}" +
(chat.emoticons.Count > 0 ? $" [+{chat.emoticons.Count} emoticon]" : ""));
onChatReceived?.Invoke(chat);
}
///
/// True if a grade flag is set. weflab marks grades as booleans (streamer/manager/fan/subscribe)
/// or as a value such as an image URL (vip) - presence of the key counts as set.
///
private static bool HasGrade(JObject grade, string key)
{
if (grade == null)
return false;
var token = grade[key];
if (token == null || token.Type == JTokenType.Null)
return false;
if (token.Type == JTokenType.Boolean)
return token.Value();
// Non-null, non-bool (e.g. a url string) => present/true
return true;
}
///
/// Extract platform badge ids from data.grade.badge (an array of strings).
///
private static string[] ParseBadges(JObject grade)
{
if (grade?["badge"] is not JArray badgeArray || badgeArray.Count == 0)
return Array.Empty();
var list = new List();
foreach (var b in badgeArray)
{
string s = b?.ToString();
if (!string.IsNullOrEmpty(s))
list.Add(s);
}
return list.ToArray();
}
///
/// Parse data.emoticon into a flat list. weflab uses a map { name: [count, imageUrl, type], ... };
/// an array form [ [name?, imageUrl], ... ] is also handled defensively.
///
private static List ParseEmoticons(JToken token)
{
var result = new List();
if (token == null)
return result;
if (token is JObject map)
{
// { "name": [count, img, type], ... }
foreach (var prop in map.Properties())
{
string img = null;
if (prop.Value is JArray arr && arr.Count >= 2)
img = arr[1]?.ToString();
else if (prop.Value is JObject obj)
img = obj["img"]?.ToString() ?? obj["imageUrl"]?.ToString();
if (!string.IsNullOrEmpty(img))
result.Add(new ChatEmoticon { name = prop.Name, imageUrl = img });
}
}
else if (token is JArray list)
{
foreach (var item in list)
{
if (item is JArray arr && arr.Count >= 2)
result.Add(new ChatEmoticon { name = arr[0]?.ToString(), imageUrl = arr[1]?.ToString() });
else if (item is JObject obj)
{
string img = obj["img"]?.ToString() ?? obj["imageUrl"]?.ToString();
if (!string.IsNullOrEmpty(img))
result.Add(new ChatEmoticon { name = obj["name"]?.ToString(), imageUrl = img });
}
}
}
return result;
}
///
/// Enqueue donation for sequential processing
///
private void EnqueueDonation(DonationData donation)
{
// Drop duplicates that arrive on more than one socket within the window
if (IsDuplicateDonation(donation))
{
VLog($"[WefLab] Duplicate donation ignored: {donation.donorName} {donation.amount} ({donation.platform})");
return;
}
// Immediate notification (before queue/lead-in) for instant side effects (e.g. running totals)
onDonationReceived?.Invoke(donation);
if (enableQueue)
{
// Check queue size limit
if (maxQueueSize > 0 && donationQueue.Count >= maxQueueSize)
{
Debug.LogWarning($"[WefLab] Queue full ({maxQueueSize}), dropping oldest donation");
donationQueue.Dequeue();
}
donationQueue.Enqueue(donation);
queueCount = donationQueue.Count;
VLog($"[WefLab] Donation queued. Queue size: {queueCount}");
// Start queue processor if not running
if (!isProcessingQueue)
{
queueProcessorCoroutine = StartCoroutine(ProcessDonationQueue());
}
}
else
{
// Direct trigger without queue (original behavior)
TriggerDonationEvents(donation);
}
}
///
/// Returns true if a donation with an identical signature was already seen within duplicateWindow.
/// Guards against the same alert being delivered on more than one socket.
///
private bool IsDuplicateDonation(DonationData donation)
{
if (duplicateWindow <= 0f)
return false;
float now = Time.time;
string signature = $"{donation.platform}|{donation.donationType}|{donation.amount}|{donation.donorName}|{donation.timestamp}";
// Purge stale entries so the dictionary does not grow unbounded
if (recentDonations.Count > 0)
{
var stale = new List();
foreach (var kvp in recentDonations)
{
if (now - kvp.Value > duplicateWindow)
stale.Add(kvp.Key);
}
foreach (var key in stale)
recentDonations.Remove(key);
}
if (recentDonations.TryGetValue(signature, out float lastSeen) && now - lastSeen < duplicateWindow)
return true;
recentDonations[signature] = now;
return false;
}
///
/// Process donation queue sequentially, mirroring the weflab overlay's one-at-a-time pacing.
/// Precise mode (syncWithAlertAudio): at the front of the queue, fetch the same TTS audio weflab
/// synthesizes, fire the event when the fetch completes (= overlay reveal), then hold for the
/// measured audio length. Fallback mode: alertLeadTime -> trigger -> alertDelay.
///
private IEnumerator ProcessDonationQueue()
{
isProcessingQueue = true;
while (donationQueue.Count > 0)
{
var donation = donationQueue.Dequeue();
queueCount = donationQueue.Count;
bool handled = false;
if (syncWithAlertAudio && settingsLoaded && !string.IsNullOrEmpty(voiceUrlBase))
{
// Fetch the TTS audio (this also consumes the same synthesis latency the overlay sees,
// so completion time ~= the overlay's popupshow/reveal moment).
var audio = new AudioFetchResult();
yield return StartCoroutine(FetchTtsDuration(ComposeTtsText(donation), audio));
if (audio.success)
{
// Small constant trim for TTS cache skew / decode
if (audioStartOffset > 0f)
yield return new WaitForSeconds(audioStartOffset);
// Reveal moment
TriggerDonationEvents(donation);
float hold = Mathf.Min(audio.length, settingPopupTimeCap);
Debug.Log($"[WefLab] Alert revealed (auto offset {audio.fetchSeconds:F2}s +{audioStartOffset:F2}). Audio {audio.length:F2}s, holding {hold:F2}s. Remaining: {donationQueue.Count}");
// Hold for the audio length; gap before next alert appears (overlay's inter-alert pause)
yield return new WaitForSeconds(hold + interAlertGap);
handled = true;
}
else
{
Debug.LogWarning($"[WefLab] TTS fetch failed ({audio.error}); using fallback timing for this alert");
}
}
if (!handled)
{
// Fallback: fixed lead-in + display time
if (alertLeadTime > 0f)
yield return new WaitForSeconds(alertLeadTime);
TriggerDonationEvents(donation);
Debug.Log($"[WefLab] Alert triggered (fallback, lead {alertLeadTime:F1}s, hold {alertDelay:F1}s). Remaining: {donationQueue.Count}");
yield return new WaitForSeconds(alertDelay);
}
}
isProcessingQueue = false;
queueProcessorCoroutine = null;
}
/// Result holder for the async TTS fetch.
private class AudioFetchResult
{
public bool success;
public float length; // measured audio clip length (seconds)
public float fetchSeconds; // wall-clock spent fetching (~= overlay reveal offset)
public string error;
}
///
/// Replicate weflab's TTS text: the alert template with tokens replaced, plus the donor message.
///
private string ComposeTtsText(DonationData donation)
{
string label = LabelFor(donation.platform, donation.subtype);
string unit = UnitFor(donation.platform, donation.subtype);
string count = donation.rawValue > 0 ? donation.rawValue.ToString() : "";
string text = ttsTemplate ?? "";
text = Regex.Replace(text, @"\{닉네임\}|\{이름\}|\{nickname\}", donation.donorName ?? "");
text = Regex.Replace(text, @"\{종류\}|\{type\}", label);
text = Regex.Replace(text, @"\{개수\}|\{개월\}|\{number\}", count);
text = Regex.Replace(text, @"\{개\}|\{unit\}", unit);
// Strip any remaining unreplaced tokens
text = Regex.Replace(text, @"\{[^}]*\}", "");
text = Regex.Replace(text, @"\s+", " ").Trim();
// weflab reads the donor message after the alert template (signsound voice)
if (!string.IsNullOrEmpty(donation.message))
text = string.IsNullOrEmpty(text) ? donation.message : text + " " + donation.message;
return text;
}
private static string LabelFor(string platform, string subtype)
{
switch ((subtype ?? "").ToUpperInvariant())
{
case "SENDBALLOON":
case "VIDEOBALLOON":
case "ADBALLOON": return "별풍선";
case "GEM": return "젬";
case "CHZZK": return "치즈";
case "SUPERCHAT": return "슈퍼챗";
case "STICKER": return "스티커";
case "BITS": return "비트";
case "BEAM": return "빔";
}
// Platform fallback
switch ((platform ?? "").ToLowerInvariant())
{
case "afreeca":
case "soop":
case "soopg": return "별풍선";
case "naver":
case "chzzk": return "치즈";
case "youtube": return "슈퍼챗";
case "twitch": return "비트";
default: return "후원";
}
}
private static string UnitFor(string platform, string subtype)
{
switch ((platform ?? "").ToLowerInvariant())
{
case "naver":
case "chzzk":
case "youtube": return "원";
default: return "개"; // balloons / bits / gem
}
}
///
/// Fetch weflab's TTS for the given text and measure the resulting audio clip length.
/// The wall-clock spent here approximates the overlay's synthesis-to-reveal latency.
///
private IEnumerator FetchTtsDuration(string text, AudioFetchResult result)
{
float t0 = Time.realtimeSinceStartup;
if (string.IsNullOrEmpty(text))
{
result.error = "empty text";
yield break;
}
// 1) Resolve the MP3 URL: GET voiceurl?text=...&speed=...&lang=...
string reqUrl = $"{voiceUrlBase}?text={UnityWebRequest.EscapeURL(text)}&speed={ttsSpeed}&lang={ttsLang}";
string mp3Url;
using (UnityWebRequest urlReq = UnityWebRequest.Get(reqUrl))
{
urlReq.SetRequestHeader("Referer", pageUrl);
yield return urlReq.SendWebRequest();
if (urlReq.result != UnityWebRequest.Result.Success)
{
result.error = $"voiceurl: {urlReq.error}";
yield break;
}
mp3Url = urlReq.downloadHandler.text?.Trim().Trim('"');
}
if (string.IsNullOrEmpty(mp3Url))
{
result.error = "empty mp3 url";
yield break;
}
if (!mp3Url.StartsWith("http"))
mp3Url = baseDomain + (mp3Url.StartsWith("/") ? "" : "/") + mp3Url;
// 2) Download the MP3 as an AudioClip and read its length
using (UnityWebRequest clipReq = UnityWebRequestMultimedia.GetAudioClip(mp3Url, AudioType.MPEG))
{
clipReq.SetRequestHeader("Referer", pageUrl);
yield return clipReq.SendWebRequest();
if (clipReq.result != UnityWebRequest.Result.Success)
{
result.error = $"mp3: {clipReq.error}";
yield break;
}
AudioClip clip = DownloadHandlerAudioClip.GetContent(clipReq);
if (clip == null || clip.length <= 0f)
{
result.error = "invalid clip";
yield break;
}
result.length = clip.length;
result.fetchSeconds = Time.realtimeSinceStartup - t0;
result.success = true;
}
}
///
/// Trigger donation events based on amount range
///
private void TriggerDonationEvents(DonationData donation)
{
if (donationTriggers == null || donationTriggers.Length == 0)
{
Debug.LogWarning("[WefLab] No donation triggers configured");
return;
}
bool triggeredAny = false;
// Check each trigger
foreach (var trigger in donationTriggers)
{
if (trigger == null)
continue;
// Check if donation amount is in range
if (trigger.IsInRange(donation.amount))
{
int repeatCount = trigger.GetRepeatCount(donation.amount);
Debug.Log($"[WefLab] Triggering: {trigger.triggerName} (Amount: {donation.amount}, Range: {trigger.minAmount}-{(trigger.maxAmount >= 0 ? trigger.maxAmount.ToString() : "unlimited")}, Repeat: {repeatCount}x)");
// Start coroutine to handle repeated invocation
StartCoroutine(InvokeRepeatedEvent(trigger, donation, repeatCount));
triggeredAny = true;
}
}
if (!triggeredAny)
{
Debug.LogWarning($"[WefLab] No triggers matched for donation amount: {donation.amount}");
}
}
///
/// Coroutine to invoke event multiple times with delay
///
private IEnumerator InvokeRepeatedEvent(DonationEventTrigger trigger, DonationData donation, int repeatCount)
{
for (int i = 0; i < repeatCount; i++)
{
// Invoke the event
trigger.onDonation?.Invoke(donation);
// Wait for delay before next repetition (skip on last iteration)
if (i < repeatCount - 1 && trigger.repeatDelay > 0)
{
yield return new WaitForSeconds(trigger.repeatDelay);
}
}
}
#endregion
#region Send Messages
///
/// Send Socket.IO CONNECT message (40)
///
private void SendSocketConnect(PlatformConnection conn)
{
VLog($"[WefLab] ({conn.Label}) Sending Socket.IO CONNECT (40)");
SendMessage(conn, "40");
}
///
/// Send the subscription message for this connection.
/// Platform sockets (ssafreeca/ssnaver/...) use "join_platform" with the platform + account id;
/// the main control socket (ssmain) uses the generic "join". Mirrors weflab's socket.connect / socket.join.
///
private void SendJoinMessage(PlatformConnection conn)
{
object joinData;
if (conn.platform == "main")
{
joinData = new
{
type = "join",
page = pageType, // "page"
idx = userIdx,
pageid = conn.page, // "alert" or "chat"
preset = "0"
};
}
else
{
// Platform subscription - this is what actually delivers donation/chat events
joinData = new
{
type = "join_platform",
platform = conn.platform,
id = conn.platformId,
page = pageType, // "page"
idx = userIdx,
pageid = conn.page, // "alert" or "chat"
preset = "0"
};
}
var message = new object[] { "msg", joinData };
string json = JsonConvert.SerializeObject(message);
string fullMessage = "42" + json;
VLog($"[WefLab] ({conn.Label}) Sending JOIN message: {fullMessage}");
SendMessage(conn, fullMessage);
}
///
/// Send raw message through a specific platform connection
///
private void SendMessage(PlatformConnection conn, string message)
{
if (conn.ws != null && conn.ws.IsAlive)
{
conn.ws.Send(message);
}
else
{
Debug.LogWarning($"[WefLab] ({conn.Label}) Cannot send message - WebSocket not connected");
}
}
#endregion
#region Thread Safety
///
/// Enqueue action to be executed on main thread
///
private void EnqueueMainThreadAction(Action action)
{
lock (actionLock)
{
mainThreadActions.Enqueue(action);
}
}
#endregion
#region Public Methods
///
/// Reconnect to WebSocket
///
public void Reconnect()
{
Disconnect();
Connect();
}
///
/// Check if connected
///
public bool IsConnected()
{
foreach (var conn in connections)
{
if (conn.connected && conn.ws != null && conn.ws.IsAlive)
return true;
}
return false;
}
///
/// Get current queue count
///
public int GetQueueCount()
{
return donationQueue.Count;
}
///
/// Check if queue is being processed
///
public bool IsQueueProcessing()
{
return isProcessingQueue;
}
///
/// Clear all pending donations in queue
///
public void ClearQueue()
{
donationQueue.Clear();
queueCount = 0;
Debug.Log("[WefLab] Queue cleared");
}
///
/// Skip current alert and move to next in queue
///
public void SkipCurrentAlert()
{
if (queueProcessorCoroutine != null)
{
StopCoroutine(queueProcessorCoroutine);
queueProcessorCoroutine = null;
}
if (donationQueue.Count > 0)
{
Debug.Log("[WefLab] Skipping to next alert");
queueProcessorCoroutine = StartCoroutine(ProcessDonationQueue());
}
else
{
isProcessingQueue = false;
Debug.Log("[WefLab] No more alerts in queue");
}
}
///
/// Pause queue processing
///
public void PauseQueue()
{
if (queueProcessorCoroutine != null)
{
StopCoroutine(queueProcessorCoroutine);
queueProcessorCoroutine = null;
isProcessingQueue = false;
Debug.Log("[WefLab] Queue paused");
}
}
///
/// Resume queue processing
///
public void ResumeQueue()
{
if (!isProcessingQueue && donationQueue.Count > 0)
{
queueProcessorCoroutine = StartCoroutine(ProcessDonationQueue());
Debug.Log("[WefLab] Queue resumed");
}
}
#endregion
}
}