4000bbd199
Security / scan (push) Successful in 12s
Updated .editorconfig to set indent_style=space and indent_size=4 for C# files. Reformat all .cs files to apply the new indentation settings. No code logic changes, just whitespace reformatting. also updated some comments in files in shorter and Precise way. No logic changes, just comment rewording for clarity and conciseness.
416 lines
13 KiB
C#
416 lines
13 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Numerics;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Dalamud.Bindings.ImGui;
|
|
using Dalamud.Interface.Textures;
|
|
using Dalamud.Interface.Textures.TextureWraps;
|
|
using Dalamud.Utility;
|
|
using SixLabors.ImageSharp;
|
|
using SixLabors.ImageSharp.PixelFormats;
|
|
|
|
namespace HellionChat;
|
|
|
|
public static class EmoteCache
|
|
{
|
|
private static readonly string[] NotWorking =
|
|
[
|
|
":tf:",
|
|
"(ditto)",
|
|
"c!",
|
|
"h!",
|
|
"l!",
|
|
"M&Mjc",
|
|
"LUL3D",
|
|
"p!",
|
|
"POLICE2",
|
|
"r!",
|
|
"Pussy",
|
|
"s!",
|
|
"v!",
|
|
"w!",
|
|
"x0r6ztGiggle",
|
|
"z!",
|
|
"xar2EDM",
|
|
"iron95Pls",
|
|
"Clap2",
|
|
"AlienPls3",
|
|
"Life",
|
|
"peepoPogClimbingTreeHard4House",
|
|
"monkaGIGAftRobertDowneyJr",
|
|
"DogLookingSussyAndCold",
|
|
"DICKS",
|
|
];
|
|
|
|
private static readonly HttpClient Client = new();
|
|
|
|
private const string BetterTTV = "https://api.betterttv.net/3";
|
|
private const string GlobalEmotes = $"{BetterTTV}/cached/emotes/global";
|
|
private const string Top100Emotes = "{0}/emotes/shared/top?before={1}&limit=100";
|
|
private const string EmotePath = "https://cdn.betterttv.net/emote/{0}/3x";
|
|
|
|
[Serializable]
|
|
private struct Top100()
|
|
{
|
|
[JsonPropertyName("emote")]
|
|
public Emote Emote { get; set; }
|
|
|
|
[JsonPropertyName("id")]
|
|
public required string Id { get; set; }
|
|
}
|
|
|
|
[Serializable]
|
|
public struct Emote()
|
|
{
|
|
[JsonPropertyName("id")]
|
|
public required string Id { get; set; }
|
|
|
|
[JsonPropertyName("code")]
|
|
public required string Code { get; set; }
|
|
|
|
[JsonPropertyName("imageType")]
|
|
public required string ImageType { get; set; }
|
|
}
|
|
|
|
public enum LoadingState
|
|
{
|
|
Unloaded,
|
|
Loading,
|
|
Done,
|
|
}
|
|
|
|
// All fields below are uninitialised while State != Done.
|
|
public static LoadingState State = LoadingState.Unloaded;
|
|
|
|
private static readonly Dictionary<string, Emote> Cache = new();
|
|
private static readonly Dictionary<string, EmoteBase> EmoteImages = new();
|
|
|
|
public static string[] SortedCodeArray = [];
|
|
|
|
// Cancelled on Dispose to stop in-flight downloads; replaced on re-enable.
|
|
private static CancellationTokenSource Cts = new();
|
|
internal static CancellationToken Token => Cts.Token;
|
|
|
|
// Tracks in-flight loads so Dispose can drain them before teardown.
|
|
private static readonly ConcurrentBag<Task> PendingLoads = new();
|
|
|
|
internal static void TrackLoad(Task loadTask, string emoteCode)
|
|
{
|
|
PendingLoads.Add(
|
|
loadTask.ContinueWith(
|
|
t =>
|
|
{
|
|
if (t.IsFaulted)
|
|
Plugin.Log.Error(t.Exception!, $"EmoteCache load failed for {emoteCode}");
|
|
},
|
|
TaskScheduler.Default
|
|
)
|
|
);
|
|
}
|
|
|
|
public static async Task LoadData()
|
|
{
|
|
if (State is not LoadingState.Unloaded)
|
|
return;
|
|
|
|
// Reset CTS if Dispose was called and the plugin is being re-enabled.
|
|
if (Cts.IsCancellationRequested)
|
|
Cts = new CancellationTokenSource();
|
|
|
|
State = LoadingState.Loading;
|
|
var ct = Cts.Token;
|
|
try
|
|
{
|
|
var global = await Client.GetAsync(GlobalEmotes, ct);
|
|
var globalList = await global.Content.ReadAsStringAsync(ct);
|
|
|
|
foreach (var emote in JsonSerializer.Deserialize<Emote[]>(globalList)!)
|
|
if (!string.IsNullOrEmpty(emote.Code) && !NotWorking.Contains(emote.Code))
|
|
Cache.TryAdd(emote.Code, emote);
|
|
|
|
var lastId = string.Empty;
|
|
for (var i = 0; i < 15; i++)
|
|
{
|
|
var top = await Client.GetAsync(Top100Emotes.Format(BetterTTV, lastId), ct);
|
|
var topList = await top.Content.ReadAsStringAsync(ct);
|
|
|
|
var jsonList = JsonSerializer.Deserialize<List<Top100>>(topList)!;
|
|
// BetterTTV occasionally returns entries with a null Code;
|
|
// skip them so a single bad row doesn't break the whole cache.
|
|
foreach (var emote in jsonList)
|
|
if (
|
|
!string.IsNullOrEmpty(emote.Emote.Code)
|
|
&& !NotWorking.Contains(emote.Emote.Code)
|
|
)
|
|
Cache.TryAdd(emote.Emote.Code, emote.Emote);
|
|
|
|
lastId = jsonList.Last().Id;
|
|
}
|
|
|
|
SortedCodeArray = Cache.Keys.Order().ToArray();
|
|
State = LoadingState.Done;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Plugin disposed mid-load; State stays on Loading so re-enable can retry.
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Reset to Unloaded so a later trigger can retry without a plugin reload.
|
|
State = LoadingState.Unloaded;
|
|
Plugin.Log.Error(ex, "BetterTTV cache wasn't initialized");
|
|
}
|
|
}
|
|
|
|
public static void Dispose()
|
|
{
|
|
Cts.Cancel();
|
|
|
|
// 5s upper bound; anything still running gets abandoned.
|
|
try
|
|
{
|
|
Task.WaitAll(PendingLoads.ToArray(), TimeSpan.FromSeconds(5));
|
|
}
|
|
catch (AggregateException)
|
|
{
|
|
// Faults already logged in TrackLoad.
|
|
}
|
|
|
|
while (PendingLoads.TryTake(out _)) { }
|
|
|
|
foreach (var emote in EmoteImages.Values)
|
|
emote.InnerDispose();
|
|
}
|
|
|
|
internal static bool Exists(string code)
|
|
{
|
|
return State is LoadingState.Done && SortedCodeArray.Contains(code);
|
|
}
|
|
|
|
internal static EmoteBase? GetEmote(string code)
|
|
{
|
|
if (State is not LoadingState.Done)
|
|
return null;
|
|
|
|
if (!Cache.TryGetValue(code, out var emoteDetail))
|
|
return null;
|
|
|
|
if (EmoteImages.TryGetValue(emoteDetail.Id, out var emote))
|
|
return emote;
|
|
|
|
try
|
|
{
|
|
if (emoteDetail.ImageType == "gif")
|
|
{
|
|
var animatedEmote = new ImGuiGif().Prepare(emoteDetail);
|
|
EmoteImages.Add(emoteDetail.Id, animatedEmote);
|
|
return animatedEmote;
|
|
}
|
|
|
|
var staticEmote = new ImGuiEmote().Prepare(emoteDetail);
|
|
EmoteImages.Add(emoteDetail.Id, staticEmote);
|
|
|
|
return staticEmote;
|
|
}
|
|
catch
|
|
{
|
|
Plugin.Log.Error("Failed to convert");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public abstract class EmoteBase
|
|
{
|
|
public bool Failed;
|
|
public bool IsLoaded;
|
|
|
|
public byte[] RawData = [];
|
|
|
|
protected IDalamudTextureWrap? Texture;
|
|
|
|
public virtual void Draw(Vector2 size)
|
|
{
|
|
ImGui.Image(Texture!.Handle, size);
|
|
}
|
|
|
|
internal async Task<byte[]> LoadAsync(Emote emote, CancellationToken ct)
|
|
{
|
|
// Path-traversal guard: resolve and verify the candidate path stays
|
|
// inside the cache directory before reading or writing.
|
|
var dir = Path.GetFullPath(
|
|
Path.Join(Plugin.Interface.ConfigDirectory.FullName, "EmoteCacheV1")
|
|
);
|
|
Directory.CreateDirectory(dir);
|
|
|
|
var dirPrefix = dir.EndsWith(Path.DirectorySeparatorChar)
|
|
? dir
|
|
: dir + Path.DirectorySeparatorChar;
|
|
var filePath = Path.GetFullPath(Path.Join(dir, $"{emote.Id}.{emote.ImageType}"));
|
|
if (!filePath.StartsWith(dirPrefix, StringComparison.Ordinal))
|
|
throw new InvalidOperationException(
|
|
$"Emote path escapes cache directory: id={emote.Id}, type={emote.ImageType}"
|
|
);
|
|
|
|
if (File.Exists(filePath))
|
|
{
|
|
RawData = await File.ReadAllBytesAsync(filePath, ct);
|
|
}
|
|
else
|
|
{
|
|
var content = await Client.GetAsync(EmotePath.Format(emote.Id), ct);
|
|
RawData = await content.Content.ReadAsByteArrayAsync(ct);
|
|
|
|
await using var stream = new FileStream(
|
|
filePath,
|
|
FileMode.Create,
|
|
FileAccess.Write,
|
|
FileShare.Read
|
|
);
|
|
await stream.WriteAsync(RawData, ct);
|
|
}
|
|
|
|
return RawData;
|
|
}
|
|
|
|
public abstract void InnerDispose();
|
|
}
|
|
|
|
public sealed class ImGuiEmote : EmoteBase
|
|
{
|
|
public ImGuiEmote Prepare(Emote emote)
|
|
{
|
|
var ct = EmoteCache.Token;
|
|
// Task.Run keeps the sync prefix off the ImGui render thread.
|
|
EmoteCache.TrackLoad(Task.Run(() => LoadAsyncTracked(emote, ct), ct), emote.Code);
|
|
return this;
|
|
}
|
|
|
|
private async Task LoadAsyncTracked(Emote emote, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var image = await LoadAsync(emote, ct);
|
|
if (image.Length <= 0)
|
|
return;
|
|
|
|
ct.ThrowIfCancellationRequested();
|
|
Texture = await Plugin.TextureProvider.CreateFromImageAsync(
|
|
image,
|
|
cancellationToken: ct
|
|
);
|
|
IsLoaded = true;
|
|
}
|
|
catch (OperationCanceledException) { }
|
|
catch (Exception ex)
|
|
{
|
|
Failed = true;
|
|
Plugin.Log.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
|
|
}
|
|
}
|
|
|
|
public override void InnerDispose()
|
|
{
|
|
Texture?.Dispose();
|
|
}
|
|
}
|
|
|
|
public sealed class ImGuiGif : EmoteBase
|
|
{
|
|
private List<(IDalamudTextureWrap Texture, float Delay)> Frames = [];
|
|
private float FrameTimer;
|
|
private int CurrentFrame;
|
|
private ulong GlobalFrameCount;
|
|
|
|
public override void Draw(Vector2 size)
|
|
{
|
|
if (Frames.Count == 0)
|
|
return;
|
|
|
|
if (CurrentFrame >= Frames.Count)
|
|
{
|
|
CurrentFrame = 0;
|
|
FrameTimer = -1f;
|
|
}
|
|
|
|
var frame = Frames[CurrentFrame];
|
|
if (FrameTimer <= 0.0f)
|
|
FrameTimer = frame.Delay;
|
|
|
|
ImGui.Image(frame.Texture.Handle, size);
|
|
|
|
if (GlobalFrameCount == Plugin.Interface.UiBuilder.FrameCount)
|
|
return;
|
|
|
|
GlobalFrameCount = Plugin.Interface.UiBuilder.FrameCount;
|
|
|
|
FrameTimer -= ImGui.GetIO().DeltaTime;
|
|
if (FrameTimer <= 0f)
|
|
CurrentFrame++;
|
|
}
|
|
|
|
public override void InnerDispose()
|
|
{
|
|
Frames.ForEach(f => f.Texture.Dispose());
|
|
Frames.Clear();
|
|
}
|
|
|
|
public ImGuiGif Prepare(Emote emote)
|
|
{
|
|
var ct = EmoteCache.Token;
|
|
EmoteCache.TrackLoad(Task.Run(() => LoadAsyncTracked(emote, ct), ct), emote.Code);
|
|
return this;
|
|
}
|
|
|
|
private async Task LoadAsyncTracked(Emote emote, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
var image = await LoadAsync(emote, ct);
|
|
if (image.Length <= 0)
|
|
return;
|
|
|
|
using var ms = new MemoryStream(image);
|
|
using var img = Image.Load<Rgba32>(ms);
|
|
if (img.Frames.Count == 0)
|
|
return;
|
|
|
|
var frames = new List<(IDalamudTextureWrap Tex, float Delay)>();
|
|
foreach (var frame in img.Frames)
|
|
{
|
|
ct.ThrowIfCancellationRequested();
|
|
|
|
var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f;
|
|
|
|
// Match browser behaviour: anything under 20ms rounds up to 100ms.
|
|
if (delay < 0.02f)
|
|
delay = 0.1f;
|
|
|
|
var buffer = new byte[4 * frame.Width * frame.Height];
|
|
frame.CopyPixelDataTo(buffer);
|
|
var tex = await Plugin.TextureProvider.CreateFromRawAsync(
|
|
RawImageSpecification.Rgba32(frame.Width, frame.Height),
|
|
buffer,
|
|
cancellationToken: ct
|
|
);
|
|
frames.Add((tex, delay));
|
|
}
|
|
|
|
Frames = frames;
|
|
IsLoaded = true;
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Plugin disposed mid-load; release any partial frames.
|
|
foreach (var f in Frames)
|
|
f.Texture.Dispose();
|
|
Frames = [];
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Failed = true;
|
|
Plugin.Log.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}");
|
|
}
|
|
}
|
|
}
|
|
}
|