using Dalamud.Interface.Textures.TextureWraps; using LightlessSync.UI; using Microsoft.Extensions.Logging; using System.Collections.Concurrent; using System.Text.Json; namespace LightlessSync.Services.Chat; public sealed class ChatEmoteService : IDisposable { private const string GlobalEmoteSetUrl = "https://7tv.io/v3/emote-sets/global"; private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly UiSharedService _uiSharedService; private readonly ConcurrentDictionary _emotes = new(StringComparer.Ordinal); private readonly SemaphoreSlim _downloadGate = new(3, 3); private readonly object _loadLock = new(); private Task? _loadTask; public ChatEmoteService(ILogger logger, HttpClient httpClient, UiSharedService uiSharedService) { _logger = logger; _httpClient = httpClient; _uiSharedService = uiSharedService; } public void EnsureGlobalEmotesLoaded() { lock (_loadLock) { if (_loadTask is not null && !_loadTask.IsCompleted) { return; } if (_emotes.Count > 0) { return; } _loadTask = Task.Run(LoadGlobalEmotesAsync); } } public IReadOnlyList GetEmoteNames() { EnsureGlobalEmotesLoaded(); var names = _emotes.Keys.ToArray(); Array.Sort(names, StringComparer.OrdinalIgnoreCase); return names; } public bool TryGetEmote(string code, out IDalamudTextureWrap? texture) { texture = null; EnsureGlobalEmotesLoaded(); if (!_emotes.TryGetValue(code, out var entry)) { return false; } if (entry.Texture is not null) { texture = entry.Texture; return true; } entry.EnsureLoading(QueueEmoteDownload); return true; } public void Dispose() { foreach (var entry in _emotes.Values) { entry.Texture?.Dispose(); } _downloadGate.Dispose(); } private async Task LoadGlobalEmotesAsync() { try { using var stream = await _httpClient.GetStreamAsync(GlobalEmoteSetUrl).ConfigureAwait(false); using var document = await JsonDocument.ParseAsync(stream).ConfigureAwait(false); if (!document.RootElement.TryGetProperty("emotes", out var emotes)) { _logger.LogWarning("7TV emote set response missing emotes array"); return; } foreach (var emoteElement in emotes.EnumerateArray()) { if (!emoteElement.TryGetProperty("name", out var nameElement)) { continue; } var name = nameElement.GetString(); if (string.IsNullOrWhiteSpace(name)) { continue; } var url = TryBuildEmoteUrl(emoteElement); if (string.IsNullOrWhiteSpace(url)) { continue; } _emotes.TryAdd(name, new EmoteEntry(url)); } } catch (Exception ex) { _logger.LogWarning(ex, "Failed to load 7TV emote set"); } } private static string? TryBuildEmoteUrl(JsonElement emoteElement) { if (!emoteElement.TryGetProperty("data", out var dataElement)) { return null; } if (!dataElement.TryGetProperty("host", out var hostElement)) { return null; } if (!hostElement.TryGetProperty("url", out var urlElement)) { return null; } var baseUrl = urlElement.GetString(); if (string.IsNullOrWhiteSpace(baseUrl)) { return null; } if (baseUrl.StartsWith("//", StringComparison.Ordinal)) { baseUrl = "https:" + baseUrl; } if (!hostElement.TryGetProperty("files", out var filesElement)) { return null; } var fileName = PickBestStaticFile(filesElement); if (string.IsNullOrWhiteSpace(fileName)) { return null; } return baseUrl.TrimEnd('/') + "/" + fileName; } private static string? PickBestStaticFile(JsonElement filesElement) { string? png1x = null; string? webp1x = null; string? pngFallback = null; string? webpFallback = null; foreach (var file in filesElement.EnumerateArray()) { if (file.TryGetProperty("static", out var staticElement) && staticElement.ValueKind == JsonValueKind.False) { continue; } if (!file.TryGetProperty("name", out var nameElement)) { continue; } var name = nameElement.GetString(); if (string.IsNullOrWhiteSpace(name)) { continue; } if (name.Equals("1x.png", StringComparison.OrdinalIgnoreCase)) { png1x = name; } else if (name.Equals("1x.webp", StringComparison.OrdinalIgnoreCase)) { webp1x = name; } else if (name.EndsWith(".png", StringComparison.OrdinalIgnoreCase) && pngFallback is null) { pngFallback = name; } else if (name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) && webpFallback is null) { webpFallback = name; } } return png1x ?? webp1x ?? pngFallback ?? webpFallback; } private void QueueEmoteDownload(EmoteEntry entry) { _ = Task.Run(async () => { await _downloadGate.WaitAsync().ConfigureAwait(false); try { var data = await _httpClient.GetByteArrayAsync(entry.Url).ConfigureAwait(false); var texture = _uiSharedService.LoadImage(data); entry.SetTexture(texture); } catch (Exception ex) { _logger.LogDebug(ex, "Failed to load 7TV emote {Url}", entry.Url); entry.MarkFailed(); } finally { _downloadGate.Release(); } }); } private sealed class EmoteEntry { private int _loadingState; public EmoteEntry(string url) { Url = url; } public string Url { get; } public IDalamudTextureWrap? Texture { get; private set; } public void EnsureLoading(Action queueDownload) { if (Texture is not null) { return; } if (Interlocked.CompareExchange(ref _loadingState, 1, 0) != 0) { return; } queueDownload(this); } public void SetTexture(IDalamudTextureWrap texture) { Texture = texture; Interlocked.Exchange(ref _loadingState, 0); } public void MarkFailed() { Interlocked.Exchange(ref _loadingState, 0); } } }