BTTV emote support part 1 & keep Input focus option

This commit is contained in:
Infi
2024-05-08 00:04:07 +02:00
parent 8709c35ff1
commit 4210d634ab
11 changed files with 409 additions and 62 deletions
+1
View File
@@ -55,6 +55,7 @@
<PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.4" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="8.0.4" />
<PackageReference Include="Pidgin" Version="3.2.2"/> <PackageReference Include="Pidgin" Version="3.2.2"/>
<PackageReference Include="SharpDX.Direct2D1" Version="4.2.0"/> <PackageReference Include="SharpDX.Direct2D1" Version="4.2.0"/>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
<PackageReference Include="XivCommon" Version="9.0.0"/> <PackageReference Include="XivCommon" Version="9.0.0"/>
</ItemGroup> </ItemGroup>
+2
View File
@@ -37,6 +37,7 @@ internal class Configuration : IPluginConfiguration
public bool SortAutoTranslate; public bool SortAutoTranslate;
public bool CollapseDuplicateMessages; public bool CollapseDuplicateMessages;
public bool PlaySounds = true; public bool PlaySounds = true;
public bool KeepInputFocus = true;
public int MaxLinesToRender = 10_000; public int MaxLinesToRender = 10_000;
public bool FontsEnabled = true; public bool FontsEnabled = true;
@@ -85,6 +86,7 @@ internal class Configuration : IPluginConfiguration
SortAutoTranslate = other.SortAutoTranslate; SortAutoTranslate = other.SortAutoTranslate;
CollapseDuplicateMessages = other.CollapseDuplicateMessages; CollapseDuplicateMessages = other.CollapseDuplicateMessages;
PlaySounds = other.PlaySounds; PlaySounds = other.PlaySounds;
KeepInputFocus = other.KeepInputFocus;
MaxLinesToRender = other.MaxLinesToRender; MaxLinesToRender = other.MaxLinesToRender;
FontsEnabled = other.FontsEnabled; FontsEnabled = other.FontsEnabled;
ExtraGlyphRanges = other.ExtraGlyphRanges; ExtraGlyphRanges = other.ExtraGlyphRanges;
+262
View File
@@ -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<string, IEmote> EmoteImages = new();
private static readonly Dictionary<string, Emote> 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<Emote[]>(globalCache)!)
Cache.TryAdd(emote.Code, emote);
var top100 = new HttpClient().GetAsync(Top100Emotes)
.Result
.Content
.ReadAsStringAsync()
.Result;
Plugin.Log.Information(top100);
var json = JsonSerializer.Deserialize<List<Top100>>(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<byte[]> 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<Rgba32>(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}");
}
}
}
}
+53 -50
View File
@@ -1,3 +1,4 @@
using System.Text;
using ChatTwo.Code; using ChatTwo.Code;
using ChatTwo.Util; using ChatTwo.Util;
using Dalamud.Game.Text.SeStringHandling; using Dalamud.Game.Text.SeStringHandling;
@@ -77,7 +78,7 @@ internal class Message
Date = DateTimeOffset.UtcNow; Date = DateTimeOffset.UtcNow;
Code = code; Code = code;
Sender = sender; Sender = sender;
Content = ReplaceContentURLs(content); Content = CheckMessageContent(content);
SenderSource = senderSource; SenderSource = senderSource;
ContentSource = contentSource; ContentSource = contentSource;
SortCode = new SortCode(Code.Type, Code.Source); SortCode = new SortCode(Code.Type, Code.Source);
@@ -130,28 +131,7 @@ internal class Message
return Guid.Empty; return Guid.Empty;
} }
/// <summary> private List<Chunk> CheckMessageContent(List<Chunk> content)
/// 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.
/// </summary>
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
);
/// <summary>
/// Finds all URL strings in all TextChunks, splits the parent TextChunk
/// apart and inserts a new TextChunk with a URIPayload.
/// </summary>
private List<Chunk> ReplaceContentURLs(List<Chunk> content)
{ {
var newChunks = new List<Chunk>(); var newChunks = new List<Chunk>();
void AddChunkWithMessage(Chunk chunk) void AddChunkWithMessage(Chunk chunk)
@@ -171,42 +151,65 @@ internal class Message
continue; continue;
} }
// Find all URLs with the regex and insert a new TextChunk with a // We replace every emote before checking for URLs
// URIPayload. var builder = new StringBuilder();
var matches = URLRegex.Matches(text.Content); foreach (var word in text.Content.Split(" "))
var remainderIndex = 0;
foreach (Match match in matches.Cast<Match>())
{ {
// Add the text before the URL. if (EmoteCache.Exists(word))
if (match.Index > remainderIndex)
{ {
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. if (URLRegex.IsMatch(word))
remainderIndex = match.Index + match.Length; {
// 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. // Create a new TextChunk with a URIPayload for the URL text.
try try
{ {
var link = UriPayload.ResolveURI(match.Value); var link = UriPayload.ResolveURI(word);
AddChunkWithMessage(text.NewWithStyle(chunk.Source, link, match.Value)); AddChunkWithMessage(text.NewWithStyle(chunk.Source, link, word));
} builder.Append(' ');
catch (UriFormatException)
{ continue;
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 catch (UriFormatException)
// beginning of the match so it'll get included in the next {
// regular text chunk. Plugin.Log.Debug($"Invalid URL accepted by Regex but failed URI parsing: '{word}'");
remainderIndex = match.Index; }
} }
builder.Append($"{word} ");
} }
// We add the leftovers
// Add the text after the last URL. AddChunkWithMessage(text.NewWithStyle(chunk.Source, chunk.Link, builder.ToString()[..^1]));
if (remainderIndex < text.Content.Length)
AddChunkWithMessage(text.NewWithStyle(chunk.Source, null, text.Content[remainderIndex..]));
} }
return newChunks; return newChunks;
} }
/// <summary>
/// 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.
/// </summary>
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
);
} }
+7
View File
@@ -28,6 +28,7 @@ internal enum PayloadMessagePackType : byte
Achievement, Achievement,
PartyFinder, PartyFinder,
Uri, Uri,
Emote,
Other = 255, Other = 255,
} }
@@ -56,6 +57,10 @@ public class PayloadMessagePackFormatter : IMessagePackFormatter<Payload?>
writer.WriteUInt8((byte)PayloadMessagePackType.Uri); writer.WriteUInt8((byte)PayloadMessagePackType.Uri);
writer.WriteString(Encoding.UTF8.GetBytes(uriPayload.Uri.ToString())); writer.WriteString(Encoding.UTF8.GetBytes(uriPayload.Uri.ToString()));
break; break;
case EmotePayload emotePayload:
writer.WriteUInt8((byte)PayloadMessagePackType.Emote);
writer.WriteString(Encoding.UTF8.GetBytes(emotePayload.Code));
break;
default: default:
writer.WriteUInt8((byte)PayloadMessagePackType.Other); writer.WriteUInt8((byte)PayloadMessagePackType.Other);
writer.Write(value.Encode()); writer.Write(value.Encode());
@@ -80,6 +85,8 @@ public class PayloadMessagePackFormatter : IMessagePackFormatter<Payload?>
return new PartyFinderPayload(reader.ReadUInt32()); return new PartyFinderPayload(reader.ReadUInt32());
case PayloadMessagePackType.Uri: case PayloadMessagePackType.Uri:
return new UriPayload(new Uri(reader.ReadString() ?? "")); return new UriPayload(new Uri(reader.ReadString() ?? ""));
case PayloadMessagePackType.Emote:
return EmotePayload.ResolveEmote(reader.ReadString() ?? "");
case PayloadMessagePackType.Other: case PayloadMessagePackType.Other:
default: default:
var bytes = reader.ReadBytes() ?? new ReadOnlySequence<byte>(); var bytes = reader.ReadBytes() ?? new ReadOnlySequence<byte>();
+18
View File
@@ -2093,6 +2093,24 @@ namespace ChatTwo.Resources {
} }
} }
/// <summary>
/// Looks up a localized string similar to Keeps the input focus, even if you enter battle or do other actions.
/// </summary>
internal static string Options_KeepInputFocus_Description {
get {
return ResourceManager.GetString("Options_KeepInputFocus_Description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Keep input focus.
/// </summary>
internal static string Options_KeepInputFocus_Name {
get {
return ResourceManager.GetString("Options_KeepInputFocus_Name", resourceCulture);
}
}
/// <summary> /// <summary>
/// Looks up a localized string similar to The way in which {0} should handle keybinds.. /// Looks up a localized string similar to The way in which {0} should handle keybinds..
/// </summary> /// </summary>
+6
View File
@@ -1012,4 +1012,10 @@
<data name="Options_AdjustPosition_Warning" xml:space="preserve"> <data name="Options_AdjustPosition_Warning" xml:space="preserve">
<value>Attention, this change applies immediately and is not discardable!</value> <value>Attention, this change applies immediately and is not discardable!</value>
</data> </data>
<data name="Options_KeepInputFocus_Name" xml:space="preserve">
<value>Keep input focus</value>
</data>
<data name="Options_KeepInputFocus_Description" xml:space="preserve">
<value>Keeps the input focus, even if you enter battle or do other actions</value>
</data>
</root> </root>
+29 -12
View File
@@ -122,18 +122,6 @@ public sealed class ChatLogWindow : Window
Plugin.AddonLifecycle.RegisterListener(AddonEvent.PostRequestedUpdate, "ItemDetail", PayloadHandler.MoveTooltip); 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() public void Dispose()
{ {
Plugin.AddonLifecycle.UnregisterListener(AddonEvent.PostRequestedUpdate, "ItemDetail", PayloadHandler.MoveTooltip); Plugin.AddonLifecycle.UnregisterListener(AddonEvent.PostRequestedUpdate, "ItemDetail", PayloadHandler.MoveTooltip);
@@ -474,6 +462,21 @@ public sealed class ChatLogWindow : Window
return !IsHidden; 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() public override void Draw()
{ {
DrawChatLog(); DrawChatLog();
@@ -1496,6 +1499,20 @@ public sealed class ChatLogWindow : Window
if (chunk is not TextChunk text) if (chunk is not TextChunk text)
return; 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; var colour = text.Foreground;
if (colour == null && text.FallbackColour != null) if (colour == null && text.FallbackColour != null)
{ {
+3
View File
@@ -23,6 +23,9 @@ internal sealed class ChatLog : ISettingsTab
{ {
ImGui.PushTextWrapPos(); 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); ImGuiUtil.OptionCheckbox(ref Mutable.PlaySounds, Language.Options_PlaySounds_Name, Language.Options_PlaySounds_Description);
ImGui.Spacing(); ImGui.Spacing();
+22
View File
@@ -85,3 +85,25 @@ internal class UriPayload(Uri uri) : Payload
throw new NotImplementedException(); 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();
}
}
+6
View File
@@ -52,6 +52,12 @@
"SharpDX.DXGI": "4.2.0" "SharpDX.DXGI": "4.2.0"
} }
}, },
"SixLabors.ImageSharp": {
"type": "Direct",
"requested": "[3.1.4, )",
"resolved": "3.1.4",
"contentHash": "lFIdxgGDA5iYkUMRFOze7BGLcdpoLFbR+a20kc1W7NepvzU7ejtxtWOg9RvgG7kb9tBoJ3ONYOK6kLil/dgF1w=="
},
"XivCommon": { "XivCommon": {
"type": "Direct", "type": "Direct",
"requested": "[9.0.0, )", "requested": "[9.0.0, )",