From 4210d634ab0420849714210d291eb2e6f7c7ea94 Mon Sep 17 00:00:00 2001 From: Infi Date: Wed, 8 May 2024 00:04:07 +0200 Subject: [PATCH] BTTV emote support part 1 & keep Input focus option --- ChatTwo/ChatTwo.csproj | 1 + ChatTwo/Configuration.cs | 2 + ChatTwo/EmoteCache.cs | 262 +++++++++++++++++++++++++ ChatTwo/Message.cs | 103 +++++----- ChatTwo/MessageStore.cs | 7 + ChatTwo/Resources/Language.Designer.cs | 18 ++ ChatTwo/Resources/Language.resx | 6 + ChatTwo/Ui/ChatLogWindow.cs | 41 ++-- ChatTwo/Ui/SettingsTabs/ChatLog.cs | 3 + ChatTwo/Util/Payloads.cs | 22 +++ ChatTwo/packages.lock.json | 6 + 11 files changed, 409 insertions(+), 62 deletions(-) create mode 100644 ChatTwo/EmoteCache.cs diff --git a/ChatTwo/ChatTwo.csproj b/ChatTwo/ChatTwo.csproj index 8263e85..c459b02 100755 --- a/ChatTwo/ChatTwo.csproj +++ b/ChatTwo/ChatTwo.csproj @@ -55,6 +55,7 @@ + diff --git a/ChatTwo/Configuration.cs b/ChatTwo/Configuration.cs index 13b9c68..db45f12 100755 --- a/ChatTwo/Configuration.cs +++ b/ChatTwo/Configuration.cs @@ -37,6 +37,7 @@ internal class Configuration : IPluginConfiguration public bool SortAutoTranslate; public bool CollapseDuplicateMessages; public bool PlaySounds = true; + public bool KeepInputFocus = true; public int MaxLinesToRender = 10_000; public bool FontsEnabled = true; @@ -85,6 +86,7 @@ internal class Configuration : IPluginConfiguration SortAutoTranslate = other.SortAutoTranslate; CollapseDuplicateMessages = other.CollapseDuplicateMessages; PlaySounds = other.PlaySounds; + KeepInputFocus = other.KeepInputFocus; MaxLinesToRender = other.MaxLinesToRender; FontsEnabled = other.FontsEnabled; ExtraGlyphRanges = other.ExtraGlyphRanges; diff --git a/ChatTwo/EmoteCache.cs b/ChatTwo/EmoteCache.cs new file mode 100644 index 0000000..a1ba6f4 --- /dev/null +++ b/ChatTwo/EmoteCache.cs @@ -0,0 +1,262 @@ +using System.Numerics; +using System.Text.Json; +using System.Text.Json.Serialization; +using Dalamud.Interface.Internal; +using Dalamud.Utility; +using ImGuiNET; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace ChatTwo; + +public static class EmoteCache +{ + private struct Top100 + { + [JsonPropertyName("emote")] + public Emote Emote { get; set; } + } + + public struct Emote + { + [JsonPropertyName("id")] + public string Id { get; set; } + [JsonPropertyName("code")] + public string Code { get; set; } + [JsonPropertyName("imageType")] + public string ImageType { get; set; } + [JsonPropertyName("animated")] + public bool Animated { get; set; } + }; + + private const string BetterTTV = "https://api.betterttv.net/3"; + private const string GlobalEmotes = $"{BetterTTV}/cached/emotes/global"; + private const string Top100Emotes = $"{BetterTTV}/emotes/shared/top?limit=100"; + private const string EmotePath = "https://cdn.betterttv.net/emote/{0}/3x"; + + private static readonly bool IsBTTVDataLoaded; + private static readonly Dictionary EmoteImages = new(); + + private static readonly Dictionary Cache = new(); + + private static readonly string[] EmoteCodeArray = []; + + static EmoteCache() + { + try + { + var globalCache = new HttpClient().GetAsync(GlobalEmotes) + .Result + .Content + .ReadAsStringAsync() + .Result; + + foreach (var emote in JsonSerializer.Deserialize(globalCache)!) + Cache.TryAdd(emote.Code, emote); + + var top100 = new HttpClient().GetAsync(Top100Emotes) + .Result + .Content + .ReadAsStringAsync() + .Result; + + Plugin.Log.Information(top100); + var json = JsonSerializer.Deserialize>(top100); + foreach (var emote in json) + { + Plugin.Log.Information($"emote {emote.Emote.Code}"); + Cache.TryAdd(emote.Emote.Code, emote.Emote); + } + + EmoteCodeArray = Cache.Keys.ToArray(); + IsBTTVDataLoaded = true; + } + catch (Exception ex) + { + Plugin.Log.Error(ex, "BetterTTV cache wasn't initialized1"); + } + } + + internal static bool Exists(string code) + { + return IsBTTVDataLoaded && EmoteCodeArray.Contains(code); + } + + internal static IEmote? GetEmote(string code) + { + if (!IsBTTVDataLoaded) + 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 class IEmote + { + public bool IsLoaded = false; + + public bool IsAnimated = false; + public IDalamudTextureWrap Texture; + + public virtual void Draw(Vector2 size) + { + ImGui.Image(Texture.ImGuiHandle, size); + } + + internal static async Task LoadAsync(Emote emote) + { + var dir = Path.Join(Plugin.Interface.ConfigDirectory.FullName, "emotes"); + Directory.CreateDirectory(dir); + + byte[] image; + var filePath = Path.Join(dir, $"{emote.Id}.{emote.ImageType}"); + if (File.Exists(filePath)) + { + image = await File.ReadAllBytesAsync(filePath); + } + else + { + var content = await new HttpClient().GetAsync(EmotePath.Format(emote.Id)); + image = await content.Content.ReadAsByteArrayAsync(); + + await using var stream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read); + stream.Write(image, 0, image.Length); + } + + return image; + } + } + + public sealed class ImGuiEmote : IEmote + { + public ImGuiEmote Prepare(Emote emote) + { + Task.Run(() => Load(emote)); + return this; + } + + private async void Load(Emote emote) + { + try + { + var image = await LoadAsync(emote); + if (image.Length <= 0) + return; + + Texture = await Plugin.Interface.UiBuilder.LoadImageAsync(image); + IsLoaded = true; + } + catch (Exception ex) + { + Plugin.Log.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}"); + } + } + } + + public sealed class ImGuiGif : IEmote + { + private List<(IDalamudTextureWrap Texture, float Delay)> Frames = []; + private float FrameTimer; + private int CurrentFrame; + private ulong GlobalFrameCount; + + public bool IsPaused; + + 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.ImGuiHandle, size); + + if (IsPaused) + return; + + if (GlobalFrameCount != Plugin.Interface.UiBuilder.FrameCount) + { + GlobalFrameCount = Plugin.Interface.UiBuilder.FrameCount; + + FrameTimer -= ImGui.GetIO().DeltaTime; + if (FrameTimer <= 0f) + CurrentFrame++; + } + } + + public void Dispose() + { + Frames.ForEach(f => f.Texture.Dispose()); + Frames.Clear(); + } + + public ImGuiGif Prepare(Emote emote) + { + Task.Run(() => Load(emote)); + return this; + } + + private async void Load(Emote emote) + { + try + { + var image = await LoadAsync(emote); + if (image.Length <= 0) + return; + + using var ms = new MemoryStream(image); + using var img = Image.Load(ms); + if (img.Frames.Count == 0) + return; + + var frames = new List<(IDalamudTextureWrap Tex, float Delay)>(); + foreach (var frame in img.Frames) + { + var delay = frame.Metadata.GetGifMetadata().FrameDelay / 100f; + var buffer = new byte[4 * frame.Width * frame.Height]; + frame.CopyPixelDataTo(buffer); + var tex = await Plugin.Interface.UiBuilder.LoadImageRawAsync(buffer, frame.Width, frame.Height, 4); + frames.Add((tex, delay)); + } + + Frames = frames; + IsLoaded = true; + } + catch (Exception ex) + { + Plugin.Log.Error(ex, $"Unable to load {emote.Code} with id {emote.Id}"); + } + } + } +} \ No newline at end of file diff --git a/ChatTwo/Message.cs b/ChatTwo/Message.cs index b25416c..bbc799b 100755 --- a/ChatTwo/Message.cs +++ b/ChatTwo/Message.cs @@ -1,3 +1,4 @@ +using System.Text; using ChatTwo.Code; using ChatTwo.Util; using Dalamud.Game.Text.SeStringHandling; @@ -77,7 +78,7 @@ internal class Message Date = DateTimeOffset.UtcNow; Code = code; Sender = sender; - Content = ReplaceContentURLs(content); + Content = CheckMessageContent(content); SenderSource = senderSource; ContentSource = contentSource; SortCode = new SortCode(Code.Type, Code.Source); @@ -130,28 +131,7 @@ internal class Message return Guid.Empty; } - /// - /// URLRegex returns a regex object that matches URLs like: - /// - https://example.com - /// - http://example.com - /// - www.example.com - /// - https://sub.example.com - /// - example.com - /// - sub.example.com - /// - /// It matches URLs with www. or https:// prefix, and also matches URLs - /// without a prefix on specific TLDs. - /// - private static Regex URLRegex = new( - @"((https?:\/\/|www\.)[a-z0-9-]+(\.[a-z0-9-]+)*|([a-z0-9-]+(\.[a-z0-9-]+)*\.(com|net|org|co|io|app)))(:[\d]{1,5})?(\/[^\s]+)?", - RegexOptions.Compiled | RegexOptions.IgnoreCase - ); - - /// - /// Finds all URL strings in all TextChunks, splits the parent TextChunk - /// apart and inserts a new TextChunk with a URIPayload. - /// - private List ReplaceContentURLs(List content) + private List CheckMessageContent(List content) { var newChunks = new List(); void AddChunkWithMessage(Chunk chunk) @@ -171,42 +151,65 @@ internal class Message continue; } - // Find all URLs with the regex and insert a new TextChunk with a - // URIPayload. - var matches = URLRegex.Matches(text.Content); - var remainderIndex = 0; - foreach (Match match in matches.Cast()) + // We replace every emote before checking for URLs + var builder = new StringBuilder(); + foreach (var word in text.Content.Split(" ")) { - // Add the text before the URL. - if (match.Index > remainderIndex) + if (EmoteCache.Exists(word)) { - AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, text.Content[remainderIndex..match.Index])); + // We add all the previous collected text parts + AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, builder.ToString())); + builder.Clear(); + + newChunks.Add(new TextChunk(chunk.Source, EmotePayload.ResolveEmote(word), "Cool BetterTTV")); + builder.Append(' '); + continue; } - // Update the remainder index. - remainderIndex = match.Index + match.Length; + if (URLRegex.IsMatch(word)) + { + // We add all the previous collected text parts + AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, builder.ToString())); + builder.Clear(); - // Create a new TextChunk with a URIPayload for the URL text. - try - { - var link = UriPayload.ResolveURI(match.Value); - AddChunkWithMessage(text.NewWithStyle(chunk.Source, link, match.Value)); - } - catch (UriFormatException) - { - Plugin.Log.Debug($"Invalid URL accepted by Regex but failed URI parsing: '{match.Value}'"); - // If the URL is invalid, set the remainder index to the - // beginning of the match so it'll get included in the next - // regular text chunk. - remainderIndex = match.Index; + // Create a new TextChunk with a URIPayload for the URL text. + try + { + var link = UriPayload.ResolveURI(word); + AddChunkWithMessage(text.NewWithStyle(chunk.Source, link, word)); + builder.Append(' '); + + continue; + } + catch (UriFormatException) + { + Plugin.Log.Debug($"Invalid URL accepted by Regex but failed URI parsing: '{word}'"); + } } + + builder.Append($"{word} "); } - - // Add the text after the last URL. - if (remainderIndex < text.Content.Length) - AddChunkWithMessage(text.NewWithStyle(chunk.Source, null, text.Content[remainderIndex..])); + // We add the leftovers + AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, builder.ToString()[..^1])); } return newChunks; } + + /// + /// URLRegex returns a regex object that matches URLs like: + /// - https://example.com + /// - http://example.com + /// - www.example.com + /// - https://sub.example.com + /// - example.com + /// - sub.example.com + /// + /// It matches URLs with www. or https:// prefix, and also matches URLs + /// without a prefix on specific TLDs. + /// + private static Regex URLRegex = new( + @"((https?:\/\/|www\.)[a-z0-9-]+(\.[a-z0-9-]+)*|([a-z0-9-]+(\.[a-z0-9-]+)*\.(com|net|org|co|io|app)))(:[\d]{1,5})?(\/[^\s]+)?", + RegexOptions.Compiled | RegexOptions.IgnoreCase + ); } diff --git a/ChatTwo/MessageStore.cs b/ChatTwo/MessageStore.cs index 92a1954..dbdf802 100644 --- a/ChatTwo/MessageStore.cs +++ b/ChatTwo/MessageStore.cs @@ -28,6 +28,7 @@ internal enum PayloadMessagePackType : byte Achievement, PartyFinder, Uri, + Emote, Other = 255, } @@ -56,6 +57,10 @@ public class PayloadMessagePackFormatter : IMessagePackFormatter writer.WriteUInt8((byte)PayloadMessagePackType.Uri); writer.WriteString(Encoding.UTF8.GetBytes(uriPayload.Uri.ToString())); break; + case EmotePayload emotePayload: + writer.WriteUInt8((byte)PayloadMessagePackType.Emote); + writer.WriteString(Encoding.UTF8.GetBytes(emotePayload.Code)); + break; default: writer.WriteUInt8((byte)PayloadMessagePackType.Other); writer.Write(value.Encode()); @@ -80,6 +85,8 @@ public class PayloadMessagePackFormatter : IMessagePackFormatter return new PartyFinderPayload(reader.ReadUInt32()); case PayloadMessagePackType.Uri: return new UriPayload(new Uri(reader.ReadString() ?? "")); + case PayloadMessagePackType.Emote: + return EmotePayload.ResolveEmote(reader.ReadString() ?? ""); case PayloadMessagePackType.Other: default: var bytes = reader.ReadBytes() ?? new ReadOnlySequence(); diff --git a/ChatTwo/Resources/Language.Designer.cs b/ChatTwo/Resources/Language.Designer.cs index 08342b0..70e050e 100755 --- a/ChatTwo/Resources/Language.Designer.cs +++ b/ChatTwo/Resources/Language.Designer.cs @@ -2093,6 +2093,24 @@ namespace ChatTwo.Resources { } } + /// + /// Looks up a localized string similar to Keeps the input focus, even if you enter battle or do other actions. + /// + internal static string Options_KeepInputFocus_Description { + get { + return ResourceManager.GetString("Options_KeepInputFocus_Description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Keep input focus. + /// + internal static string Options_KeepInputFocus_Name { + get { + return ResourceManager.GetString("Options_KeepInputFocus_Name", resourceCulture); + } + } + /// /// Looks up a localized string similar to The way in which {0} should handle keybinds.. /// diff --git a/ChatTwo/Resources/Language.resx b/ChatTwo/Resources/Language.resx index ea62279..1a23366 100644 --- a/ChatTwo/Resources/Language.resx +++ b/ChatTwo/Resources/Language.resx @@ -1012,4 +1012,10 @@ Attention, this change applies immediately and is not discardable! + + Keep input focus + + + Keeps the input focus, even if you enter battle or do other actions + diff --git a/ChatTwo/Ui/ChatLogWindow.cs b/ChatTwo/Ui/ChatLogWindow.cs index 94013d2..1f81f82 100644 --- a/ChatTwo/Ui/ChatLogWindow.cs +++ b/ChatTwo/Ui/ChatLogWindow.cs @@ -122,18 +122,6 @@ public sealed class ChatLogWindow : Window Plugin.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, "ItemDetail", PayloadHandler.MoveTooltip); } - public override void PreDraw() - { - if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null }) - StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Push(); - } - - public override void PostDraw() - { - if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null }) - StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Pop(); - } - public void Dispose() { Plugin.AddonLifecycle.UnregisterListener(AddonEvent.PostRequestedUpdate, "ItemDetail", PayloadHandler.MoveTooltip); @@ -474,6 +462,21 @@ public sealed class ChatLogWindow : Window return !IsHidden; } + public override void PreDraw() + { + if (Plugin.Config.KeepInputFocus && Activate) + ImGui.SetWindowFocus(WindowName); + + if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null }) + StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Push(); + } + + public override void PostDraw() + { + if (Plugin.Config is { OverrideStyle: true, ChosenStyle: not null }) + StyleModel.GetConfiguredStyles()?.FirstOrDefault(style => style.Name == Plugin.Config.ChosenStyle)?.Pop(); + } + public override void Draw() { DrawChatLog(); @@ -1496,6 +1499,20 @@ public sealed class ChatLogWindow : Window if (chunk is not TextChunk text) return; + if (chunk.Link?.Type == (PayloadType)0x53) + { + var emoteSize = ImGui.CalcTextSize("W"); + emoteSize = emoteSize with { Y = emoteSize.X } * 1.5f; + + var emotePayload = (EmotePayload) chunk.Link; + var image = EmoteCache.GetEmote(emotePayload.Code); + if (image is { IsLoaded: true }) + image.Draw(emoteSize); + else + ImGui.Dummy(emoteSize); + return; + } + var colour = text.Foreground; if (colour == null && text.FallbackColour != null) { diff --git a/ChatTwo/Ui/SettingsTabs/ChatLog.cs b/ChatTwo/Ui/SettingsTabs/ChatLog.cs index ef21153..11c3560 100644 --- a/ChatTwo/Ui/SettingsTabs/ChatLog.cs +++ b/ChatTwo/Ui/SettingsTabs/ChatLog.cs @@ -23,6 +23,9 @@ internal sealed class ChatLog : ISettingsTab { ImGui.PushTextWrapPos(); + ImGuiUtil.OptionCheckbox(ref Mutable.KeepInputFocus, Language.Options_KeepInputFocus_Name, Language.Options_KeepInputFocus_Description); + ImGui.Spacing(); + ImGuiUtil.OptionCheckbox(ref Mutable.PlaySounds, Language.Options_PlaySounds_Name, Language.Options_PlaySounds_Description); ImGui.Spacing(); diff --git a/ChatTwo/Util/Payloads.cs b/ChatTwo/Util/Payloads.cs index 6fc81fc..f8761ad 100755 --- a/ChatTwo/Util/Payloads.cs +++ b/ChatTwo/Util/Payloads.cs @@ -85,3 +85,25 @@ internal class UriPayload(Uri uri) : Payload throw new NotImplementedException(); } } + +internal class EmotePayload : Payload +{ + public override PayloadType Type => (PayloadType) 0x53; + + public string Code; + + public static EmotePayload ResolveEmote(string code) + { + return new EmotePayload { Code = code }; + } + + protected override void DecodeImpl(BinaryReader reader, long endOfStream) + { + throw new NotImplementedException(); + } + + protected override byte[] EncodeImpl() + { + throw new NotImplementedException(); + } +} diff --git a/ChatTwo/packages.lock.json b/ChatTwo/packages.lock.json index f3eb1f5..dfeed34 100644 --- a/ChatTwo/packages.lock.json +++ b/ChatTwo/packages.lock.json @@ -52,6 +52,12 @@ "SharpDX.DXGI": "4.2.0" } }, + "SixLabors.ImageSharp": { + "type": "Direct", + "requested": "[3.1.4, )", + "resolved": "3.1.4", + "contentHash": "lFIdxgGDA5iYkUMRFOze7BGLcdpoLFbR+a20kc1W7NepvzU7ejtxtWOg9RvgG7kb9tBoJ3ONYOK6kLil/dgF1w==" + }, "XivCommon": { "type": "Direct", "requested": "[9.0.0, )",