276 lines
7.3 KiB
C#
276 lines
7.3 KiB
C#
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<ChatEmoteService> _logger;
|
|
private readonly HttpClient _httpClient;
|
|
private readonly UiSharedService _uiSharedService;
|
|
private readonly ConcurrentDictionary<string, EmoteEntry> _emotes = new(StringComparer.Ordinal);
|
|
private readonly SemaphoreSlim _downloadGate = new(3, 3);
|
|
|
|
private readonly object _loadLock = new();
|
|
private Task? _loadTask;
|
|
|
|
public ChatEmoteService(ILogger<ChatEmoteService> 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<string> 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<EmoteEntry> 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);
|
|
}
|
|
}
|
|
}
|