771 lines
24 KiB
C#
771 lines
24 KiB
C#
using Dalamud.Interface.Textures.TextureWraps;
|
|
using LightlessSync.LightlessConfiguration;
|
|
using LightlessSync.UI;
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using System.Text.Json;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.Formats.Gif;
|
|
using SixLabors.ImageSharp.Formats.Webp;
|
|
using SixLabors.ImageSharp.Metadata;
|
|
using SixLabors.ImageSharp.PixelFormats;
|
|
using SixLabors.ImageSharp.Processing;
|
|
|
|
namespace LightlessSync.Services.Chat;
|
|
|
|
public sealed class ChatEmoteService : IDisposable
|
|
{
|
|
private const string GlobalEmoteSetUrl = "https://7tv.io/v3/emote-sets/global";
|
|
private const int DefaultFrameDelayMs = 100;
|
|
private const int MinFrameDelayMs = 20;
|
|
|
|
private readonly ILogger<ChatEmoteService> _logger;
|
|
private readonly HttpClient _httpClient;
|
|
private readonly UiSharedService _uiSharedService;
|
|
private readonly ChatConfigService _chatConfigService;
|
|
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, ChatConfigService chatConfigService)
|
|
{
|
|
_logger = logger;
|
|
_httpClient = httpClient;
|
|
_uiSharedService = uiSharedService;
|
|
_chatConfigService = chatConfigService;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
var allowAnimation = _chatConfigService.Current.EnableAnimatedEmotes;
|
|
if (entry.TryGetTexture(allowAnimation, out texture))
|
|
{
|
|
if (allowAnimation && entry.NeedsAnimationLoad && !entry.HasAttemptedAnimation)
|
|
{
|
|
entry.EnsureLoading(allowAnimation, QueueEmoteDownload, allowWhenStaticLoaded: true);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
entry.EnsureLoading(allowAnimation, QueueEmoteDownload);
|
|
return true;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var entry in _emotes.Values)
|
|
{
|
|
entry.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 source = TryBuildEmoteSource(emoteElement);
|
|
if (source is null || (!source.Value.HasStatic && !source.Value.HasAnimation))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
_emotes.TryAdd(name, new EmoteEntry(name, source.Value));
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Failed to load 7TV emote set");
|
|
}
|
|
}
|
|
|
|
private static EmoteSource? TryBuildEmoteSource(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 files = ReadEmoteFiles(filesElement);
|
|
if (files.Count == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var animatedFile = PickBestAnimatedFile(files);
|
|
var animatedUrl = animatedFile is null ? null : BuildEmoteUrl(baseUrl, animatedFile.Value.Name);
|
|
|
|
var staticName = animatedFile?.StaticName;
|
|
if (string.IsNullOrWhiteSpace(staticName))
|
|
{
|
|
staticName = PickBestStaticFileName(files);
|
|
}
|
|
|
|
var staticUrl = string.IsNullOrWhiteSpace(staticName) ? null : BuildEmoteUrl(baseUrl, staticName);
|
|
if (string.IsNullOrWhiteSpace(animatedUrl) && string.IsNullOrWhiteSpace(staticUrl))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new EmoteSource(staticUrl, animatedUrl);
|
|
}
|
|
|
|
private static string BuildEmoteUrl(string baseUrl, string fileName)
|
|
=> baseUrl.TrimEnd('/') + "/" + fileName;
|
|
|
|
private static List<EmoteFile> ReadEmoteFiles(JsonElement filesElement)
|
|
{
|
|
var files = new List<EmoteFile>();
|
|
foreach (var file in filesElement.EnumerateArray())
|
|
{
|
|
if (!file.TryGetProperty("name", out var nameElement))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var name = nameElement.GetString();
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string? staticName = null;
|
|
if (file.TryGetProperty("static_name", out var staticNameElement) && staticNameElement.ValueKind == JsonValueKind.String)
|
|
{
|
|
staticName = staticNameElement.GetString();
|
|
}
|
|
|
|
var frameCount = 1;
|
|
if (file.TryGetProperty("frame_count", out var frameCountElement) && frameCountElement.ValueKind == JsonValueKind.Number)
|
|
{
|
|
frameCountElement.TryGetInt32(out frameCount);
|
|
frameCount = Math.Max(frameCount, 1);
|
|
}
|
|
|
|
string? format = null;
|
|
if (file.TryGetProperty("format", out var formatElement) && formatElement.ValueKind == JsonValueKind.String)
|
|
{
|
|
format = formatElement.GetString();
|
|
}
|
|
|
|
files.Add(new EmoteFile(name, staticName, frameCount, format));
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
private static EmoteFile? PickBestAnimatedFile(IReadOnlyList<EmoteFile> files)
|
|
{
|
|
EmoteFile? webp1x = null;
|
|
EmoteFile? gif1x = null;
|
|
EmoteFile? webpFallback = null;
|
|
EmoteFile? gifFallback = null;
|
|
|
|
foreach (var file in files)
|
|
{
|
|
if (file.FrameCount <= 1 || !IsAnimatedFormatSupported(file))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (file.Name.Equals("1x.webp", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
webp1x = file;
|
|
}
|
|
else if (file.Name.Equals("1x.gif", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
gif1x = file;
|
|
}
|
|
else if (file.Name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase) && webpFallback is null)
|
|
{
|
|
webpFallback = file;
|
|
}
|
|
else if (file.Name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) && gifFallback is null)
|
|
{
|
|
gifFallback = file;
|
|
}
|
|
}
|
|
|
|
return webp1x ?? gif1x ?? webpFallback ?? gifFallback;
|
|
}
|
|
|
|
private static string? PickBestStaticFileName(IReadOnlyList<EmoteFile> files)
|
|
{
|
|
string? png1x = null;
|
|
string? webp1x = null;
|
|
string? gif1x = null;
|
|
string? pngFallback = null;
|
|
string? webpFallback = null;
|
|
string? gifFallback = null;
|
|
|
|
foreach (var file in files)
|
|
{
|
|
if (file.FrameCount > 1)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var name = file.StaticName ?? file.Name;
|
|
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.Equals("1x.gif", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
gif1x = 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;
|
|
}
|
|
else if (name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase) && gifFallback is null)
|
|
{
|
|
gifFallback = name;
|
|
}
|
|
}
|
|
|
|
return png1x ?? webp1x ?? gif1x ?? pngFallback ?? webpFallback ?? gifFallback;
|
|
}
|
|
|
|
private static bool IsAnimatedFormatSupported(EmoteFile file)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(file.Format))
|
|
{
|
|
return file.Format.Equals("WEBP", StringComparison.OrdinalIgnoreCase)
|
|
|| file.Format.Equals("GIF", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
return file.Name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase)
|
|
|| file.Name.EndsWith(".gif", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private readonly record struct EmoteSource(string? StaticUrl, string? AnimatedUrl)
|
|
{
|
|
public bool HasStatic => !string.IsNullOrWhiteSpace(StaticUrl);
|
|
public bool HasAnimation => !string.IsNullOrWhiteSpace(AnimatedUrl);
|
|
}
|
|
|
|
private readonly record struct EmoteFile(string Name, string? StaticName, int FrameCount, string? Format);
|
|
|
|
private void QueueEmoteDownload(EmoteEntry entry, bool allowAnimation)
|
|
{
|
|
_ = Task.Run(async () =>
|
|
{
|
|
await _downloadGate.WaitAsync().ConfigureAwait(false);
|
|
try
|
|
{
|
|
if (allowAnimation)
|
|
{
|
|
if (entry.HasAnimatedSource)
|
|
{
|
|
entry.MarkAnimationAttempted();
|
|
if (await TryLoadAnimatedEmoteAsync(entry).ConfigureAwait(false))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (entry.HasStaticSource && !entry.HasStaticTexture && await TryLoadStaticEmoteAsync(entry).ConfigureAwait(false))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
if (entry.HasStaticSource && await TryLoadStaticEmoteAsync(entry).ConfigureAwait(false))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (entry.HasAnimatedSource)
|
|
{
|
|
entry.MarkAnimationAttempted();
|
|
if (await TryLoadAnimatedEmoteAsync(entry).ConfigureAwait(false))
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
entry.MarkFailed();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Failed to load 7TV emote {Emote}", entry.Code);
|
|
entry.MarkFailed();
|
|
}
|
|
finally
|
|
{
|
|
_downloadGate.Release();
|
|
}
|
|
});
|
|
}
|
|
|
|
private async Task<bool> TryLoadAnimatedEmoteAsync(EmoteEntry entry)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(entry.AnimatedUrl))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
var data = await _httpClient.GetByteArrayAsync(entry.AnimatedUrl).ConfigureAwait(false);
|
|
var isWebp = entry.AnimatedUrl.EndsWith(".webp", StringComparison.OrdinalIgnoreCase);
|
|
if (!TryDecodeAnimation(data, isWebp, out var animation))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
entry.SetAnimation(animation);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Failed to decode animated 7TV emote {Emote}", entry.Code);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private async Task<bool> TryLoadStaticEmoteAsync(EmoteEntry entry)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(entry.StaticUrl))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
try
|
|
{
|
|
var data = await _httpClient.GetByteArrayAsync(entry.StaticUrl).ConfigureAwait(false);
|
|
var texture = _uiSharedService.LoadImage(data);
|
|
entry.SetStaticTexture(texture);
|
|
return true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogDebug(ex, "Failed to decode static 7TV emote {Emote}", entry.Code);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private bool TryDecodeAnimation(byte[] data, bool isWebp, out EmoteAnimation? animation)
|
|
{
|
|
animation = null;
|
|
List<EmoteFrame>? frames = null;
|
|
|
|
try
|
|
{
|
|
Image<Rgba32> image;
|
|
if (isWebp)
|
|
{
|
|
using var stream = new MemoryStream(data);
|
|
image = WebpDecoder.Instance.Decode<Rgba32>(
|
|
new WebpDecoderOptions { BackgroundColorHandling = BackgroundColorHandling.Ignore },
|
|
stream);
|
|
}
|
|
else
|
|
{
|
|
image = Image.Load<Rgba32>(data);
|
|
}
|
|
|
|
using (image)
|
|
{
|
|
if (image.Frames.Count <= 1)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
using var composite = new Image<Rgba32>(image.Width, image.Height, Color.Transparent);
|
|
Image<Rgba32>? restoreCanvas = null;
|
|
GifDisposalMethod? pendingGifDisposal = null;
|
|
WebpDisposalMethod? pendingWebpDisposal = null;
|
|
|
|
frames = new List<EmoteFrame>(image.Frames.Count);
|
|
for (var i = 0; i < image.Frames.Count; i++)
|
|
{
|
|
var frameMetadata = image.Frames[i].Metadata;
|
|
var delayMs = GetFrameDelayMs(frameMetadata);
|
|
|
|
ApplyDisposal(composite, ref restoreCanvas, pendingGifDisposal, pendingWebpDisposal);
|
|
|
|
GifDisposalMethod? currentGifDisposal = null;
|
|
WebpDisposalMethod? currentWebpDisposal = null;
|
|
var blendMethod = WebpBlendMethod.Over;
|
|
|
|
if (isWebp)
|
|
{
|
|
if (frameMetadata.TryGetWebpFrameMetadata(out var webpMetadata))
|
|
{
|
|
currentWebpDisposal = webpMetadata.DisposalMethod;
|
|
blendMethod = webpMetadata.BlendMethod;
|
|
}
|
|
}
|
|
else if (frameMetadata.TryGetGifMetadata(out var gifMetadata))
|
|
{
|
|
currentGifDisposal = gifMetadata.DisposalMethod;
|
|
}
|
|
|
|
if (currentGifDisposal == GifDisposalMethod.RestoreToPrevious)
|
|
{
|
|
restoreCanvas?.Dispose();
|
|
restoreCanvas = composite.Clone();
|
|
}
|
|
|
|
using var frameImage = image.Frames.CloneFrame(i);
|
|
var alphaMode = blendMethod == WebpBlendMethod.Source
|
|
? PixelAlphaCompositionMode.Src
|
|
: PixelAlphaCompositionMode.SrcOver;
|
|
composite.Mutate(ctx => ctx.DrawImage(frameImage, PixelColorBlendingMode.Normal, alphaMode, 1f));
|
|
|
|
using var renderedFrame = composite.Clone();
|
|
using var ms = new MemoryStream();
|
|
renderedFrame.SaveAsPng(ms);
|
|
|
|
var texture = _uiSharedService.LoadImage(ms.ToArray());
|
|
frames.Add(new EmoteFrame(texture, delayMs));
|
|
|
|
pendingGifDisposal = currentGifDisposal;
|
|
pendingWebpDisposal = currentWebpDisposal;
|
|
}
|
|
|
|
restoreCanvas?.Dispose();
|
|
|
|
animation = new EmoteAnimation(frames);
|
|
return true;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
if (frames is not null)
|
|
{
|
|
foreach (var frame in frames)
|
|
{
|
|
frame.Texture.Dispose();
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static int GetFrameDelayMs(ImageFrameMetadata metadata)
|
|
{
|
|
if (metadata.TryGetGifMetadata(out var gifMetadata))
|
|
{
|
|
var delayMs = (long)gifMetadata.FrameDelay * 10L;
|
|
return NormalizeFrameDelayMs(delayMs);
|
|
}
|
|
|
|
if (metadata.TryGetWebpFrameMetadata(out var webpMetadata))
|
|
{
|
|
return NormalizeFrameDelayMs(webpMetadata.FrameDelay);
|
|
}
|
|
|
|
return DefaultFrameDelayMs;
|
|
}
|
|
|
|
private static int NormalizeFrameDelayMs(long delayMs)
|
|
{
|
|
if (delayMs <= 0)
|
|
{
|
|
return DefaultFrameDelayMs;
|
|
}
|
|
|
|
var clamped = delayMs > int.MaxValue ? int.MaxValue : (int)delayMs;
|
|
return Math.Max(clamped, MinFrameDelayMs);
|
|
}
|
|
|
|
private static void ApplyDisposal(
|
|
Image<Rgba32> composite,
|
|
ref Image<Rgba32>? restoreCanvas,
|
|
GifDisposalMethod? gifDisposal,
|
|
WebpDisposalMethod? webpDisposal)
|
|
{
|
|
if (gifDisposal is not null)
|
|
{
|
|
switch (gifDisposal)
|
|
{
|
|
case GifDisposalMethod.RestoreToBackground:
|
|
composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent));
|
|
break;
|
|
case GifDisposalMethod.RestoreToPrevious:
|
|
if (restoreCanvas is not null)
|
|
{
|
|
composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent));
|
|
var restoreSnapshot = restoreCanvas;
|
|
composite.Mutate(ctx => ctx.DrawImage(restoreSnapshot, PixelColorBlendingMode.Normal, PixelAlphaCompositionMode.Src, 1f));
|
|
restoreCanvas.Dispose();
|
|
restoreCanvas = null;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
else if (webpDisposal == WebpDisposalMethod.RestoreToBackground)
|
|
{
|
|
composite.Mutate(ctx => ctx.BackgroundColor(Color.Transparent));
|
|
}
|
|
}
|
|
|
|
private sealed class EmoteAnimation : IDisposable
|
|
{
|
|
private readonly EmoteFrame[] _frames;
|
|
private readonly int _durationMs;
|
|
private readonly long _startTimestamp;
|
|
|
|
public EmoteAnimation(IReadOnlyList<EmoteFrame> frames)
|
|
{
|
|
_frames = frames.ToArray();
|
|
_durationMs = Math.Max(1, frames.Sum(frame => frame.DurationMs));
|
|
_startTimestamp = Stopwatch.GetTimestamp();
|
|
}
|
|
|
|
public IDalamudTextureWrap? GetCurrentFrame()
|
|
{
|
|
if (_frames.Length == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
if (_frames.Length == 1)
|
|
{
|
|
return _frames[0].Texture;
|
|
}
|
|
|
|
var elapsedTicks = Stopwatch.GetTimestamp() - _startTimestamp;
|
|
var elapsedMs = (elapsedTicks * 1000L) / Stopwatch.Frequency;
|
|
var targetMs = (int)(elapsedMs % _durationMs);
|
|
var accumulated = 0;
|
|
|
|
foreach (var frame in _frames)
|
|
{
|
|
accumulated += frame.DurationMs;
|
|
if (targetMs < accumulated)
|
|
{
|
|
return frame.Texture;
|
|
}
|
|
}
|
|
|
|
return _frames[^1].Texture;
|
|
}
|
|
|
|
public IDalamudTextureWrap? GetStaticFrame()
|
|
{
|
|
if (_frames.Length == 0)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return _frames[0].Texture;
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
foreach (var frame in _frames)
|
|
{
|
|
frame.Texture.Dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
private readonly record struct EmoteFrame(IDalamudTextureWrap Texture, int DurationMs);
|
|
|
|
private sealed class EmoteEntry : IDisposable
|
|
{
|
|
private int _loadingState;
|
|
private int _animationAttempted;
|
|
private IDalamudTextureWrap? _staticTexture;
|
|
private EmoteAnimation? _animation;
|
|
|
|
public EmoteEntry(string code, EmoteSource source)
|
|
{
|
|
Code = code;
|
|
StaticUrl = source.StaticUrl;
|
|
AnimatedUrl = source.AnimatedUrl;
|
|
}
|
|
|
|
public string Code { get; }
|
|
public string? StaticUrl { get; }
|
|
public string? AnimatedUrl { get; }
|
|
public bool HasStaticSource => !string.IsNullOrWhiteSpace(StaticUrl);
|
|
public bool HasAnimatedSource => !string.IsNullOrWhiteSpace(AnimatedUrl);
|
|
public bool HasStaticTexture => _staticTexture is not null;
|
|
public bool HasAttemptedAnimation => Interlocked.CompareExchange(ref _animationAttempted, 0, 0) != 0;
|
|
public bool NeedsAnimationLoad => _animation is null && HasAnimatedSource;
|
|
|
|
public void MarkAnimationAttempted()
|
|
{
|
|
Interlocked.Exchange(ref _animationAttempted, 1);
|
|
}
|
|
|
|
public bool TryGetTexture(bool allowAnimation, out IDalamudTextureWrap? texture)
|
|
{
|
|
if (allowAnimation && _animation is not null)
|
|
{
|
|
texture = _animation.GetCurrentFrame();
|
|
return true;
|
|
}
|
|
|
|
if (_staticTexture is not null)
|
|
{
|
|
texture = _staticTexture;
|
|
return true;
|
|
}
|
|
|
|
if (!allowAnimation && _animation is not null)
|
|
{
|
|
texture = _animation.GetStaticFrame();
|
|
return true;
|
|
}
|
|
|
|
texture = null;
|
|
return false;
|
|
}
|
|
|
|
public void EnsureLoading(bool allowAnimation, Action<EmoteEntry, bool> queueDownload, bool allowWhenStaticLoaded = false)
|
|
{
|
|
if (_animation is not null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!allowWhenStaticLoaded && _staticTexture is not null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (Interlocked.CompareExchange(ref _loadingState, 1, 0) != 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
queueDownload(this, allowAnimation);
|
|
}
|
|
|
|
public void SetAnimation(EmoteAnimation animation)
|
|
{
|
|
_staticTexture?.Dispose();
|
|
_staticTexture = null;
|
|
_animation?.Dispose();
|
|
_animation = animation;
|
|
Interlocked.Exchange(ref _loadingState, 0);
|
|
}
|
|
|
|
public void SetStaticTexture(IDalamudTextureWrap texture)
|
|
{
|
|
_staticTexture?.Dispose();
|
|
_staticTexture = texture;
|
|
Interlocked.Exchange(ref _loadingState, 0);
|
|
}
|
|
|
|
public void MarkFailed()
|
|
{
|
|
Interlocked.Exchange(ref _loadingState, 0);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_animation?.Dispose();
|
|
_staticTexture?.Dispose();
|
|
}
|
|
}
|
|
}
|